Coverage for src/somesy/codemeta/enrich.py: 94%
177 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"""Fill optional CodeMeta fields from configured project files and Git."""
3from __future__ import annotations
5import json
6import re
7from collections.abc import Iterable
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
20def enrich(
21 codemeta: dict[str, Any],
22 sources: dict[str, Path | list[Path] | None],
23 root: Path,
24) -> None:
25 """Fill CodeMeta fields absent after canonical Somesy synchronization.
27 ``codemeta`` already contains data written from somesy.toml. This function
28 therefore only adds optional fields and never replaces an existing value.
29 """
30 values: dict[str, Any] = {"requirements": [], "languages": [], "runtimes": []}
31 for source_type, paths in sources.items():
32 for path in _paths(paths):
33 if path.is_file():
34 _read_source(source_type, path, values)
36 _add(codemeta, "readme", values.get("readme"))
37 _add(codemeta, "issueTracker", values.get("issueTracker"))
38 _add(codemeta, "releaseNotes", values.get("releaseNotes"))
39 _add(codemeta, "softwareRequirements", _unique(values["requirements"]))
40 _add(codemeta, "programmingLanguage", ", ".join(_unique(values["languages"])))
41 _add(codemeta, "runtimePlatform", ", ".join(_unique(values["runtimes"])))
43 _read_git(root, values)
44 for key in (
45 "codeRepository",
46 "issueTracker",
47 "dateCreated",
48 "dateModified",
49 "version",
50 ):
51 _add(codemeta, key, values.get(key))
54def _paths(paths: Path | list[Path] | None) -> list[Path]:
55 if paths is None:
56 return []
57 return paths if isinstance(paths, list) else [paths]
60def _read_source(source_type: str, path: Path, values: dict[str, Any]) -> None:
61 if source_type == "pyproject":
62 _read_pyproject(path, values)
63 elif source_type == "package_json":
64 _read_package_json(path, values)
65 elif source_type in {"julia", "fortran", "rust"}:
66 _read_toml_project(source_type, path, values)
67 elif source_type == "pom_xml":
68 _read_pom(path, values)
71def _read_pyproject(path: Path, values: dict[str, Any]) -> None:
72 data = tomlkit.parse(path.read_text())
73 project = data.get("project") or data.get("tool", {}).get("poetry", {})
74 if not project:
75 return
76 values["languages"].append("Python")
77 requires_python = project.get("requires-python")
78 if requires_python:
79 values["runtimes"].append(f"Python {requires_python}")
80 urls = project.get("urls", {})
81 _urls(urls, values)
82 _url(values, "readme", project.get("readme"))
84 dependencies = project.get("dependencies", [])
85 if isinstance(dependencies, dict): # Poetry v1
86 python = dependencies.pop("python", None)
87 if python:
88 values["runtimes"].append(f"Python {python}")
89 locked = _poetry_lock_versions(path.with_name("poetry.lock"))
90 for name, spec in dependencies.items():
91 _named_requirement(values, name, _poetry_version(spec), "Python", locked)
92 return
93 locked = _poetry_lock_versions(path.with_name("poetry.lock"))
94 for dependency in dependencies:
95 _requirement(values, dependency, "Python", locked)
98def _poetry_version(spec: Any) -> str:
99 return spec if isinstance(spec, str) else spec.get("version", "")
102def _poetry_lock_versions(path: Path) -> dict[str, str]:
103 if not path.is_file():
104 return {}
105 try:
106 packages = tomlkit.parse(path.read_text()).get("package", [])
107 return {_normalise(package["name"]): package["version"] for package in packages}
108 except (KeyError, TypeError, ParseError):
109 return {}
112def _read_package_json(path: Path, values: dict[str, Any]) -> None:
113 data = json.loads(path.read_text())
114 values["languages"].append("JavaScript")
115 if node := data.get("engines", {}).get("node"):
116 values["runtimes"].append(f"Node.js {node}")
117 bugs = data.get("bugs")
118 _url(values, "issueTracker", bugs.get("url") if isinstance(bugs, dict) else bugs)
119 for section in ("dependencies", "peerDependencies", "optionalDependencies"):
120 for name, version in data.get(section, {}).items():
121 _named_requirement(values, name, version, "Node.js")
124def _read_toml_project(source_type: str, path: Path, values: dict[str, Any]) -> None:
125 data = tomlkit.parse(path.read_text())
126 if source_type == "rust":
127 package = data.get("package", {})
128 values["languages"].append("Rust")
129 if version := package.get("rust-version"):
130 values["runtimes"].append(f"Rust {version}")
131 dependencies = data.get("dependencies", {})
132 runtime = "Rust"
133 elif source_type == "julia":
134 values["languages"].append("Julia")
135 compat = data.get("compat", {})
136 if version := compat.get("julia"):
137 values["runtimes"].append(f"Julia {version}")
138 dependencies = {name: compat.get(name, "") for name in data.get("deps", {})}
139 runtime = "Julia"
140 else:
141 values["languages"].append("Fortran")
142 dependencies = data.get("dependencies", {})
143 runtime = "Fortran"
144 for name, version in dependencies.items():
145 _named_requirement(values, name, _poetry_version(version), runtime)
148def _read_pom(path: Path, values: dict[str, Any]) -> None:
149 root = ElementTree.parse(path).getroot()
150 if root is None:
151 return
152 namespace = root.tag[1:].partition("}")[0] if root.tag.startswith("{") else ""
153 prefix = f"{{{namespace}}}" if namespace else ""
154 values["languages"].append("Java")
155 properties = root.find(f"{prefix}properties")
156 if properties is not None:
157 for key in (
158 "maven.compiler.release",
159 "maven.compiler.target",
160 "maven.compiler.source",
161 ):
162 if version := properties.findtext(f"{prefix}{key}"):
163 values["runtimes"].append(f"Java {version}")
164 break
165 for dependency in root.findall(f".//{prefix}dependency"):
166 name = dependency.findtext(f"{prefix}artifactId")
167 if name:
168 version = dependency.findtext(f"{prefix}version") or ""
169 _named_requirement(values, name, version, "Java")
172def _urls(urls: Any, values: dict[str, Any]) -> None:
173 if not isinstance(urls, dict):
174 return
175 for name, url in urls.items():
176 lowered = name.lower()
177 if lowered == "issues":
178 _url(values, "issueTracker", url)
179 elif lowered == "changelog":
180 _url(values, "releaseNotes", url)
183def _url(values: dict[str, Any], key: str, value: Any) -> None:
184 if (
185 key not in values
186 and isinstance(value, str)
187 and urlparse(value).scheme in {"http", "https"}
188 ):
189 values[key] = value
192def _requirement(
193 values: dict[str, Any],
194 value: str,
195 runtime: str,
196 locked: dict[str, str] | None = None,
197) -> None:
198 try:
199 requirement = Requirement(value)
200 except (TypeError, ValueError):
201 match = re.fullmatch(r"((?:@[^/]+/)?[A-Za-z0-9_.-]+)(.*)", value)
202 if match is None:
203 return
204 name, version = match.groups()
205 else:
206 name, version = requirement.name, str(requirement.specifier)
207 _named_requirement(values, name, version, runtime, locked)
210def _named_requirement(
211 values: dict[str, Any],
212 name: str,
213 version: Any,
214 runtime: str,
215 locked: dict[str, str] | None = None,
216) -> None:
217 version = (locked or {}).get(_normalise(name), str(version))
218 values["requirements"].append(
219 {
220 "@type": "SoftwareApplication",
221 "identifier": name,
222 "name": name,
223 "runtimePlatform": runtime,
224 **({"version": version} if version else {}),
225 }
226 )
229def _normalise(name: str) -> str:
230 return re.sub(r"[-_.]+", "-", name).lower()
233def _unique(values: Iterable[Any]) -> list[Any]:
234 seen: set[str] = set()
235 result = []
236 for value in values:
237 key = (
238 json.dumps(value, sort_keys=True) if isinstance(value, dict) else str(value)
239 )
240 if value and key not in seen:
241 seen.add(key)
242 result.append(value)
243 return result
246def _add(codemeta: dict[str, Any], key: str, value: Any) -> None:
247 if key not in codemeta and value not in (None, "", []):
248 codemeta[key] = value
251def _read_git(root: Path, values: dict[str, Any]) -> None:
252 metadata = harvest_git(root)
253 if metadata is None:
254 return
255 repository = metadata.repository
256 if repository:
257 if match := re.fullmatch(r"git@([^:]+):(.+)", repository):
258 repository = f"https://{match[1]}/{match[2]}"
259 values["codeRepository"] = repository.removesuffix(".git")
260 parsed = urlparse(values["codeRepository"])
261 if parsed.netloc in {"github.com", "gitlab.com"}:
262 _url(values, "issueTracker", values["codeRepository"] + "/issues")
263 values["dateCreated"] = (
264 metadata.date_created.isoformat() if metadata.date_created else None
265 )
266 values["dateModified"] = (
267 metadata.date_modified.isoformat() if metadata.date_modified else None
268 )
269 values["version"] = metadata.version