Coverage for src/somesy/codemeta/enrich.py: 96%
197 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"""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())
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 spec if isinstance(spec, str) else spec.get("version", "")
129def _lock_versions(start: Path, root: Path) -> dict[str, str]:
130 """Return exact versions from the nearest lock file at or above ``start``.
132 Workspace members (as used by uv) keep their lock file at the workspace
133 root, so directories are searched upwards, but never above ``root``.
134 """
135 for directory in _directories(start, root):
136 for name in _LOCK_FILES:
137 path = directory / name
138 if path.is_file():
139 return _parse_lock(path)
140 return {}
143def _directories(start: Path, root: Path) -> Iterator[Path]:
144 directory = start.resolve()
145 stop = root.resolve()
146 if stop != directory and stop not in directory.parents:
147 yield directory # the file lies outside the project, do not walk up
148 return
149 while True:
150 yield directory
151 if directory == stop or directory == directory.parent:
152 return
153 directory = directory.parent
156def _parse_lock(path: Path) -> dict[str, str]:
157 """Return the resolved version of every package named in a lock file.
159 Entries without a version are skipped, they carry no version to report.
160 """
161 try:
162 packages = tomlkit.parse(path.read_text()).get("package", [])
163 return {
164 _normalise(package["name"]): package["version"]
165 for package in packages
166 if package.get("name") and package.get("version")
167 }
168 except (AttributeError, KeyError, TypeError, ParseError):
169 return {}
172def _read_package_json(path: Path, values: dict[str, Any]) -> None:
173 data = json.loads(path.read_text())
174 values["languages"].append("JavaScript")
175 if node := data.get("engines", {}).get("node"):
176 values["runtimes"].append(f"Node.js {node}")
177 bugs = data.get("bugs")
178 _url(values, "issueTracker", bugs.get("url") if isinstance(bugs, dict) else bugs)
179 for section in ("dependencies", "peerDependencies", "optionalDependencies"):
180 for name, version in data.get(section, {}).items():
181 _named_requirement(values, name, version, "Node.js")
184def _read_toml_project(source_type: str, path: Path, values: dict[str, Any]) -> None:
185 data = tomlkit.parse(path.read_text())
186 if source_type == "rust":
187 package = data.get("package", {})
188 values["languages"].append("Rust")
189 if version := package.get("rust-version"):
190 values["runtimes"].append(f"Rust {version}")
191 dependencies = data.get("dependencies", {})
192 runtime = "Rust"
193 elif source_type == "julia":
194 values["languages"].append("Julia")
195 compat = data.get("compat", {})
196 if version := compat.get("julia"):
197 values["runtimes"].append(f"Julia {version}")
198 dependencies = {name: compat.get(name, "") for name in data.get("deps", {})}
199 runtime = "Julia"
200 else:
201 values["languages"].append("Fortran")
202 dependencies = data.get("dependencies", {})
203 runtime = "Fortran"
204 for name, version in dependencies.items():
205 _named_requirement(values, name, _poetry_version(version), runtime)
208def _read_pom(path: Path, values: dict[str, Any]) -> None:
209 root = ElementTree.parse(path).getroot()
210 if root is None:
211 return
212 namespace = root.tag[1:].partition("}")[0] if root.tag.startswith("{") else ""
213 prefix = f"{{{namespace}}}" if namespace else ""
214 values["languages"].append("Java")
215 properties = root.find(f"{prefix}properties")
216 if properties is not None:
217 for key in (
218 "maven.compiler.release",
219 "maven.compiler.target",
220 "maven.compiler.source",
221 ):
222 if version := properties.findtext(f"{prefix}{key}"):
223 values["runtimes"].append(f"Java {version}")
224 break
225 for dependency in root.findall(f".//{prefix}dependency"):
226 name = dependency.findtext(f"{prefix}artifactId")
227 if name:
228 version = dependency.findtext(f"{prefix}version") or ""
229 _named_requirement(values, name, version, "Java")
232def _urls(urls: Any, values: dict[str, Any]) -> None:
233 """Map free-form `[project.urls]` entries to CodeMeta fields.
235 PEP 621 does not standardize the key names, so common spellings such as
236 "Bug Tracker" or "Release Notes" are matched next to "Issues" and "Changelog".
237 """
238 if not isinstance(urls, dict):
239 return
240 for name, url in urls.items():
241 normalized = normalize_url_key(name)
242 for key, aliases in _URL_ALIASES.items():
243 if normalized in aliases:
244 _url(values, key, url)
245 break
248def _url(values: dict[str, Any], key: str, value: Any) -> None:
249 if (
250 key not in values
251 and isinstance(value, str)
252 and urlparse(value).scheme in {"http", "https"}
253 ):
254 values[key] = value
257def _requirement(
258 values: dict[str, Any],
259 value: str,
260 runtime: str,
261 locked: dict[str, str] | None = None,
262) -> None:
263 try:
264 requirement = Requirement(value)
265 except (TypeError, ValueError):
266 match = re.fullmatch(r"((?:@[^/]+/)?[A-Za-z0-9_.-]+)(.*)", value)
267 if match is None:
268 return
269 name, version = match.groups()
270 else:
271 name, version = requirement.name, str(requirement.specifier)
272 _named_requirement(values, name, version, runtime, locked)
275def _named_requirement(
276 values: dict[str, Any],
277 name: str,
278 version: Any,
279 runtime: str,
280 locked: dict[str, str] | None = None,
281) -> None:
282 version = (locked or {}).get(_normalise(name), str(version))
283 values["requirements"].append(
284 {
285 "@type": "SoftwareApplication",
286 "identifier": name,
287 "name": name,
288 "runtimePlatform": runtime,
289 **({"version": version} if version else {}),
290 }
291 )
294def _normalise(name: str) -> str:
295 return re.sub(r"[-_.]+", "-", name).lower()
298def _unique(values: Iterable[Any]) -> list[Any]:
299 seen: set[str] = set()
300 result = []
301 for value in values:
302 key = (
303 json.dumps(value, sort_keys=True) if isinstance(value, dict) else str(value)
304 )
305 if value and key not in seen:
306 seen.add(key)
307 result.append(value)
308 return result
311def _add(codemeta: dict[str, Any], key: str, value: Any) -> None:
312 if key not in codemeta and value not in (None, "", []):
313 codemeta[key] = value
316def _read_git(root: Path, values: dict[str, Any]) -> None:
317 metadata = harvest_git(root)
318 if metadata is None:
319 return
320 repository = metadata.repository
321 if repository:
322 if match := re.fullmatch(r"git@([^:]+):(.+)", repository):
323 repository = f"https://{match[1]}/{match[2]}"
324 values["codeRepository"] = repository.removesuffix(".git")
325 parsed = urlparse(values["codeRepository"])
326 if parsed.netloc in {"github.com", "gitlab.com"}:
327 _url(values, "issueTracker", values["codeRepository"] + "/issues")
328 values["dateCreated"] = (
329 metadata.date_created.isoformat() if metadata.date_created else None
330 )
331 values["dateModified"] = (
332 metadata.date_modified.isoformat() if metadata.date_modified else None
333 )
334 values["version"] = metadata.version