Coverage for src/somesy/commands/sync.py: 97%

111 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-18 08:48 +0000

1"""Sync selected metadata files with given input file.""" 

2 

3import logging 

4import xml.etree.ElementTree as ET 

5from collections.abc import Mapping 

6from pathlib import Path 

7from typing import Any 

8 

9from rich.pretty import pretty_repr 

10 

11from somesy.cff.writer import CFF 

12from somesy.codemeta import CodeMeta 

13from somesy.codemeta.enrich import enrich as enrich_codemeta 

14from somesy.core.core import INPUT_FILES_ORDERED 

15from somesy.core.log import VERBOSE 

16from somesy.core.models import ProjectMetadata, SomesyConfig, SomesyInput 

17from somesy.core.writer import ProjectMetadataWriter 

18from somesy.fortran.writer import Fortran 

19from somesy.julia.writer import Julia 

20from somesy.mkdocs import MkDocs 

21from somesy.package_json.writer import PackageJSON 

22from somesy.pom_xml.writer import POM 

23from somesy.pom_xml.xmlproxy import XMLProxy 

24from somesy.pyproject.writer import Pyproject 

25from somesy.rust import Rust 

26 

27logger = logging.getLogger("somesy") 

28 

29 

30def _semantic_data(data: Any) -> Any: 

31 """Return format-neutral data without formatting trivia.""" 

32 if isinstance(data, XMLProxy): 

33 xml = ET.tostring(data._node, encoding="unicode") 

34 return ET.canonicalize(xml, strip_text=True) 

35 if unwrap := getattr(data, "unwrap", None): 

36 data = unwrap() 

37 if isinstance(data, Mapping): 

38 return {key: _semantic_data(value) for key, value in data.items()} 

39 if isinstance(data, (list, tuple)): 

40 return [_semantic_data(value) for value in data] 

41 return data 

42 

43 

44def _sync_file( 

45 metadata: ProjectMetadata, 

46 file: Path, 

47 writer_cls: type[ProjectMetadataWriter], 

48 merge_codemeta: bool | None = False, 

49 pass_validation: bool | None = False, 

50 codemeta_sources: dict[str, Path | list[Path] | None] | None = None, 

51 codemeta_root: Path | None = None, 

52 codemeta_project_root: Path | None = None, 

53): 

54 """Sync metadata to a file using the provided writer.""" 

55 logger.log(VERBOSE, f"Loading '{file.name}' ...") 

56 if writer_cls == CodeMeta: 

57 writer: ProjectMetadataWriter = writer_cls( 

58 file, merge=merge_codemeta, pass_validation=pass_validation 

59 ) 

60 else: 

61 writer = writer_cls(file, pass_validation=pass_validation) 

62 logger.log(VERBOSE, f"Syncing '{file.name}' ...") 

63 original_data = _semantic_data(writer._data) 

64 writer.sync(metadata) 

65 if writer_cls == CodeMeta and codemeta_sources is not None: 

66 root = codemeta_root or file.parent 

67 enrich_codemeta( 

68 writer._data, codemeta_sources, root, codemeta_project_root or root 

69 ) 

70 if _semantic_data(writer._data) != original_data: 

71 writer.save(file) 

72 logger.log(VERBOSE, f"Saved synced '{file.name}'.\n") 

73 

74 

75def _sync_files( 

76 metadata, files, writer_class, create_if_missing: bool = False, **kwargs 

77): 

78 """Sync metadata to files using the provided writer. 

79 

80 Args: 

81 metadata: Project metadata to sync 

82 files: Path or list of paths to sync 

83 writer_class: Writer class to use 

84 create_if_missing: Whether to create the file if it doesn't exist 

85 **kwargs: Additional arguments passed to the writer 

86 

87 """ 

88 if isinstance(files, Path): 

89 files = [files] 

90 for file in files: 

91 if file.is_file() or create_if_missing: 

92 _sync_file(metadata, file, writer_class, **kwargs) 

93 

94 

95def sync( 

96 somesy_input: SomesyInput, 

97 is_package: bool = False, 

98 project_root: Path | None = None, 

99): 

100 """Sync selected metadata files with given input file. 

101 

102 Args: 

103 somesy_input: The input configuration and metadata to sync 

104 is_package: Whether this is a package (subfolder) being synced 

105 project_root: Root of the overall project, differs from the base 

106 directory for packages of a multi-package repository. Files shared 

107 by all packages, such as a lock file, are looked up from here. 

108 

109 """ 

110 conf, metadata = somesy_input.config, somesy_input.project 

111 

112 # Get the base directory from the input file's location 

113 if somesy_input._origin is None: 

114 logger.warning( 

115 "No origin found for somesy input, using current working directory." 

116 ) 

117 base_dir = Path.cwd() 

118 else: 

119 base_dir = somesy_input._origin.parent 

120 

121 # Resolve all paths in the config relative to the base directory 

122 conf.resolve_paths(base_dir) 

123 

124 if is_package: 

125 logger.info("\n[bold green]Synchronizing package metadata...[/bold green]") 

126 else: 

127 logger.info("\n[bold green]Synchronizing root project metadata...[/bold green]") 

128 

129 pp_metadata = pretty_repr(metadata.model_dump(exclude_defaults=True)) 

130 logger.debug(f"Project metadata: {pp_metadata}") 

131 

132 # First sync the current project 

133 _sync_root_project(conf, metadata, base_dir, project_root or base_dir) 

134 

135 # Then sync each package if defined 

136 if conf.packages: 

137 packages = [conf.packages] if isinstance(conf.packages, Path) else conf.packages 

138 for package in packages: 

