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.
@@ -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())