Coverage for src/somesy/codemeta/enrich.py: 96%
212 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"""Fill optional CodeMeta fields from configured project files and Git."""
3from __future__ import annotations
5import json
6import re
7from collections.abc import Iterable, Iterator
8from pathlib import Path
9from typing import Any
10from urllib.parse import urlparse
12import tomlkit
13from defusedxml import ElementTree
14from packaging.requirements import Requirement
15from tomlkit.exceptions import ParseError
17from somesy.git import harvest as harvest_git
18from somesy.pyproject.writer import normalize_url_key
20_LOCK_FILES = ("uv.lock", "poetry.lock", "pdm.lock")
21"""Lock files understood for exact dependency versions, in order of precedence.
23All of them list resolved packages as `[[package]]` tables carrying a `name`,
24so a single parser covers them. The `version` may be absent, for example uv
25omits it for the project itself when its version is dynamic.
26"""
28_URL_ALIASES = {
29 "issueTracker": {"issues", "issuetracker", "bugtracker", "bugreports", "bugs"},
30 "releaseNotes": {"changelog", "changes", "releasenotes", "history"},
31}
32"""Normalized `[project.urls]` key names mapped to the CodeMeta field they fill."""
35def enrich(
36 codemeta: dict[str, Any],
37 sources: dict[str, Path | list[Path] | None],
38 root: Path,
39 project_root: Path | None = None,
40) -> None:
41 """Fill CodeMeta fields absent after canonical Somesy synchronization.
43 ``codemeta`` already contains data written from somesy.toml. This function
44 therefore only adds optional fields and never replaces an existing value.
46 Args:
47 codemeta: The CodeMeta data to fill in place.
48 sources: Project files to read, by source type.
49 root: Directory this project is rooted at, used to harvest Git history.
50 project_root: Root of the overall project, differs from ``root`` for a
51 package of a multi-package repository. Files shared by all
52 packages, such as a lock file, are looked up from here. Defaults to
53 ``root``.
55 """
56 values: dict[str, Any] = {"requirements": [], "languages": [], "runtimes": []}
57 for source_type, paths in sources.items():
58 for path in _paths(paths):
59 if path.is_file():
60 _read_source(source_type, path, values, project_root or root)
62 _add(codemeta, "readme", values.get("readme"))
63 _add(codemeta, "issueTracker", values.get("issueTracker"))
64 _add(codemeta, "releaseNotes", values.get("releaseNotes"))
65 _add(codemeta, "softwareRequirements", _unique(values["requirements"]))
66 _add(codemeta, "programmingLanguage", ", ".join(_unique(values["languages"])))
67 _add(codemeta, "runtimePlatform", ", ".join(_unique(values["runtimes"])))
69 _read_git(root, values)
70 for key in (
71 "codeRepository",
72 "issueTracker",
73 "dateCreated",
74 "dateModified",
75 "version",
76 ):
77 _add(codemeta, key, values.get(key))
80def _paths(paths: Path | list[Path] | None) -> list[Path]:
81 if paths is None:
82 return []
83 return paths if isinstance(paths, list) else [paths]
86def _read_source(
87 source_type: str, path: Path, values: dict[str, Any], project_root: Path
88) -> None:
89 if source_type == "pyproject":
90 _read_pyproject(path, values, project_root)
91 elif source_type == "package_json":
92 _read_package_json(path, values)
93 elif source_type in {"julia", "fortran", "rust"}:
94 _read_toml_project(source_type, path, values)
95 elif source_type == "pom_xml":
96 _read_pom(path, values)
99def _read_pyproject(path: Path, values: dict[str, Any], project_root: Path) -> None:
100 data = tomlkit.parse(path.read_text(encoding="utf-8"))
101 project = data.get("project") or data.get("tool", {}).get("poetry", {})
102 if not project:
103 return
104 values["languages"].append("Python")
105 requires_python = project.get("requires-python")
106 if requires_python:
107 values["runtimes"].append(f"Python {requires_python}")
108 urls = project.get("urls", {})
109 _urls(urls, values)
110 _url(values, "readme", project.get("readme"))
112 locked = _lock_versions(path.parent, project_root)
113 dependencies = project.get("dependencies", [])
114 if isinstance(dependencies, dict): # Poetry v1
115 python = dependencies.pop("python", None)
116 if python:
117 values["runtimes"].append(f"Python {python}")
118 for name, spec in dependencies.items():
119 _named_requirement(values, name, _poetry_version(spec), "Python", locked)
120 return
121 for dependency in dependencies:
122 _requirement(values, dependency, "Python", locked)
125def _poetry_version(spec: Any) -> str:
126 """Return the version constraint of a dependency spec.
128 A list of specs (multiple constraints, e.g. per Python version) yields the
129 distinct constraints joined by ", ".
130 """
131 if isinstance(spec, str):
132 return spec
133 if isinstance(spec, list):
134 versions = (_poetry_version(item) for item in spec)
135 return ", ".join(dict.fromkeys(v for v in versions if v))
136 return spec.get("version", "")
139def _lock_versions(start: Path, root: Path) -> dict[str, str]:
140 """Return exact versions from the nearest lock file at or above ``start``.
142 Workspace members (as used by uv) keep their lock file at the workspace
143 root, so directories are searched upwards, but never above ``root``.
144 """
145 for directory in _directories(start, root):
146 for name in _LOCK_FILES:
147 path = directory / name
148 if path.is_file():
149 return _parse_lock(path)
150 return {}
153def _directories(start: Path, root: Path) -> Iterator[Path]:
154 directory = start.resolve()
155 stop = root.resolve()
156 if stop != directory and stop not in directory.parents:
157 yield directory # the file lies outside the project, do not walk up
158 return
159 while True:
160 yield directory
161 if directory == stop or directory == directory.parent:
162 return
163 directory = directory.parent
166def _parse_lock(path: Path) -> dict[str, str]:
167 """Return the resolved version of every package named in a lock file.
169 Entries without a version are skipped, they carry no version to report.
170 Packages locked at several distinct versions are omitted.
171 """
172 try:
173 packages = tomlkit.parse(path.read_text(encoding="utf-8")).get("package", [])
174 versions: dict[str, set[str]] = {}
175 for package in packages:
176 if package.get("name") and package.get("version"):
177 versions.setdefault(_normalise(package["name"]), set()).add(
178 package["version"]
179 )
180 # a package locked at several versions has no single exact version
181 return {
182 name: next(iter(found))
183 for name, found in versions.items()
184 if len(found) == 1
185 }
186 except (AttributeError, KeyError, TypeError, ParseError):
187 return {}
190def _read_package_json(path: Path, values: dict[str, Any]) -> None:
191 data = json.loads(path.read_text(encoding="utf-8"))
192 values["languages"].append(_javascript_language(path))
193 if node := data.get("engines", {}).get("node"):
194 values["runtimes"].append(f"Node.js {node}")
195 bugs = data.get("bugs")
196 _url(values, "issueTracker", bugs.get("url") if isinstance(bugs, dict) else bugs)
197 for section in ("dependencies", "peerDependencies", "optionalDependencies"):
198 for name, version in data.get(section, {}).items():
199 _named_requirement(values, name, version, "Node.js")
202def _javascript_language(path: Path) -> str:
203 """Return the language of the project described by a `package.json`.
205 A TypeScript configuration next to the file, such as `tsconfig.json` or a
206 variant like `tsconfig.build.json`, marks the project as TypeScript.
207 """
208 if any(config.is_file() for config in path.parent.glob("tsconfig*.json")):
209 return "TypeScript"
210 return "JavaScript"
213def _read_toml_project(source_type: str, path: Path, values: dict[str, Any]) -> None:
214 data = tomlkit.parse(path.read_text(encoding="utf-8"))
215 if source_type == "rust":
216 package = data.get("package", {})
217 values["languages"].append("Rust")
218 if version := package.get("rust-version"):
219 values["runtimes"].append(f"Rust {version}")
220 dependencies = data.get("dependencies", {})
221 runtime = "Rust"
222 elif source_type == "julia":
223 values["languages"].append("Julia")
224 compat = data.get("compat", {})
225 if version := compat.get("julia"):
226 values["runtimes"].append(f"Julia {version}")
227 dependencies = {name: compat.get(name, "") for name in data.get("deps", {})}
228 runtime = "Julia"
229 else:
230 values["languages"].append("Fortran")
231 dependencies = data.get("dependencies", {})
232 runtime = "Fortran"
233 for name, version in dependencies.items():
234 _named_requirement(values, name, _poetry_version(version), runtime)
237def _read_pom(path: Path, values: dict[str, Any]) -> None:
238 root = ElementTree.parse(path).getroot()
239 if root is None: # only for type checkers; parse() always yields a root
240 return
241 namespace = root.tag[1:].partition("}")[0] if root.tag.startswith("{") else ""
242 prefix = f"{{{namespace}}}" if namespace else ""
243 values["languages"].append("Java")
244 properties = root.find(f"{prefix}properties")
245 if properties is not None:
246 for key in (
247 "maven.compiler.release",
248 "maven.compiler.target",
249 "maven.compiler.source",
250 ):
251 if version := properties.findtext(f"{prefix}{key}"):
252 values["runtimes"].append(f"Java {version}")
253 break
254 for dependency in root.findall(f"{prefix}dependencies/{prefix}dependency"):
255 name = dependency.findtext(f"{prefix}artifactId")
256 if name:
257 version = dependency.findtext(f"{prefix}version") or ""
258 if "${" in version:
259 version = ""
260 _named_requirement(values, name, version, "Java")
263def _urls(urls: Any, values: dict[str, Any]) -> None:
264 """Map free-form `[project.urls]` entries to CodeMeta fields.
266 PEP 621 does not standardize the key names, so common spellings such as
267 "Bug Tracker" or "Release Notes" are matched next to "Issues" and "Changelog".
268 """
269 if not isinstance(urls, dict):
270 return
271 for name, url in urls.items():
272 normalized = normalize_url_key(name)
273 for key, aliases in _URL_ALIASES.items():
274 if normalized in aliases:
275 _url(values, key, url)
276 break
279def _url(values: dict[str, Any], key: str, value: Any) -> None:
280 if (
281 key not in values
282 and isinstance(value, str)
283 and urlparse(value).scheme in {"http", "https"}
284 ):
285 values[key] = value
288def _requirement(
289 values: dict[str, Any],
290 value: str,
291 runtime: str,
292 locked: dict[str, str] | None = None,
293) -> None:
294 try:
295 requirement = Requirement(value)
296 except (TypeError, ValueError):
297 match = re.fullmatch(r"((?:@[^/]+/)?[A-Za-z0-9_.-]+)(.*)", value)
298 if match is None:
299 return
300 name, version = match.groups()
301 else:
302 name, version = requirement.name, str(requirement.specifier)
303 _named_requirement(values, name, version, runtime, locked)
306def _named_requirement(
307 values: dict[str, Any],
308 name: str,
309 version: Any,
310 runtime: str,
311 locked: dict[str, str] | None = None,
312) -> None:
313 version = (locked or {}).get(_normalise(name), str(version))
314 values["requirements"].append(
315 {
316 "@type": "SoftwareApplication",
317 "identifier": name,
318 "name": name,
319 "runtimePlatform": runtime,
320 **({"version": version} if version else {}),
321 }
322 )
325def _normalise(name: str) -> str:
326 return re.sub(r"[-_.]+", "-", name).lower()
329def _unique(values: Iterable[Any]) -> list[Any]:
330 seen: set[str] = set()
331 result = []
332 for value in values:
333 key = (
334 json.dumps(value, sort_keys=True) if isinstance(value, dict) else str(value)
335 )
336 if value and key not in seen:
337 seen.add(key)
338 result.append(value)
339 return result
342def _add(codemeta: dict[str, Any], key: str, value: Any) -> None:
343 if key not in codemeta and value not in (None, "", []):
344 codemeta[key] = value
347def _read_git(root: Path, values: dict[str, Any]) -> None:
348 metadata = harvest_git(root)
349 if metadata is None:
350 return
351 repository = metadata.repository
352 if repository:
353 if match := re.fullmatch(r"git@([^:]+):(.+)", repository):
354 repository = f"https://{match[1]}/{match[2]}"
355 values["codeRepository"] = repository.removesuffix(".git")
356 parsed = urlparse(values["codeRepository"])
357 if parsed.netloc in {"github.com", "gitlab.com"}:
358 _url(values, "issueTracker", values["codeRepository"] + "/issues")
359 values["dateCreated"] = (
360 metadata.date_created.isoformat() if metadata.date_created else None
361 )
362 values["dateModified"] = (
363 metadata.date_modified.isoformat() if metadata.date_modified else None
364 )
365 values["version"] = metadata.version