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/codecs.py ADDED
@@ -0,0 +1,245 @@
1
+ """How a value is spelled in each format, as opposed to where it sits (the mapping's job).
2
+
3
+ Each codec has ``decode``, from what a file holds to the normalised value, and
4
+ ``encode``, from the normalised value to one of the spellings a format uses.
5
+ ``key`` gives what two normalised values are compared by.
6
+ """
7
+
8
+ import re
9
+ import unicodedata
10
+ from urllib.parse import urlsplit
11
+
12
+ from packaging.version import InvalidVersion, Version
13
+
14
+ from citesync.errors import CannotWrite
15
+
16
+ _SPDX_URL = re.compile(r"^https?://spdx\.org/licenses/(?P<id>[^/]+?)(?:\.html|\.json)?/?$", re.IGNORECASE)
17
+ _ORCID_URL = re.compile(r"^https?://(?:www\.)?orcid\.org/", re.IGNORECASE)
18
+ _SLASHES_AND_SPACE = re.compile(r"^[\s/]+|[\s/]+$") # "/\r0" is "0", as decoding it again gives
19
+ _DOI_PREFIX = re.compile(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", re.IGNORECASE)
20
+ _DOI = re.compile(r"^10\.\d+/\S+$")
21
+ _NPM_HOSTS = {"github": "github.com", "gitlab": "gitlab.com", "bitbucket": "bitbucket.org"}
22
+ _NPM_SHORTHAND = re.compile(r"^(?P<host>github|gitlab|bitbucket):(?P<path>[\w.-]+/[\w.-]+)$")
23
+ _SSH = re.compile(r"^(?:ssh://)?git@(?P<host>[^:/]+)[:/](?P<path>.+)$")
24
+ # What follows the repository on a forge's web pages: a branch, tag, file or GitLab's /-/ pages.
25
+ _OWNER_REPO_TAIL = r"^(/[^/]+/[^/]+)/{}(?:/.*)?$"
26
+ _REF_TAIL = {
27
+ "github.com": re.compile(_OWNER_REPO_TAIL.format("(?:tree|blob)")),
28
+ "gitlab.com": re.compile(r"^(/.+?)/-(?:/.*)?$"), # groups nest, so the repository is everything before /-/
29
+ "codeberg.org": re.compile(_OWNER_REPO_TAIL.format("src")),
30
+ "gitea.com": re.compile(_OWNER_REPO_TAIL.format("src")),
31
+ "bitbucket.org": re.compile(_OWNER_REPO_TAIL.format("src")),
32
+ }
33
+ _CONTACT = re.compile(r"^(?P<name>[^<(]*?)\s*(?:<[^>]*>)?\s*(?:\([^)]*\))?$")
34
+ _SEMVER_TAG = {"a": "alpha", "b": "beta", "rc": "rc"}
35
+
36
+
37
+ def clean(text: str) -> str:
38
+ """Whitespace collapsed to single spaces, and none at either end."""
39
+ return " ".join(text.split())
40
+
41
+
42
+ # Licence: an SPDX id, an SPDX URL, or Zenodo's lowercase id.
43
+
44
+
45
+ def license_decode(raw: object) -> str | None:
46
+ """The SPDX id in a licence field; PEP 621's legacy ``{text = ...}`` table gives its text.
47
+
48
+ A list (CFF, codemeta) reads as the SPDX expression ``A OR B``, in the order given.
49
+ """
50
+ if isinstance(raw, list):
51
+ ids = [i for i in map(license_decode, raw) if i]
52
+ return " OR ".join(ids) or None
53
+ if isinstance(raw, dict):
54
+ raw = raw.get("text") # a {file = ...} table names a file, not a licence
55
+ if not isinstance(raw, str) or not raw.strip():
56
+ return None
57
+ raw = raw.strip()
58
+ match = _SPDX_URL.match(raw)
59
+ return match["id"] if match else raw
60
+
61
+
62
+ def license_encode(spdx: str, form: str) -> str:
63
+ """``spdx`` as ``"spdx"`` (the id), ``"spdx-url"`` or ``"zenodo"`` (lowercase id: Zenodo's vocabulary)."""
64
+ return {"spdx": spdx, "spdx-url": f"https://spdx.org/licenses/{spdx}", "zenodo": spdx.lower()}[form]
65
+
66
+
67
+ def license_key(spdx: str) -> str:
68
+ """Licences compare case-insensitively, since Zenodo lowercases them.
69
+
70
+ Parentheses group nothing when ``OR`` is the only operator, so they are dropped then.
71
+ """
72
+ key = spdx.casefold()
73
+ return key if {"and", "with"} & set(key.split()) else key.replace("(", "").replace(")", "")
74
+
75
+
76
+ # ORCID: a URL or a bare iD.
77
+
78
+
79
+ def orcid_decode(raw: object) -> str | None:
80
+ """The bare iD (``0000-0002-1825-0097``), with a final ``x`` upper-cased."""
81
+ if not isinstance(raw, str) or not raw.strip():
82
+ return None
83
+ bare = _SLASHES_AND_SPACE.sub("", _ORCID_URL.sub("", raw.strip()))
84
+ return bare.upper() or None
85
+
86
+
87
+ def orcid_encode(orcid: str, form: str) -> str:
88
+ """``orcid`` as ``"url"`` (CFF, codemeta ``@id``) or ``"bare"`` (Zenodo)."""
89
+ return {"url": f"https://orcid.org/{orcid}", "bare": orcid}[form]
90
+
91
+
92
+ # DOI: bare, a doi.org URL or a doi: prefix.
93
+
94
+
95
+ def doi_decode(raw: object) -> str | None:
96
+ """The bare DOI (``10.5281/zenodo.1234567``), or None for a value that isn't one.
97
+
98
+ codemeta's ``identifier`` may be any URL, a schema.org PropertyValue or a list: the first DOI
99
+ in it counts. A PropertyValue holds a DOI in its ``value`` or ``url`` (``propertyID`` ``DOI``);
100
+ any other, such as a SWHID or ROR id, is ignored.
101
+ """
102
+ if isinstance(raw, dict):
103
+ raw = [raw.get("value"), raw.get("url")]
104
+ if isinstance(raw, list):
105
+ return next(filter(None, map(doi_decode, raw)), None)
106
+ if not isinstance(raw, str):
107
+ return None
108
+ doi = _DOI_PREFIX.sub("", raw.strip())
109
+ return doi if _DOI.match(doi) else None
110
+
111
+
112
+ def doi_encode(doi: str, form: str) -> str:
113
+ """``doi`` as ``"bare"`` (CFF, Zenodo) or ``"url"`` (codemeta ``identifier``)."""
114
+ return {"bare": doi, "url": f"https://doi.org/{doi}"}[form]
115
+
116
+
117
+ def doi_key(doi: str) -> str:
118
+ """DOIs are case-insensitive."""
119
+ return doi.casefold()
120
+
121
+
122
+ # Repository URL: strip git+, .git and a trailing slash.
123
+
124
+
125
+ def repo_decode(raw: object) -> str | None:
126
+ """The repository URL without ``git+``, ``.git`` or a trailing slash.
127
+
128
+ npm's shorthands (``github:o/r``) and SSH remotes (``git@github.com:o/r.git``)
129
+ expand to the https URL.
130
+ """
131
+ if not isinstance(raw, str) or not raw.strip():
132
+ return None
133
+ url = raw.strip().removeprefix("git+")
134
+ if match := _NPM_SHORTHAND.match(url):
135
+ url = f"https://{_NPM_HOSTS[match['host']]}/{match['path']}"
136
+ elif match := _SSH.match(url):
137
+ url = f"https://{match['host']}/{match['path']}"
138
+ while url.endswith(("/", ".git")):
139
+ url = url.rstrip("/").removesuffix(".git")
140
+ return url or None
141
+
142
+
143
+ def repo_key(url: str) -> str:
144
+ """The repository a URL names, as npm's hosted-git-info reads it: https, the host lowercased without ``www.``.
145
+
146
+ No credentials, query, fragment (npm's ``#ref``), ``.git`` or trailing slash. On a known forge, a
147
+ branch, tag or file page names its repository (``/tree/v1.0`` on GitHub, ``/-/...`` on GitLab,
148
+ ``/src/...`` on Codeberg, Gitea and Bitbucket), and on github.com, which ignores case, the path is casefolded.
149
+ """
150
+ try:
151
+ parts = urlsplit(url.strip())
152
+ except ValueError: # an unbalanced [ in the host
153
+ return url.strip()
154
+ if not parts.netloc:
155
+ return url.strip()
156
+ host = re.sub(r"^(?:www\.)+", "", parts.netloc.rpartition("@")[2].lower())
157
+ path = parts.path.casefold() if host == "github.com" else parts.path
158
+ tail, old = _REF_TAIL.get(host), None
159
+ while path != old: # until stable: stripping .git can expose a tail, and dropping a tail a .git
160
+ old = path
161
+ path = path.rstrip("/").removesuffix(".git")
162
+ if tail and (match := tail.match(path)):
163
+ path = match[1]
164
+ return f"https://{host}{path}"
165
+
166
+
167
+ # Person name: given and family, Zenodo's "Family, Given", or one "Name <email> (url)" string.
168
+
169
+
170
+ def zenodo_name_decode(raw: object) -> tuple[str | None, str] | None:
171
+ """(given, family) from ``"Family, Given"``. Without a comma, the whole string is the family name."""
172
+ if not isinstance(raw, str) or not raw.strip():
173
+ return None
174
+ family, sep, given = raw.partition(",")
175
+ if not sep:
176
+ return None, clean(family)
177
+ return (clean(given) or None), clean(family)
178
+
179
+
180
+ def zenodo_name_encode(given: str | None, family: str) -> str:
181
+ """``"Family, Given"``, or the family name alone."""
182
+ return f"{family}, {given}" if given else family
183
+
184
+
185
+ def contact_name_decode(raw: object) -> str | None:
186
+ """The name in an npm or Cargo person string, ``"Name <email> (url)"``."""
187
+ if not isinstance(raw, str):
188
+ return None
189
+ match = _CONTACT.match(raw.strip())
190
+ name = clean(match["name"]) if match else clean(raw)
191
+ return name or None
192
+
193
+
194
+ def display_name(given: str | None, family: str | None) -> str | None:
195
+ """``"Given Family"`` from the parts a format holds."""
196
+ name = clean(" ".join(part for part in (given, family) if part))
197
+ return name or None
198
+
199
+
200
+ def name_key(name: str) -> str:
201
+ """Names compare case-insensitively, with whitespace collapsed and Unicode in NFC."""
202
+ return unicodedata.normalize("NFC", clean(name)).casefold()
203
+
204
+
205
+ # Version: equal as PEP 440 versions, so v1.2.3 = 1.2.3, 1.0 = 1.0.0 and 0.3.1-rc.1 = 0.3.1rc1.
206
+
207
+
208
+ def version_decode(raw: object) -> str | None:
209
+ """The version as written; YAML may have read ``1.0`` as a number."""
210
+ if raw is None or isinstance(raw, (bool, dict, list)):
211
+ return None
212
+ text = str(raw).strip()
213
+ return text or None
214
+
215
+
216
+ def version_key(version: str) -> Version | str:
217
+ """The parsed PEP 440 version, or for one that isn't (``unreleased``) the string itself.
218
+
219
+ A ``Version`` never equals a ``str``, so a PEP 440 version and anything else differ.
220
+ """
221
+ try:
222
+ return Version(version)
223
+ except InvalidVersion:
224
+ return version.strip()
225
+
226
+
227
+ def version_encode_semver(version: str) -> str:
228
+ """``version`` in semver spelling: ``v1.2rc1`` is ``1.2.0-rc.1``, ``1.0.dev3`` is ``1.0.0-dev.3``.
229
+
230
+ Raises:
231
+ CannotWrite: for a version semver has no spelling for: not PEP 440, a post-release,
232
+ an epoch, or more than three release numbers.
233
+ """
234
+ try:
235
+ v = Version(version)
236
+ except InvalidVersion:
237
+ v = None
238
+ if v is None or v.post is not None or v.epoch or len(v.release) > 3: # semver has major.minor.patch only
239
+ msg = f"semver has no spelling for {version}"
240
+ raise CannotWrite(msg)
241
+ pre = [_SEMVER_TAG[v.pre[0]], v.pre[1]] if v.pre else []
242
+ dev = ["dev", v.dev] if v.dev is not None else []
243
+ suffix = ".".join(map(str, pre + dev))
244
+ release = ".".join(map(str, (*v.release, 0, 0)[:3]))
245
+ return release + (f"-{suffix}" if suffix else "") + (f"+{v.local}" if v.local else "")
citesync/config.py ADDED
@@ -0,0 +1,168 @@
1
+ """Find and validate the config: the first location found wins, flags override it."""
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, replace
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any
8
+
9
+ from citesync.errors import CheckError, UnreadableError
10
+ from citesync.formats import CITATION_FILES, CODEMETA_VERSIONS, FORMAT_OF, MANIFESTS, load
11
+ from citesync.mapping import TERMS, resolve
12
+ from citesync.rules import RULES
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Config:
17
+ """The settings a check runs with. ``None`` for files or manifests means the default discovery."""
18
+
19
+ source: str = "CITATION.cff"
20
+ codemeta_version: str = "3.0"
21
+ files: tuple[str, ...] | None = None
22
+ manifests: tuple[str, ...] | None = None
23
+ write_manifests: tuple[str, ...] = ()
24
+ keywords_case_sensitive: bool = False
25
+ ignore: tuple[str, ...] = ()
26
+ origin: str = "defaults"
27
+
28
+
29
+ # Config tables inside manifests, in the D7 order: (file, mapping path, origin).
30
+ _MANIFEST_TABLES = (
31
+ ("pyproject.toml", "tool.citesync", "pyproject.toml [tool.citesync]"),
32
+ ("Cargo.toml", "workspace.metadata.citesync", "Cargo.toml [workspace.metadata.citesync]"),
33
+ ("Cargo.toml", "package.metadata.citesync", "Cargo.toml [package.metadata.citesync]"),
34
+ ("package.json", "citesync", 'package.json "citesync"'),
35
+ )
36
+
37
+
38
+ def find_config(root: Path) -> tuple[dict[str, Any], str]:
39
+ """The raw config table and where it came from, in the D7 order; ``({}, "defaults")`` when none.
40
+
41
+ A manifest that can't be read, or has a non-table on the way to the config table,
42
+ holds no config here: the check reports it as ``unreadable`` if it is broken.
43
+ """
44
+ for name in ("citesync.toml", ".citesync.toml"):
45
+ if (root / name).is_file():
46
+ return load(root / name, name), name
47
+ for name, path, origin in _MANIFEST_TABLES:
48
+ if (root / name).is_file():
49
+ try:
50
+ table = next(iter(resolve(load(root / name, name), path)), None)
51
+ except UnreadableError:
52
+ continue
53
+ if table is not None:
54
+ return table, origin
55
+ return {}, "defaults"
56
+
57
+
58
+ def find_root(start: Path) -> Path:
59
+ """The nearest directory from ``start`` up that holds a citation file or a citesync config, else ``start``.
60
+
61
+ The walk stops at the directory holding ``.git``, so a submodule or nested repo never takes its parent's files.
62
+ """
63
+ for directory in (start, *start.parents):
64
+ names = (*CITATION_FILES, "citesync.toml", ".citesync.toml")
65
+ if any((directory / name).is_file() for name in names) or find_config(directory)[1] != "defaults":
66
+ return directory
67
+ if (directory / ".git").exists():
68
+ break
69
+ return start
70
+
71
+
72
+ def load_config(root: Path, source: str | None = None) -> Config:
73
+ """The config in use under ``root``, with ``source`` from the command line on top.
74
+
75
+ Raises:
76
+ CheckError: on an unknown key or a value citesync can't use.
77
+ """
78
+ raw, origin = find_config(root)
79
+ if not isinstance(raw, dict):
80
+ msg = f"{origin}: the citesync config is not a table"
81
+ raise CheckError(msg)
82
+ unknown = sorted(set(raw) - set(_PARSE))
83
+ if unknown:
84
+ msg = f"{origin}: unknown key {', '.join(unknown)}; known keys: {', '.join(_PARSE)}"
85
+ raise CheckError(msg)
86
+ config = Config(origin=origin, **{key.replace("-", "_"): _PARSE[key](value, origin) for key, value in raw.items()})
87
+ if source is not None:
88
+ config = replace(config, source=_path(source, FORMAT_OF, "--source", "source"))
89
+ return config
90
+
91
+
92
+ def _codemeta_version(value: object, origin: str) -> str:
93
+ if value not in CODEMETA_VERSIONS:
94
+ msg = f"{origin}: codemeta-version must be one of {', '.join(CODEMETA_VERSIONS)}"
95
+ raise CheckError(msg)
96
+ return str(value)
97
+
98
+
99
+ def _bool(value: object, origin: str, key: str) -> bool:
100
+ if not isinstance(value, bool):
101
+ msg = f"{origin}: {key} must be true or false"
102
+ raise CheckError(msg)
103
+ return value
104
+
105
+
106
+ def _path(value: object, names: dict[str, str], origin: str, key: str) -> str:
107
+ if not isinstance(value, str) or PurePosixPath(value).name not in names:
108
+ msg = f"{origin}: {key} must name one of {', '.join(names)}, got {value!r}"
109
+ raise CheckError(msg)
110
+ path = PurePosixPath(value)
111
+ if path.is_absolute() or ".." in path.parts:
112
+ msg = f"{origin}: {key} {value!r} must be relative to the repo root, inside it"
113
+ raise CheckError(msg)
114
+ return str(path)
115
+
116
+
117
+ def _paths(value: object, names: dict[str, str], origin: str, key: str) -> tuple[str, ...]:
118
+ if not isinstance(value, list):
119
+ msg = f"{origin}: {key} must be a list of paths"
120
+ raise CheckError(msg)
121
+ return tuple(_path(item, names, origin, key) for item in value)
122
+
123
+
124
+ def _write_manifests(value: object, origin: str) -> tuple[str, ...]:
125
+ items = [value] if isinstance(value, str) else value
126
+ if not isinstance(items, list) or not all(isinstance(i, str) and (i in TERMS or i == "all") for i in items):
127
+ msg = f"{origin}: write-manifests must be 'all' or a list of {', '.join(TERMS)}, got {value!r}"
128
+ raise CheckError(msg)
129
+ return tuple(TERMS) if "all" in items else tuple(items)
130
+
131
+
132
+ def _ignore(value: object, origin: str) -> tuple[str, ...]:
133
+ ignorable = [rule for rule in RULES if rule != "unreadable"] # a file citesync can't read always fails
134
+ if not isinstance(value, list) or not all(isinstance(i, str) and i in ignorable for i in value):
135
+ msg = f"{origin}: ignore must be a list of rule ids from {', '.join(ignorable)}, got {value!r}"
136
+ raise CheckError(msg)
137
+ return tuple(value)
138
+
139
+
140
+ # One parser per key; each raises CheckError naming the key.
141
+ _PARSE: dict[str, Callable[[object, str], Any]] = {
142
+ "source": lambda value, origin: _path(value, FORMAT_OF, origin, "source"),
143
+ "codemeta-version": _codemeta_version,
144
+ "files": lambda value, origin: _paths(value, CITATION_FILES, origin, "files"),
145
+ "manifests": lambda value, origin: _paths(value, MANIFESTS, origin, "manifests"),
146
+ "write-manifests": _write_manifests,
147
+ "keywords-case-sensitive": lambda value, origin: _bool(value, origin, "keywords-case-sensitive"),
148
+ "ignore": _ignore,
149
+ }
150
+
151
+
152
+ def render(config: Config) -> str:
153
+ """The config as TOML, headed by where it came from (``citesync config``)."""
154
+ lines = [
155
+ f"# from {config.origin}",
156
+ f"source = {json.dumps(config.source)}",
157
+ f"codemeta-version = {json.dumps(config.codemeta_version)}",
158
+ f"files = {json.dumps(list(config.files))}"
159
+ if config.files is not None
160
+ else "# files: not set, so every citation file present",
161
+ f"manifests = {json.dumps(list(config.manifests))}"
162
+ if config.manifests is not None
163
+ else "# manifests: not set, so every release manifest at the repo root",
164
+ f"write-manifests = {json.dumps(list(config.write_manifests))}",
165
+ f"keywords-case-sensitive = {json.dumps(config.keywords_case_sensitive)}",
166
+ f"ignore = {json.dumps(list(config.ignore))}",
167
+ ]
168
+ return "\n".join(lines)
citesync/errors.py ADDED
@@ -0,0 +1,23 @@
1
+ """The errors citesync raises: :class:`CheckError` becomes exit 2, :class:`UnreadableError` a finding.
2
+
3
+ :class:`CannotWrite` never leaves ``write``: the field is left as it was, and its finding says why.
4
+ """
5
+
6
+
7
+ class CheckError(Exception):
8
+ """A config error, a missing file or a bad mapping, with the path named."""
9
+
10
+
11
+ class UnreadableError(CheckError):
12
+ """A file citesync can't read, parse or use, reported as an ``unreadable`` finding on ``path``.
13
+
14
+ ``line`` and ``column`` are 1-based, when the parser gives them.
15
+ """
16
+
17
+ def __init__(self, path: str, message: str, line: int | None = None, column: int | None = None) -> None:
18
+ super().__init__(f"{path}{f':{line}:{column}' if line else ''}: {message}")
19
+ self.path, self.message, self.line, self.column = path, message, line, column
20
+
21
+
22
+ class CannotWrite(Exception): # noqa: N818 - a reason, not a failure: write skips the field
23
+ """A value write can't spell in a file's format without losing something, with the reason."""