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

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

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#============================================================================# 

19 

20 

21''' Configuration loading and management. ''' 

22 

23 

24from . import __ 

25from . import exceptions as _exceptions 

26 

27 

28_VCS_MARKERS = ( '.git', '.hg', '.svn' ) 

29 

30 

31class ValidationCommand( __.immut.DataclassObject ): 

32 ''' A single validation command with args and working directory. ''' 

33 

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 

43 

44 

45class Configuration( __.immut.DataclassObject ): 

46 ''' Complete validation configuration. ''' 

47 

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 

77 

78 

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. 

84 

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 ) 

94 

95 

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( ) 

106 

107 

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 

130 

131 

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 ) 

146 

147 

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 ) 

159 

160 

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 ) 

176 

177 

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 

185 

186 

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 )