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
« 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."""
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):
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")
71def _sync_files(
72 metadata, files, writer_class, create_if_missing: bool = False, **kwargs
73):
74 """Sync metadata to files using the provided writer.
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
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)
91def sync(somesy_input: SomesyInput, is_package: bool = False):
92 """Sync selected metadata files with given input file.
94 Args:
95 somesy_input: The input configuration and metadata to sync
96 is_package: Whether this is a package (subfolder) being synced
98 """
99 conf, metadata = somesy_input.config, somesy_input.project
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
110 # Resolve all paths in the config relative to the base directory
111 conf.resolve_paths(base_dir)
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]")
118 pp_metadata = pretty_repr(metadata.model_dump(exclude_defaults=True))
119 logger.debug(f"Project metadata: {pp_metadata}")
121 # First sync the current project
122 _sync_root_project(conf, metadata, base_dir)
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]")
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
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
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
152 if config_file is None:
153 continue
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)
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")
178 # Recursively call sync on the package
179 sync(package_input, is_package=True)
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )