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.
- citesync-0.2.0/PKG-INFO +118 -0
- citesync-0.2.0/README.md +89 -0
- {citesync-0.1.0 → citesync-0.2.0}/pyproject.toml +6 -2
- {citesync-0.1.0 → citesync-0.2.0}/pyproject.toml.orig +5 -3
- citesync-0.2.0/src/citesync/MAPPING_SOURCE +5 -0
- citesync-0.2.0/src/citesync/__main__.py +7 -0
- citesync-0.2.0/src/citesync/check.py +383 -0
- citesync-0.2.0/src/citesync/cli.py +458 -0
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/codecs.py +82 -14
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/config.py +67 -16
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/formats.py +148 -51
- citesync-0.2.0/src/citesync/jsonsplice.py +77 -0
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/mapping-local.json +221 -3
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/mapping.py +30 -6
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/mapping.schema.json +1 -1
- citesync-0.2.0/src/citesync/positions.py +180 -0
- citesync-0.2.0/src/citesync/rules.py +230 -0
- citesync-0.2.0/src/citesync/write.py +695 -0
- citesync-0.1.0/PKG-INFO +0 -213
- citesync-0.1.0/README.md +0 -184
- citesync-0.1.0/src/citesync/MAPPING_SOURCE +0 -5
- citesync-0.1.0/src/citesync/check.py +0 -247
- citesync-0.1.0/src/citesync/cli.py +0 -202
- citesync-0.1.0/src/citesync/rules.py +0 -153
- citesync-0.1.0/src/citesync/write.py +0 -524
- {citesync-0.1.0 → citesync-0.2.0}/LICENSE +0 -0
- {citesync-0.1.0 → citesync-0.2.0}/NOTICE +0 -0
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/__init__.py +0 -0
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/errors.py +0 -0
- {citesync-0.1.0 → citesync-0.2.0}/src/citesync/py.typed +0 -0
citesync-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml)
|
|
33
|
+
[](https://pypi.org/project/citesync/)
|
|
34
|
+
[](https://pypi.org/project/citesync/)
|
|
35
|
+
[](https://scorecard.dev/viewer/?uri=github.com/simonvanlierde/citesync)
|
|
36
|
+
[](https://github.com/simonvanlierde/citesync/blob/main/LICENSE)
|
|
37
|
+
[](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).
|
citesync-0.2.0/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# citesync
|
|
2
|
+
|
|
3
|
+
[](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml)
|
|
4
|
+
[](https://pypi.org/project/citesync/)
|
|
5
|
+
[](https://pypi.org/project/citesync/)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/simonvanlierde/citesync)
|
|
7
|
+
[](https://github.com/simonvanlierde/citesync/blob/main/LICENSE)
|
|
8
|
+
[](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.
|
|
4
|
-
description = "Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree,
|
|
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.
|
|
4
|
-
description = "Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree,
|
|
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,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] + "...")
|