parla 0.0.1__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.
Files changed (148) hide show
  1. parla/__init__.py +3 -0
  2. parla/assets/fonts/LICENSE-Latin-Modern.txt +171 -0
  3. parla/assets/fonts/lmmono10-italic.otf +0 -0
  4. parla/assets/fonts/lmmono10-regular.otf +0 -0
  5. parla/assets/fonts/lmmonolt10-bold.otf +0 -0
  6. parla/assets/fonts/lmsans10-bold.otf +0 -0
  7. parla/assets/fonts/lmsans10-boldoblique.otf +0 -0
  8. parla/assets/fonts/lmsans10-oblique.otf +0 -0
  9. parla/assets/fonts/lmsans10-regular.otf +0 -0
  10. parla/casebook.py +180 -0
  11. parla/checks/__init__.py +3 -0
  12. parla/checks/finding.py +28 -0
  13. parla/checks/ids.py +59 -0
  14. parla/checks/shared.py +57 -0
  15. parla/cli.py +559 -0
  16. parla/config.py +371 -0
  17. parla/constraints.py +432 -0
  18. parla/data/config.example.toml +32 -0
  19. parla/docker/Dockerfile +49 -0
  20. parla/docker/tl-packages.txt +11 -0
  21. parla/docker/tl.profile +14 -0
  22. parla/engines/__init__.py +0 -0
  23. parla/engines/base.py +97 -0
  24. parla/engines/latex/__init__.py +0 -0
  25. parla/engines/latex/autoload.py +62 -0
  26. parla/engines/latex/bib.py +348 -0
  27. parla/engines/latex/build.py +269 -0
  28. parla/engines/latex/checks.py +446 -0
  29. parla/engines/latex/diff.py +100 -0
  30. parla/engines/latex/engine.py +82 -0
  31. parla/engines/latex/export.py +298 -0
  32. parla/engines/latex/image.py +90 -0
  33. parla/engines/latex/links.py +36 -0
  34. parla/engines/none/__init__.py +0 -0
  35. parla/engines/none/engine.py +36 -0
  36. parla/engines/registry.py +22 -0
  37. parla/engines/svg/__init__.py +0 -0
  38. parla/engines/svg/build.py +102 -0
  39. parla/engines/svg/checks.py +460 -0
  40. parla/engines/svg/engine.py +56 -0
  41. parla/engines/svg/fonts.py +58 -0
  42. parla/frontmatter.py +16 -0
  43. parla/guides/__init__.py +3 -0
  44. parla/guides/audience/default.md +7 -0
  45. parla/guides/constraints.jsonl +6 -0
  46. parla/guides/edit.py +191 -0
  47. parla/guides/engine/beamer.md +8 -0
  48. parla/guides/engine/cv.md +12 -0
  49. parla/guides/engine/latex.md +11 -0
  50. parla/guides/engine/none.md +7 -0
  51. parla/guides/engine/svg.md +10 -0
  52. parla/guides/general.md +4 -0
  53. parla/guides/lint.py +115 -0
  54. parla/guides/modality/cv.md +24 -0
  55. parla/guides/modality/diagram.md +18 -0
  56. parla/guides/modality/paper.md +23 -0
  57. parla/guides/modality/short-paper.md +9 -0
  58. parla/guides/modality/social-post.md +18 -0
  59. parla/guides/modality/talk.md +24 -0
  60. parla/guides/modality/thesis.md +11 -0
  61. parla/guides/move/compress.md +15 -0
  62. parla/guides/move/elaborate.md +7 -0
  63. parla/guides/move/hedge.md +9 -0
  64. parla/guides/move/sharpen.md +9 -0
  65. parla/guides/pages.py +151 -0
  66. parla/guides/procedure/condense-table.md +7 -0
  67. parla/guides/procedure/edit.md +7 -0
  68. parla/guides/procedure/fit-pages.md +7 -0
  69. parla/guides/procedure/ground.md +7 -0
  70. parla/guides/procedure/read-figure.md +7 -0
  71. parla/guides/procedure/read-paper.md +7 -0
  72. parla/guides/rules.py +129 -0
  73. parla/guides/show.py +391 -0
  74. parla/guides/topic/abstract.md +10 -0
  75. parla/guides/topic/bullets.md +6 -0
  76. parla/guides/topic/captions.md +6 -0
  77. parla/guides/topic/citations.md +6 -0
  78. parla/guides/topic/claims.md +6 -0
  79. parla/guides/topic/contributions.md +6 -0
  80. parla/guides/topic/deck.md +8 -0
  81. parla/guides/topic/diagram.md +8 -0
  82. parla/guides/topic/figures.md +6 -0
  83. parla/guides/topic/flow.md +6 -0
  84. parla/guides/topic/selection.md +6 -0
  85. parla/guides/topic/skills.md +8 -0
  86. parla/guides/topic/summary.md +6 -0
  87. parla/guides/topic/tables.md +6 -0
  88. parla/guides/topic/terminology.md +6 -0
  89. parla/guides/trait/narrative/engineering.md +5 -0
  90. parla/guides/trait/narrative/executive.md +5 -0
  91. parla/guides/trait/narrative/scientific.md +5 -0
  92. parla/guides/trait/narrative/teaching.md +5 -0
  93. parla/guides/trait/pacing/balanced.md +5 -0
  94. parla/guides/trait/pacing/generous.md +5 -0
  95. parla/guides/trait/pacing/tight.md +5 -0
  96. parla/guides/trait/reader/cross-domain.md +5 -0
  97. parla/guides/trait/reader/newcomer.md +5 -0
  98. parla/guides/trait/reader/peer.md +5 -0
  99. parla/guides/trait/reader/screener.md +5 -0
  100. parla/guides/trait/visual/academic.md +5 -0
  101. parla/guides/trait/visual/keynote.md +5 -0
  102. parla/jobs.py +170 -0
  103. parla/names.py +25 -0
  104. parla/py.typed +0 -0
  105. parla/router.py +237 -0
  106. parla/settings.py +93 -0
  107. parla/skills/parla/SKILL.md +108 -0
  108. parla/skills/parla/artifacts.md +25 -0
  109. parla/skills/parla/asking.md +27 -0
  110. parla/skills/parla/constraints.md +25 -0
  111. parla/skills/parla/distilling.md +40 -0
  112. parla/skills/parla/jobs.md +37 -0
  113. parla/skills/parla/learning.md +59 -0
  114. parla/skills/parla/moves.md +17 -0
  115. parla/skills/parla/relocating.md +29 -0
  116. parla/skills/parla/seeding.md +9 -0
  117. parla/skills/parla/suite.md +38 -0
  118. parla/skills/parla/worker.md +30 -0
  119. parla/template.py +275 -0
  120. parla/texmf/bibtex/bib/arxiv-references.bib +27 -0
  121. parla/texmf/tex/latex/arxiv-style/License.txt +21 -0
  122. parla/texmf/tex/latex/arxiv-style/README.md +49 -0
  123. parla/texmf/tex/latex/arxiv-style/arxiv.sty +262 -0
  124. parla/texmf/tex/latex/arxiv-style/orcid.pdf +0 -0
  125. parla/texmf/tex/latex/arxiv-style/template.pdf +0 -0
  126. parla/texmf/tex/latex/arxiv-style/template.tex +214 -0
  127. parla/tools/__init__.py +0 -0
  128. parla/tools/claims.py +124 -0
  129. parla/tools/formula.py +84 -0
  130. parla/tools/measure.py +72 -0
  131. parla/tools/pdf/__init__.py +39 -0
  132. parla/tools/pdf/base.py +28 -0
  133. parla/tools/pdf/heuristics.py +529 -0
  134. parla/tools/pdf/pdftotext.py +131 -0
  135. parla/tools/pdf/pymupdf.py +127 -0
  136. parla/tools/pdf/pymupdf4llm.py +112 -0
  137. parla/tools/sections.py +909 -0
  138. parla/tools/source.py +109 -0
  139. parla/tools/table.py +64 -0
  140. parla/tools/table_fit.py +181 -0
  141. parla/tools/table_parse.py +357 -0
  142. parla/tools/table_render.py +191 -0
  143. parla-0.0.1.dist-info/METADATA +94 -0
  144. parla-0.0.1.dist-info/RECORD +148 -0
  145. parla-0.0.1.dist-info/WHEEL +5 -0
  146. parla-0.0.1.dist-info/entry_points.txt +2 -0
  147. parla-0.0.1.dist-info/licenses/LICENSE +13 -0
  148. parla-0.0.1.dist-info/top_level.txt +1 -0
