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

109 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:13 +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, NamedTuple 

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 

30class SyncTarget(NamedTuple): 

31 """A metadata file type that somesy can sync. 

32 

33 The config attributes are `no_sync_<key>` and `<key>_file`. 

34 """ 

35 

36 key: str 

37 writer: type[Any] # a ProjectMetadataWriter subclass 

38 label: str # name shown in the CLI log 

39 create_if_missing: bool = False # create the file if it does not exist 

40 codemeta_source: bool = False # enriches codemeta.json under the same key 

41 log_gap: bool = False # CLI log: blank line after this entry 

42 

43 

44# in sync order 

45SYNC_TARGETS = [ 

46 SyncTarget("pyproject", Pyproject, "pyproject.toml", codemeta_source=True), 

47 SyncTarget("package_json", PackageJSON, "package.json", codemeta_source=True), 

48 SyncTarget("julia", Julia, "Project.toml", codemeta_source=True, log_gap=True), 

49 SyncTarget("fortran", Fortran, "fpm.toml(fortran)", codemeta_source=True), 

50 SyncTarget("pom_xml", POM, "pom.xml", codemeta_source=True, log_gap=True), 

51 SyncTarget("mkdocs", MkDocs, "mkdocs.yml"), 

52 SyncTarget("rust", Rust, "Cargo.toml", codemeta_source=True), 

53 SyncTarget("cff", CFF, "CITATION.cff", create_if_missing=True), 

54 SyncTarget( 

55 "codemeta", CodeMeta, "codemeta.json", create_if_missing=True, log_gap=True 

56 ), 

57] 

58 

59 

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

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

62 if isinstance(data, XMLProxy): 

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

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

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

66 data = unwrap() 

67 if isinstance(data, Mapping): 

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

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

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

71 return data 

72 

73 

74def _sync_file( 

75 metadata: ProjectMetadata, 

76 file: Path, 

77 writer_cls: type[ProjectMetadataWriter], 

78 merge_codemeta: bool | None = False, 

79 pass_validation: bool | None = False, 

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

81 codemeta_root: Path | None = None, 

82 codemeta_project_root: Path | None = None, 

83): 

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

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

86 if writer_cls == CodeMeta: 

87 writer: ProjectMetadataWriter = writer_cls( 

88 file, merge=merge_codemeta, pass_validation=pass_validation 

89 ) 

90 else: 

91 writer = writer_cls(file, pass_validation=pass_validation) 

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

93 original_data = _semantic_data(writer._data) 

94 writer.sync(metadata) 

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

96 root = codemeta_root or file.parent 

97 enrich_codemeta( 

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

99 ) 

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

101 writer.save(file) 

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

103 

104 

105def _sync_files( 

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

107): 

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

109 

110 Args: 

111 metadata: Project metadata to sync 

112 files: Path or list of paths to sync 

113 writer_class: Writer class to use 

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

115 **kwargs: Additional arguments passed to the writer 

116 

117 """ 

118 if isinstance(files, Path): 

119 files = [files] 

120 for file in files: 

121 if file.is_file() or create_if_missing: 

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

123 

124 

125def sync( 

126 somesy_input: SomesyInput, 

127 is_package: bool = False, 

128 project_root: Path | None = None, 

129): 

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

131 

132 Args: 

133 somesy_input: The input configuration and metadata to sync 

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

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

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

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

138 

139 """ 

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

141 

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

143 if somesy_input._origin is None: 

144 logger.warning( 

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

146 ) 

147 base_dir = Path.cwd() 

148 else: 

149 base_dir = somesy_input._origin.parent 

150 

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

152 conf.resolve_paths(base_dir) 

153 

154 if is_package: 

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

156 else: 

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

158 

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

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

161 

162 # First sync the current project 

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

164 

165 # Then sync each package if defined 

166 if conf.packages: 

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

168 for package in packages: 

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

170 

171 # Try all possible input files in order of priority 

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

173 package_input = None 

174 config_file: Path | None = None 

175 

176 for config_file in config_files: 

177 try: 

178 package_input = SomesyInput.from_input_file( 

179 config_file, allow_incomplete=bool(conf.pass_validation) 

180 ) 

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

182 break 

183 except (FileNotFoundError, RuntimeError): 

184 continue 

185 

186 if package_input is None: 

187 logger.warning( 

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

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

190 ) 

191 continue 

192 

193 # The package's own config is the base; only options explicitly 

194 # passed on the root CLI override it (nothing is inherited from the 

195 # root config file or defaults). 

196 cli_overrides = { 

197 key: value 

198 for key, value in conf._cli_overrides.items() 

199 if key.startswith("no_sync_") 

200 or key in ("merge_codemeta", "pass_validation") 

201 } 

202 package_input.config = SomesyConfig( 

203 **{ 

204 **package_input.config.model_dump(), 

205 **cli_overrides, 

206 "input_file": config_file, 

207 "packages": None, # avoid recursive package handling 

208 } 

209 ) 

210 

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

212 if not package_input.config.no_sync_cff: 

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

214 if not package_input.config.no_sync_codemeta: 

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

216 

217 # Recursively call sync on the package 

218 sync( 

219 package_input, 

220 is_package=True, 

221 project_root=project_root or base_dir, 

222 ) 

223 

224 

225def _sync_root_project( 

226 conf: SomesyConfig, 

227 metadata: ProjectMetadata, 

228 base_dir: Path, 

229 project_root: Path, 

230) -> None: 

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

232 for target in SYNC_TARGETS: 

233 if getattr(conf, f"no_sync_{target.key}"): 

234 continue 

235 files = getattr(conf, f"{target.key}_file") 

236 # targets that are not created by default are updated only if they exist 

237 if not (files or target.create_if_missing): 

238 continue 

239 kwargs: dict[str, Any] = {} 

240 if target.writer is CodeMeta: 

241 kwargs = { 

242 "merge_codemeta": conf.merge_codemeta, 

243 "codemeta_root": base_dir, 

244 "codemeta_project_root": project_root, 

245 "codemeta_sources": { 

246 t.key: ( 

247 None 

248 if getattr(conf, f"no_sync_{t.key}") 

249 else getattr(conf, f"{t.key}_file") 

250 ) 

251 for t in SYNC_TARGETS 

252 if t.codemeta_source 

253 }, 

254 } 

255 _sync_files( 

256 metadata, 

257 files, 

258 target.writer, 

259 create_if_missing=target.create_if_missing, 

260 pass_validation=conf.pass_validation, 

261 **kwargs, 

262 )