citesync 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
citesync/formats.py ADDED
@@ -0,0 +1,359 @@
1
+ """Read each of the six files into one record keyed by CodeMeta terms.
2
+
3
+ The mapping says where a term sits. The adapters here only reshape what a path
4
+ can't express (Cargo workspace inheritance, npm strings that may be objects,
5
+ PEP 621 ``dynamic``), and the codecs in :mod:`citesync.codecs` read the spelling.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ import tomllib
11
+ from dataclasses import dataclass
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import Any
14
+
15
+ from ruamel.yaml import YAML
16
+ from ruamel.yaml.error import MarkedYAMLError, YAMLError
17
+
18
+ from citesync import codecs
19
+ from citesync.errors import CheckError, UnreadableError
20
+ from citesync.mapping import PERSON_TERMS, TERMS, Mapping, relative, resolve
21
+
22
+ CITATION_FILES = {"CITATION.cff": "cff-1.2.0", "codemeta.json": "codemeta", ".zenodo.json": "zenodo"}
23
+ MANIFESTS = {"pyproject.toml": "pep621", "Cargo.toml": "cargo", "package.json": "npm"}
24
+ FORMAT_OF = CITATION_FILES | MANIFESTS
25
+ CODEMETA_VERSIONS = ("2.0", "3.0", "3.1", "4.0")
26
+ CFF_VERSIONS = ("1.2.0",)
27
+
28
+ _CODEMETA_CONTEXT = re.compile(r"^https?://w3id\.org/codemeta/v?(\d+\.\d+)/?$")
29
+ _NPM_BARE_REPO = re.compile(r"^[\w.-]+/[\w.-]+$")
30
+ _URL_LABELS = {"source": "Source", "sourcecode": "Source", "repository": "Repository", "homepage": "Homepage"}
31
+ _TOML_AT = re.compile(r" \(at (?:line (\d+), column (\d+)|end of document)\)$")
32
+ _CODEMETA_2_DOI = "https://doi.org/10.5063/schema/codemeta-2.0"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Person:
37
+ """One author. ``name`` is ``"Given Family"``; a format with one name string has no parts."""
38
+
39
+ name: str | None
40
+ given: str | None = None
41
+ family: str | None = None
42
+ orcid: str | None = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Doc:
47
+ """One file as citesync sees it.
48
+
49
+ Attributes:
50
+ path: Relative to the repo root, with ``/``.
51
+ format: The mapping's format id.
52
+ record: Term to normalised value; a term with no value is left out.
53
+ slots: Terms and person sub-terms the format has a place for (a non-null mapping path).
54
+ version: The detected format version (CFF and codemeta only).
55
+ release: False for a manifest that isn't released: a pyproject.toml with no
56
+ ``[build-system]``, a private package.json.
57
+ readable: False for a file citesync couldn't read, whose record is empty.
58
+ dangling_roles: For codemeta.json, (list, ORCID) for each Role whose person isn't listed.
59
+ """
60
+
61
+ path: str
62
+ format: str
63
+ record: dict[str, Any]
64
+ slots: frozenset[str]
65
+ version: str | None
66
+ release: bool
67
+ readable: bool = True
68
+ dangling_roles: tuple[tuple[str, str], ...] = ()
69
+
70
+ @property
71
+ def is_citation(self) -> bool:
72
+ """CITATION.cff, codemeta.json or .zenodo.json."""
73
+ return self.format in CITATION_FILES.values()
74
+
75
+
76
+ def format_of(path: str) -> str:
77
+ """The format id of a file, from its name.
78
+
79
+ Raises:
80
+ CheckError: when the name is none of the six files.
81
+ """
82
+ fmt = FORMAT_OF.get(PurePosixPath(path).name)
83
+ if fmt is None:
84
+ msg = f"{path}: not one of {', '.join(FORMAT_OF)}"
85
+ raise CheckError(msg)
86
+ return fmt
87
+
88
+
89
+ def read_doc(root: Path, rel: str, mapping: Mapping) -> Doc:
90
+ """Read ``rel`` under ``root`` into its record.
91
+
92
+ Raises:
93
+ UnreadableError: when the file can't be read or parsed, has a format version
94
+ citesync doesn't read, or has a key holding a shape its format doesn't allow.
95
+ """
96
+ fmt = format_of(rel)
97
+ data = load(root / rel, rel)
98
+ version = _detect_version(fmt, data, rel)
99
+ release = _is_release(fmt, data)
100
+ record, roles = {}, ()
101
+ try:
102
+ data = _adapt(fmt, data, root, rel)
103
+ if fmt == "codemeta":
104
+ roles = dangling_roles(data)
105
+ for term in TERMS:
106
+ path = mapping[fmt, term]
107
+ if path is None:
108
+ continue
109
+ value = _read_term(fmt, term, data, path, mapping)
110
+ if value is not None:
111
+ record[term] = value
112
+ except (AttributeError, TypeError) as err: # a key holding a shape its format doesn't allow
113
+ raise UnreadableError(rel, f"unexpected shape ({err})") from err
114
+ return Doc(rel, fmt, record, slots(fmt, mapping), version, release, dangling_roles=roles)
115
+
116
+
117
+ def dangling_roles(data: dict[str, Any]) -> tuple[tuple[str, str], ...]:
118
+ """Each CodeMeta Role in ``author`` or ``contributor`` whose ``schema:author`` ORCID no listed Person has.
119
+
120
+ A Role names its person by ORCID (credit-matrix writes CRediT roles this way); a Role
121
+ with no ORCID there is skipped. The Role itself is never read.
122
+ """
123
+ entries = [
124
+ (key, entry)
125
+ for key in ("author", "contributor")
126
+ for entry in (data[key] if isinstance(data.get(key), list) else [data.get(key)])
127
+ if isinstance(entry, dict)
128
+ ]
129
+ people = {codecs.orcid_decode(e.get("@id")) for _, e in entries if e.get("@type") != "Role"}
130
+ found = []
131
+ for key, entry in entries:
132
+ target = entry.get("schema:author")
133
+ orcid = codecs.orcid_decode(target.get("@id") if isinstance(target, dict) else target)
134
+ if entry.get("@type") == "Role" and orcid and orcid not in people:
135
+ found.append((key, orcid))
136
+ return tuple(found)
137
+
138
+
139
+ def slots(fmt: str, mapping: Mapping) -> frozenset[str]:
140
+ """The terms and person sub-terms ``fmt`` has a place for."""
141
+ return frozenset(term for term in TERMS + PERSON_TERMS if mapping[fmt, term] is not None)
142
+
143
+
144
+ def load(path: Path, rel: str) -> dict[str, Any]:
145
+ """Parse the TOML, JSON or YAML file at ``path``, named ``rel`` in errors.
146
+
147
+ Raises:
148
+ UnreadableError: when it can't be read or parsed, or its top level isn't a mapping.
149
+ """
150
+ try:
151
+ text = path.read_text(encoding="utf-8")
152
+ except OSError as err:
153
+ raise UnreadableError(rel, f"cannot read: {err.strerror}") from err
154
+ except UnicodeDecodeError as err:
155
+ raise UnreadableError(rel, f"not UTF-8 at byte {err.start}") from err
156
+ try:
157
+ if path.suffix == ".toml":
158
+ data = tomllib.loads(text)
159
+ elif path.suffix == ".json":
160
+ data = json.loads(text)
161
+ else:
162
+ data = YAML(typ="base").load(text) # every scalar a string: version 1.10 is not the float 1.1
163
+ except json.JSONDecodeError as err:
164
+ raise UnreadableError(rel, err.msg, err.lineno, err.colno) from err
165
+ except tomllib.TOMLDecodeError as err: # its position is only in the message before Python 3.14
166
+ match = _TOML_AT.search(str(err))
167
+ line, column = (int(match[1]), int(match[2])) if match and match[1] else (None, None)
168
+ raise UnreadableError(rel, _TOML_AT.sub("", str(err)), line, column) from err
169
+ except MarkedYAMLError as err:
170
+ mark = err.problem_mark
171
+ raise UnreadableError(rel, err.problem or str(err), mark and mark.line + 1, mark and mark.column + 1) from err
172
+ except YAMLError as err:
173
+ raise UnreadableError(rel, str(err)) from err
174
+ if not isinstance(data, dict):
175
+ raise UnreadableError(rel, "the top level is not a mapping")
176
+ return data
177
+
178
+
179
+ def codemeta_version(context: object) -> str | None:
180
+ """The CodeMeta version named in an ``@context`` string or list, or None."""
181
+ for item in context if isinstance(context, list) else [context]:
182
+ if item == _CODEMETA_2_DOI:
183
+ return "2.0"
184
+ if isinstance(item, str) and (match := _CODEMETA_CONTEXT.match(item)):
185
+ return match[1]
186
+ return None
187
+
188
+
189
+ def _detect_version(fmt: str, data: dict[str, Any], rel: str) -> str | None:
190
+ if fmt == "codemeta":
191
+ version = codemeta_version(data.get("@context"))
192
+ if version not in CODEMETA_VERSIONS:
193
+ msg = f"@context {data.get('@context')!r} is not CodeMeta {', '.join(CODEMETA_VERSIONS)}"
194
+ raise UnreadableError(rel, msg)
195
+ return version
196
+ if fmt == "cff-1.2.0":
197
+ version = str(data.get("cff-version"))
198
+ if version not in CFF_VERSIONS:
199
+ raise UnreadableError(rel, f"cff-version {data.get('cff-version')!r}, citesync reads 1.2.0")
200
+ return version
201
+ return None
202
+
203
+
204
+ def _is_release(fmt: str, data: dict[str, Any]) -> bool:
205
+ if fmt == "pep621":
206
+ return "build-system" in data
207
+ if fmt == "npm":
208
+ return data.get("private") is not True
209
+ return True
210
+
211
+
212
+ def _adapt(fmt: str, data: dict[str, Any], root: Path, rel: str) -> dict[str, Any]:
213
+ if fmt == "cargo":
214
+ return _adapt_cargo(data, root, rel)
215
+ if fmt == "pep621" and isinstance(project := data.get("project"), dict):
216
+ dynamic = set(project.get("dynamic", [])) # a dynamic field is skipped, the rest still checked
217
+ project = {k: v for k, v in project.items() if k not in dynamic}
218
+ if isinstance(urls := project.get("urls"), dict):
219
+ project["urls"] = _url_labels(urls)
220
+ return {**data, "project": project}
221
+ if fmt == "cff-1.2.0" and isinstance(data.get("authors"), list):
222
+ return {**data, "authors": [cff_person(a) if isinstance(a, dict) else a for a in data["authors"]]}
223
+ if fmt == "npm":
224
+ data = dict(data)
225
+ if isinstance(data.get("author"), str):
226
+ data["author"] = {"name": data["author"]}
227
+ if isinstance(data.get("repository"), dict):
228
+ data["repository"] = data["repository"].get("url")
229
+ if isinstance(data.get("repository"), str) and _NPM_BARE_REPO.match(data["repository"]):
230
+ data["repository"] = f"github:{data['repository']}" # npm reads a bare owner/repo as GitHub
231
+ return data
232
+ if fmt == "codemeta" and isinstance(authors := data.get("author"), (dict, list)):
233
+ authors = authors if isinstance(authors, list) else [authors] # JSON-LD allows one node for a list of one
234
+ return {**data, "author": [codemeta_person(a) for a in authors]}
235
+ return data
236
+
237
+
238
+ def _url_labels(urls: dict[str, Any]) -> dict[str, Any]:
239
+ """``urls`` with each known label also under its normalised name, as :func:`url_label` gives it."""
240
+ named = dict(urls)
241
+ for label, url in urls.items():
242
+ known = url_label(label)
243
+ if known:
244
+ named.setdefault(known, url)
245
+ return named
246
+
247
+
248
+ def url_label(label: str) -> str | None:
249
+ """A PEP 621 url label normalised as packaging does (``Source Code`` and ``source-code`` are ``Source``)."""
250
+ return _URL_LABELS.get("".join(c for c in label.lower() if c.isalnum()))
251
+
252
+
253
+ def cff_person(author: dict[str, Any]) -> dict[str, Any]:
254
+ """Fold ``name-particle`` and ``name-suffix`` into the family name, and read an entity's ``name`` as one.
255
+
256
+ So CFF's (Simon, van, Lierde) reads as Zenodo's ``"van Lierde, Simon"`` does.
257
+ """
258
+ family = author.get("family-names") or (author.get("name") if "given-names" not in author else None)
259
+ parts = [author.get("name-particle"), family, author.get("name-suffix")]
260
+ family = " ".join(p for p in parts if isinstance(p, str) and p.strip()) or None
261
+ return {**author, "family-names": family} if family else author
262
+
263
+
264
+ def codemeta_person(author: object) -> object:
265
+ """Read a codemeta author with only a ``name``, such as an Organization, as a family name, as CFF's entity is."""
266
+ if (
267
+ isinstance(author, dict)
268
+ and isinstance(author.get("name"), str)
269
+ and not {"givenName", "familyName"} & author.keys()
270
+ ):
271
+ return {**author, "familyName": author["name"]}
272
+ return author
273
+
274
+
275
+ def _adapt_cargo(data: dict[str, Any], root: Path, rel: str) -> dict[str, Any]:
276
+ """Present a crate or a virtual workspace as ``package``, with ``.workspace = true`` keys resolved."""
277
+ workspace = data if "workspace" in data else _find_workspace(root, rel)
278
+ inherited = workspace.get("workspace", {}).get("package", {}) if workspace else {}
279
+ package = data.get("package")
280
+ if package is None:
281
+ return {**data, "package": dict(inherited)} # a virtual workspace reads as its [workspace.package]
282
+ resolved = {}
283
+ for key, value in package.items():
284
+ if not (isinstance(value, dict) and value.get("workspace") is True):
285
+ resolved[key] = value
286
+ elif key in inherited:
287
+ resolved[key] = inherited[key]
288
+ return {**data, "package": resolved}
289
+
290
+
291
+ def _find_workspace(root: Path, rel: str) -> dict[str, Any] | None:
292
+ """The nearest Cargo.toml above ``rel`` with a ``[workspace]``, inside the repo root."""
293
+ for parent in PurePosixPath(rel).parents[1:]:
294
+ candidate = root / parent / "Cargo.toml"
295
+ if candidate.is_file():
296
+ data = load(candidate, str(parent / "Cargo.toml"))
297
+ if "workspace" in data:
298
+ return data
299
+ return None
300
+
301
+
302
+ def _read_term(fmt: str, term: str, data: dict[str, Any], path: str, mapping: Mapping) -> Any: # noqa: ANN401
303
+ values = resolve(data, path)
304
+ if term == "author":
305
+ people = tuple(p for p in (person(fmt, el, path, mapping) for el in values) if p)
306
+ return people or None
307
+ if not values:
308
+ return None
309
+ if term == "codeRepository": # Zenodo's isSupplementTo also lists papers by DOI
310
+ values = [v for v in values if codecs.doi_decode(v) is None] or [None]
311
+ raw = values[0]
312
+ if term == "keywords":
313
+ items = (
314
+ raw if isinstance(raw, list) else raw.split(",") if fmt == "codemeta" and isinstance(raw, str) else [raw]
315
+ )
316
+ words = tuple(codecs.clean(w) for w in items if isinstance(w, str) and w.strip())
317
+ return words or None
318
+ if term in {"name", "description"}:
319
+ return (codecs.clean(raw) or None) if isinstance(raw, str) else None
320
+ return _DECODE[term](raw)
321
+
322
+
323
+ _DECODE = {
324
+ "version": codecs.version_decode,
325
+ "license": codecs.license_decode,
326
+ "identifier": codecs.doi_decode,
327
+ "codeRepository": codecs.repo_decode,
328
+ }
329
+
330
+
331
+ def person(fmt: str, element: object, parent: str, mapping: Mapping) -> Person | None:
332
+ """The author in ``element``, one entry of the list at ``parent``; None when it has no name or ORCID."""
333
+
334
+ def sub(term: str) -> object:
335
+ path = mapping[fmt, term]
336
+ if path is None:
337
+ return None
338
+ rel = relative(path, parent)
339
+ values = [element] if rel == "" else resolve(element, rel)
340
+ return values[0] if values else None
341
+
342
+ def text(value: object) -> str | None:
343
+ return (codecs.clean(value) or None) if isinstance(value, str) else None
344
+
345
+ orcid = codecs.orcid_decode(sub("author.@id"))
346
+ given, family = None, None
347
+ if mapping[fmt, "author.givenName"] == mapping[fmt, "author.familyName"]:
348
+ raw = sub("author.givenName")
349
+ if fmt == "zenodo":
350
+ given, family = codecs.zenodo_name_decode(raw) or (None, None)
351
+ name = codecs.display_name(given, family)
352
+ else:
353
+ name = codecs.contact_name_decode(raw)
354
+ else:
355
+ given, family = text(sub("author.givenName")), text(sub("author.familyName"))
356
+ name = codecs.display_name(given, family)
357
+ if name is None and orcid is None:
358
+ return None
359
+ return Person(name, given, family, orcid)