citesync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citesync/MAPPING_SOURCE +5 -0
- citesync/__init__.py +1 -0
- citesync/check.py +247 -0
- citesync/cli.py +202 -0
- citesync/codecs.py +245 -0
- citesync/config.py +168 -0
- citesync/errors.py +23 -0
- citesync/formats.py +359 -0
- citesync/mapping-local.json +408 -0
- citesync/mapping.py +118 -0
- citesync/mapping.schema.json +70 -0
- citesync/py.typed +0 -0
- citesync/rules.py +153 -0
- citesync/write.py +524 -0
- citesync-0.1.0.dist-info/METADATA +213 -0
- citesync-0.1.0.dist-info/RECORD +20 -0
- citesync-0.1.0.dist-info/WHEEL +4 -0
- citesync-0.1.0.dist-info/entry_points.txt +3 -0
- citesync-0.1.0.dist-info/licenses/LICENSE +201 -0
- citesync-0.1.0.dist-info/licenses/NOTICE +6 -0
citesync/MAPPING_SOURCE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Vendored from simonvanlierde/codemeta-drift; tests/test_mapping.py checks the sha256 lines.
|
|
2
|
+
tag v0.1.0
|
|
3
|
+
commit 129bb9c8f1d75281441911451fa5616bd28187f4
|
|
4
|
+
sha256 12313e26bf1ca0375d869f2498b71a301e0129c35759e45c309c024c59b2e3c9 mapping-local.json examples/mapping-3.1.json
|
|
5
|
+
sha256 50d66f08c465669ba7264f7d45398df9be8bc999bf724db8f2ff55b7ef93fd38 mapping.schema.json src/codemeta_drift/mapping.schema.json
|
citesync/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Check that citation files and package manifests agree, and rewrite them to match one source."""
|
citesync/check.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
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
|
|
9
|
+
from citesync.errors import CheckError, UnreadableError
|
|
10
|
+
from citesync.formats import CITATION_FILES, MANIFESTS, Doc, Person, format_of, read_doc, slots
|
|
11
|
+
from citesync.mapping import FORMATS, TERMS, Mapping, load_mapping
|
|
12
|
+
from citesync.rules import DOCS_URL, MISMATCH, RULES
|
|
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
|
+
def sort_key(self) -> tuple[bool, str, str, str]:
|
|
36
|
+
"""Findings sort by file, rule and term; those on no file come last."""
|
|
37
|
+
return (self.file is None, self.file or "", self.rule, self.term or "")
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, str | int | None]:
|
|
40
|
+
"""The JSON form."""
|
|
41
|
+
rule = RULES[self.rule]
|
|
42
|
+
return {
|
|
43
|
+
"rule": self.rule,
|
|
44
|
+
"severity": rule.severity,
|
|
45
|
+
"file": self.file,
|
|
46
|
+
"term": self.term,
|
|
47
|
+
"message": self.message,
|
|
48
|
+
"help": rule.help,
|
|
49
|
+
"url": f"{DOCS_URL}#{self.rule}",
|
|
50
|
+
"line": self.line,
|
|
51
|
+
"column": self.column,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def discover(root: Path, config: Config) -> list[str]:
|
|
56
|
+
"""The files to compare, source first.
|
|
57
|
+
|
|
58
|
+
Default: every citation file present, and every manifest at the root. A
|
|
59
|
+
``manifests`` list replaces the default list. A listed citation file that
|
|
60
|
+
doesn't exist yet is skipped: there is nothing to compare.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
CheckError: when the source or a listed manifest is missing.
|
|
64
|
+
"""
|
|
65
|
+
if not (root / config.source).is_file():
|
|
66
|
+
msg = f"{config.source}: the source file is missing"
|
|
67
|
+
raise CheckError(msg)
|
|
68
|
+
citation = config.files if config.files is not None else tuple(CITATION_FILES)
|
|
69
|
+
paths = [config.source, *(p for p in citation if (root / p).is_file())]
|
|
70
|
+
if config.manifests is None:
|
|
71
|
+
paths += [p for p in MANIFESTS if (root / p).is_file()]
|
|
72
|
+
else:
|
|
73
|
+
for path in config.manifests:
|
|
74
|
+
if not (root / path).is_file():
|
|
75
|
+
msg = f"{path}: listed under manifests but missing"
|
|
76
|
+
raise CheckError(msg)
|
|
77
|
+
paths += config.manifests
|
|
78
|
+
return list(dict.fromkeys(paths))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_docs(root: Path, config: Config, mapping: Mapping) -> tuple[list[Doc], list[Finding]]:
|
|
82
|
+
"""Read the files to compare, leaving out root manifests that aren't released.
|
|
83
|
+
|
|
84
|
+
The source comes first, then the others in a fixed order (citation files, then
|
|
85
|
+
manifests, by format), so which file a finding names never depends on config order.
|
|
86
|
+
A file that can't be read gives an ``unreadable`` finding and an empty doc in its place.
|
|
87
|
+
"""
|
|
88
|
+
docs, unreadable = [], []
|
|
89
|
+
for path in discover(root, config):
|
|
90
|
+
try:
|
|
91
|
+
docs.append(read_doc(root, path, mapping))
|
|
92
|
+
except UnreadableError as err:
|
|
93
|
+
unreadable.append(Finding(err.path, None, "unreadable", err.message, err.line, err.column))
|
|
94
|
+
fmt = format_of(path)
|
|
95
|
+
docs.append(Doc(path, fmt, {}, slots(fmt, mapping), None, release=True, readable=False))
|
|
96
|
+
if config.manifests is None: # a manifest named in config, or the source, is checked whatever it is
|
|
97
|
+
docs = [d for d in docs if d.release or d.is_citation or d.path == config.source]
|
|
98
|
+
return docs[:1] + sorted(docs[1:], key=lambda d: (FORMATS.index(d.format), d.path)), unreadable
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check(root: Path, config: Config) -> tuple[list[Finding], int]:
|
|
102
|
+
"""Every finding for the repo at ``root``, sorted, and the number of files checked."""
|
|
103
|
+
docs, unreadable = read_docs(root, config, load_mapping())
|
|
104
|
+
return findings(docs, unreadable, config), len(docs)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def findings(docs: list[Doc], unreadable: list[Finding], config: Config) -> list[Finding]:
|
|
108
|
+
"""Every finding for ``docs`` (the source first) as :func:`read_docs` gives them, sorted."""
|
|
109
|
+
found: set[Finding] = set(unreadable)
|
|
110
|
+
source = docs[0]
|
|
111
|
+
for doc in docs:
|
|
112
|
+
if doc.readable and doc.format == "codemeta" and doc.version != config.codemeta_version:
|
|
113
|
+
detail = f"{doc.version}, configured {config.codemeta_version}"
|
|
114
|
+
found.add(Finding(doc.path, "@context", "context-mismatch", detail))
|
|
115
|
+
for key in dict.fromkeys(key for key, _ in doc.dangling_roles):
|
|
116
|
+
orcids = ", ".join(orcid for k, orcid in doc.dangling_roles if k == key)
|
|
117
|
+
detail = f"a Role points at {orcids}, which no Person in author or contributor has"
|
|
118
|
+
found.add(Finding(doc.path, key, "role-dangling", detail))
|
|
119
|
+
for term in TERMS:
|
|
120
|
+
if not source.readable and has_slot(source, term):
|
|
121
|
+
found.add(Finding(source.path, term, "not-checked", "not compared with the source: it can't be read"))
|
|
122
|
+
elif not any(term in d.record for d in docs):
|
|
123
|
+
found.add(Finding(None, term, "not-checked", "no file holds a value"))
|
|
124
|
+
continue
|
|
125
|
+
found.update(compare_term(term, docs, keywords_case_sensitive=config.keywords_case_sensitive))
|
|
126
|
+
return sorted(found, key=Finding.sort_key)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def has_slot(doc: Doc, term: str) -> bool:
|
|
130
|
+
"""Whether ``doc`` can be the reference for ``term``: a manifest's name and description never are."""
|
|
131
|
+
return term in doc.slots and (doc.is_citation or term not in {"name", "description"})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def compare_term(term: str, docs: list[Doc], *, keywords_case_sensitive: bool) -> Iterable[Finding]:
|
|
135
|
+
"""Compare ``term`` in ``docs[1:]`` with the source ``docs[0]``.
|
|
136
|
+
|
|
137
|
+
When the source has no slot or no value for it (D2), the other files that hold
|
|
138
|
+
it are compared with each other. A source with no ORCID slot (a manifest) also
|
|
139
|
+
gets its authors' ORCIDs compared that way, and a manifest source gets the
|
|
140
|
+
keywords of the rest compared that way, since a subset of one isn't a subset of all.
|
|
141
|
+
"""
|
|
142
|
+
source, others = docs[0], [d for d in docs[1:] if term in d.record]
|
|
143
|
+
if has_slot(source, term) and term in source.record:
|
|
144
|
+
found = [compare(term, source, doc, keywords_case_sensitive=keywords_case_sensitive) for doc in others]
|
|
145
|
+
if term == "author" and "author.@id" not in source.slots:
|
|
146
|
+
found += _between(term, others, orcid_only=True)
|
|
147
|
+
elif term == "keywords" and not source.is_citation:
|
|
148
|
+
found += _between(term, others, keywords_case_sensitive=keywords_case_sensitive)
|
|
149
|
+
else:
|
|
150
|
+
found = _between(term, others, keywords_case_sensitive=keywords_case_sensitive)
|
|
151
|
+
return [f for f in found if f]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _between(
|
|
155
|
+
term: str, holders: list[Doc], *, orcid_only: bool = False, keywords_case_sensitive: bool = False
|
|
156
|
+
) -> list[Finding | None]:
|
|
157
|
+
return [
|
|
158
|
+
compare(term, earlier, later, orcid_only=orcid_only, keywords_case_sensitive=keywords_case_sensitive)
|
|
159
|
+
for i, later in enumerate(holders)
|
|
160
|
+
for earlier in holders[:i]
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def compare(
|
|
165
|
+
term: str, ref: Doc, doc: Doc, *, orcid_only: bool = False, keywords_case_sensitive: bool
|
|
166
|
+
) -> Finding | None:
|
|
167
|
+
"""A finding on ``doc`` when its ``term`` disagrees with ``ref``'s, by the rule for that term and pair.
|
|
168
|
+
|
|
169
|
+
With ``orcid_only``, authors compare by ORCID alone, for people whose names match. Keywords
|
|
170
|
+
compare as names do, unless ``keywords_case_sensitive``.
|
|
171
|
+
"""
|
|
172
|
+
a, b = ref.record[term], doc.record[term]
|
|
173
|
+
both_citation = ref.is_citation and doc.is_citation
|
|
174
|
+
if term in _KEY:
|
|
175
|
+
equal = _KEY[term](a) == _KEY[term](b)
|
|
176
|
+
elif term == "author":
|
|
177
|
+
equal = _same_people(ref, doc, names=not orcid_only)
|
|
178
|
+
a, b = _show_people(a), _show_people(b)
|
|
179
|
+
elif term == "keywords":
|
|
180
|
+
key = keyword_key(case_sensitive=keywords_case_sensitive)
|
|
181
|
+
if not both_citation:
|
|
182
|
+
return _keywords_subset(ref, doc, key)
|
|
183
|
+
equal = set(map(key, a)) == set(map(key, b))
|
|
184
|
+
else: # name, description
|
|
185
|
+
if not both_citation:
|
|
186
|
+
return None # a manifest name is a package id, a description a one-line summary
|
|
187
|
+
equal = a == b
|
|
188
|
+
if equal:
|
|
189
|
+
return None
|
|
190
|
+
return Finding(doc.path, term, MISMATCH[term], f"{_show(b)}, {ref.path} has {_show(a)}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_KEY = {
|
|
194
|
+
"version": codecs.version_key,
|
|
195
|
+
"license": codecs.license_key,
|
|
196
|
+
"identifier": codecs.doi_key,
|
|
197
|
+
"codeRepository": codecs.repo_key,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def keyword_key(*, case_sensitive: bool) -> Callable[[str], str]:
|
|
202
|
+
"""What keywords compare by: as names do, or with only the spacing collapsed when ``case_sensitive``."""
|
|
203
|
+
return codecs.clean if case_sensitive else codecs.name_key
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _keywords_subset(ref: Doc, doc: Doc, key: Callable[[str], str]) -> Finding | None:
|
|
207
|
+
"""A manifest's keywords must all be in the citation file's, whichever is the source."""
|
|
208
|
+
if not ref.is_citation and not doc.is_citation:
|
|
209
|
+
return None # no rule between two manifests
|
|
210
|
+
manifest, citation = (ref, doc) if doc.is_citation else (doc, ref)
|
|
211
|
+
known = set(map(key, citation.record["keywords"]))
|
|
212
|
+
extra = [k for k in manifest.record["keywords"] if key(k) not in known]
|
|
213
|
+
if not extra:
|
|
214
|
+
return None
|
|
215
|
+
detail = f"{', '.join(extra)}: in {manifest.path}, not in {citation.path}"
|
|
216
|
+
return Finding(doc.path, "keywords", "keywords-not-subset", detail)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _same_people(ref: Doc, doc: Doc, *, names: bool) -> bool:
|
|
220
|
+
"""Same names in the same order, ORCIDs equal where both have one. npm's one author is the first.
|
|
221
|
+
|
|
222
|
+
Without ``names``, only the ORCIDs of people at the same place with the same name count.
|
|
223
|
+
"""
|
|
224
|
+
a: tuple[Person, ...] = ref.record["author"]
|
|
225
|
+
b: tuple[Person, ...] = doc.record["author"]
|
|
226
|
+
if "npm" in {ref.format, doc.format}:
|
|
227
|
+
a, b = a[:1], b[:1]
|
|
228
|
+
if names and len(a) != len(b):
|
|
229
|
+
return False
|
|
230
|
+
for p, q in zip(a, b, strict=False):
|
|
231
|
+
same_name = codecs.name_key(p.name or "") == codecs.name_key(q.name or "")
|
|
232
|
+
if names and not same_name:
|
|
233
|
+
return False
|
|
234
|
+
if same_name and p.orcid and q.orcid and p.orcid != q.orcid:
|
|
235
|
+
return False
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _show_people(people: tuple[Person, ...]) -> str:
|
|
240
|
+
return "; ".join(f"{p.name or '?'}" + (f" ({p.orcid})" if p.orcid else "") for p in people)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _show(value: object) -> str:
|
|
244
|
+
if isinstance(value, tuple):
|
|
245
|
+
return ", ".join(map(str, value))
|
|
246
|
+
text = str(value)
|
|
247
|
+
return repr(text if len(text) <= 80 else text[:77] + "...")
|
citesync/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command line: ``citesync check|write|config|rules|explain``.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 when the files agree (after ``write``, once it has written), 1 on any error
|
|
4
|
+
finding (that ``write`` may not fix), 2 when citesync couldn't run. ``write --diff`` exits 1
|
|
5
|
+
when it would change a file or can't read one, as ``ruff format --diff`` does. ``write`` then wrote nothing,
|
|
6
|
+
unless moving a written file into place failed: files moved before it stay written.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import difflib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from citesync.check import Finding, check
|
|
19
|
+
from citesync.config import Config, find_root, load_config, render
|
|
20
|
+
from citesync.errors import CheckError
|
|
21
|
+
from citesync.rules import DOCS_URL, RULES, explain
|
|
22
|
+
from citesync.write import advice, write
|
|
23
|
+
|
|
24
|
+
JSON_VERSION = 1
|
|
25
|
+
_ANSI = {"error": "1;31", "warning": "1;33", "info": "1;34"}
|
|
26
|
+
_GITHUB_LEVEL = {"error": "error", "warning": "warning", "info": "notice"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parser() -> argparse.ArgumentParser:
|
|
30
|
+
parser = argparse.ArgumentParser(prog="citesync", description=__doc__.splitlines()[0])
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('citesync')}")
|
|
32
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
root = argparse.ArgumentParser(add_help=False)
|
|
34
|
+
root.add_argument(
|
|
35
|
+
"--root",
|
|
36
|
+
type=Path,
|
|
37
|
+
help="repo root (default: the nearest directory up with a citation file or config, inside the git repo)",
|
|
38
|
+
)
|
|
39
|
+
root.add_argument("--source", help="the file the others must agree with; overrides config")
|
|
40
|
+
report = argparse.ArgumentParser(add_help=False, parents=[root])
|
|
41
|
+
report.add_argument("--format", choices=("text", "json", "github"), default="text")
|
|
42
|
+
report.add_argument("--json", action="store_const", const="json", dest="format", help="alias of --format json")
|
|
43
|
+
shown = report.add_mutually_exclusive_group()
|
|
44
|
+
shown.add_argument("-v", "--verbose", action="store_true", help="also print not-checked info")
|
|
45
|
+
shown.add_argument("-q", "--quiet", action="store_true", help="print only errors, and no summary")
|
|
46
|
+
sub.add_parser("check", parents=[report], help="report where the files disagree")
|
|
47
|
+
write_ = sub.add_parser("write", parents=[report], help="rewrite the files to agree with the source")
|
|
48
|
+
write_.add_argument("--diff", action="store_true", help="print the changes as a unified diff and write nothing")
|
|
49
|
+
sub.add_parser("config", parents=[root], help="print the config in use and where it came from")
|
|
50
|
+
rules = sub.add_parser("rules", help="list the rules")
|
|
51
|
+
rules.add_argument("--format", choices=("text", "json"), default="text")
|
|
52
|
+
sub.add_parser("explain", help="print a rule's documentation").add_argument("rule", help="a rule id")
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
"""Run the command line and return its exit code."""
|
|
58
|
+
parser = _parser()
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
if getattr(args, "diff", False) and args.format != "text":
|
|
61
|
+
parser.error("--diff prints a unified diff; it takes no --format")
|
|
62
|
+
if args.command in {"rules", "explain"}:
|
|
63
|
+
return _rules(args)
|
|
64
|
+
try:
|
|
65
|
+
args.root = args.root or find_root(Path.cwd())
|
|
66
|
+
config = load_config(args.root, args.source)
|
|
67
|
+
if args.command == "config":
|
|
68
|
+
print(render(config))
|
|
69
|
+
return 0
|
|
70
|
+
if getattr(args, "diff", False):
|
|
71
|
+
return _write_diff(args.root, config)
|
|
72
|
+
written, skipped = write(args.root, config) if args.command == "write" else ({}, {})
|
|
73
|
+
findings, files = check(args.root, config)
|
|
74
|
+
except CheckError as err:
|
|
75
|
+
print(f"citesync: {err}", file=sys.stderr)
|
|
76
|
+
return 2
|
|
77
|
+
except Exception as err: # noqa: BLE001 - a crash is "couldn't check", never exit 1 ("they disagree")
|
|
78
|
+
print(f"citesync: {args.root}: unexpected error: {type(err).__name__}: {err}", file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
findings = _ignore(findings, config.ignore)
|
|
81
|
+
if args.command == "write":
|
|
82
|
+
if not args.quiet: # stdout stays one document in the json and github formats
|
|
83
|
+
for path in written:
|
|
84
|
+
print(f"wrote {path}", file=sys.stdout if args.format == "text" else sys.stderr)
|
|
85
|
+
findings = [
|
|
86
|
+
f if f.severity == "info" else replace(f, message=f"{f.message} ({advice(f, config, skipped)})")
|
|
87
|
+
for f in findings
|
|
88
|
+
]
|
|
89
|
+
_report(findings, files, args)
|
|
90
|
+
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_diff(root: Path, config: Config) -> int:
|
|
94
|
+
"""Print the edits write would make as a unified diff, and any file it can't read on stderr."""
|
|
95
|
+
texts, _ = write(root, config, dry_run=True)
|
|
96
|
+
sys.stdout.write(_diff(root, texts))
|
|
97
|
+
unreadable = [f for f in check(root, config)[0] if f.rule == "unreadable"]
|
|
98
|
+
for f in unreadable: # the diff can't show a file write never touches, so say so
|
|
99
|
+
where = f"{f.file}:{f.line}:{f.column}" if f.line else f.file
|
|
100
|
+
print(f"{where}: error[unreadable] {f.message}", file=sys.stderr)
|
|
101
|
+
return 1 if texts or unreadable else 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ignore(findings: list[Finding], ignore: tuple[str, ...]) -> list[Finding]:
|
|
105
|
+
"""``findings`` less the ignored rules', warning about an ignore entry that matched nothing."""
|
|
106
|
+
for rule in ignore:
|
|
107
|
+
if not any(f.rule == rule for f in findings):
|
|
108
|
+
print(f"citesync: warning: ignore entry {rule!r} matched no finding", file=sys.stderr)
|
|
109
|
+
return [f for f in findings if f.rule not in ignore]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _report(findings: list[Finding], files: int, args: argparse.Namespace) -> None:
|
|
113
|
+
counts = {s: sum(f.severity == s for f in findings) for s in ("error", "warning", "info")}
|
|
114
|
+
if args.format == "json":
|
|
115
|
+
summary = {"files": files, "errors": counts["error"], "warnings": counts["warning"], "info": counts["info"]}
|
|
116
|
+
report = {
|
|
117
|
+
"version": JSON_VERSION,
|
|
118
|
+
"tool": {"name": "citesync", "version": version("citesync")},
|
|
119
|
+
"summary": summary,
|
|
120
|
+
"findings": [f.as_dict() for f in findings],
|
|
121
|
+
}
|
|
122
|
+
print(json.dumps(report, indent=2))
|
|
123
|
+
return
|
|
124
|
+
if args.quiet:
|
|
125
|
+
findings = [f for f in findings if f.severity == "error"]
|
|
126
|
+
elif not args.verbose:
|
|
127
|
+
findings = [f for f in findings if f.severity != "info"]
|
|
128
|
+
total = f"{_count(counts['error'], 'error')}, {_count(counts['warning'], 'warning')} in {_count(files, 'file')}"
|
|
129
|
+
if args.format == "github":
|
|
130
|
+
for f in findings:
|
|
131
|
+
where = f"file={_escape(f.file, property_=True)}," if f.file else ""
|
|
132
|
+
where += f"line={f.line},col={f.column}," if f.line else ""
|
|
133
|
+
message = _escape(f"{f.term}: {f.message}" if f.term else f.message)
|
|
134
|
+
print(f"::{_GITHUB_LEVEL[f.severity]} {where}title=citesync {f.rule}::{message}")
|
|
135
|
+
if not args.quiet: # GitHub shows only the first 10 annotations of a level, so give the totals
|
|
136
|
+
print(f"::notice::citesync: {total}")
|
|
137
|
+
return
|
|
138
|
+
colour = _colour()
|
|
139
|
+
for f in findings:
|
|
140
|
+
where = f"{f.file}{f':{f.line}:{f.column}' if f.line else ''}: " if f.file else ""
|
|
141
|
+
severity = f"\033[{_ANSI[f.severity]}m{f.severity}\033[0m" if colour else f.severity
|
|
142
|
+
print(f"{where}{severity}[{f.rule}] " + (f"{f.term}: {f.message}" if f.term else f.message))
|
|
143
|
+
if not args.quiet:
|
|
144
|
+
print(total)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _diff(root: Path, texts: dict[str, str]) -> str:
|
|
148
|
+
"""The unified diff from each file to its new text, with git's ``a/`` and ``b/`` prefixes."""
|
|
149
|
+
return "".join(
|
|
150
|
+
line
|
|
151
|
+
for rel, new in texts.items()
|
|
152
|
+
for line in difflib.unified_diff(
|
|
153
|
+
(root / rel).read_bytes().decode("utf-8").splitlines(keepends=True),
|
|
154
|
+
new.splitlines(keepends=True),
|
|
155
|
+
f"a/{rel}",
|
|
156
|
+
f"b/{rel}",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _count(n: int, noun: str) -> str:
|
|
162
|
+
return f"{n} {noun}{'' if n == 1 else 's'}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _colour() -> bool:
|
|
166
|
+
"""Colour on a terminal. ``NO_COLOR`` turns it off; ``FORCE_COLOR`` turns it on, or off as 0 or false."""
|
|
167
|
+
if os.environ.get("NO_COLOR"):
|
|
168
|
+
return False
|
|
169
|
+
if force := os.environ.get("FORCE_COLOR"):
|
|
170
|
+
return force.lower() not in {"0", "false"}
|
|
171
|
+
return sys.stdout.isatty()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _rules(args: argparse.Namespace) -> int:
|
|
175
|
+
"""``citesync rules`` and ``citesync explain``."""
|
|
176
|
+
if args.command == "explain":
|
|
177
|
+
if args.rule not in RULES:
|
|
178
|
+
print(f"citesync: unknown rule {args.rule!r}; `citesync rules` lists them", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
print(explain(args.rule), end="")
|
|
181
|
+
return 0
|
|
182
|
+
if args.format == "json":
|
|
183
|
+
rules = [
|
|
184
|
+
{"rule": rule, "severity": r.severity, "summary": r.summary, "url": f"{DOCS_URL}#{rule}"}
|
|
185
|
+
for rule, r in RULES.items()
|
|
186
|
+
]
|
|
187
|
+
print(json.dumps(rules, indent=2))
|
|
188
|
+
return 0
|
|
189
|
+
width = max(map(len, RULES))
|
|
190
|
+
for rule, r in RULES.items():
|
|
191
|
+
print(f"{rule:<{width}} {r.severity:<7} {r.summary}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _escape(text: str, *, property_: bool = False) -> str:
|
|
196
|
+
"""Escape for a workflow command; a property value also escapes ``:`` and ``,``."""
|
|
197
|
+
text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
198
|
+
return text.replace(":", "%3A").replace(",", "%2C") if property_ else text
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|