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

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

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 spec if isinstance(spec, str) else spec.get("version", "") 

127 

128 

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

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

131 

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

141 

142 

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 

154 

155 

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

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

158 

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

170 

171 

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

182 

183 

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) 

206 

207 

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

230 

231 

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

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

234 

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 

246 

247 

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 

255 

256 

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) 

273 

274 

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 ) 

292 

293 

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

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

296 

297 

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 

309 

310 

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 

314 

315 

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