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