139 logger.info(f"\n[bold blue]Processing package {package}...[/bold blue]") 

140 

141 # Try all possible input files in order of priority 

142 config_files = [package / file for file in INPUT_FILES_ORDERED] 

143 package_input = None 

144 config_file: Path | None = None 

145 

146 for config_file in config_files: 

147 try: 

148 package_input = SomesyInput.from_input_file( 

149 config_file, allow_incomplete=bool(conf.pass_validation) 

150 ) 

151 logger.debug(f"Found config file: {config_file}") 

152 break 

153 except (FileNotFoundError, RuntimeError): 

154 continue 

155 

156 if package_input is None: 

157 logger.warning( 

158 f"No valid somesy config found in package {package} " 

159 f"(tried: {', '.join(str(f) for f in config_files)})" 

160 ) 

161 continue 

162 

163 if config_file is None: 

164 continue 

165 

166 # Create new config with CLI options and package's input file 

167 cli_options = { 

168 "no_sync_pyproject": conf.no_sync_pyproject, 

169 "no_sync_package_json": conf.no_sync_package_json, 

170 "no_sync_julia": conf.no_sync_julia, 

171 "no_sync_fortran": conf.no_sync_fortran, 

172 "no_sync_pom_xml": conf.no_sync_pom_xml, 

173 "no_sync_mkdocs": conf.no_sync_mkdocs, 

174 "no_sync_rust": conf.no_sync_rust, 

175 "no_sync_cff": conf.no_sync_cff, 

176 "no_sync_codemeta": conf.no_sync_codemeta, 

177 "merge_codemeta": conf.merge_codemeta, 

178 "pass_validation": conf.pass_validation, 

179 "packages": None, # Don't pass packages to avoid recursive package handling 

180 } 

181 package_input.config = SomesyConfig(input_file=config_file, **cli_options) 

182 

183 # Set default CFF and CodeMeta paths in package directory if not specified 

184 if not package_input.config.no_sync_cff: 

185 package_input.config.cff_file = Path("CITATION.cff") 

186 if not package_input.config.no_sync_codemeta: 

187 package_input.config.codemeta_file = Path("codemeta.json") 

188 

189 # Recursively call sync on the package 

190 sync( 

191 package_input, 

192 is_package=True, 

193 project_root=project_root or base_dir, 

194 ) 

195 

196 

197def _sync_root_project( 

198 conf: SomesyConfig, 

199 metadata: ProjectMetadata, 

200 base_dir: Path, 

201 project_root: Path, 

202) -> None: 

203 """Sync metadata files for the root project.""" 

204 # update these only if they exist: 

205 if conf.pyproject_file and not conf.no_sync_pyproject: 

206 _sync_files( 

207 metadata, 

208 conf.pyproject_file, 

209 Pyproject, 

210 pass_validation=conf.pass_validation, 

211 ) 

212 

213 if conf.package_json_file and not conf.no_sync_package_json: 

214 _sync_files( 

215 metadata, 

216 conf.package_json_file, 

217 PackageJSON, 

218 pass_validation=conf.pass_validation, 

219 ) 

220 

221 if conf.julia_file and not conf.no_sync_julia: 

222 _sync_files( 

223 metadata, 

224 conf.julia_file, 

225 Julia, 

226 pass_validation=conf.pass_validation, 

227 ) 

228 

229 if conf.fortran_file and not conf.no_sync_fortran: 

230 _sync_files( 

231 metadata, 

232 conf.fortran_file, 

233 Fortran, 

234 pass_validation=conf.pass_validation, 

235 ) 

236 

237 if conf.pom_xml_file and not conf.no_sync_pom_xml: 

238 _sync_files( 

239 metadata, 

240 conf.pom_xml_file, 

241 POM, 

242 pass_validation=conf.pass_validation, 

243 ) 

244 

245 if conf.mkdocs_file and not conf.no_sync_mkdocs: 

246 _sync_files( 

247 metadata, 

248 conf.mkdocs_file, 

249 MkDocs, 

250 pass_validation=conf.pass_validation, 

251 ) 

252 

253 if conf.rust_file and not conf.no_sync_rust: 

254 _sync_files( 

255 metadata, 

256 conf.rust_file, 

257 Rust, 

258 pass_validation=conf.pass_validation, 

259 ) 

260 

261 # create these by default if they are missing: 

262 if not conf.no_sync_cff: 

263 _sync_files( 

264 metadata, 

265 conf.cff_file, 

266 CFF, 

267 create_if_missing=True, 

268 pass_validation=conf.pass_validation, 

269 ) 

270 

271 if not conf.no_sync_codemeta: 

272 _sync_files( 

273 metadata, 

274 conf.codemeta_file, 

275 CodeMeta, 

276 create_if_missing=True, 

277 merge_codemeta=conf.merge_codemeta, 

278 pass_validation=conf.pass_validation, 

279 codemeta_root=base_dir, 

280 codemeta_project_root=project_root, 

281 codemeta_sources={ 

282 "pyproject": None if conf.no_sync_pyproject else conf.pyproject_file, 

283 "package_json": ( 

284 None if conf.no_sync_package_json else conf.package_json_file 

285 ), 

286 "julia": None if conf.no_sync_julia else conf.julia_file, 

287 "fortran": None if conf.no_sync_fortran else conf.fortran_file, 

288 "pom_xml": None if conf.no_sync_pom_xml else conf.pom_xml_file, 

289 "mkdocs": None if conf.no_sync_mkdocs else conf.mkdocs_file, 

290 "rust": None if conf.no_sync_rust else conf.rust_file, 

291 }, 

292 )