citesync 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (30) hide show
  1. citesync-0.2.0/PKG-INFO +118 -0
  2. citesync-0.2.0/README.md +89 -0
  3. {citesync-0.1.0 → citesync-0.2.0}/pyproject.toml +6 -2
  4. {citesync-0.1.0 → citesync-0.2.0}/pyproject.toml.orig +5 -3
  5. citesync-0.2.0/src/citesync/MAPPING_SOURCE +5 -0
  6. citesync-0.2.0/src/citesync/__main__.py +7 -0
  7. citesync-0.2.0/src/citesync/check.py +383 -0
  8. citesync-0.2.0/src/citesync/cli.py +458 -0
  9. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/codecs.py +82 -14
  10. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/config.py +67 -16
  11. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/formats.py +148 -51
  12. citesync-0.2.0/src/citesync/jsonsplice.py +77 -0
  13. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/mapping-local.json +221 -3
  14. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/mapping.py +30 -6
  15. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/mapping.schema.json +1 -1
  16. citesync-0.2.0/src/citesync/positions.py +180 -0
  17. citesync-0.2.0/src/citesync/rules.py +230 -0
  18. citesync-0.2.0/src/citesync/write.py +695 -0
  19. citesync-0.1.0/PKG-INFO +0 -213
  20. citesync-0.1.0/README.md +0 -184
  21. citesync-0.1.0/src/citesync/MAPPING_SOURCE +0 -5
  22. citesync-0.1.0/src/citesync/check.py +0 -247
  23. citesync-0.1.0/src/citesync/cli.py +0 -202
  24. citesync-0.1.0/src/citesync/rules.py +0 -153
  25. citesync-0.1.0/src/citesync/write.py +0 -524
  26. {citesync-0.1.0 → citesync-0.2.0}/LICENSE +0 -0
  27. {citesync-0.1.0 → citesync-0.2.0}/NOTICE +0 -0
  28. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/__init__.py +0 -0
  29. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/errors.py +0 -0
  30. {citesync-0.1.0 → citesync-0.2.0}/src/citesync/py.typed +0 -0
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: citesync
3
+ Version: 0.2.0
4
+ Summary: Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree, rewrite them to match one source file, and start a CITATION.cff from them, using the CodeMeta crosswalks
5
+ Keywords: citation,CITATION.cff,codemeta,zenodo,software-metadata,pre-commit
6
+ Author: Simon van Lierde
7
+ Author-email: Simon van Lierde <code@duinlab.nl>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: packaging>=26.3
22
+ Requires-Dist: ruamel-yaml>=0.19
23
+ Requires-Dist: tomlkit>=0.15.2
24
+ Requires-Python: >=3.12
25
+ Project-URL: Homepage, https://github.com/simonvanlierde/citesync
26
+ Project-URL: Issues, https://github.com/simonvanlierde/citesync/issues
27
+ Project-URL: Changelog, https://github.com/simonvanlierde/citesync/blob/main/CHANGELOG.md
28
+ Description-Content-Type: text/markdown
29
+
30
+ # citesync
31
+
32
+ [![python](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml/badge.svg)](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/citesync)](https://pypi.org/project/citesync/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/citesync)](https://pypi.org/project/citesync/)
35
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/simonvanlierde/citesync/badge)](https://scorecard.dev/viewer/?uri=github.com/simonvanlierde/citesync)
36
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/simonvanlierde/citesync/blob/main/LICENSE)
37
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23779706.svg)](https://doi.org/10.5281/zenodo.23779706)
38
+
39
+ Status: experimental. It may change in ways that break your code, without notice.
40
+
41
+ Questions and bugs: see [CONTRIBUTING.md](https://github.com/simonvanlierde/citesync/blob/main/.github/CONTRIBUTING.md).
42
+
43
+ citesync checks that your software's citation metadata says the same thing everywhere: CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json). It can also rewrite the files so they agree. It knows where each field sits in each file from CodeMeta's crosswalks, as exported by [codemeta-drift](https://github.com/simonvanlierde/codemeta-drift).
44
+
45
+ ## Usage
46
+
47
+ citesync compares eleven fields, from title to release date, with one file: the source. The source is CITATION.cff unless you pick another with `--source`. With neither, citesync exits 2. Use citesync through its command line and the pre-commit hook. Its Python modules are internal and may change in any release.
48
+
49
+ ```sh
50
+ uv tool install citesync # or run it once with uvx citesync check
51
+ citesync check # exit 0: no error finding; 1: an error finding; 2: citesync couldn't run
52
+ citesync check --source pyproject.toml
53
+ citesync write --diff # print what write would change, and change nothing
54
+ citesync write # rewrite the other files to agree with the source
55
+ citesync init # write a CITATION.cff from codemeta.json, .zenodo.json and the manifests
56
+ citesync explain version-mismatch # what a rule checks and how to fix it
57
+ citesync config # the config in use, and the file it came from
58
+ ```
59
+
60
+ ```text
61
+ codemeta.json:3:3: error[version-mismatch] version: '2.0.0', CITATION.cff has '1.0.0'
62
+ help: Make the file agree with the source, or the other way round if the source is wrong.
63
+ 1 error, 0 warnings in 3 files
64
+ 1 fixable with `citesync write`
65
+ ```
66
+
67
+ A config is optional. A one-line `citesync.toml` is `ignore = ["description-mismatch"]`.
68
+
69
+ `citesync --help` lists every command and option. The reference is in `docs/`:
70
+
71
+ - [How check works](https://github.com/simonvanlierde/citesync/blob/main/docs/check.md): the fields, files, options and exit codes.
72
+ - [How write works](https://github.com/simonvanlierde/citesync/blob/main/docs/write.md): what it changes and what it leaves alone.
73
+ - [How init works](https://github.com/simonvanlierde/citesync/blob/main/docs/init.md): where a new CITATION.cff takes each field from.
74
+ - [Rules](https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md): each rule id, what it checks and how to fix it.
75
+ - [Configuration](https://github.com/simonvanlierde/citesync/blob/main/docs/config.md): where config goes, and every key.
76
+ - [Output formats](https://github.com/simonvanlierde/citesync/blob/main/docs/output.md): JSON, GitHub annotations and SARIF.
77
+ - [Tested on real repositories](https://github.com/simonvanlierde/citesync/blob/main/docs/tested-repositories.md): what citesync reports on five public repositories.
78
+
79
+ ## Pre-commit hook
80
+
81
+ For [prek](https://prek.j178.dev) or [pre-commit](https://pre-commit.com), in `.pre-commit-config.yaml`:
82
+
83
+ ```yaml
84
+ repos:
85
+ - repo: https://github.com/simonvanlierde/citesync
86
+ rev: v0.2.0
87
+ hooks:
88
+ - id: citesync
89
+ - id: citesync-write
90
+ ```
91
+
92
+ The `citesync` hook runs when a commit changes one of the six metadata files or a citesync config, at any depth. It checks every file, not only the staged ones, and fails the commit on an error finding. The `citesync-write` hook runs only when you ask for it: `prek run citesync-write --stage manual --all-files`.
93
+
94
+ ## Development
95
+
96
+ You need [uv](https://docs.astral.sh/uv/) and [just](https://just.systems).
97
+
98
+ ```sh
99
+ uv sync
100
+ uv tool install prek # once per machine
101
+ prek install # git hooks
102
+ ```
103
+
104
+ `just` lists the tasks. They mirror what [.github/workflows/](https://github.com/simonvanlierde/citesync/tree/main/.github/workflows) runs.
105
+
106
+ ```sh
107
+ just check # lint, types and tests for every stack here
108
+ just fmt # apply each stack's fixes and formatting
109
+ just hooks # the git hooks, as the checks workflow runs them
110
+ ```
111
+
112
+ `just mutation` mutation-tests the modules `only_mutate` lists under `[tool.mutmut]` in `pyproject.toml`. CI doesn't run it. It fails when the tests miss a change that `tests/mutation-baseline.txt` doesn't list. A change that can't alter behaviour goes in that file, with the reason. It also fails when the tests now catch a listed change, so drop that line.
113
+
114
+ uv locks only releases at least a week old. If `just check` fails on a newer security fix, add the package to `exclude-newer-package` under `[tool.uv]` in `pyproject.toml`, and remove it a week later ([how](https://github.com/simonvanlierde/project-templates/blob/main/docs/tooling.md#dependencies)).
115
+
116
+ ## Licence
117
+
118
+ See [LICENSE](https://github.com/simonvanlierde/citesync/blob/main/LICENSE).
@@ -0,0 +1,89 @@
1
+ # citesync
2
+
3
+ [![python](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml/badge.svg)](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/citesync)](https://pypi.org/project/citesync/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/citesync)](https://pypi.org/project/citesync/)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/simonvanlierde/citesync/badge)](https://scorecard.dev/viewer/?uri=github.com/simonvanlierde/citesync)
7
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/simonvanlierde/citesync/blob/main/LICENSE)
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23779706.svg)](https://doi.org/10.5281/zenodo.23779706)
9
+
10
+ Status: experimental. It may change in ways that break your code, without notice.
11
+
12
+ Questions and bugs: see [CONTRIBUTING.md](https://github.com/simonvanlierde/citesync/blob/main/.github/CONTRIBUTING.md).
13
+
14
+ citesync checks that your software's citation metadata says the same thing everywhere: CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json). It can also rewrite the files so they agree. It knows where each field sits in each file from CodeMeta's crosswalks, as exported by [codemeta-drift](https://github.com/simonvanlierde/codemeta-drift).
15
+
16
+ ## Usage
17
+
18
+ citesync compares eleven fields, from title to release date, with one file: the source. The source is CITATION.cff unless you pick another with `--source`. With neither, citesync exits 2. Use citesync through its command line and the pre-commit hook. Its Python modules are internal and may change in any release.
19
+
20
+ ```sh
21
+ uv tool install citesync # or run it once with uvx citesync check
22
+ citesync check # exit 0: no error finding; 1: an error finding; 2: citesync couldn't run
23
+ citesync check --source pyproject.toml
24
+ citesync write --diff # print what write would change, and change nothing
25
+ citesync write # rewrite the other files to agree with the source
26
+ citesync init # write a CITATION.cff from codemeta.json, .zenodo.json and the manifests
27
+ citesync explain version-mismatch # what a rule checks and how to fix it
28
+ citesync config # the config in use, and the file it came from
29
+ ```
30
+
31
+ ```text
32
+ codemeta.json:3:3: error[version-mismatch] version: '2.0.0', CITATION.cff has '1.0.0'
33
+ help: Make the file agree with the source, or the other way round if the source is wrong.
34
+ 1 error, 0 warnings in 3 files
35
+ 1 fixable with `citesync write`
36
+ ```
37
+
38
+ A config is optional. A one-line `citesync.toml` is `ignore = ["description-mismatch"]`.
39
+
40
+ `citesync --help` lists every command and option. The reference is in `docs/`:
41
+
42
+ - [How check works](https://github.com/simonvanlierde/citesync/blob/main/docs/check.md): the fields, files, options and exit codes.
43
+ - [How write works](https://github.com/simonvanlierde/citesync/blob/main/docs/write.md): what it changes and what it leaves alone.
44
+ - [How init works](https://github.com/simonvanlierde/citesync/blob/main/docs/init.md): where a new CITATION.cff takes each field from.
45
+ - [Rules](https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md): each rule id, what it checks and how to fix it.
46
+ - [Configuration](https://github.com/simonvanlierde/citesync/blob/main/docs/config.md): where config goes, and every key.
47
+ - [Output formats](https://github.com/simonvanlierde/citesync/blob/main/docs/output.md): JSON, GitHub annotations and SARIF.
48
+ - [Tested on real repositories](https://github.com/simonvanlierde/citesync/blob/main/docs/tested-repositories.md): what citesync reports on five public repositories.
49
+
50
+ ## Pre-commit hook
51
+
52
+ For [prek](https://prek.j178.dev) or [pre-commit](https://pre-commit.com), in `.pre-commit-config.yaml`:
53
+
54
+ ```yaml
55
+ repos:
56
+ - repo: https://github.com/simonvanlierde/citesync
57
+ rev: v0.2.0
58
+ hooks:
59
+ - id: citesync
60
+ - id: citesync-write
61
+ ```
62
+
63
+ The `citesync` hook runs when a commit changes one of the six metadata files or a citesync config, at any depth. It checks every file, not only the staged ones, and fails the commit on an error finding. The `citesync-write` hook runs only when you ask for it: `prek run citesync-write --stage manual --all-files`.
64
+
65
+ ## Development
66
+
67
+ You need [uv](https://docs.astral.sh/uv/) and [just](https://just.systems).
68
+
69
+ ```sh
70
+ uv sync
71
+ uv tool install prek # once per machine
72
+ prek install # git hooks
73
+ ```
74
+
75
+ `just` lists the tasks. They mirror what [.github/workflows/](https://github.com/simonvanlierde/citesync/tree/main/.github/workflows) runs.
76
+
77
+ ```sh
78
+ just check # lint, types and tests for every stack here
79
+ just fmt # apply each stack's fixes and formatting
80
+ just hooks # the git hooks, as the checks workflow runs them
81
+ ```
82
+
83
+ `just mutation` mutation-tests the modules `only_mutate` lists under `[tool.mutmut]` in `pyproject.toml`. CI doesn't run it. It fails when the tests miss a change that `tests/mutation-baseline.txt` doesn't list. A change that can't alter behaviour goes in that file, with the reason. It also fails when the tests now catch a listed change, so drop that line.
84
+
85
+ uv locks only releases at least a week old. If `just check` fails on a newer security fix, add the package to `exclude-newer-package` under `[tool.uv]` in `pyproject.toml`, and remove it a week later ([how](https://github.com/simonvanlierde/project-templates/blob/main/docs/tooling.md#dependencies)).
86
+
87
+ ## Licence
88
+
89
+ See [LICENSE](https://github.com/simonvanlierde/citesync/blob/main/LICENSE).
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "citesync"
3
- version = "0.1.0"
4
- description = "Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree, and rewrite them to match one source file, using the CodeMeta crosswalks"
3
+ version = "0.2.0"
4
+ description = "Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree, rewrite them to match one source file, and start a CITATION.cff from them, using the CodeMeta crosswalks"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = "Apache-2.0"
@@ -51,7 +51,9 @@ Changelog = "https://github.com/simonvanlierde/citesync/blob/main/CHANGELOG.md"
51
51
  dev = [
52
52
  "hypothesis>=6.140",
53
53
  "jsonschema>=4.25",
54
+ "memray>=1.20.0 ; sys_platform != 'win32'",
54
55
  "mutmut==3.8.0",
56
+ "pyinstrument>=5.1.3",
55
57
  "pytest>=9.1.1",
56
58
  "pytest-cov>=7.1.0",
57
59
  "ruff>=0.17.1",
@@ -123,6 +125,8 @@ source_paths = ["src/citesync"]
123
125
  only_mutate = [
124
126
  "src/citesync/check.py",
125
127
  "src/citesync/codecs.py",
128
+ "src/citesync/write.py",
129
+ "src/citesync/jsonsplice.py",
126
130
  ]
127
131
  pytest_add_cli_args_test_selection = ["tests"]
128
132
  also_copy = [
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "citesync"
3
- version = "0.1.0"
4
- description = "Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree, and rewrite them to match one source file, using the CodeMeta crosswalks"
3
+ version = "0.2.0"
4
+ description = "Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree, rewrite them to match one source file, and start a CITATION.cff from them, using the CodeMeta crosswalks"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  authors = [{ name = "Simon van Lierde", email = "code@duinlab.nl" }]
@@ -38,7 +38,9 @@ Changelog = "https://github.com/simonvanlierde/citesync/blob/main/CHANGELOG.md"
38
38
  dev = [
39
39
  "hypothesis>=6.140",
40
40
  "jsonschema>=4.25",
41
+ "memray>=1.20.0 ; sys_platform != 'win32'",
41
42
  "mutmut==3.8.0",
43
+ "pyinstrument>=5.1.3",
42
44
  "pytest>=9.1.1",
43
45
  "pytest-cov>=7.1.0",
44
46
  "ruff>=0.17.1",
@@ -109,7 +111,7 @@ testpaths = ["tests"]
109
111
  # Mutation testing (`just mutation`, not in CI) covers the comparison code only.
110
112
  [tool.mutmut]
111
113
  source_paths = ["src/citesync"]
112
- only_mutate = ["src/citesync/check.py", "src/citesync/codecs.py"]
114
+ only_mutate = ["src/citesync/check.py", "src/citesync/codecs.py", "src/citesync/write.py", "src/citesync/jsonsplice.py"]
113
115
  pytest_add_cli_args_test_selection = ["tests"]
114
116
  also_copy = ["docs", ".pre-commit-hooks.yaml"]
115
117
 
@@ -0,0 +1,5 @@
1
+ # Vendored from simonvanlierde/codemeta-drift; tests/test_mapping.py checks the sha256 lines.
2
+ tag v0.3.0
3
+ commit cbc7ccd729791205cb0ca1e3049b291c9cd3bbdd
4
+ sha256 0d3e46d362f328044582dd991493966314827e7b1eb409f2e974112b57af3504 mapping-local.json examples/mapping-3.1.json
5
+ sha256 fae508f2a82abe0b396021da95b87bca9ff86402c6f7821de6f736fa45a5975d mapping.schema.json src/codemeta_drift/mapping.schema.json
@@ -0,0 +1,7 @@
1
+ """``python -m citesync``, the same as the ``citesync`` command."""
2
+
3
+ import sys
4
+
5
+ from citesync.cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,383 @@
1
+ """Compare the files' records term by term and report what disagrees."""
2
+
3
+ from collections.abc import Callable, Iterable
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from citesync import codecs
8
+ from citesync.config import Config, git_top
9
+ from citesync.errors import CheckError, UnreadableError
10
+ from citesync.formats import CITATION_FILES, MANIFESTS, Doc, Person, format_of, read_doc, same_name, slots
11
+ from citesync.mapping import FORMATS, PEOPLE, TERMS, Mapping, load_mapping
12
+ from citesync.rules import MISMATCH, RULES, url
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Finding:
17
+ """One finding from a rule in :data:`citesync.rules.RULES`.
18
+
19
+ ``file`` is None for a ``not-checked`` term no file holds, ``term`` None for an
20
+ ``unreadable`` file. ``line`` and ``column`` are 1-based, when known.
21
+ """
22
+
23
+ file: str | None
24
+ term: str | None
25
+ rule: str
26
+ message: str = field(compare=False) # one finding per file, term and rule: the first comparison wins
27
+ line: int | None = field(default=None, compare=False)
28
+ column: int | None = field(default=None, compare=False)
29
+
30
+ @property
31
+ def severity(self) -> str:
32
+ """``error``, ``warning`` or ``info``, from the rule."""
33
+ return RULES[self.rule].severity
34
+
35
+ @property
36
+ def text(self) -> str:
37
+ """The message, after the term it is about."""
38
+ return f"{self.term}: {self.message}" if self.term else self.message
39
+
40
+ @property
41
+ def where(self) -> str | None:
42
+ """``file:line:column``, or the file alone when the line isn't known."""
43
+ return f"{self.file}:{self.line}:{self.column}" if self.line else self.file
44
+
45
+ def sort_key(self) -> tuple[bool, str, str, str]:
46
+ """Findings sort by file, rule and term; those on no file come last."""
47
+ return (self.file is None, self.file or "", self.rule, self.term or "")
48
+
49
+ def as_dict(self) -> dict[str, str | int | None]:
50
+ """The JSON form."""
51
+ rule = RULES[self.rule]
52
+ return {
53
+ "rule": self.rule,
54
+ "severity": rule.severity,
55
+ "file": self.file,
56
+ "term": self.term,
57
+ "message": self.message,
58
+ "help": rule.help,
59
+ "url": url(self.rule),
60
+ "line": self.line,
61
+ "column": self.column,
62
+ }
63
+
64
+
65
+ def discover(root: Path, config: Config) -> list[str]:
66
+ """The files to compare, source first.
67
+
68
+ Default: every citation file present, and every manifest at the root. A
69
+ ``manifests`` list replaces the default list. A listed citation file that
70
+ doesn't exist yet is skipped here; :func:`read_docs` reports it.
71
+
72
+ Raises:
73
+ CheckError: when the source or a listed manifest is missing.
74
+ """
75
+ if not (root / config.source).is_file():
76
+ msg = f"{config.source}: the source file is missing from {root}"
77
+ msg += "; name another with --source, or the repo with --root"
78
+ raise CheckError(msg)
79
+ citation = config.files if config.files is not None else tuple(CITATION_FILES)
80
+ paths = [config.source, *(p for p in citation if (root / p).is_file())]
81
+ if config.manifests is None:
82
+ paths += [p for p in MANIFESTS if (root / p).is_file()]
83
+ else:
84
+ for path in config.manifests:
85
+ if not (root / path).is_file():
86
+ msg = f"{path}: listed under manifests but missing"
87
+ raise CheckError(msg)
88
+ paths += config.manifests
89
+ return list(dict.fromkeys(paths))
90
+
91
+
92
+ def missing(root: Path, config: Config) -> list[str]:
93
+ """The citation files the ``files`` setting lists that don't exist."""
94
+ return [p for p in config.files or () if not (root / p).is_file()]
95
+
96
+
97
+ def read_docs(root: Path, config: Config, mapping: Mapping) -> tuple[list[Doc], list[Finding]]:
98
+ """Read the files to compare, leaving out root manifests that aren't released.
99
+
100
+ The source comes first, then the others in a fixed order (citation files, then
101
+ manifests, by format), so which file a finding names never depends on config order.
102
+ A file that can't be read gives an ``unreadable`` finding and an empty doc in its place,
103
+ and a manifest left out a ``manifest-skipped`` one.
104
+ """
105
+ docs, unreadable = [], [Finding(p, None, "file-missing", "listed under files") for p in missing(root, config)]
106
+ for path in discover(root, config):
107
+ try:
108
+ docs.append(read_doc(root, path, mapping))
109
+ except UnreadableError as err:
110
+ unreadable.append(Finding(err.path, None, "unreadable", err.message, err.line, err.column))
111
+ fmt = format_of(path)
112
+ docs.append(Doc(path, fmt, {}, slots(fmt, mapping), None, release=True, readable=False))
113
+ if config.manifests is None: # a manifest named in config, or the source, is checked whatever it is
114
+ kept = [d for d in docs if d.release or d.is_citation or d.path == config.source]
115
+ unreadable += [
116
+ Finding(d.path, None, "manifest-skipped", _NOT_RELEASED[d.format]) for d in docs if d not in kept
117
+ ]
118
+ docs = kept
119
+ return docs[:1] + sorted(docs[1:], key=lambda d: (FORMATS.index(d.format), d.path)), unreadable
120
+
121
+
122
+ _NOT_RELEASED = {
123
+ "pep621": "no [build-system], so it isn't a package",
124
+ "npm": '"private": true, so it is never published',
125
+ }
126
+
127
+
128
+ def check(root: Path, config: Config) -> tuple[list[Finding], int, list[str]]:
129
+ """Every finding for the repo at ``root``, sorted, the number of files checked, and the unused ignore entries."""
130
+ mapping = load_mapping()
131
+ docs, read = read_docs(root, config, mapping)
132
+ found, unused = findings(docs, [*read, *version_behind_tag(docs[0], git_tags(root))], config)
133
+ if any(f.file and f.term for f in found): # a check with nothing to point at reads no file twice
134
+ from citesync.positions import place # noqa: PLC0415
135
+
136
+ found = place(root, docs, found, mapping)
137
+ return sorted(found, key=Finding.sort_key), len(docs), unused
138
+
139
+
140
+ def git_tags(root: Path) -> list[str]:
141
+ """The tag names of the git repository holding ``root``, read from its files; none outside one.
142
+
143
+ Reads loose refs and ``packed-refs`` in the common git dir, so a linked worktree sees the
144
+ clone's tags. Running ``git`` would cost a process per check.
145
+ """
146
+ top = git_top(root)
147
+ if top is None:
148
+ return []
149
+ git = top / ".git"
150
+ if git.is_file(): # a linked worktree: "gitdir: <path>", whose commondir file points at the clone's .git
151
+ git = (top / git.read_text(encoding="utf-8").removeprefix("gitdir:").strip()).resolve()
152
+ if (git / "commondir").is_file():
153
+ git = (git / (git / "commondir").read_text(encoding="utf-8").strip()).resolve()
154
+ tags = [p.relative_to(git / "refs/tags").as_posix() for p in (git / "refs/tags").rglob("*") if p.is_file()]
155
+ if (git / "packed-refs").is_file():
156
+ lines = (git / "packed-refs").read_text(encoding="utf-8").splitlines()
157
+ tags += [
158
+ ref.removeprefix("refs/tags/")
159
+ for _, _, ref in (line.partition(" ") for line in lines)
160
+ if ref.startswith("refs/tags/")
161
+ ]
162
+ return tags
163
+
164
+
165
+ def version_behind_tag(source: Doc, tags: list[str]) -> list[Finding]:
166
+ """A warning when the source's version is older than the newest tag that reads as a version."""
167
+ version = source.record.get("version")
168
+ if not version or not tags:
169
+ return []
170
+ from packaging.version import InvalidVersion, Version # noqa: PLC0415 - 2.5 ms, only in a repo with tags
171
+
172
+ versions = {}
173
+ for tag in tags:
174
+ try:
175
+ versions[tag] = Version(tag)
176
+ except InvalidVersion:
177
+ continue
178
+ try:
179
+ current = Version(version)
180
+ except InvalidVersion:
181
+ return []
182
+ newest = max(versions, key=versions.__getitem__, default=None)
183
+ if newest is None or versions[newest] <= current:
184
+ return []
185
+ return [Finding(source.path, "version", "version-behind-tag", f"{version}, the newest tag is {newest}")]
186
+
187
+
188
+ def findings(docs: list[Doc], extra: list[Finding], config: Config) -> tuple[set[Finding], list[str]]:
189
+ """The findings for ``docs`` and ``extra`` less the rules ``ignore`` lists, and the entries that matched none.
190
+
191
+ ``docs`` (the source first) and ``extra`` are as :func:`read_docs` gives them. :func:`check` adds
192
+ ``version-behind-tag`` to ``extra``: it needs the repo's tags, and write never fixes it.
193
+ """
194
+ found: set[Finding] = set(extra)
195
+ source = docs[0]
196
+ for doc in docs:
197
+ configured = config.codemeta_version
198
+ if configured and doc.readable and doc.format == "codemeta" and doc.version != configured:
199
+ detail = f"{doc.version}, configured {configured}"
200
+ found.add(Finding(doc.path, "@context", "context-mismatch", detail))
201
+ for key in dict.fromkeys(key for key, _ in doc.dangling_roles):
202
+ orcids = ", ".join(orcid for k, orcid in doc.dangling_roles if k == key)
203
+ detail = f"a Role points at {orcids}, which no Person in author or contributor has"
204
+ found.add(Finding(doc.path, key, "role-dangling", detail))
205
+ for key, rule in doc.zenodo_gaps:
206
+ found.add(Finding(doc.path, key, rule, ZENODO_GAPS[rule]))
207
+ for term in TERMS:
208
+ if not source.readable and has_slot(source, term):
209
+ found.add(Finding(source.path, term, "not-checked", "not compared with the source: it can't be read"))
210
+ elif not any(term in d.record for d in docs):
211
+ found.add(Finding(None, term, "not-checked", "no file holds a value"))
212
+ continue
213
+ found.update(compare_term(term, docs, kw_key=keyword_key(config)))
214
+ unused = [rule for rule in config.ignore if not any(f.rule == rule for f in found)]
215
+ return {f for f in found if f.rule not in config.ignore}, unused
216
+
217
+
218
+ ZENODO_GAPS = {
219
+ "zenodo-license-list": "a list: with no .zenodo.json, Zenodo reads one licence and refuses the release",
220
+ "zenodo-drops-field": "with no .zenodo.json, Zenodo leaves this out of the record",
221
+ }
222
+
223
+
224
+ def has_slot(doc: Doc, term: str) -> bool:
225
+ """Whether ``doc`` can be the reference for ``term``: a manifest's name and description never are."""
226
+ return term in doc.slots and (doc.is_citation or term not in {"name", "description"})
227
+
228
+
229
+ def compare_term(term: str, docs: list[Doc], *, kw_key: Callable[[str], str]) -> Iterable[Finding]:
230
+ """Compare ``term`` in ``docs[1:]`` with the source ``docs[0]``.
231
+
232
+ When the source has no slot or no value for it (docs/check.md), the other files that hold
233
+ it are compared with each other. A source with no ORCID slot (a manifest) also
234
+ gets its authors' ORCIDs compared that way, and a manifest source gets the
235
+ keywords of the rest compared that way, since a subset of one isn't a subset of all.
236
+ """
237
+ source, others = docs[0], [d for d in docs[1:] if term in d.record]
238
+ if has_slot(source, term) and term in source.record:
239
+ found = [compare(term, source, doc, kw_key=kw_key) for doc in others]
240
+ if term in PEOPLE and f"{term}.@id" not in source.slots:
241
+ found += _between(term, others, orcid_only=True, kw_key=kw_key)
242
+ elif term == "keywords" and not source.is_citation:
243
+ found += _between(term, others, kw_key=kw_key)
244
+ else:
245
+ found = _between(term, others, kw_key=kw_key)
246
+ return [f for f in found if f]
247
+
248
+
249
+ def _between(
250
+ term: str, holders: list[Doc], *, orcid_only: bool = False, kw_key: Callable[[str], str]
251
+ ) -> list[Finding | None]:
252
+ return [
253
+ compare(term, earlier, later, orcid_only=orcid_only, kw_key=kw_key)
254
+ for i, later in enumerate(holders)
255
+ for earlier in holders[:i]
256
+ ]
257
+
258
+
259
+ def compare(term: str, ref: Doc, doc: Doc, *, orcid_only: bool = False, kw_key: Callable[[str], str]) -> Finding | None:
260
+ """A finding on ``doc`` when its ``term`` disagrees with ``ref``'s, by the rule for that term and pair.
261
+
262
+ With ``orcid_only``, authors compare by ORCID alone, for people whose names match. Keywords
263
+ compare by ``kw_key`` (:func:`keyword_key`).
264
+ """
265
+ a, b = ref.record[term], doc.record[term]
266
+ both_citation = ref.is_citation and doc.is_citation
267
+ if term in _KEY:
268
+ equal = a == b or _KEY[term](a) == _KEY[term](b) # equal text needs no parsing (packaging costs 2.5 ms)
269
+ elif term in PEOPLE:
270
+ equal = _same_people(ref, doc, term, names=not orcid_only)
271
+ a, b = _show_people(a), _show_people(b)
272
+ elif term == "keywords":
273
+ kw_key = cargo_keyword_key(kw_key, ref, doc)
274
+ return _keywords_differ(ref, doc, kw_key) if both_citation else _keywords_subset(ref, doc, kw_key)
275
+ else: # name, description, datePublished: only citation files have a release date
276
+ if not both_citation:
277
+ return None # a manifest name is a package id, a description a one-line summary
278
+ equal = a == b
279
+ if equal:
280
+ return None
281
+ return Finding(doc.path, term, MISMATCH[term], f"{_show(b)}, {ref.path} has {_show(a)}")
282
+
283
+
284
+ _KEY = {
285
+ "version": codecs.version_key,
286
+ "license": codecs.license_key,
287
+ "identifier": codecs.doi_key,
288
+ "swhid": codecs.swhid_key,
289
+ "codeRepository": codecs.repo_key,
290
+ }
291
+
292
+
293
+ def keyword_key(config: Config) -> Callable[[str], str]:
294
+ """What keywords compare by: as names do, or with only the spacing collapsed when case-sensitive.
295
+
296
+ With ``keywords-ignore-separators``, hyphens and underscores count as spaces too.
297
+ """
298
+ key = codecs.clean if config.keywords_case_sensitive else codecs.name_key
299
+ return _ignore_separators(key) if config.keywords_ignore_separators else key
300
+
301
+
302
+ def cargo_keyword_key(key: Callable[[str], str], *docs: Doc) -> Callable[[str], str]:
303
+ """``key``, ignoring separators when one of ``docs`` is a Cargo.toml: crates.io refuses a keyword with a space."""
304
+ return _ignore_separators(key) if any(d.format == "cargo" for d in docs) else key
305
+
306
+
307
+ def _ignore_separators(key: Callable[[str], str]) -> Callable[[str], str]:
308
+ return lambda k: key(codecs.spaced(k))
309
+
310
+
311
+ def near_misses(others: Iterable[str], key: Callable[[str], str]) -> Callable[[str], str | None]:
312
+ """For a keyword, the first of ``others`` it matches with separators read as spaces.
313
+
314
+ That is the keyword itself when it is among ``others``, so callers skip a near miss already present.
315
+ The keys of ``others`` are made once here, not once per keyword: a long list would take quadratic time.
316
+ """
317
+ spaced: dict[str, str] = {}
318
+ for other in others:
319
+ spaced.setdefault(key(codecs.spaced(other)), other)
320
+ return lambda keyword: spaced.get(key(codecs.spaced(keyword)))
321
+
322
+
323
+ def _keywords_differ(ref: Doc, doc: Doc, key: Callable[[str], str]) -> Finding | None:
324
+ """Between citation files: the keywords ``doc`` lacks, those it has beyond ``ref``'s, and the near misses."""
325
+ a, b = ref.record["keywords"], doc.record["keywords"]
326
+ a_keys, b_keys = set(map(key, a)), set(map(key, b))
327
+ near_miss = near_misses(a, key)
328
+ pairs = {k: near for k in b if (near := near_miss(k)) and key(near) not in b_keys}
329
+ missing = [k for k in a if key(k) not in b_keys and k not in pairs.values()]
330
+ extra = [k for k in b if key(k) not in a_keys and k not in pairs]
331
+ parts = [f"missing {', '.join(missing)}"] if missing else []
332
+ parts += [f"extra {', '.join(extra)}"] if extra else []
333
+ parts += [f"{k!r} differs from {near!r} only in separators" for k, near in pairs.items()]
334
+ if not parts:
335
+ return None
336
+ return Finding(doc.path, "keywords", "keywords-mismatch", f"{'; '.join(parts)} (compared with {ref.path})")
337
+
338
+
339
+ def _keywords_subset(ref: Doc, doc: Doc, key: Callable[[str], str]) -> Finding | None:
340
+ """A manifest's keywords must all be in the citation file's, whichever is the source."""
341
+ if not ref.is_citation and not doc.is_citation:
342
+ return None # no rule between two manifests
343
+ manifest, citation = (ref, doc) if doc.is_citation else (doc, ref)
344
+ known = set(map(key, citation.record["keywords"]))
345
+ extra = [k for k in manifest.record["keywords"] if key(k) not in known]
346
+ if not extra:
347
+ return None
348
+ near_miss = near_misses(citation.record["keywords"], key)
349
+ words = [
350
+ f"{k} ({citation.path} has {near!r}: differs only in separators)" if (near := near_miss(k)) else k
351
+ for k in extra
352
+ ]
353
+ detail = f"{', '.join(words)}: in {manifest.path}, not in {citation.path}"
354
+ return Finding(doc.path, "keywords", "keywords-not-subset", detail)
355
+
356
+
357
+ def _same_people(ref: Doc, doc: Doc, term: str, *, names: bool) -> bool:
358
+ """Same names in the same order, ORCIDs equal where both have one. npm's one author is the first.
359
+
360
+ Without ``names``, only the ORCIDs of people at the same place with the same name count.
361
+ """
362
+ a: tuple[Person, ...] = ref.record[term]
363
+ b: tuple[Person, ...] = doc.record[term]
364
+ if term == "author" and "npm" in {ref.format, doc.format}:
365
+ a, b = a[:1], b[:1]
366
+ if names and len(a) != len(b):
367
+ return False
368
+ for p, q in zip(a, b, strict=False):
369
+ named = same_name(p, q)
370
+ if names and not named:
371
+ return False
372
+ if named and p.orcid and q.orcid and p.orcid != q.orcid:
373
+ return False
374
+ return True
375
+
376
+
377
+ def _show_people(people: tuple[Person, ...]) -> str:
378
+ return "; ".join(f"{p.name or '?'}" + (f" ({p.orcid})" if p.orcid else "") for p in people)
379
+
380
+
381
+ def _show(value: object) -> str:
382
+ text = str(value)
383
+ return repr(text if len(text) <= 80 else text[:77] + "...")