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

110 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 11:27 +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): 

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

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

55 if writer_cls == CodeMeta: 

56 writer: ProjectMetadataWriter = writer_cls( 

57 file, merge=merge_codemeta, pass_validation=pass_validation 

58 ) 

59 else: 

60 writer = writer_cls(file, pass_validation=pass_validation) 

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

62 original_data = _semantic_data(writer._data) 

63 writer.sync(metadata) 

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

65 enrich_codemeta(writer._data, codemeta_sources, codemeta_root or file.parent) 

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

67 writer.save(file) 

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

69 

70 

71def _sync_files( 

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

73): 

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

75 

76 Args: 

77 metadata: Project metadata to sync 

78 files: Path or list of paths to sync 

79 writer_class: Writer class to use 

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

81 **kwargs: Additional arguments passed to the writer 

82 

83 """ 

84 if isinstance(files, Path): 

85 files = [files] 

86 for file in files: 

87 if file.is_file() or create_if_missing: 

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

89 

90 

91def sync(somesy_input: SomesyInput, is_package: bool = False): 

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

93 

94 Args: 

95 somesy_input: The input configuration and metadata to sync 

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

97 

98 """ 

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

100 

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

102 if somesy_input._origin is None: 

103 logger.warning( 

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

105 ) 

106 base_dir = Path.cwd() 

107 else: 

108 base_dir = somesy_input._origin.parent 

109 

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

111 conf.resolve_paths(base_dir) 

112 

113 if is_package: 

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

115 else: 

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

117 

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

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

120 

121 # First sync the current project 

122 _sync_root_project(conf, metadata, base_dir) 

123 

124 # Then sync each package if defined 

125 if conf.packages: 

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

127 for package in packages: 

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

129 

130 # Try all possible input files in order of priority 

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

132 package_input = None 

133 config_file: Path | None = None 

134 

135 for config_file in config_files: 

136 try: 

137 package_input = SomesyInput.from_input_file( 

138 config_file, allow_incomplete=bool(conf.pass_validation) 

139 ) 

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

141 break 

142 except (FileNotFoundError, RuntimeError): 

143 continue 

144 

145 if package_input is None: 

146 logger.warning( 

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

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

149 ) 

150 continue 

151 

152 if config_file is None: 

153 continue 

154 

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

156 cli_options = { 

157 "no_sync_pyproject": conf.no_sync_pyproject, 

158 "no_sync_package_json": conf.no_sync_package_json, 

159 "no_sync_julia": conf.no_sync_julia, 

160 "no_sync_fortran": conf.no_sync_fortran, 

161 "no_sync_pom_xml": conf.no_sync_pom_xml, 

162 "no_sync_mkdocs": conf.no_sync_mkdocs, 

163 "no_sync_rust": conf.no_sync_rust, 

164 "no_sync_cff": conf.no_sync_cff, 

165 "no_sync_codemeta": conf.no_sync_codemeta, 

166 "merge_codemeta": conf.merge_codemeta, 

167 "pass_validation": conf.pass_validation, 

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

169 } 

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

171 

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

173 if not package_input.config.no_sync_cff: 

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

175 if not package_input.config.no_sync_codemeta: 

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

177 

178 # Recursively call sync on the package 

179 sync(package_input, is_package=True) 

180 

181 

182def _sync_root_project( 

183 conf: SomesyConfig, metadata: ProjectMetadata, base_dir: Path 

184) -> None: 

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

186 # update these only if they exist: 

187 if conf.pyproject_file and not conf.no_sync_pyproject: 

188 _sync_files( 

189 metadata, 

190 conf.pyproject_file, 

191 Pyproject, 

192 pass_validation=conf.pass_validation, 

193 ) 

194 

195 if conf.package_json_file and not conf.no_sync_package_json: 

196 _sync_files( 

197 metadata, 

198 conf.package_json_file, 

199 PackageJSON, 

200 pass_validation=conf.pass_validation, 

201 ) 

202 

203 if conf.julia_file and not conf.no_sync_julia: 

204 _sync_files( 

205 metadata, 

206 conf.julia_file, 

207 Julia, 

208 pass_validation=conf.pass_validation, 

209 ) 

210 

211 if conf.fortran_file and not conf.no_sync_fortran: 

212 _sync_files( 

213 metadata, 

214 conf.fortran_file, 

215 Fortran, 

216 pass_validation=conf.pass_validation, 

217 ) 

218 

219 if conf.pom_xml_file and not conf.no_sync_pom_xml: 

220 _sync_files( 

221 metadata, 

222 conf.pom_xml_file, 

223 POM, 

224 pass_validation=conf.pass_validation, 

225 ) 

226 

227 if conf.mkdocs_file and not conf.no_sync_mkdocs: 

228 _sync_files( 

229 metadata, 

230 conf.mkdocs_file, 

231 MkDocs, 

232 pass_validation=conf.pass_validation, 

233 ) 

234 

235 if conf.rust_file and not conf.no_sync_rust: 

236 _sync_files( 

237 metadata, 

238 conf.rust_file, 

239 Rust, 

240 pass_validation=conf.pass_validation, 

241 ) 

242 

243 # create these by default if they are missing: 

244 if not conf.no_sync_cff: 

245 _sync_files( 

246 metadata, 

247 conf.cff_file, 

248 CFF, 

249 create_if_missing=True, 

250 pass_validation=conf.pass_validation, 

251 ) 

252 

253 if not conf.no_sync_codemeta: 

254 _sync_files( 

255 metadata, 

256 conf.codemeta_file, 

257 CodeMeta, 

258 create_if_missing=True, 

259 merge_codemeta=conf.merge_codemeta, 

260 pass_validation=conf.pass_validation, 

261 codemeta_root=base_dir, 

262 codemeta_sources={ 

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

264 "package_json": ( 

265 None if conf.no_sync_package_json else conf.package_json_file 

266 ), 

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

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

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

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

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

272 }, 

273 )