ordinator 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.
ordinator/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """The machinery: a dataset passed through named, swappable, recorded steps.
2
+
3
+ A consumer declares its vocabulary as data and answers what this package asks through
4
+ the names it declares. Nothing here imports a consumer, and nothing here is imported on
5
+ import of this package: take the modules you need.
6
+ """
7
+
8
+ #: The kernel's own version, apart from any consumer's. Read by this package's
9
+ #: pyproject.toml, which sits beside this file.
10
+ __version__ = "0.1.0"
@@ -0,0 +1,270 @@
1
+ """Citations, read from ``references.bib`` -- the one place they are written.
2
+
3
+ A ``references=`` entry is a *string*, and the manifest merges a run's citations
4
+ by exact string equality, so two spellings of one source are two sources: they
5
+ print twice, and a reader comparing two runs sees a difference that is only
6
+ typography.
7
+
8
+ So a registration cites a *key*::
9
+
10
+ references=cite('webb1980')
11
+
12
+ and the text comes from the BibTeX file the consumer declares with :func:`use`
13
+ (its ``references.bib``). Nothing restates a
14
+ citation in Python, the file is ordinary BibTeX a manuscript can use directly,
15
+ and the short author-year label the qaqc routines stamp on their output is
16
+ *derived* from the same entry rather than stored, so the two forms cannot drift
17
+ apart.
18
+
19
+ The parser here is deliberately small: enough BibTeX for a hand-maintained file
20
+ of a few dozen entries, and no dependency for a package whose job is not
21
+ bibliography management.
22
+ """
23
+
24
+ import re
25
+ from pathlib import Path
26
+
27
+ #: The bibliography in use, declared through :func:`use`. It is the consumer's and ships
28
+ #: as its package data: an installed wheel has to carry it or a run's manifest could not
29
+ #: name its own sources.
30
+ BIB_PATH = None
31
+
32
+ _ENTRY = re.compile(r'@(\w+)\s*\{')
33
+ _FIELD = re.compile(r'[\s,]*(\w[\w-]*)\s*=\s*')
34
+ _BARE = re.compile(r'[^,\s]*')
35
+
36
+
37
+ class Citation(str):
38
+ """A citation already formatted -- an entry of the bibliography, or what
39
+ :func:`credit` built -- which :func:`cite` passes through as written."""
40
+
41
+
42
+ def _closing(text, start, close):
43
+ """The index of the ``close`` that ends what opens just before ``start``,
44
+ braces nested inside it counted, or ``len(text)`` where nothing does."""
45
+ depth, i = 0, start
46
+ while i < len(text) and (depth or text[i] != close):
47
+ depth += {'{': 1, '}': -1}.get(text[i], 0)
48
+ i += 1
49
+ return i
50
+
51
+
52
+ def _fields(body):
53
+ """``{field: value}`` of one entry's body: braced, quoted or bare values."""
54
+ fields, i = {}, 0
55
+ while (m := _FIELD.match(body, i)):
56
+ i = m.end()
57
+ if i < len(body) and body[i] in '{"':
58
+ end = _closing(body, i + 1, '}' if body[i] == '{' else '"')
59
+ value, i = body[i + 1:end], end + 1
60
+ else:
61
+ value = _BARE.match(body, i).group()
62
+ i += len(value)
63
+ fields[m.group(1).lower()] = ' '.join(value.split())
64
+ return fields
65
+
66
+
67
+ def _parse(text):
68
+ """``{key: {'type': ..., field: value}}`` for a hand-written BibTeX file.
69
+
70
+ An entry ends at the brace that balances its opening one, wherever it sits:
71
+ a ``}`` on the last field's line closes that entry and not the next one.
72
+ """
73
+ entries, pos = {}, 0
74
+ while (m := _ENTRY.search(text, pos)):
75
+ end = _closing(text, m.end(), '}')
76
+ pos = end + 1
77
+ if m.group(1).lower() in ('comment', 'preamble', 'string'):
78
+ continue
79
+ key, _, body = text[m.end():end].partition(',')
80
+ entries[key.strip()] = {'type': m.group(1).lower(), **_fields(body)}
81
+ return entries
82
+
83
+
84
+ _ENTRIES = {}
85
+
86
+
87
+ def use(path):
88
+ """Read the bibliography citations are taken from."""
89
+ global BIB_PATH
90
+ BIB_PATH = Path(path)
91
+ _ENTRIES.clear()
92
+ _ENTRIES.update(_parse(BIB_PATH.read_text(encoding='utf8')))
93
+
94
+
95
+ def _authors(entry):
96
+ """The author (or editor) names, ``others`` kept as the et-al marker."""
97
+ raw = entry.get('author') or entry.get('editor') or ''
98
+ return [n.strip() for n in raw.split(' and ') if n.strip()]
99
+
100
+
101
+ def _surname(name):
102
+ """``Webb, E. K.`` and ``B. Heinesch`` both give ``Webb`` / ``Heinesch``."""
103
+ return name.split(',')[0].strip() if ',' in name else name.split()[-1]
104
+
105
+
106
+ def _entry(key):
107
+ try:
108
+ return _ENTRIES[key]
109
+ except KeyError:
110
+ raise KeyError(
111
+ f"no reference {key!r} in {getattr(BIB_PATH, 'name', 'the bibliography')}; add the entry there "
112
+ f"rather than writing the citation at the call site") from None
113
+
114
+
115
+ def _byline(entry):
116
+ """``A and B`` for two names, ``A, B, and C`` for more, ``A, et al.`` for
117
+ an ``and others`` list -- the house style the citations were written in."""
118
+ names = _authors(entry)
119
+ if names and names[-1] == 'others':
120
+ # ``Vitale, D., et al.`` but ``B. Heinesch et al.``: the comma belongs to
121
+ # the surname-first form, and the software credits are written the other
122
+ # way round.
123
+ sep = ', ' if ',' in names[0] else ' '
124
+ return f"{names[0]}{sep}et al."
125
+ if len(names) == 2:
126
+ return f"{names[0]} and {names[1]}"
127
+ if len(names) > 2:
128
+ return ", ".join(names[:-1]) + f", and {names[-1]}"
129
+ return names[0] if names else ''
130
+
131
+
132
+ def _format(key):
133
+ """One entry as the citation string that reaches a manifest."""
134
+ return Citation(_formatted(_entry(key)))
135
+
136
+
137
+ def _formatted(e):
138
+ who = _byline(e)
139
+ if e['type'] == 'software':
140
+ # Credited by name first: the software is the thing being cited, and the
141
+ # authors qualify it. :func:`credit` appends the routine.
142
+ text = f"{e['title']} ({who})"
143
+ if e.get('note'):
144
+ text = f"{e['title']} ({who}, {e['note']})"
145
+ link = _link(e)
146
+ return f"{text}, {link}" if link else text + '.'
147
+ if e.get('editor') and e['type'] == 'book':
148
+ who += ' (eds.)'
149
+
150
+ year = e.get('year')
151
+ parts = [f"{who} ({year})." if who and year else who]
152
+ parts.append(f"{e['title']}.")
153
+ if e['type'] == 'article':
154
+ tail = e.get('journal', '')
155
+ if e.get('volume'):
156
+ tail = f"{tail} {e['volume']}".strip()
157
+ if e.get('pages'):
158
+ tail = f"{tail}, {e['pages']}".strip(', ')
159
+ if tail:
160
+ parts.append(f"{tail}.")
161
+ elif e['type'] == 'incollection':
162
+ tail = f"In: {e.get('booktitle', '')}"
163
+ for extra in (e.get('publisher'), e.get('pages')):
164
+ if extra:
165
+ tail += f", {extra}"
166
+ parts.append(f"{tail}.")
167
+ elif e['type'] == 'book' and e.get('publisher'):
168
+ parts.append(f"{e['publisher']}.")
169
+ if e.get('note'):
170
+ parts.append(f"({e['note']}.)")
171
+ parts.append(_link(e))
172
+ return " ".join(p for p in parts if p).strip()
173
+
174
+
175
+ def _link(entry):
176
+ """The one resolver a citation carries: the DOI when the work has one,
177
+ else its URL. A DOI is written bare in the file (``10.1002/...``) and
178
+ printed as the ``doi.org`` address so a manifest reader can follow it."""
179
+ if entry.get('doi'):
180
+ return f"https://doi.org/{entry['doi']}"
181
+ return entry.get('url') or ''
182
+
183
+
184
+ def cite(*references):
185
+ """The given works as the list ``references=`` expects, without repeats.
186
+
187
+ Each argument is a key in ``references.bib``, or a :class:`Citation`
188
+ already formatted (what :func:`credit` and this function return). Order is
189
+ preserved: a registration lists its primary source first, and ``describe``
190
+ prints them in that order.
191
+
192
+ Raises:
193
+ KeyError: an argument is neither -- a misspelt key would otherwise be
194
+ published as its own citation in every manifest.
195
+ """
196
+ seen, out = set(), []
197
+ for ref in references:
198
+ for one in (ref if isinstance(ref, (list, tuple)) else [ref]):
199
+ text = one if isinstance(one, Citation) else _format(one)
200
+ if text not in seen:
201
+ seen.add(text)
202
+ out.append(text)
203
+ return out
204
+
205
+
206
+ def short(*references):
207
+ """The works as one ``'A (yyyy); B & C (yyyy)'`` label.
208
+
209
+ For the ``reference`` attribute a qaqc routine stamps on its output, where a
210
+ full bibliographic string would swamp the variable's attrs. Derived from the
211
+ same entry the registration cites, so a routine cannot stamp one paper while
212
+ its registration names two.
213
+
214
+ Anything with no year (the software credits) is passed over: ``source=``
215
+ already names those. A key with no entry raises, as it does in :func:`cite`:
216
+ passed over, it would stamp one paper where the routine names two.
217
+ """
218
+ seen, out = set(), []
219
+ for key in references:
220
+ entry = _entry(key)
221
+ if not entry.get('year'):
222
+ continue
223
+ raw = _authors(entry)
224
+ names = [_surname(n) for n in raw if n != 'others']
225
+ if 'others' in raw or len(names) > 2:
226
+ who = f"{names[0]} et al."
227
+ elif len(names) == 2:
228
+ who = f"{names[0]} & {names[1]}"
229
+ else:
230
+ who = names[0] if names else '?'
231
+ label = f"{who} ({entry['year']})"
232
+ if label not in seen:
233
+ seen.add(label)
234
+ out.append(label)
235
+ return '; '.join(out)
236
+
237
+
238
+ def short_of(references):
239
+ """The short label for citations that are already formatted.
240
+
241
+ The inverse of what a registration does: a bridged method stamps its output
242
+ with the same author-year label as the entry it twins, and reading it back
243
+ off that entry's ``references`` is what keeps the two from drifting -- the
244
+ alternative being the bib keys restated in a second file.
245
+
246
+ Anything that is not a formatted entry of ``references.bib`` -- a software
247
+ credit, a free-text note -- has no year and is passed over, as in
248
+ :func:`short`.
249
+ """
250
+ reverse = {_format(key): key for key in _ENTRIES}
251
+ return short(*[reverse[text] for text in (references or [])
252
+ if text in reverse])
253
+
254
+
255
+ def credit(key, what, version=None):
256
+ """Credit a piece of software for ``what`` -- a routine, a table, a recipe.
257
+
258
+ One phrasing for all of them, built from the entry ``key`` names. Two
259
+ orderings of the same words were in use -- the authors before the routine in
260
+ one module and after it in another -- so one source deduped as two in the
261
+ run manifest.
262
+ """
263
+ e = _entry(key)
264
+ name = e['title'] + (f' {version}' if version else '')
265
+ return Citation(f"{name} ({_byline(e)}, {e['note']}), {what}.")
266
+
267
+
268
+ def keys():
269
+ """Every key ``references.bib`` defines, for tests and for the docs build."""
270
+ return sorted(_ENTRIES)