parla/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """parla."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,171 @@
1
+ This package was debianized by Florent Rougon <f.rougon@free.fr> on
2
+ Tue, 27 Jan 2004 20:12:21 +0100.
3
+
4
+ The following TeX Live packages were downloaded from
5
+ http://www.ctan.org/tex-archive/systems/texlive/tlnet/archive/
6
+ and merged into one orig.tar.gz file:
7
+ lm.tar.xz
8
+ lm.doc.tar.xz
9
+ lm-math.tar.xz
10
+ lm-math.doc.tar.xz
11
+
12
+
13
+ Upstream work
14
+ -------------
15
+
16
+ The upstream README-Latin-Modern.txt file says:
17
+
18
+ Font: The Latin Modern Family of Fonts
19
+ Designer (Computer Modern Family of Fonts): Donald E. Knuth
20
+ Author: Bogus\l{}aw Jackowski and Janusz M. Nowacki
21
+ Version: 2.005
22
+ Date: 21 III 2021
23
+ Downloads: http://www.gust.org.pl/projects/e-foundry/latin-modern/
24
+ License:
25
+ % Copyright 2003--2021 by B. Jackowski and J.M. Nowacki
26
+ % (on behalf of TeX Users Groups).
27
+ % This work is released under the GUST Font License
28
+ % -- see GUST-FONT-LICENSE.txt.
29
+ % This work has the LPPL maintenance status "maintained".
30
+ % The Current Maintainer of this work is Bogus\l{}aw Jackowski
31
+ % This work consists of the files listed in the MANIFEST.txt file.
32
+
33
+ [...]
34
+
35
+ The current LM package contains the most recent version
36
+ of the Latin Modern family of fonts in the PostScript Type 1 and
37
+ OpenType format. The fonts are based on Donald E. Knuth's Computer Modern
38
+ fonts in the PostScript Type 1 format, released into public domain by the
39
+ American Mathematical Society (for the history of the outline version of
40
+ the CM fonts see, e.g., http://www.math.utah.edu/~beebe/fonts/bluesky.html ).
41
+ The project is supported by TeX users groups: CSTUG, DANTE eV, GUST,
42
+ GUTenberg, NTG, and TUG.
43
+
44
+
45
+ The current README-Latin-Modern-Math.txt says
46
+ License:
47
+ % Copyright 2012--2014 for the Latin Modern math extensions by B. Jackowski,
48
+ % P. Strzelczyk and P. Pianowski (on behalf of TeX Users Groups).
49
+ %
50
+ % This work can be freely used and distributed under
51
+ % the GUST Font License (GFL -- see GUST-FONT-LICENSE.txt)
52
+ % which is actually an instance of the LaTeX Project Public License
53
+ % (LPPL -- see http://www.latex-project.org/lppl.txt).
54
+ %
55
+ % This work has the maintenance status "maintained". The Current Maintainer
56
+ % of this work is Bogus\l{}aw Jackowski, Piotr Strzelczyk and Piotr Pianowski.
57
+ %
58
+ % This work consists of the files listed
59
+ % in the MANIFEST-Latin-Modern-Math.txt file.
60
+
61
+
62
+ See the appendix B for the GUST Font License.
63
+
64
+ Please read the appendix A below if you want to examine the licensing terms
65
+ for the Computer Modern fonts in Type 1 format on which the Latin Modern fonts
66
+ are based.
67
+
68
+
69
+ Debian packaging
70
+ ----------------
71
+
72
+ Copyright (c) 2004-2007 Florent Rougon
73
+ Copyright (c) 2005-2019 Norbert Preining
74
+ Copyright (c) 2021-2022 Hilmar Preusse
75
+
76
+ This program is free software; you can redistribute it and/or modify
77
+ it under the terms of the GNU General Public License as published by
78
+ the Free Software Foundation; version 2 dated June, 1991.
79
+
80
+ This program is distributed in the hope that it will be useful, but
81
+ WITHOUT ANY WARRANTY; without even the implied warranty of
82
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
83
+ General Public License for more details.
84
+
85
+ You should have received a copy of the GNU General Public License
86
+ along with this program; see the file COPYING. If not, write to the
87
+ Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
88
+ Boston, MA 02110-1301 USA.
89
+
90
+ On Debian systems, the complete text of the GNU General Public License version
91
+ 2 can be found in `/usr/share/common-licenses/GPL-2'.
92
+
93
+
94
+ Appendix A -- Licensing terms for the Computer Modern fonts in Type 1 format
95
+ on which the Latin Modern fonts are based.
96
+ -----------------------------------------------------------------------------
97
+
98
+ Running t1disasm (from the t1utils package) on
99
+ /usr/share/texmf/fonts/type1/bluesky/cm/*.pfb and
100
+ /usr/share/texmf/fonts/type1/bluesky/cmextra/*.pfb yields:
101
+
102
+ Copyright (C) 1997 American Mathematical Society. All Rights Reserved
103
+
104
+ [ Florent Rougon's note: well, for
105
+ /usr/share/texmf/fonts/type1/bluesky/cm/cmb{,sy}10.pfb, you will only get
106
+ one space between "Society." and "All" (with tetex-extra 2.0.2-5.1). ;-) ]
107
+
108
+ The precise distribution conditions for these fonts can be found in
109
+ /usr/share/doc/texmf/fonts/bluesky/README from the tetex-doc package. I will
110
+ duplicate here the relevant excerpt for your convenience:
111
+
112
+ The PostScript Type 1 implementation of the Computer Modern fonts produced
113
+ by and previously distributed by Blue Sky Research and Y&Y, Inc. are now
114
+ freely available for general use. This has been accomplished through the
115
+ cooperation of a consortium of scientific publishers with Blue Sky Research
116
+ and Y&Y. Members of this consortium include:
117
+
118
+ Elsevier Science
119
+ IBM Corporation
120
+ Society for Industrial and Applied Mathematics (SIAM)
121
+ Springer-Verlag
122
+ American Mathematical Society (AMS)
123
+
124
+ In order to assure the authenticity of these fonts, copyright will be held
125
+ by the American Mathematical Society. This is not meant to restrict in any
126
+ way the legitimate use of the fonts, such as (but not limited to) electronic
127
+ distribution of documents containing these fonts, inclusion of these fonts
128
+ into other public domain or commercial font collections or computer
129
+ applications, use of the outline data to create derivative fonts and/or
130
+ faces, etc. However, the AMS does require that the AMS copyright notice be
131
+ removed from any derivative versions of the fonts which have been altered in
132
+ any way. In addition, to ensure the fidelity of TeX documents using Computer
133
+ Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
134
+ has requested that any alterations which yield different font metrics be
135
+ given a different name.
136
+
137
+
138
+ Appendix B -- GUST Font License
139
+ -------------------------------
140
+
141
+ What follows is the exact contents of GUST-FONT-LICENSE.txt from the
142
+ upstream distribution of the Latin Modern fonts.
143
+
144
+ % This is version 1.0, dated 22 June 2009, of the GUST Font License.
145
+ % (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
146
+ %
147
+ % For the most recent version of this license see
148
+ % http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
149
+ % or
150
+ % http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
151
+ %
152
+ % This work may be distributed and/or modified under the conditions
153
+ % of the LaTeX Project Public License, either version 1.3c of this
154
+ % license or (at your option) any later version.
155
+ %
156
+ % Please also observe the following clause:
157
+ % 1) it is requested, but not legally required, that derived works be
158
+ % distributed only after changing the names of the fonts comprising this
159
+ % work and given in an accompanying "manifest", and that the
160
+ % files comprising the Work, as listed in the manifest, also be given
161
+ % new names. Any exceptions to this request are also given in the
162
+ % manifest.
163
+ %
164
+ % We recommend the manifest be given in a separate file named
165
+ % MANIFEST-<fontid>.txt, where <fontid> is some unique identification
166
+ % of the font family. If a separate "readme" file accompanies the Work,
167
+ % we recommend a name of the form README-<fontid>.txt.
168
+ %
169
+ % The latest version of the LaTeX Project Public License is in
170
+ % http://www.latex-project.org/lppl.txt and version 1.3c or later
171
+ % is part of all distributions of LaTeX version 2006/05/20 or later.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
parla/casebook.py ADDED
@@ -0,0 +1,180 @@
1
+ """The casebook: approved corrections as an append-only JSON-lines store. See `specs/casebook.md`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, date, datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ from . import config as config_mod
10
+ from .checks import Finding
11
+ from .checks.ids import finding
12
+
13
+ STATUSES = ("active", "distilled", "contradicted", "pruned")
14
+ REQUIRED = ("artifact", "part", "topics", "hook", "problem", "before", "after", "lesson")
15
+ CASES_FILE = "cases.jsonl"
16
+ USE_FILE = "cases.use.jsonl"
17
+
18
+
19
+ class CaseError(Exception):
20
+ """A case command cannot proceed."""
21
+
22
+
23
+ def _path(name: str) -> Path:
24
+ return config_mod.guides_private() / name
25
+
26
+
27
+ def _lines(name: str) -> list[dict]:
28
+ path = _path(name)
29
+ if not path.is_file():
30
+ return []
31
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
32
+
33
+
34
+ def _append(name: str, entry: dict) -> None:
35
+ path = _path(name)
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ with path.open("a", encoding="utf-8") as fh:
38
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
39
+
40
+
41
+ def load() -> dict[str, dict]:
42
+ """The current state of every case: its lines folded in order. Insertion order is first-seen."""
43
+ cases: dict[str, dict] = {}
44
+ for entry in _lines(CASES_FILE):
45
+ cases.setdefault(entry["id"], {}).update(entry)
46
+ return cases
47
+
48
+
49
+ def add(root: Path, payload: dict, known_topics: set[str], *, today: date | None = None) -> str:
50
+ missing = [k for k in REQUIRED if not payload.get(k)]
51
+ if missing:
52
+ raise CaseError(f"missing field(s): {', '.join(missing)}")
53
+ if not isinstance(payload["topics"], list):
54
+ raise CaseError("topics must be a list")
55
+ unknown = [t for t in payload["topics"] if t not in known_topics]
56
+ if unknown:
57
+ raise CaseError(f"unknown topic(s): {', '.join(unknown)} (have: {', '.join(sorted(known_topics))})")
58
+ for key in ("hook", "lesson"):
59
+ if "\n" in payload[key].strip():
60
+ raise CaseError(f"the {key} must be one line")
61
+ card = config_mod.load_card(root, payload["artifact"])
62
+ cases = load()
63
+ number = max((int(i.removeprefix("c-")) for i in cases), default=0) + 1
64
+ entry = {
65
+ "id": f"c-{number:04d}",
66
+ "date": (today or datetime.now(UTC).date()).isoformat(),
67
+ **{k: payload[k] for k in REQUIRED},
68
+ "modality": card.modality,
69
+ "audience": card.audience,
70
+ "contradicts": payload.get("contradicts", []),
71
+ "status": "active",
72
+ }
73
+ _append(CASES_FILE, entry)
74
+ return entry["id"]
75
+
76
+
77
+ EDITABLE = ("hook", "lesson", "problem", "topics")
78
+
79
+
80
+ def edit(case_id: str, fields: dict, known_topics: set[str]) -> None:
81
+ """Appends an update to a case. Only the hook, lesson, problem and topics can change; the before
82
+ and after stay as the author left them."""
83
+ if case_id not in load():
84
+ raise CaseError(f"unknown case '{case_id}'")
85
+ bad = set(fields) - set(EDITABLE)
86
+ if bad or not fields:
87
+ raise CaseError(f"edit takes {', '.join(EDITABLE)}; got {', '.join(sorted(bad)) or 'nothing'}")
88
+ for key in ("hook", "lesson"):
89
+ if key in fields and ("\n" in str(fields[key]).strip() or not str(fields[key]).strip()):
90
+ raise CaseError(f"the {key} must be one non-empty line")
91
+ unknown = [t for t in fields.get("topics", []) if t not in known_topics]
92
+ if unknown:
93
+ raise CaseError(f"unknown topic(s): {', '.join(unknown)} (have: {', '.join(sorted(known_topics))})")
94
+ _append(CASES_FILE, {"id": case_id, **fields})
95
+
96
+
97
+ def mark(case_id: str, status: str) -> None:
98
+ if status not in STATUSES:
99
+ raise CaseError(f"unknown status '{status}' (have: {', '.join(STATUSES)})")
100
+ if case_id not in load():
101
+ raise CaseError(f"unknown case '{case_id}'")
102
+ _append(CASES_FILE, {"id": case_id, "status": status})
103
+
104
+
105
+ def list_lines(topic: str | None = None, status: str | None = None) -> list[str]:
106
+ return [
107
+ f"{c['id']} {c['status']} {','.join(c['topics'])} {c['audience']} {c.get('hook', '-')} | {c['lesson']}"
108
+ for c in load().values()
109
+ if (topic is None or topic in c["topics"]) and (status is None or c["status"] == status)
110
+ ]
111
+
112
+
113
+ def retrieve(
114
+ *, topics: set[str], modality: str, audience: str | None, artifact: str, max_cases: int
115
+ ) -> list[dict]:
116
+ """Active or distilled cases on the loaded topics whose audience and modality match or are
117
+ absent, best first: same artifact, same audience, same modality, most recent."""
118
+ hits = [
119
+ c for c in load().values()
120
+ if c["status"] in ("active", "distilled")
121
+ and topics & set(c["topics"])
122
+ and (not c.get("audience") or c["audience"] == audience)
123
+ and (not c.get("modality") or c["modality"] == modality)
124
+ ]
125
+ hits.sort(
126
+ key=lambda c: (c["artifact"] == artifact, c.get("audience") == audience, c.get("modality") == modality, c["date"], c["id"]),
127
+ reverse=True,
128
+ )
129
+ return hits[:max_cases]
130
+
131
+
132
+ def show(case_ids: list[str], artifact: str) -> str:
133
+ """The cases in full as XML, in the order asked; logs one use per case."""
134
+ cases = load()
135
+ unknown = [i for i in case_ids if i not in cases]
136
+ if unknown:
137
+ raise CaseError(f"unknown case(s): {', '.join(unknown)}")
138
+ log_use(case_ids, artifact)
139
+ return "\n\n".join(
140
+ f'<case id="{i}">\n'
141
+ + "\n".join(f"<{k}>{cases[i][k]}</{k}>" for k in ("problem", "after", "lesson"))
142
+ + "\n</case>"
143
+ for i in case_ids
144
+ )
145
+
146
+
147
+ def log_use(case_ids: list[str], artifact: str, *, today: date | None = None) -> None:
148
+ stamp = (today or datetime.now(UTC).date()).isoformat()
149
+ for case_id in case_ids:
150
+ _append(USE_FILE, {"case": case_id, "date": stamp, "artifact": artifact})
151
+
152
+
153
+ def lint(*, distill_min: int, stale_days: int | None, today: date | None = None) -> list[Finding]:
154
+ today = today or datetime.now(UTC).date()
155
+ cases = load()
156
+ first_line: dict[str, int] = {}
157
+ for number, entry in enumerate(_lines(CASES_FILE), 1):
158
+ first_line.setdefault(entry["id"], number)
159
+ active = [c for c in cases.values() if c["status"] == "active"]
160
+ findings: list[Finding] = []
161
+ groups: dict[tuple[str, str], list[str]] = {}
162
+ for c in active:
163
+ for topic in c["topics"]:
164
+ groups.setdefault((topic, c.get("audience", "")), []).append(c["id"])
165
+ for (topic, audience), ids in sorted(groups.items()):
166
+ if len(ids) >= distill_min:
167
+ findings.append(finding(
168
+ "case-distill", CASES_FILE, first_line[ids[0]],
169
+ f"{len(ids)} active cases on '{topic}' for '{audience}' ({', '.join(ids)}): propose a principle",
170
+ ))
171
+ if stale_days is not None:
172
+ last_use: dict[str, str] = {}
173
+ for use in _lines(USE_FILE):
174
+ last_use[use["case"]] = max(last_use.get(use["case"], ""), use["date"])
175
+ cutoff = today - timedelta(days=stale_days)
176
+ for c in active:
177
+ seen = date.fromisoformat(last_use.get(c["id"], c["date"]))
178
+ if seen < cutoff:
179
+ findings.append(finding("case-stale", CASES_FILE, first_line[c["id"]], f"{c['id']} was not retrieved since {seen}"))
180
+ return findings
@@ -0,0 +1,3 @@
1
+ from .finding import CheckFailed, Finding, Severity
2
+
3
+ __all__ = ["CheckFailed", "Finding", "Severity"]
@@ -0,0 +1,28 @@
1
+ """One reported problem. See `specs/checks.md#finding`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ Severity = Literal["error", "warning", "probe", "resolved"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Finding:
13
+ file: str # relative to parla/
14
+ line: int
15
+ severity: Severity
16
+ id: str
17
+ message: str
18
+
19
+ def format(self) -> str:
20
+ return f"{self.file}:{self.line}: {self.severity} {self.id}: {self.message}"
21
+
22
+
23
+ class CheckFailed(Exception):
24
+ """A check reported an error finding; nothing was built."""
25
+
26
+ def __init__(self, findings: list[Finding]):
27
+ self.findings = findings
28
+ super().__init__("\n".join(f.format() for f in findings if f.severity == "error"))
parla/checks/ids.py ADDED
@@ -0,0 +1,59 @@
1
+ """Every check id and its severity. Ids are stable: projects list them in `[checks].ignore`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .finding import Finding, Severity
6
+
7
+ SEVERITY: dict[str, Severity] = {
8
+ "card-invalid": "error",
9
+ "source-missing": "error",
10
+ "cite-unresolved": "error",
11
+ "figure-missing": "error",
12
+ "bib-name-syntax": "error",
13
+ "unescaped-char": "error",
14
+ "brace-unbalanced": "error",
15
+ "macro-missing": "error",
16
+ "translation-parity": "error",
17
+ "ref-dangling": "warning",
18
+ "float-unreferenced": "warning",
19
+ "float-order": "warning",
20
+ "figure-duplicate": "warning",
21
+ "abbreviation-repeated": "warning",
22
+ "cite-style": "warning",
23
+ "todo-marker": "warning",
24
+ "appendix-structure": "warning",
25
+ "svg-overflow": "error",
26
+ "svg-collision": "error",
27
+ "svg-arrow-crossing": "error",
28
+ "svg-glyph-missing": "error",
29
+ "svg-href-missing": "error",
30
+ "svg-malformed": "error",
31
+ "svg-math-in-text": "error",
32
+ "svg-unsupported": "warning",
33
+ "rule-malformed": "error",
34
+ "rule-origin-missing": "error",
35
+ "rule-duplicate": "error",
36
+ "rule-overrides-missing": "error",
37
+ "public-rule": "error",
38
+ "brief-missing": "error",
39
+ "link-broken": "error",
40
+ "guide-over-budget": "warning",
41
+ "guide-page-over-cap": "warning",
42
+ "hook-missing": "error",
43
+ "topic-applicable-unknown": "error",
44
+ "constraint-example-failed": "error",
45
+ "constraint-refine": "warning",
46
+ "frame-overflow": "error",
47
+ "overfull-line": "warning",
48
+ "rule-contradicted": "warning",
49
+ "case-distill": "warning",
50
+ "case-stale": "warning",
51
+ }
52
+
53
+
54
+ def finding(check_id: str, file: str, line: int, message: str) -> Finding:
55
+ return Finding(file, line, SEVERITY[check_id], check_id, message)
56
+
57
+
58
+ def is_constraint(f: Finding) -> bool:
59
+ return f.id.startswith("constraint/")
parla/checks/shared.py ADDED
@@ -0,0 +1,57 @@
1
+ """Checks every engine shares. See `specs/checks.md#ids`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .. import config as config_mod
9
+ from ..names import PROJECT_DIR
10
+ from .finding import Finding
11
+ from .ids import finding
12
+
13
+ TEXT_SUFFIXES = {".tex", ".md", ".svg", ".txt"}
14
+ SKIPPED_FILES = {"card.md", "references.bib"}
15
+ _FIGURE_EXTENSIONS = (".pdf", ".png", ".jpg", ".jpeg", ".eps", ".svg", ".tex")
16
+ _COMMENT_RE = re.compile(r"(?<!\\)%.*")
17
+ _SOURCE_PATH_RE = re.compile(r"(?<![\w/.\-])sources/[^\s{}()\[\],\"'<>]+")
18
+
19
+
20
+ def card_invalid(root: Path, artifact: str) -> Finding | None:
21
+ """A finding when the artifact has a card that does not validate; `None` when there is no
22
+ card file (the caller fails on that) or the card is valid."""
23
+ if not config_mod.card_path(root, artifact).is_file():
24
+ return None
25
+ try:
26
+ config_mod.load_card(root, artifact)
27
+ except config_mod.ConfigError as exc:
28
+ return finding("card-invalid", f"artifacts/{artifact}/card.md", 1, str(exc))
29
+ return None
30
+
31
+
32
+ def artifact_files(root: Path, artifact: str) -> list[Path]:
33
+ base = root / PROJECT_DIR / "artifacts" / artifact
34
+ return [
35
+ p for p in sorted(base.rglob("*"))
36
+ if p.is_file() and p.suffix in TEXT_SUFFIXES and p.name not in SKIPPED_FILES
37
+ ]
38
+
39
+
40
+ def _exists(base: Path, rel: str) -> bool:
41
+ path = base / rel
42
+ return path.exists() or any(path.with_name(path.name + ext).exists() for ext in _FIGURE_EXTENSIONS)
43
+
44
+
45
+ def source_missing(root: Path, artifact: str) -> list[Finding]:
46
+ """Every `sources/...` path an artifact file names that does not exist under `parla/`."""
47
+ base = root / PROJECT_DIR
48
+ findings: list[Finding] = []
49
+ for path in artifact_files(root, artifact):
50
+ rel_file = path.relative_to(base).as_posix()
51
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
52
+ text = _COMMENT_RE.sub("", line) if path.suffix == ".tex" else line
53
+ for match in _SOURCE_PATH_RE.finditer(text):
54
+ target = match.group(0).rstrip(".:;")
55
+ if not _exists(base, target):
56
+ findings.append(finding("source-missing", rel_file, number, f"{target} does not exist"))
57
+ return findings