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
« 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."""
3import logging
4import xml.etree.ElementTree as ET
5from collections.abc import Mapping
6from pathlib import Path
7from typing import Any
9from rich.pretty import pretty_repr
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
27logger = logging.getLogger("somesy")
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
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")
75def _sync_files(
76 metadata, files, writer_class, create_if_missing: bool = False, **kwargs
77):
78 """Sync metadata to files using the provided writer.
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
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)
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.
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.
109 """
110 conf, metadata = somesy_input.config, somesy_input.project
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
121 # Resolve all paths in the config relative to the base directory
122 conf.resolve_paths(base_dir)
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]")
129 pp_metadata = pretty_repr(metadata.model_dump(exclude_defaults=True))
130 logger.debug(f"Project metadata: {pp_metadata}")
132 # First sync the current project
133 _sync_root_project(conf, metadata, base_dir, project_root or base_dir)
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]")
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
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
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
163 if config_file is None:
164 continue
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)
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")
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )