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/rules.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The rules a finding can come from: id, severity, a one-line summary, what to do, and the details.
|
|
2
|
+
|
|
3
|
+
``docs/rules.md`` is generated from these: ``uv run python -m citesync.rules > docs/rules.md``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DOCS_URL = "https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Rule:
|
|
13
|
+
"""One rule. ``help`` says what to do about a finding; ``details`` how the rule compares."""
|
|
14
|
+
|
|
15
|
+
severity: str
|
|
16
|
+
summary: str
|
|
17
|
+
help: str
|
|
18
|
+
details: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_FIX = "Make the file agree with the source, or the other way round if the source is wrong."
|
|
22
|
+
_D2 = (
|
|
23
|
+
" When the source has no place or no value for the field (pyproject.toml has no DOI),"
|
|
24
|
+
" the other files that hold it must agree with each other."
|
|
25
|
+
)
|
|
26
|
+
# term: (rule id, summary, how the field compares)
|
|
27
|
+
_FIELDS = {
|
|
28
|
+
"name": (
|
|
29
|
+
"name-mismatch",
|
|
30
|
+
"The title differs from the source's.",
|
|
31
|
+
(
|
|
32
|
+
"CFF `title` and CodeMeta and Zenodo `name`/`title` compare as written, with whitespace collapsed."
|
|
33
|
+
" Manifests are not compared: a manifest `name` is a package id."
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
"version": (
|
|
37
|
+
"version-mismatch",
|
|
38
|
+
"The version differs from the source's.",
|
|
39
|
+
"Semver pre-release tags are read as PEP 440 first, so `0.3.1-rc.1` equals `0.3.1rc1`.",
|
|
40
|
+
),
|
|
41
|
+
"author": (
|
|
42
|
+
"author-mismatch",
|
|
43
|
+
"The authors differ from the source's.",
|
|
44
|
+
(
|
|
45
|
+
"Citation files must list the same people in the same order, with equal ORCIDs where both have one."
|
|
46
|
+
" Names compare case-insensitively after Unicode normalisation, and Zenodo's `Family, Given` and"
|
|
47
|
+
" npm or Cargo `Name <email>` are read as names. Manifests are compared on names only, and npm's"
|
|
48
|
+
" one `author` with the first author."
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
"license": (
|
|
52
|
+
"license-mismatch",
|
|
53
|
+
"The licence differs from the source's.",
|
|
54
|
+
(
|
|
55
|
+
"SPDX ids, SPDX URLs and Zenodo's lowercase ids are the same licence, compared case-insensitively."
|
|
56
|
+
" A list of licences reads as `A OR B`."
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
"identifier": (
|
|
60
|
+
"doi-mismatch",
|
|
61
|
+
"The DOI differs from the source's.",
|
|
62
|
+
"DOIs compare case-insensitively, with or without `https://doi.org/`. Manifests have no DOI.",
|
|
63
|
+
),
|
|
64
|
+
"codeRepository": (
|
|
65
|
+
"repository-mismatch",
|
|
66
|
+
"The repository URL differs from the source's.",
|
|
67
|
+
(
|
|
68
|
+
"`git+`, `.git` and a trailing slash are removed first, and npm shorthands and SSH remotes"
|
|
69
|
+
" are read as https URLs."
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
"description": (
|
|
73
|
+
"description-mismatch",
|
|
74
|
+
"The description differs from the source's.",
|
|
75
|
+
(
|
|
76
|
+
"Compared as written, with whitespace collapsed. Manifests are not compared: a manifest"
|
|
77
|
+
" description is a one-line summary, not an abstract."
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
"keywords": (
|
|
81
|
+
"keywords-mismatch",
|
|
82
|
+
"The keywords differ from the source's.",
|
|
83
|
+
(
|
|
84
|
+
"Citation files must hold the same keywords, in any order. A manifest's keywords are"
|
|
85
|
+
" checked by `keywords-not-subset` instead."
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
MISMATCH = {term: rule for term, (rule, _, _) in _FIELDS.items()}
|
|
90
|
+
|
|
91
|
+
RULES = {
|
|
92
|
+
**{rule: Rule("error", summary, _FIX, details + _D2) for rule, summary, details in _FIELDS.values()},
|
|
93
|
+
"keywords-not-subset": Rule(
|
|
94
|
+
"error",
|
|
95
|
+
"A manifest has a keyword the citation file doesn't.",
|
|
96
|
+
"Add the keyword to the citation file, or remove it from the manifest.",
|
|
97
|
+
"A manifest's keywords must all be in the citation file's, whichever of the two is the source:"
|
|
98
|
+
" a citation file can hold more keywords than a registry allows (crates.io allows five).",
|
|
99
|
+
),
|
|
100
|
+
"context-mismatch": Rule(
|
|
101
|
+
"error",
|
|
102
|
+
"codemeta.json's `@context` is not the configured CodeMeta version.",
|
|
103
|
+
"Change the `@context` to the configured version, or set `codemeta-version` in the config.",
|
|
104
|
+
"The configured version is `codemeta-version`, 3.0 by default. citesync reads CodeMeta 2.0, 3.0, 3.1 and 4.0.",
|
|
105
|
+
),
|
|
106
|
+
"role-dangling": Rule(
|
|
107
|
+
"warning",
|
|
108
|
+
"A CodeMeta Role in codemeta.json points at a person who isn't listed.",
|
|
109
|
+
"Add the person back, point the Role at their ORCID, or remove the Role.",
|
|
110
|
+
"A `Role` entry in codemeta.json's `author` or `contributor` list, as credit-matrix writes CRediT roles,"
|
|
111
|
+
" names its person by ORCID in `schema:author`. The rule warns when no `Person` in either list has that"
|
|
112
|
+
" ORCID as its `@id`, as after someone is replaced. The role itself is not checked, and `write` never"
|
|
113
|
+
" changes a Role.",
|
|
114
|
+
),
|
|
115
|
+
"unreadable": Rule(
|
|
116
|
+
"error",
|
|
117
|
+
"citesync could not read the file.",
|
|
118
|
+
"Fix the file so its parser accepts it.",
|
|
119
|
+
"The file could not be opened, is not UTF-8, does not parse, has a top level that is not a mapping,"
|
|
120
|
+
" holds a value of a shape its format doesn't allow, or is a format version citesync doesn't read"
|
|
121
|
+
" (CFF other than 1.2.0, a codemeta.json whose `@context` is not CodeMeta). The finding gives the"
|
|
122
|
+
" parser's message, and its line and column when the parser reports them. The other files are still"
|
|
123
|
+
" checked against each other. When the unreadable file is the source, each field it has a place for is"
|
|
124
|
+
" reported as `not-checked`. This rule can't be ignored.",
|
|
125
|
+
),
|
|
126
|
+
"not-checked": Rule(
|
|
127
|
+
"info",
|
|
128
|
+
"A field was not compared.",
|
|
129
|
+
"Add the field to the source if the files should carry it.",
|
|
130
|
+
"Either no file holds a value for the field, or the source could not be read. Info only: it never"
|
|
131
|
+
" changes the exit code, and text and GitHub output show it only with `--verbose`.",
|
|
132
|
+
),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def explain(rule: str) -> str:
|
|
137
|
+
"""The rule's section of ``docs/rules.md``."""
|
|
138
|
+
r = RULES[rule]
|
|
139
|
+
return f"## {rule}\n\n{r.summary} Severity: {r.severity}.\n\n{r.details}\n\nTo fix: {r.help}\n"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def markdown() -> str:
|
|
143
|
+
"""The whole of ``docs/rules.md``."""
|
|
144
|
+
head = (
|
|
145
|
+
"# Rules\n\n<!-- Generated from src/citesync/rules.py by"
|
|
146
|
+
" `uv run python -m citesync.rules > docs/rules.md`. Edit that file, not this one. -->\n\n"
|
|
147
|
+
"`citesync rules` lists these rules, and `citesync explain <id>` prints one section.\n"
|
|
148
|
+
)
|
|
149
|
+
return "\n".join([head, *map(explain, RULES)])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
print(markdown(), end="") # noqa: T201
|
citesync/write.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Rewrite the files that disagree with the source, changing only what check reports.
|
|
2
|
+
|
|
3
|
+
Each file is edited in a form that keeps the rest of it as it was: CFF through
|
|
4
|
+
ruamel.yaml's round trip (comments, key order, quoting), TOML through tomlkit
|
|
5
|
+
(comments, layout), and JSON by splicing each changed value into the original
|
|
6
|
+
text (key order, indentation, inline arrays, trailing newline). Values are
|
|
7
|
+
spelled the way the target format spells them, through :mod:`citesync.codecs`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import tomlkit
|
|
22
|
+
from ruamel.yaml import YAML
|
|
23
|
+
from ruamel.yaml.comments import CommentedMap
|
|
24
|
+
from ruamel.yaml.scalarstring import ScalarString
|
|
25
|
+
from ruamel.yaml.util import load_yaml_guess_indent
|
|
26
|
+
|
|
27
|
+
from citesync import codecs
|
|
28
|
+
from citesync.check import Finding, findings, has_slot, keyword_key, read_docs
|
|
29
|
+
from citesync.config import Config
|
|
30
|
+
from citesync.errors import CannotWrite
|
|
31
|
+
from citesync.formats import (
|
|
32
|
+
MANIFESTS,
|
|
33
|
+
Doc,
|
|
34
|
+
Person,
|
|
35
|
+
cff_person,
|
|
36
|
+
codemeta_person,
|
|
37
|
+
codemeta_version,
|
|
38
|
+
format_of,
|
|
39
|
+
person,
|
|
40
|
+
url_label,
|
|
41
|
+
)
|
|
42
|
+
from citesync.mapping import PERSON_TERMS, TERMS, Mapping, load_mapping, relative, resolve
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write(
|
|
46
|
+
root: Path, config: Config, mapping: Mapping | None = None, *, dry_run: bool = False
|
|
47
|
+
) -> tuple[dict[str, str], dict[tuple[str, str], str]]:
|
|
48
|
+
"""Write every finding write may fix; return the new text of each path it changed, and why it skipped a field.
|
|
49
|
+
|
|
50
|
+
It fixes the findings check reports, less those config ``ignore`` lists: in a derived
|
|
51
|
+
citation file every term, in a manifest only the terms in ``write-manifests``, and only
|
|
52
|
+
where the source holds the term and is what the file was compared with. A field the file
|
|
53
|
+
leaves out stays out. An unreadable file is never written. A value the file's format can't
|
|
54
|
+
spell without losing something is left as it was, its (path, term) mapped to the reason.
|
|
55
|
+
Every new text is made before any file is replaced, so an error leaves the files as they were.
|
|
56
|
+
With ``dry_run``, nothing is replaced.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
CheckError: as :func:`citesync.check.check` does.
|
|
60
|
+
"""
|
|
61
|
+
mapping = mapping or load_mapping()
|
|
62
|
+
docs, unreadable = read_docs(root, config, mapping)
|
|
63
|
+
fix = {
|
|
64
|
+
(f.file, f.term)
|
|
65
|
+
for f in findings(docs, unreadable, config)
|
|
66
|
+
if f.severity == "error" and f.rule not in config.ignore
|
|
67
|
+
}
|
|
68
|
+
source, texts, skipped = docs[0], {}, {}
|
|
69
|
+
key = keyword_key(case_sensitive=config.keywords_case_sensitive)
|
|
70
|
+
for doc in docs: # the source too, for its @context
|
|
71
|
+
terms = [
|
|
72
|
+
t
|
|
73
|
+
for t in TERMS
|
|
74
|
+
if (doc.path, t) in fix
|
|
75
|
+
and has_slot(source, t) # else the others were compared with each other (D2): left alone
|
|
76
|
+
and t in source.record
|
|
77
|
+
and t in doc.record
|
|
78
|
+
and (doc.is_citation or t in config.write_manifests)
|
|
79
|
+
]
|
|
80
|
+
context = config.codemeta_version if (doc.path, "@context") in fix else None
|
|
81
|
+
if not terms and not context:
|
|
82
|
+
continue
|
|
83
|
+
old = (root / doc.path).read_bytes().decode("utf-8") # bytes, so CRLF line ends stay
|
|
84
|
+
crlf = "\r\n" in old # edited with LF line ends, which the YAML and JSON writers add
|
|
85
|
+
new, reasons = _rewrite(doc, source, terms, old.replace("\r\n", "\n") if crlf else old, mapping, context, key)
|
|
86
|
+
new = new.replace("\n", "\r\n") if crlf else new
|
|
87
|
+
skipped |= {(doc.path, term): reason for term, reason in reasons.items()}
|
|
88
|
+
if new != old:
|
|
89
|
+
texts[doc.path] = new
|
|
90
|
+
if not dry_run:
|
|
91
|
+
_replace({root / rel: text for rel, text in texts.items()})
|
|
92
|
+
return dict(sorted(texts.items())), skipped
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def advice(finding: Finding, config: Config, skipped: dict[tuple[str, str], str]) -> str:
|
|
96
|
+
"""What to do about a finding write left: why write skipped it, or that a manifest field can be opted in."""
|
|
97
|
+
if (reason := skipped.get((str(finding.file), str(finding.term)))) is not None:
|
|
98
|
+
return f"edit it by hand: {reason}"
|
|
99
|
+
if (
|
|
100
|
+
finding.file
|
|
101
|
+
and finding.term
|
|
102
|
+
and format_of(finding.file) in MANIFESTS.values()
|
|
103
|
+
and finding.term not in config.write_manifests
|
|
104
|
+
):
|
|
105
|
+
return f"edit it by hand, or add {finding.term} to write-manifests"
|
|
106
|
+
return "edit it by hand"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _replace(texts: dict[Path, str]) -> None:
|
|
110
|
+
"""Write each text to a temporary file beside its path, then move them all into place.
|
|
111
|
+
|
|
112
|
+
A symlink's target is written, and the link stays. A failure before the moves leaves every
|
|
113
|
+
file as it was; only a failed move (a rename within one directory) leaves earlier ones moved.
|
|
114
|
+
"""
|
|
115
|
+
temps: dict[Path, Path] = {}
|
|
116
|
+
try:
|
|
117
|
+
for link, text in texts.items():
|
|
118
|
+
path = link.resolve()
|
|
119
|
+
fd, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
120
|
+
temps[Path(name)] = path
|
|
121
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as file:
|
|
122
|
+
file.write(text)
|
|
123
|
+
shutil.copymode(path, name)
|
|
124
|
+
for tmp, path in temps.items():
|
|
125
|
+
tmp.replace(path)
|
|
126
|
+
finally:
|
|
127
|
+
for tmp in temps:
|
|
128
|
+
tmp.unlink(missing_ok=True) # moved ones are gone already
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _rewrite(
|
|
132
|
+
doc: Doc,
|
|
133
|
+
source: Doc,
|
|
134
|
+
terms: list[str],
|
|
135
|
+
text: str,
|
|
136
|
+
mapping: Mapping,
|
|
137
|
+
context: str | None,
|
|
138
|
+
keyword_key: Callable[[str], str],
|
|
139
|
+
) -> tuple[str, dict[str, str]]:
|
|
140
|
+
"""``text`` with ``terms`` set from ``source``, and the reason for each term it left as it was."""
|
|
141
|
+
if doc.format == "cff-1.2.0":
|
|
142
|
+
yaml = _yaml(text)
|
|
143
|
+
data = yaml.load(text)
|
|
144
|
+
skipped = _apply(doc, source, terms, data, mapping, context, keyword_key)
|
|
145
|
+
out = io.StringIO()
|
|
146
|
+
yaml.dump(data, out)
|
|
147
|
+
return out.getvalue(), skipped
|
|
148
|
+
if doc.path.endswith(".toml"):
|
|
149
|
+
data = tomlkit.parse(text)
|
|
150
|
+
skipped = _apply(doc, source, terms, data, mapping, context, keyword_key)
|
|
151
|
+
return tomlkit.dumps(data), skipped
|
|
152
|
+
old = json.loads(text)
|
|
153
|
+
data = copy.deepcopy(old)
|
|
154
|
+
skipped = _apply(doc, source, terms, data, mapping, context, keyword_key)
|
|
155
|
+
return _splice(text, old, data), skipped
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _yaml(text: str) -> YAML:
|
|
159
|
+
"""A round-trip YAML set to the file's own indentation, with no line wrapping."""
|
|
160
|
+
_, indent, offset = load_yaml_guess_indent(text)
|
|
161
|
+
yaml = YAML()
|
|
162
|
+
yaml.preserve_quotes = True
|
|
163
|
+
yaml.width = 1 << 16
|
|
164
|
+
yaml.explicit_start = bool(re.search(r"^---", text, re.MULTILINE))
|
|
165
|
+
if indent:
|
|
166
|
+
yaml.indent(mapping=max(indent - (offset or 0), 2), sequence=indent, offset=offset or 0)
|
|
167
|
+
return yaml
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _apply(
|
|
171
|
+
doc: Doc,
|
|
172
|
+
source: Doc,
|
|
173
|
+
terms: list[str],
|
|
174
|
+
data: Any, # noqa: ANN401
|
|
175
|
+
mapping: Mapping,
|
|
176
|
+
context: str | None,
|
|
177
|
+
keyword_key: Callable[[str], str],
|
|
178
|
+
) -> dict[str, str]:
|
|
179
|
+
skipped = {}
|
|
180
|
+
if context:
|
|
181
|
+
iri = f"https://w3id.org/codemeta/{context}"
|
|
182
|
+
old = data["@context"]
|
|
183
|
+
data["@context"] = [iri if codemeta_version(c) else c for c in old] if isinstance(old, list) else iri
|
|
184
|
+
if doc.format == "cargo" and "package" not in data:
|
|
185
|
+
data = {"package": data["workspace"]["package"]} # a virtual workspace, edited in place
|
|
186
|
+
for term in terms:
|
|
187
|
+
path = mapping[doc.format, term]
|
|
188
|
+
assert path is not None # noqa: S101 - write() only passes terms the file holds
|
|
189
|
+
slot = _slot(term, data, path)
|
|
190
|
+
if slot is None:
|
|
191
|
+
continue
|
|
192
|
+
container, key = slot
|
|
193
|
+
old = container[key]
|
|
194
|
+
if isinstance(old, dict) and old.get("workspace") is True:
|
|
195
|
+
continue # Cargo inherits it from [workspace.package]: edit it there by hand
|
|
196
|
+
try:
|
|
197
|
+
if term == "author":
|
|
198
|
+
new = _people(doc.format, old, source, path, mapping)
|
|
199
|
+
else:
|
|
200
|
+
new = _encode(doc, source, term, source.record[term], old, keyword_key)
|
|
201
|
+
except CannotWrite as err:
|
|
202
|
+
skipped[term] = str(err)
|
|
203
|
+
continue
|
|
204
|
+
if new is not old:
|
|
205
|
+
_set(container, key, _styled(new, old), old)
|
|
206
|
+
return skipped
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _slot(term: str, data: Any, path: str) -> tuple[Any, str] | None: # noqa: ANN401
|
|
210
|
+
"""(container, key) of the value the reader reads at ``path``: the first alternative that has one."""
|
|
211
|
+
for alternative in path.split(" | "):
|
|
212
|
+
parent, _, key = alternative.rpartition(".")
|
|
213
|
+
key = key.removesuffix("[]")
|
|
214
|
+
for node in resolve(data, parent) if parent else [data]:
|
|
215
|
+
if not isinstance(node, dict):
|
|
216
|
+
continue
|
|
217
|
+
# a PEP 621 url label reads as packaging normalises it, the exact label first
|
|
218
|
+
label = key if key in node else next((k for k in node if url_label(k) == key), None)
|
|
219
|
+
if label is None or (term == "codeRepository" and codecs.doi_decode(node[label])):
|
|
220
|
+
continue # Zenodo's isSupplementTo also lists papers by DOI
|
|
221
|
+
return node, label
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _encode(doc: Doc, source: Doc, term: str, value: Any, old: Any, keyword_key: Callable[[str], str]) -> Any: # noqa: ANN401, PLR0911
|
|
226
|
+
"""``value`` spelled for ``doc``'s format, in the shape ``old`` has; ``keyword_key`` compares keywords."""
|
|
227
|
+
fmt = doc.format
|
|
228
|
+
if term == "version":
|
|
229
|
+
if fmt in {"cargo", "npm"}:
|
|
230
|
+
return codecs.version_encode_semver(value)
|
|
231
|
+
if fmt == "pep621":
|
|
232
|
+
if isinstance(key := codecs.version_key(value), str):
|
|
233
|
+
msg = f"{value} is not a PEP 440 version"
|
|
234
|
+
raise CannotWrite(msg)
|
|
235
|
+
return str(key)
|
|
236
|
+
return value
|
|
237
|
+
if term == "license":
|
|
238
|
+
return _license(fmt, value, old)
|
|
239
|
+
if term == "identifier":
|
|
240
|
+
return _identifier(value, old)
|
|
241
|
+
if term == "codeRepository":
|
|
242
|
+
if isinstance(old, dict): # npm's {type, url}
|
|
243
|
+
old["url"] = _encode(doc, source, term, value, old["url"], keyword_key)
|
|
244
|
+
return old
|
|
245
|
+
old = old.strip()
|
|
246
|
+
return ("git+" if old.startswith("git+") else "") + value + (".git" if old.endswith(".git") else "")
|
|
247
|
+
if term == "keywords":
|
|
248
|
+
return _keywords(old, value, keyword_key, add=doc.is_citation, remove=source.is_citation or not doc.is_citation)
|
|
249
|
+
return value
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _identifier(doi: str, old: Any) -> Any: # noqa: ANN401
|
|
253
|
+
"""``doi`` in ``old``'s form: a DOI URL or bare, in place inside a codemeta list or PropertyValue."""
|
|
254
|
+
if isinstance(old, list): # codemeta: the first DOI in the list is the one read
|
|
255
|
+
i = next(i for i, el in enumerate(old) if codecs.doi_decode(el))
|
|
256
|
+
return [*old[:i], _identifier(doi, old[i]), *old[i + 1 :]]
|
|
257
|
+
if isinstance(old, dict): # a schema.org PropertyValue: set the keys holding the DOI, keep the rest
|
|
258
|
+
for key in ("value", "url"):
|
|
259
|
+
if codecs.doi_decode(old.get(key)):
|
|
260
|
+
old[key] = _identifier(doi, old[key])
|
|
261
|
+
return old
|
|
262
|
+
form = "url" if isinstance(old, str) and old.strip().lower().startswith("http") else "bare"
|
|
263
|
+
return codecs.doi_encode(doi, form)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _license(fmt: str, spdx: str, old: Any) -> Any: # noqa: ANN401
|
|
267
|
+
"""CFF and codemeta write ``A OR B`` as a list, codemeta as SPDX URLs where the file does.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
CannotWrite: for any compound expression in Zenodo, and ``AND`` or ``WITH`` in CFF and codemeta.
|
|
271
|
+
"""
|
|
272
|
+
operators = set(spdx.split()) & {"AND", "OR", "WITH"}
|
|
273
|
+
if fmt == "zenodo" and operators:
|
|
274
|
+
msg = "Zenodo takes one licence id"
|
|
275
|
+
raise CannotWrite(msg)
|
|
276
|
+
if fmt == "zenodo":
|
|
277
|
+
return codecs.license_encode(spdx, "zenodo")
|
|
278
|
+
if isinstance(old, dict) and "text" in old: # PEP 621's legacy {text = ...}
|
|
279
|
+
old["text"] = spdx
|
|
280
|
+
return old
|
|
281
|
+
if fmt not in {"cff-1.2.0", "codemeta"}:
|
|
282
|
+
return spdx
|
|
283
|
+
if operators - {"OR"}:
|
|
284
|
+
msg = "a licence list can only say OR, not AND or WITH"
|
|
285
|
+
raise CannotWrite(msg)
|
|
286
|
+
first = old[0] if isinstance(old, list) and old else old
|
|
287
|
+
form = "spdx-url" if isinstance(first, str) and first.startswith("http") else "spdx"
|
|
288
|
+
ids = [codecs.license_encode(i, form) for i in spdx.replace("(", "").replace(")", "").split(" OR ")]
|
|
289
|
+
return ids if len(ids) > 1 or isinstance(old, list) else ids[0]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _keywords(old: Any, value: tuple[str, ...], key: Callable[[str], str], *, add: bool, remove: bool) -> Any: # noqa: ANN401
|
|
293
|
+
"""``old`` with the words not in ``value`` removed, and those missing added at the end, as the rule asks.
|
|
294
|
+
|
|
295
|
+
Words compare by ``key``.
|
|
296
|
+
|
|
297
|
+
Between citation files the sets must be equal (both); a manifest's must be a
|
|
298
|
+
subset of a citation file's, so only the manifest loses words, or only the citation file gains them.
|
|
299
|
+
"""
|
|
300
|
+
items = old if isinstance(old, list) else [old]
|
|
301
|
+
want = set(map(key, value))
|
|
302
|
+
if remove:
|
|
303
|
+
for i in reversed(range(len(items))):
|
|
304
|
+
if key(str(items[i])) not in want:
|
|
305
|
+
del items[i]
|
|
306
|
+
if add:
|
|
307
|
+
have = {key(str(k)) for k in items}
|
|
308
|
+
items.extend(_styled(k, items[0] if items else None) for k in value if key(k) not in have)
|
|
309
|
+
return items
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _people(fmt: str, old: Any, source: Doc, path: str, mapping: Mapping) -> Any: # noqa: ANN401
|
|
313
|
+
"""The author list for ``fmt``, with the source's people in the source's order.
|
|
314
|
+
|
|
315
|
+
An existing entry is kept, with its other fields, when its name or ORCID
|
|
316
|
+
matches; it is renamed on an ORCID match and gets the source's ORCID where
|
|
317
|
+
both have one. Anyone else becomes a new entry, so an email or affiliation
|
|
318
|
+
never moves to a different person. Entries with no name or ORCID stay, at the
|
|
319
|
+
end. npm keeps one author, so only the first is set and the rest stay.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
CannotWrite: when an entry it would drop holds more than a name and ORCID.
|
|
323
|
+
"""
|
|
324
|
+
items = list(old) if isinstance(old, list) else [old]
|
|
325
|
+
decoded = [_decode(fmt, el, path, mapping) for el in items]
|
|
326
|
+
npm = "npm" in {fmt, source.format}
|
|
327
|
+
people: tuple[Person, ...] = source.record["author"][:1] if npm else source.record["author"]
|
|
328
|
+
used: set[int] = set()
|
|
329
|
+
matches = []
|
|
330
|
+
for p in people:
|
|
331
|
+
matches.append(i := _match(decoded, used, p))
|
|
332
|
+
if i is not None:
|
|
333
|
+
used.add(i)
|
|
334
|
+
kept = {matches[0]} if fmt == "npm" else {i for i, q in enumerate(decoded) if i in used or npm or q is None}
|
|
335
|
+
for i, el in enumerate(items): # before any entry changes, so a skipped field stays as it was
|
|
336
|
+
if i not in kept and (extra := _extra(fmt, el, path, mapping)):
|
|
337
|
+
q = decoded[i]
|
|
338
|
+
msg = f"renaming would drop the {extra} of {(q.name if q and q.name else el)!r}"
|
|
339
|
+
raise CannotWrite(msg)
|
|
340
|
+
out = []
|
|
341
|
+
for p, i in zip(people, matches, strict=True):
|
|
342
|
+
if i is None:
|
|
343
|
+
out.append(_new_person(fmt, p, path, mapping, items[0] if items else None))
|
|
344
|
+
continue
|
|
345
|
+
q = decoded[i]
|
|
346
|
+
assert q is not None # noqa: S101 - _match skips entries that read as no one
|
|
347
|
+
if codecs.name_key(q.name or "") != codecs.name_key(p.name or ""):
|
|
348
|
+
_rename(fmt, items[i], p, path, mapping)
|
|
349
|
+
if q.orcid and p.orcid and q.orcid != p.orcid:
|
|
350
|
+
okey = relative(str(mapping[fmt, "author.@id"]), path)
|
|
351
|
+
items[i][okey] = _styled(_fields(fmt, p, path, mapping)[okey], items[i][okey])
|
|
352
|
+
out.append(items[i])
|
|
353
|
+
out += [el for i, el in enumerate(items) if i not in used and (npm or decoded[i] is None)]
|
|
354
|
+
if not isinstance(old, list):
|
|
355
|
+
return out[0] if len(out) == 1 or fmt == "npm" else out
|
|
356
|
+
_relist(old, out)
|
|
357
|
+
return old
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _extra(fmt: str, element: Any, path: str, mapping: Mapping) -> str | None: # noqa: ANN401
|
|
361
|
+
"""What an author entry holds besides a name and ORCID: a key, or an npm or Cargo string's email or url."""
|
|
362
|
+
if isinstance(element, str):
|
|
363
|
+
return "email" if "<" in element else "url" if "(" in element else None
|
|
364
|
+
if not isinstance(element, dict):
|
|
365
|
+
return None
|
|
366
|
+
names = {relative(at, path) for t in PERSON_TERMS if (at := mapping[fmt, t])}
|
|
367
|
+
return next((str(k) for k in element if k not in names | {"@type", "name", "name-particle", "name-suffix"}), None)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _relist(old: list[Any], new: list[Any]) -> None:
|
|
371
|
+
"""Make ``old`` hold ``new``'s elements in place, by deletes and appends where that will do.
|
|
372
|
+
|
|
373
|
+
In place keeps the list's own layout (a multi-line TOML array, a YAML sequence's comments).
|
|
374
|
+
"""
|
|
375
|
+
kept = [el for el in old if any(el is n for n in new)]
|
|
376
|
+
if len(kept) <= len(new) and all(a is b for a, b in zip(kept, new, strict=False)):
|
|
377
|
+
for i in reversed(range(len(old))):
|
|
378
|
+
if not any(old[i] is n for n in kept):
|
|
379
|
+
del old[i]
|
|
380
|
+
old.extend(new[len(kept) :])
|
|
381
|
+
else: # a reorder: rebuild the list
|
|
382
|
+
old.clear()
|
|
383
|
+
old.extend(new)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _decode(fmt: str, element: Any, path: str, mapping: Mapping) -> Person | None: # noqa: ANN401
|
|
387
|
+
if fmt == "cff-1.2.0" and isinstance(element, dict):
|
|
388
|
+
element = cff_person(element)
|
|
389
|
+
elif fmt == "codemeta":
|
|
390
|
+
element = codemeta_person(element)
|
|
391
|
+
elif fmt == "npm" and isinstance(element, str):
|
|
392
|
+
element = {"name": element}
|
|
393
|
+
return person(fmt, element, path, mapping)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _match(decoded: list[Person | None], used: set[int], p: Person) -> int | None:
|
|
397
|
+
"""The first unused entry with ``p``'s name, else the first with ``p``'s ORCID."""
|
|
398
|
+
free = [(i, q) for i, q in enumerate(decoded) if q is not None and i not in used]
|
|
399
|
+
by_name = (i for i, q in free if p.name and codecs.name_key(q.name or "") == codecs.name_key(p.name))
|
|
400
|
+
by_orcid = (i for i, q in free if p.orcid and q.orcid == p.orcid)
|
|
401
|
+
return next(by_name, next(by_orcid, None))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _fields(fmt: str, p: Person, path: str, mapping: Mapping) -> dict[str, str]:
|
|
405
|
+
"""A person's name and ORCID under the keys ``fmt`` keeps them in."""
|
|
406
|
+
gkey, fkey, okey = (relative(at, path) if (at := mapping[fmt, t]) else None for t in PERSON_TERMS)
|
|
407
|
+
given, family = p.given, p.family
|
|
408
|
+
if family is None: # shortcut: from a one-string name the last word is the family name; fix by hand if not
|
|
409
|
+
rest, _, family = (p.name or "").rpartition(" ")
|
|
410
|
+
given = rest or None
|
|
411
|
+
if gkey == fkey:
|
|
412
|
+
fields = {gkey: codecs.zenodo_name_encode(given, family) if fmt == "zenodo" else p.name}
|
|
413
|
+
else:
|
|
414
|
+
fields = {gkey: given, fkey: family}
|
|
415
|
+
if okey and p.orcid:
|
|
416
|
+
fields[okey] = codecs.orcid_encode(p.orcid, "bare" if fmt == "zenodo" else "url")
|
|
417
|
+
return {str(k): v for k, v in fields.items() if v}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _new_person(fmt: str, p: Person, path: str, mapping: Mapping, like: Any) -> Any: # noqa: ANN401
|
|
421
|
+
fields = _fields(fmt, p, path, mapping)
|
|
422
|
+
if fmt == "cargo" or (fmt == "npm" and not isinstance(like, dict)):
|
|
423
|
+
return p.name or ""
|
|
424
|
+
if fmt == "pep621":
|
|
425
|
+
table = tomlkit.inline_table()
|
|
426
|
+
table.update(fields)
|
|
427
|
+
return table
|
|
428
|
+
if fmt == "codemeta":
|
|
429
|
+
return {"@type": "Person", **fields}
|
|
430
|
+
if fmt == "cff-1.2.0":
|
|
431
|
+
return CommentedMap((k, _styled(v, like.get(k) if isinstance(like, dict) else None)) for k, v in fields.items())
|
|
432
|
+
return fields
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _rename(fmt: str, element: Any, p: Person, path: str, mapping: Mapping) -> None: # noqa: ANN401
|
|
436
|
+
"""Set the name of an entry matched by ORCID, in place; only the citation files have ORCIDs."""
|
|
437
|
+
fields = _fields(fmt, p, path, mapping)
|
|
438
|
+
if fmt == "codemeta" and codemeta_person(element) is not element: # a name-only entry keeps its one name
|
|
439
|
+
element["name"] = p.name
|
|
440
|
+
return
|
|
441
|
+
if fmt == "cff-1.2.0":
|
|
442
|
+
for key in ("name-particle", "name-suffix"):
|
|
443
|
+
element.pop(key, None) # folded into family-names by now
|
|
444
|
+
for key in {relative(str(mapping[fmt, t]), path) for t in PERSON_TERMS[:2]}:
|
|
445
|
+
if key in fields:
|
|
446
|
+
element[key] = _styled(fields[key], element.get(key))
|
|
447
|
+
else:
|
|
448
|
+
element.pop(key, None)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _set(container: Any, key: str, new: Any, old: Any) -> None: # noqa: ANN401
|
|
452
|
+
"""``container[key] = new``, moving a YAML end-of-line comment by as much as the value grew."""
|
|
453
|
+
container[key] = new
|
|
454
|
+
comment = getattr(container, "ca", None) and container.ca.items.get(key)
|
|
455
|
+
if comment and comment[2] is not None and isinstance(old, str) and isinstance(new, str):
|
|
456
|
+
comment[2].column = max(comment[2].column + len(new) - len(old), 0)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _styled(new: Any, old: Any) -> Any: # noqa: ANN401
|
|
460
|
+
"""``new`` in ``old``'s YAML quoting style, so a quoted CFF value stays quoted."""
|
|
461
|
+
if isinstance(old, ScalarString) and isinstance(new, str) and not isinstance(new, ScalarString):
|
|
462
|
+
return type(old)(new)
|
|
463
|
+
return new
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# JSON: splice changed values into the original text, so everything else keeps its bytes.
|
|
467
|
+
|
|
468
|
+
_DECODER = json.JSONDecoder()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _skip(text: str, i: int) -> int:
|
|
472
|
+
while i < len(text) and text[i] in " \t\r\n":
|
|
473
|
+
i += 1
|
|
474
|
+
return i
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _node(text: str, i: int) -> tuple[int, int, dict[Any, Any] | None]:
|
|
478
|
+
"""The (start, end, children) of the JSON value at ``i``; children by key or index, None for a scalar."""
|
|
479
|
+
i = _skip(text, i)
|
|
480
|
+
opener = text[i]
|
|
481
|
+
if opener not in "[{":
|
|
482
|
+
return i, _DECODER.raw_decode(text, i)[1], None
|
|
483
|
+
kids: dict[Any, Any] = {}
|
|
484
|
+
j = i + 1
|
|
485
|
+
while True:
|
|
486
|
+
j = _skip(text, j)
|
|
487
|
+
if text[j] in "]}":
|
|
488
|
+
return i, j + 1, kids
|
|
489
|
+
if opener == "{":
|
|
490
|
+
key, end = _DECODER.raw_decode(text, j)
|
|
491
|
+
j = _skip(text, end) + 1 # past the colon
|
|
492
|
+
else:
|
|
493
|
+
key = len(kids)
|
|
494
|
+
kids[key] = _node(text, j)
|
|
495
|
+
j = _skip(text, kids[key][1])
|
|
496
|
+
j += text[j] == ","
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _splice(text: str, old: Any, new: Any) -> str: # noqa: ANN401
|
|
500
|
+
indent = re.search(r"\n([ \t]+)\S", text)
|
|
501
|
+
edits: list[tuple[int, int, str]] = []
|
|
502
|
+
_diff(text, _node(text, 0), old, new, edits, indent[1] if indent else " ")
|
|
503
|
+
for start, end, value in sorted(edits, reverse=True):
|
|
504
|
+
text = text[:start] + value + text[end:]
|
|
505
|
+
return text
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _diff(text: str, node: tuple[int, int, Any], old: Any, new: Any, edits: list, indent: str) -> None: # noqa: ANN401
|
|
509
|
+
"""Collect a replacement for each smallest value that differs between ``old`` and ``new``."""
|
|
510
|
+
start, end, kids = node
|
|
511
|
+
if type(old) is type(new) and old == new:
|
|
512
|
+
return
|
|
513
|
+
if isinstance(old, dict) and isinstance(new, dict) and list(old) == list(new):
|
|
514
|
+
for key in old:
|
|
515
|
+
_diff(text, kids[key], old[key], new[key], edits, indent)
|
|
516
|
+
elif isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
|
|
517
|
+
for i, (a, b) in enumerate(zip(old, new, strict=True)):
|
|
518
|
+
_diff(text, kids[i], a, b, edits, indent)
|
|
519
|
+
elif "\n" in text[start:end]: # a multi-line value stays multi-line, at the indentation of its line
|
|
520
|
+
line = text[text.rfind("\n", 0, start) + 1 :]
|
|
521
|
+
margin = line[: len(line) - len(line.lstrip(" \t"))]
|
|
522
|
+
edits.append((start, end, json.dumps(new, indent=indent, ensure_ascii=False).replace("\n", "\n" + margin)))
|
|
523
|
+
else:
|
|
524
|
+
edits.append((start, end, json.dumps(new, ensure_ascii=False)))
|