Coverage for src/somesy/commands/sync.py: 97%
109 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +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, NamedTuple
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")
30class SyncTarget(NamedTuple):
31 """A metadata file type that somesy can sync.
33 The config attributes are `no_sync_<key>` and `<key>_file`.
34 """
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
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]
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
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")
105def _sync_files(
106 metadata, files, writer_class, create_if_missing: bool = False, **kwargs
107):
108 """Sync metadata to files using the provided writer.
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
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)
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.
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.
139 """
140 conf, metadata = somesy_input.config, somesy_input.project
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
151 # Resolve all paths in the config relative to the base directory
152 conf.resolve_paths(base_dir)
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]")
159 pp_metadata = pretty_repr(metadata.model_dump(exclude_defaults=True))
160 logger.debug(f"Project metadata: {pp_metadata}")
162 # First sync the current project
163 _sync_root_project(conf, metadata, base_dir, project_root or base_dir)
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]")
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
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
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
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 )
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")
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 )
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 )