Coverage for sources/agentsmgr/userdata.py: 11%
80 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-23 02:37 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-23 02:37 +0000
1# vim: set filetype=python fileencoding=utf-8:
2# -*- coding: utf-8 -*-
4#============================================================================#
5# #
6# Licensed under the Apache License, Version 2.0 (the "License"); #
7# you may not use this file except in compliance with the License. #
8# You may obtain a copy of the License at #
9# #
10# http://www.apache.org/licenses/LICENSE-2.0 #
11# #
12# Unless required by applicable law or agreed to in writing, software #
13# distributed under the License is distributed on an "AS IS" BASIS, #
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
15# See the License for the specific language governing permissions and #
16# limitations under the License. #
17# #
18#============================================================================#
21''' Global settings management for coder configurations.
23 Provides functionality for populating per-user global settings files,
24 including direct file copying and JSON settings merging with user
25 preservation semantics.
26'''
29import json as _json
31from . import __
32from . import exceptions as _exceptions
33from . import renderers as _renderers
36def _is_json_dict(
37 value: __.typx.Any
38) -> __.typx.TypeGuard[ dict[ str, __.typx.Any ] ]:
39 ''' Type guard for JSON dictionary values. '''
40 return isinstance( value, dict )
43def populate_globals(
44 data_location: __.Path,
45 coders: __.cabc.Sequence[ str ],
46 application_configuration: __.cabc.Mapping[ str, __.typx.Any ],
47 simulate: bool = False,
48) -> tuple[ int, int ]:
49 ''' Populates per-user global files for configured coders.
51 Surveys defaults/globals directory for coder-specific files and
52 populates them to per-user locations. Handles two types of files:
53 direct copy for non-settings files and merge for settings files
54 (preserving user values).
56 Returns tuple of (files_attempted, files_updated) counts.
57 '''
58 globals_directory = data_location / 'globals'
59 if not globals_directory.exists( ):
60 return ( 0, 0 )
61 files_attempted = 0
62 files_updated = 0
63 for coder in coders:
64 coder_globals = globals_directory / coder
65 if not coder_globals.exists( ):
66 continue
67 try: renderer = _renderers.RENDERERS[ coder ]
68 except KeyError as exception:
69 raise _exceptions.CoderAbsence( coder ) from exception
70 per_user_directory = renderer.resolve_base_directory(
71 mode = 'per-user',
72 target = __.Path.cwd( ),
73 configuration = application_configuration,
74 environment = __.os.environ,
75 )
76 for global_file in coder_globals.iterdir( ):
77 if not global_file.is_file( ):
78 continue
79 files_attempted += 1
80 target_file = per_user_directory / global_file.name
81 if _is_settings_file( global_file, coder ):
82 updated = _merge_settings_file(
83 global_file, target_file, simulate )
84 else:
85 updated = _copy_file_directly(
86 global_file, target_file, simulate )
87 if updated:
88 files_updated += 1
89 return ( files_attempted, files_updated )
92def _is_settings_file( file: __.Path, coder: str ) -> bool:
93 ''' Determines whether file is a settings file requiring merge logic.
95 Settings files have coder-specific names and contain JSON
96 configuration that should be merged rather than replaced. Non-settings
97 files are directly copied, replacing any existing version.
98 '''
99 settings_names: dict[ str, tuple[ str, ... ] ] = {
100 'claude': ( 'settings.json', ),
101 'opencode': ( 'opencode.json', 'opencode.jsonc' ),
102 'codex': ( 'config.json', ),
103 }
104 return file.name in settings_names.get( coder, ( ) )
107def _copy_file_directly(
108 source: __.Path, target: __.Path, simulate: bool
109) -> bool:
110 ''' Copies file directly from source to target location.
112 Creates target directory if needed. Returns True if file was
113 updated (or would be updated in simulation mode).
114 '''
115 if simulate:
116 return True
117 target.parent.mkdir( parents = True, exist_ok = True )
118 try: __.shutil.copy2( source, target )
119 except ( OSError, IOError ) as exception:
120 raise _exceptions.GlobalsPopulationFailure(
121 source, target
122 ) from exception
123 return True
126def _merge_settings_file(
127 source: __.Path, target: __.Path, simulate: bool
128) -> bool:
129 ''' Merges JSON settings file preserving user values.
131 Loads both source template and target user settings, performs deep
132 merge adding missing keys from template while preserving all user
133 values. Creates backup before writing merged result. Returns True
134 if file was updated (or would be updated in simulation mode).
135 '''
136 template = _load_json_file( source, target )
137 user_settings: dict[ str, __.typx.Any ] = (
138 _load_json_file( target, target ) if target.exists( )
139 else { } )
140 merged = _deep_merge_settings( user_settings, template )
141 if simulate:
142 return True
143 _write_merged_settings( target, merged )
144 return True
147def _load_json_file(
148 filepath: __.Path, target_context: __.Path
149) -> dict[ str, __.typx.Any ]:
150 ''' Loads JSON file with error handling.
152 Raises GlobalsPopulationFailure with source context on any error.
153 '''
154 try: content = filepath.read_text( encoding = 'utf-8' )
155 except ( OSError, IOError ) as exception:
156 raise _exceptions.GlobalsPopulationFailure(
157 filepath, target_context ) from exception
158 try:
159 loaded: __.typx.Any = _json.loads( content )
160 except ValueError as exception:
161 raise _exceptions.GlobalsPopulationFailure(
162 filepath, target_context ) from exception
163 if not _is_json_dict( loaded ):
164 raise _exceptions.GlobalsPopulationFailure( filepath, target_context )
165 return loaded
168def _write_merged_settings(
169 target: __.Path, merged: dict[ str, __.typx.Any ]
170) -> None:
171 ''' Writes merged settings with backup of existing file.
173 Creates target directory if needed. Backs up existing file before
174 writing merged result.
175 '''
176 target.parent.mkdir( parents = True, exist_ok = True )
177 if target.exists( ):
178 backup_path = target.with_suffix( '.json.backup' )
179 try: __.shutil.copy2( target, backup_path )
180 except ( OSError, IOError ) as exception:
181 raise _exceptions.GlobalsPopulationFailure(
182 target, target ) from exception
183 try:
184 target.write_text(
185 _json.dumps( merged, indent = 2 ), encoding = 'utf-8' )
186 except ( OSError, IOError ) as exception:
187 raise _exceptions.GlobalsPopulationFailure(
188 target, target
189 ) from exception
192def _deep_merge_settings(
193 target: dict[ str, __.typx.Any ], source: dict[ str, __.typx.Any ]
194) -> dict[ str, __.typx.Any ]:
195 ''' Recursively merges source into target preserving target values.
197 Implements additive merge: adds keys from source that are missing
198 in target. When both contain same key with dict values, recursively
199 merges nested dicts. For conflicting scalar values, target value
200 wins (user preferences preserved).
201 '''
202 result = target.copy( )
203 for key, source_value in source.items( ):
204 if key not in result:
205 result[ key ] = source_value
206 elif (
207 _is_json_dict( result[ key ] )
208 and _is_json_dict( source_value )
209 ):
210 result[ key ] = _deep_merge_settings(
211 result[ key ], source_value )
212 return result