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

1"""Fill optional CodeMeta fields from configured project files and Git.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import re 

7from collections.abc import Iterable, Iterator 

8from pathlib import Path 

9from typing import Any 

10from urllib.parse import urlparse 

11 

12import tomlkit 

13from defusedxml import ElementTree 

14from packaging.requirements import Requirement 

15from tomlkit.exceptions import ParseError 

16 

17from somesy.git import harvest as harvest_git 

18from somesy.pyproject.writer import normalize_url_key 

19 

20_LOCK_FILES = ("uv.lock", "poetry.lock", "pdm.lock") 

21"""Lock files understood for exact dependency versions, in order of precedence. 

22 

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""" 

27 

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.""" 

33 

34 

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. 

42 

43 ``codemeta`` already contains data written from somesy.toml. This function 

44 therefore only adds optional fields and never replaces an existing value. 

45 

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``. 

54 

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) 

61 

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"]))) 

68 

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)) 

78 

79 

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] 

84 

85 

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) 

97 

98 

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")) 

111 

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) 

123 

124 

125def _poetry_version(spec: Any) -> str: 

126 """Return the version constraint of a dependency spec. 

127 

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", "") 

137 

138 

139def _lock_versions(start: Path, root: Path) -> dict[str, str]: 

140 """Return exact versions from the nearest lock file at or above ``start``. 

141 

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 {} 

151 

152 

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 

164 

165 

166def _parse_lock(path: Path) -> dict[str, str]: 

167 """Return the resolved version of every package named in a lock file. 

168 

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 {} 

188 

189 

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") 

200 

201 

202def _javascript_language(path: Path) -> str: 

203 """Return the language of the project described by a `package.json`. 

204 

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" 

211 

212 

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) 

235 

236 

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") 

261 

262 

263def _urls(urls: Any, values: dict[str, Any]) -> None: 

264 """Map free-form `[project.urls]` entries to CodeMeta fields. 

265 

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 

277 

278 

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 

286 

287 

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) 

304 

305 

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 ) 

323 

324 

325def _normalise(name: str) -> str: 

326 return re.sub(r"[-_.]+", "-", name).lower() 

327 

328 

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 

340 

341 

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 

345 

346 

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