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/MAPPING_SOURCE +5 -0
- citesync/__init__.py +1 -0
- citesync/check.py +247 -0
- citesync/cli.py +202 -0
- citesync/codecs.py +245 -0
- citesync/config.py +168 -0
- citesync/errors.py +23 -0
- citesync/formats.py +359 -0
- citesync/mapping-local.json +408 -0
- citesync/mapping.py +118 -0
- citesync/mapping.schema.json +70 -0
- citesync/py.typed +0 -0
- citesync/rules.py +153 -0
- citesync/write.py +524 -0
- citesync-0.1.0.dist-info/METADATA +213 -0
- citesync-0.1.0.dist-info/RECORD +20 -0
- citesync-0.1.0.dist-info/WHEEL +4 -0
- citesync-0.1.0.dist-info/entry_points.txt +3 -0
- citesync-0.1.0.dist-info/licenses/LICENSE +201 -0
- citesync-0.1.0.dist-info/licenses/NOTICE +6 -0
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)
|