Coverage for sources/copiertv/configuration.py: 94%
89 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-14 02:11 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-14 02:11 +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''' Configuration loading and management. '''
24from . import __
25from . import exceptions as _exceptions
28_VCS_MARKERS = ( '.git', '.hg', '.svn' )
31class ValidationCommand( __.immut.DataclassObject ):
32 ''' A single validation command with args and working directory. '''
34 args: __.typx.Annotated[
35 tuple[ str, ... ],
36 __.typx.Doc( ''' Argument sequence for the command. ''' ),
37 ]
38 cwd: __.typx.Annotated[
39 __.Absential[ str ],
40 __.typx.Doc(
41 ''' Working directory, with placeholder support. ''' ),
42 ] = __.absent
45class Configuration( __.immut.DataclassObject ):
46 ''' Complete validation configuration. '''
48 answers_directory: __.typx.Annotated[
49 __.Absential[ __.Path ],
50 __.typx.Doc(
51 ''' Directory containing ``answers-*.yaml`` files. ''' ),
52 ] = __.absent
53 commands: __.typx.Annotated[
54 tuple[ ValidationCommand, ... ],
55 __.typx.Doc( ''' Validation commands to execute. ''' ),
56 ] = ( )
57 template_directory: __.typx.Annotated[
58 __.Absential[ __.Path ],
59 __.typx.Doc( ''' Template source directory. ''' ),
60 ] = __.absent
61 preserve: __.typx.Annotated[
62 __.Absential[ bool ],
63 __.typx.Doc( ''' Preserve temporary directories. ''' ),
64 ] = __.absent
65 variant_filter: __.typx.Annotated[
66 __.Absential[ tuple[ str, ... ] ],
67 __.typx.Doc( ''' Only validate these variants. ''' ),
68 ] = __.absent
69 vcs_ref: __.typx.Annotated[
70 __.Absential[ str ],
71 __.typx.Doc( ''' Git ref for copier copy. ''' ),
72 ] = __.absent
73 unsafe: __.typx.Annotated[
74 __.Absential[ bool ],
75 __.typx.Doc( ''' Allow unsafe Copier features. ''' ),
76 ] = __.absent
79def acquire_configuration(
80 appcore_configuration: __.cabc.Mapping[ str, __.typx.Any ],
81 cli_overrides: __.Absential[ Configuration ] = __.absent,
82) -> Configuration:
83 ''' Acquires config from appcore dict and project config.
85 Merges project configuration with appcore-provided user
86 configuration. CLI overrides take final precedence.
87 '''
88 user_config = _parse_configuration_data( appcore_configuration )
89 project_config = _acquire_project_configuration(
90 appcore_configuration )
91 file_config = merge_configurations( user_config, project_config )
92 if __.is_absent( cli_overrides ): return file_config
93 return merge_configurations( file_config, cli_overrides )
96def detect_project_root( ) -> __.Path:
97 ''' Detects project root by walking up for VCS markers. '''
98 path = __.Path.cwd( )
99 while True:
100 for marker in _VCS_MARKERS:
101 if ( path / marker ).exists( ): return path
102 parent = path.parent
103 if parent == path: break
104 path = parent
105 return __.Path.cwd( )
108def interpolate_command(
109 command: ValidationCommand,
110 template_directory: __.Path,
111 project_directory: __.Path,
112 temporary_directory: __.Path,
113 variant: str,
114) -> tuple[ tuple[ str, ... ], __.Path ]:
115 ''' Interpolates placeholders in command args and cwd. '''
116 placeholders = {
117 '{template_directory}': str( template_directory ),
118 '{project_directory}': str( project_directory ),
119 '{temporary_directory}': str( temporary_directory ),
120 '{variant}': variant,
121 }
122 args = tuple(
123 _interpolate_string( arg, placeholders )
124 for arg in command.args
125 )
126 if __.is_absent( command.cwd ): cwd = template_directory
127 else: cwd = __.Path(
128 _interpolate_string( command.cwd, placeholders ) )
129 return args, cwd
132def merge_configurations(
133 base: Configuration, override: Configuration
134) -> Configuration:
135 ''' Merges configurations, override taking precedence. '''
136 kwargs: dict[ str, __.typx.Any ] = { }
137 for field in __.dcls.fields( Configuration ):
138 if field.name.startswith( '_' ): continue
139 override_value = getattr( override, field.name )
140 base_value = getattr( base, field.name )
141 if not __.is_absent( override_value ):
142 kwargs[ field.name ] = override_value
143 else:
144 kwargs[ field.name ] = base_value
145 return Configuration( **kwargs )
148def parse_toml_configuration( path: __.Path ) -> Configuration:
149 ''' Parses configuration from a TOML file. '''
150 try: content = path.read_text( encoding = 'utf-8' )
151 except ( OSError, IOError ) as exception:
152 raise _exceptions.FileOperationFailure(
153 path, 'read configuration' ) from exception
154 try: data = __.tomllib.loads( content )
155 except ValueError as exception:
156 raise _exceptions.DataInvalidity(
157 path, 'Invalid TOML' ) from exception
158 return _parse_configuration_data( data, source = path )
161def _acquire_project_configuration(
162 appcore_configuration: __.cabc.Mapping[ str, __.typx.Any ],
163) -> Configuration:
164 ''' Acquires per-project configuration. '''
165 options_data = appcore_configuration.get( 'options', { } )
166 project_config_raw = options_data.get( 'project-configuration' )
167 if project_config_raw:
168 config_path = __.Path( project_config_raw )
169 else:
170 project_root = detect_project_root( )
171 config_path = (
172 project_root / '.auxiliary' / 'configuration'
173 / 'copiertv' / 'general.toml' )
174 if not config_path.is_file( ): return Configuration( )
175 return parse_toml_configuration( config_path )
178def _interpolate_string(
179 value: str, placeholders: dict[ str, str ]
180) -> str:
181 ''' Replaces placeholder substrings in a string. '''
182 for placeholder, replacement in placeholders.items():
183 value = value.replace( placeholder, replacement )
184 return value
187def _parse_configuration_data(
188 data: __.cabc.Mapping[ str, __.typx.Any ],
189 source: __.Absential[ __.Path ] = __.absent,
190) -> Configuration:
191 ''' Parses configuration mapping into dataclass. '''
192 answers_data = data.get( 'answers', { } )
193 answers_dir_raw = answers_data.get( 'directory' )
194 answers_directory = (
195 __.Path( answers_dir_raw )
196 if answers_dir_raw else __.absent )
197 commands_data = data.get( 'commands', ( ) )
198 commands: list[ ValidationCommand ] = [ ]
199 for cmd in commands_data:
200 try: args = tuple( cmd[ 'args' ] )
201 except KeyError as exception:
202 message = "missing 'args' in command"
203 if not __.is_absent( source ):
204 message = f"{message}: {source}"
205 raise _exceptions.DataInvalidity(
206 source if not __.is_absent( source )
207 else __.Path( '<configuration>' ),
208 message ) from exception
209 commands.append( ValidationCommand(
210 args = args,
211 cwd = cmd.get( 'cwd', __.absent ),
212 ) )
213 options_data = data.get( 'options', { } )
214 template_dir_raw = options_data.get( 'template-directory' )
215 template_directory = (
216 __.Path( template_dir_raw )
217 if template_dir_raw else __.absent )
218 variants_raw = options_data.get( 'variants' )
219 variant_filter = (
220 tuple( variants_raw ) if variants_raw else __.absent )
221 vcs_ref = options_data.get( 'vcs-ref', __.absent )
222 preserve_raw = options_data.get( 'preserve' )
223 unsafe_raw = options_data.get( 'unsafe' )
224 return Configuration(
225 answers_directory = answers_directory,
226 commands = tuple( commands ),
227 template_directory = template_directory,
228 preserve = preserve_raw if preserve_raw is not None else __.absent,
229 variant_filter = variant_filter,
230 vcs_ref = vcs_ref if vcs_ref else __.absent,
231 unsafe = unsafe_raw if unsafe_raw is not None else __.absent,
232 )