diff --git a/.claude/skills/dgs-editor/SKILL.md b/.claude/skills/dgs-editor/SKILL.md
index d66a5123..ca391676 100644
--- a/.claude/skills/dgs-editor/SKILL.md
+++ b/.claude/skills/dgs-editor/SKILL.md
@@ -40,7 +40,7 @@ a reference key and `sk:` is a language — so neither gets one.
`POST /api/evaluate` does the work, in-process and writing nothing. It reads the **buffers** out
of the request rather than the files, because the whole point is to answer for the text on
screen, which is usually unsaved; and it shares `build_render_context` and `render_twice` with
-`core/builder/renderer.py`, so it cannot answer differently from what `make` would write —
+`core/builder/renderer.py`, so it cannot answer differently from what the build would write —
including whether an `eq:` display carries its `{#eq:…}` label, which depends on the open tab
through the module's `equation_numbering`. A test asserts that agreement rather than trusting it.
@@ -55,12 +55,12 @@ in the pane and the rest are map lookups; a keystroke in either pane, a differen
different problem throws the lot away.
**It degrades rather than failing.** The three output tabs are the pipeline's three stages --
-Rendered Markdown is Jinja and needs only make, TeX adds pandoc, the PDF adds all of TeX Live and
-the fonts -- and `tools/editor/capabilities.py` probes for each at startup. A stage this machine
+Rendered Markdown is Jinja and needs only Ninja, TeX adds pandoc, the PDF adds all of TeX Live
+and the fonts -- and `tools/editor/capabilities.py` probes for each at startup. A stage this machine
cannot do is greyed, and the pane behind it says what is missing and the command that fixes it, so
a translator with no TeX Live can still check their own rendering. `install.md` has the tiers;
`uv run python tools/editor/capabilities.py` prints the same report. `uv` is not required --
-without it the app calls `make` directly with its own interpreter's directory on `PATH`.
+without it the app calls `ninja` directly with its own interpreter's directory on `PATH`.
It must run from the root, and it `chdir`s there itself: several `core` modules open
their data by a repository-relative path, so `core/i18n` fails to import from anywhere
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index e8a1635c..b443a1a4 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -33,11 +33,19 @@ jobs:
# `poppler-utils` is `pdftocairo`, which converts every picture for the web, and
# `gnuplot-nox` draws the plots. Both were absent, so `test_picture_svg.py` skipped itself
# and CI had never once exercised the picture pipeline.
+ #
+ # `mupdf-tools` is `mutool`, and it is dvisvgm's PDF reader now. Ghostscript dropped the
+ # interpreter dvisvgm drove in **10.01.0**, so on a runner carrying 10.05 every `--pdf`
+ # run ends in `To process PDF files, either Ghostscript < 10.01.0 or mutool is required` --
+ # which is the whole of the HTML maths, and it surfaced the moment those tests stopped
+ # skipping. dvisvgm picks mutool up by itself when it finds it; `DVISVGM_PDF_PROC` forces
+ # the choice. Ghostscript stays -- `pdfcrop` is Ghostscript, and dvisvgm still wants it for
+ # PostScript specials.
- name: "Install utilities"
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
- librsvg2-bin lcdf-typetools dvisvgm ghostscript poppler-utils gnuplot-nox
+ librsvg2-bin lcdf-typetools dvisvgm ghostscript mupdf-tools poppler-utils gnuplot-nox
# MathJax is the other HTML maths engine, and it is node's. A hosted runner has node
# whether or not anyone installed the packages, which is exactly how CI reported
# `Cannot find module '@mathjax/mathjax-pagella-font/package.json'` from a test that should
@@ -122,7 +130,7 @@ jobs:
- name: "Verify the HTML output's prerequisites"
run: |
missing=""
- for tool in dvisvgm pdftocairo gnuplot rsvg-convert; do
+ for tool in dvisvgm mutool pdftocairo gnuplot rsvg-convert; do
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
for package in mathjax @mathjax/mathjax-pagella-font; do
@@ -135,12 +143,35 @@ jobs:
- name: Test with pytest
run: |
uv run python -m pytest -s -v
+ # `source/seminar/test`, which is where `modules/seminar/editor.yaml` says this repository
+ # goes. It was cloned to `.../testing` while nothing read it; a scope is named after the
+ # directory, so the two had to agree before anything could be built by name.
- name: "Clone testing repository"
uses: actions/checkout@master
with:
repository: TrojstenSK/dgs-seminar-testing
- path: source/seminar/testing
- - name: "Build testing/01/1/1"
+ path: source/seminar/test
+
+ # The graph before the build, as its own step. It reads the whole source tree and writes
+ # every edge, so a generator that crashes or emits two different edges for one output fails
+ # here -- named, and in under two seconds -- rather than as a confusing Ninja parse error in
+ # the step below.
+ - name: "Generate the build graph"
+ run: |
+ uv run python -m tools.ninja.generate --scope all
+
+ # One real round, end to end: Jinja, pandoc, the pictures and two xelatex passes. The scope
+ # is read out of the graph rather than written here, because it is this repository's own
+ # layout and a path typed into CI is a second copy of it that goes stale silently -- which
+ # is exactly what the commented-out line this replaces had done.
+ - name: "Build a seminar round"
run: |
- pwd
- # pipenv run make output/seminar/test/01/1/1
+ set -euo pipefail
+ scope=$(./dgs-build -t targets all \
+ | sed -n 's|^output/seminar/\(.*\)/all: phony.*|\1|p' | head -1)
+ if [ -z "$scope" ]; then
+ echo "::error::no seminar round in the graph; is source/seminar/test populated?"
+ exit 1
+ fi
+ echo "building output/seminar/$scope/all"
+ ./dgs-build -k 0 "output/seminar/$scope/all"
diff --git a/.gitignore b/.gitignore
index d8f13bd2..6cf7997a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,12 @@
*.dblite
.idea/
build/
+
+# The Ninja spike (`tools/ninja/generate.py`). The graph is generated, never written by hand,
+# and `.ninja_log` / `.ninja_deps` are Ninja's own state beside it.
+build.ninja
+.ninja_log
+.ninja_deps
render/
output/
source/
diff --git a/CLAUDE.md b/CLAUDE.md
index 3fe0120a..064eb018 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,7 +47,7 @@ stale entry is still worth catching in the sources rather than in a PDF. Both di
are checked (`unit-unlisted`, `listed-missing`). The page can sort alphabetically instead,
and keeps showing the competition number when it does. A pass over the whole repository is a fifth of a second, so
nothing is cached — except the two checks that cannot be answered by reading files, which land in
-`build/.audit/` with a fingerprint of the sources: the build checks, which shell out to make, and
+`build/.audit/` with a fingerprint of the sources: the build checks, which shell out to the build, and
the macro sweep.
**There is a third direction, and it is the one make cannot fix: `build-orphan`.** Rename a
@@ -58,12 +58,38 @@ because `\protectedInput` finds the stale `.tex` and the booklet goes on printin
problem instead of the red box it is owed; volume 19's `onion` was exactly that. So
`listed-missing` is the error and this is the warning, and together they say what happened.
-`make stale` lists them and **`make prune` removes them** — two targets on purpose, since one
+`./dgs-build stale` lists them and **`./dgs-build prune` removes them** (`make stale` and
+`make prune` are the same two targets) — two targets on purpose, since one
word between asking and deleting is not a safe thing to offer. `core/audit/stale.py` is the whole
of it, and it is the one check that reads outside `source/`: a directory listing, no file opened,
and a tree that has never been built reports nothing.
-**It looks at unit directories and nothing coarser**, which is the only reason it is usable.
+**It looks at two kinds of directory and nothing coarser**, which is the only reason it is
+usable. A **unit** directory, above; and a **language** directory, which the unit sweep cannot
+see, because `languages/` is not a place units live. That half is self-limiting rather than a
+rule about Náboj: a module with no `source//languages/` has no notion of one and yields
+nothing, which is how seminar and scholar pass through.
+
+It is worth having for the same reason as the first half, only louder. The language documents in
+`modules/naboj/module.mk` are pattern rules, so `%` matches any string and the rest of each
+prerequisite list is `$(wildcard …)`, which yields *nothing* for a language that is not there
+rather than failing. A stale `build//languages//` therefore supplies everything
+they ask for: `make output/naboj/phys/29/languages/uk/booklet.pdf` built a **40-page Ukrainian
+problems booklet** out of intermediates whose sources had been removed, for a volume that has
+`en`, `es` and `sk`. Clearing `build/` was the only thing that turned it back into an error.
+
+Two changes came out of that, and they catch the two halves. Every language document now takes
+**`source/naboj/$*/meta.yaml`** as a prerequisite — the one thing in those lists not derived from
+something else — so a cold build of a language that does not exist fails at once and names the
+missing file. And this check finds the warm one, where the stale `build/` is already there. The
+aggregates never had the problem: `output/naboj/%/languages` and `booklets` wildcard
+`source/naboj/$*/languages/*` and always did.
+
+`fks-naboj`'s ten language directories have no meta and are unbuildable by name as a result.
+They already were — it spells languages `slovak` rather than `sk`, so nothing in
+`SUPPORTED_LANGUAGES` ever matched them.
+
+The rest of the narrow rule, and why it is narrow:
`build/` is full of directories that never had a source — `answers/`, `solutions/`, `build/core/`,
every aggregate a template produces — so the obvious rule, *is there a source directory of this
name*, calls **567** of them stale. What makes a unit different is that the audit already knows
@@ -556,7 +582,39 @@ terminal counts in, and a script that wants its own canvas writes
set terminal @gpterm size 432*gppt, 108*gppt
-A script that wants the default writes nothing at all, which is 39 of the 42.
+A script that wants the default writes nothing at all, which is 30 of the 42. Of the 12 that do
+not, nine are scholar's and were written this way; the other three are naboj's and were not --
+they still opened `set terminal pdf size 6, 1.5` when the web path was added, and so did
+`FKS/42/1/1/03`'s `blocks:` preamble, which is a thirteenth script that no `.gp` on disk carries.
+All four say `@gpterm` now.
+
+> **Open: scholar's nine say `@gpterm` and then set a font of their own, and none of them
+> draws.** Every one of them -- the `monte-carlo.gp` of `TA1/2018`, `2019`, `2022`, `2024` and
+> `2025`, the `voigt.gp` of `2018`, `2019` and `2022`, plus the copy inside `TA1/2025/2025/` --
+> opens
+>
+> set terminal @gpterm size 510.236*gppt, 311.811*gppt font "Minion Pro, 10"
+>
+> and `@gpterm` already carries the font, so pdfcairo refuses the line outright: *Duplicated or
+> contradicting arguments in cairo terminal options*. They have been broken since `@gpterm`
+> landed and nothing said so, because nothing built scholar until the Ninja graph did. It is
+> one failing edge today, `build/scholar/TA1/2025/homework/03/monte-carlo.pdf`, and it is the
+> only thing between `TA1/2025` and a clean build.
+>
+> **Deliberately not fixed, and the reason is that the fix is a choice.** Deleting the trailing
+> `font` makes all nine build at once, at the 6pt the build supplies rather than the 10pt their
+> authors asked for on a 510pt canvas -- visibly smaller labels on somebody else's drawings.
+> Letting `@gpterm` take a size instead is a change here rather than in the content, and it is
+> the only way a script can ask for larger type without re-specifying the face. Neither is
+> urgent: scholar is not being run, and Náboj is.
+
+**`set terminal pdf` left in a script is not a print defect and is a web one**, which is what
+made it survive. Re-issued over a `pdfcairo` that is already current it keeps the font *and* the
+`fontscale`, so the printed figure is unchanged -- all six of those plots are pixel-identical
+across the conversion, and so is volume 29's whole booklet. On the `svg` terminal it is simply a
+different terminal, so gnuplot writes a PDF into a file named `.svg` and the page shows a blank
+figure. Hence the sniff below, and hence the rule that the terminal is the build's to supply
+even where leaving it in the script appears to cost nothing.
Three things about that, each of which cost a wrong picture to find:
diff --git a/Containerfile b/Containerfile
index 1f083c4f..bea6f02f 100644
--- a/Containerfile
+++ b/Containerfile
@@ -112,6 +112,12 @@ FROM common AS tex
# --no-install-recommends silently drops dvisvgm and ghostscript, which is why
# both are named: Makefile:208 runs pdfcrop (ghostscript) and :226 dvisvgm.
#
+# mupdf-tools is mutool, which is dvisvgm's PDF reader now: Ghostscript dropped
+# the interpreter dvisvgm drove in 10.01.0 and trixie ships 10.05, so without it
+# every `dvisvgm --pdf` in the image dies on "either Ghostscript < 10.01.0 or
+# mutool is required" -- i.e. all of the HTML maths. Ghostscript stays anyway,
+# for pdfcrop and for PostScript specials.
+#
# texlive-full is 5-6 GB and drags in CJK, ConTeXt, asymptote and the doc tree.
# This list is about 2.4 GB. texlive-fonts-extra is 1.7 GB of that and is not
# optional: MnSymbol (dgs.cls:93), esvect (:100), ifsym and skull (fonts.tex:51)
@@ -125,7 +131,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-lang-german texlive-lang-french texlive-lang-polish \
texlive-lang-spanish texlive-lang-portuguese texlive-lang-cyrillic \
lcdf-typetools fontconfig \
- ghostscript dvisvgm librsvg2-bin poppler-utils gnuplot-nox \
+ ghostscript dvisvgm mupdf-tools librsvg2-bin poppler-utils gnuplot-nox \
pdftk-java zip rsync \
&& rm -rf /var/lib/apt/lists/*
@@ -135,7 +141,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN missing=""; \
for tool in tex fontinst vptovf pltotf otfinfo otftotfm cfftot1 kpsewhich \
mktexlsr updmap-sys perl \
- xelatex texfot pdfcrop pdfjam pdftk pdftoppm dvisvgm \
+ xelatex texfot pdfcrop pdfjam pdftk pdftoppm dvisvgm mutool \
rsvg-convert gnuplot zip rsync fc-cache; do \
command -v "$tool" >/dev/null || missing="$missing $tool"; \
done; \
diff --git a/core/audit/__init__.py b/core/audit/__init__.py
index 6fd959b0..7a27a443 100644
--- a/core/audit/__init__.py
+++ b/core/audit/__init__.py
@@ -3,7 +3,8 @@
Two layers. This one is source-only: it reads every file once and runs a registry of checks over
the result, which costs about a fifth of a second for the whole repository, so nothing needs to be
-cached or made incremental. `core.audit.build` is the other one, and it shells out to make.
+cached or made incremental. `core.audit.build` is the other one, and it shells out to the
+build -- to whatever runner its caller injects, which is the editor's Ninja one.
from core.audit import audit
report = audit(Path('source/naboj'), 'naboj', 'phys/28', units)
diff --git a/core/audit/build.py b/core/audit/build.py
index 8b815362..f84db5cf 100644
--- a/core/audit/build.py
+++ b/core/audit/build.py
@@ -2,8 +2,8 @@
The audit checks that cost minutes rather than milliseconds: does it render, does it compile.
Kept apart from `core.audit.checks` because everything there is a file read and a regex, and this
-shells out to make. Run one scope at a time, when asked, and cache the answer -- the source-only
-layer can afford to recompute on every request and this cannot.
+shells out to the build. Run one scope at a time, when asked, and cache the answer -- the
+source-only layer can afford to recompute on every request and this cannot.
"""
import json
import re
@@ -56,14 +56,15 @@ def run_targets(repo_root: Path, module: str, scope: str, languages, targets, *,
"""
Build every target of every language, collecting what came back.
- `run` is injected rather than called directly so the Flask app can pass its own make runner --
- the one that holds `BUILD_LOCK` and strips ANSI -- and so this is testable without a toolchain.
+ `run` is injected rather than called directly so the Flask app can pass its own build
+ runner -- the one that holds `BUILD_LOCK` and strips ANSI -- and so this is testable without
+ a toolchain.
"""
results = []
for language in languages:
for target in targets:
- make_target = f"output/{module}/{scope}/languages/{language}/{target}.pdf"
- outcome = run(make_target, timeout=timeout)
+ build_target = f"output/{module}/{scope}/languages/{language}/{target}.pdf"
+ outcome = run(build_target, timeout=timeout)
log = RE_ANSI.sub('', outcome.get('log', ''))
overfulls = sorted(
({'box': m['box'], 'size': float(m['size'])} for m in RE_OVERFULL.finditer(log)),
diff --git a/core/audit/checks.py b/core/audit/checks.py
index 1ccd826a..0dba3e0d 100644
--- a/core/audit/checks.py
+++ b/core/audit/checks.py
@@ -825,11 +825,17 @@ def build_orphan(sources):
without its sweep.
"""
for orphan in stale.orphans(sources):
+ # The tail differs because the consequence does. A stale unit is pulled in by
+ # `\protectedInput` and prints the problem that was deleted; a stale language is a whole
+ # booklet that still builds for a translation nobody has any more.
+ consequence = (f"if {sources.scope}/meta.yaml still names it, the booklet is printing "
+ f"this copy" if orphan.kind == 'unit' else
+ "the language documents are pattern rules, so this is enough to build a "
+ "whole booklet from")
yield Finding('build-orphan', 'warning',
f"`{orphan}` has no source directory left -- {orphan.files} "
f"file{'' if orphan.files == 1 else 's'} from a rename or a deletion. "
- f"`make prune` removes it; if {sources.scope}/meta.yaml still names it, "
- f"the booklet is printing this copy")
+ f"`dgs-build prune` removes it; {consequence}")
@check('translation-set', 'warning',
diff --git a/core/audit/stale.py b/core/audit/stale.py
index f944901e..95390fd9 100644
--- a/core/audit/stale.py
+++ b/core/audit/stale.py
@@ -12,7 +12,7 @@
case is an error, and `listed-missing` already reports it from the sources; this reports the
artefact, which is the half nobody can see.
-**A unit directory, and nothing coarser.** `build/` is full of directories with no source
+**A unit directory or a language directory, and nothing coarser.** `build/` is full of directories with no source
counterpart by design -- `answers/`, `solutions/`, `build/core/`, every aggregate a template
produces -- and a rule that merely asks "is there a source directory of this name" calls 567 of
them stale. What makes a *unit* different is that the audit already knows where units live: the
@@ -36,34 +36,28 @@
@dataclass(frozen=True, slots=True)
class Orphan:
- """One built unit directory with no source left."""
+ """One built directory with no source left."""
stage: str # 'render' or 'build'
rel: str # path below the stage root, e.g. 'phys/pool/problems/old-name'
path: Path # absolute
files: int # how much is in there, for a report that says what it would cost
+ #: What went missing, because the consequence differs and so must the sentence that reports
+ #: it. A stale unit is included by `\protectedInput` and prints the deleted problem; a stale
+ #: language is a whole booklet nobody asked for, built for a translation that is gone.
+ kind: str = 'unit' # 'unit' or 'language'
def __str__(self):
return f'{self.stage}/{self.rel}'
-def orphans(sources) -> list[Orphan]:
+def _sweep(sources, parents: set, known: set, kind: str) -> list[Orphan]:
"""
- Every stale unit directory for one scope, `render/` before `build/`.
+ Directories beside `parents` whose names are not in `known` and have no source left.
- Reads directories and nothing else -- no file is opened, nothing is written, and a tree with
- no `build/` in it simply yields nothing, the way `macro-undefined` reports nothing when its
- sweep has never run.
+ The one loop both passes below share. Reads directories and nothing else -- no file is
+ opened, nothing is written, and a tree with no `build/` in it simply yields nothing, the way
+ `macro-undefined` reports nothing when its sweep has never run.
"""
- units = [Path(unit.path) for unit in sources.unit_list]
- if not units:
- return [] # no units, so no notion of where a unit belongs
-
- # Where units live, and which names are theirs. Both come from the sources, which is what
- # keeps this module-agnostic: naboj puts them under `problems/`, seminar under `1/1`, `1/2`
- # and so on, and neither is written down here.
- parents = {unit.parent for unit in units}
- known = {str(unit) for unit in units}
-
found = []
for stage in STAGES:
for parent in sorted(parents):
@@ -77,10 +71,57 @@ def orphans(sources) -> list[Orphan]:
if str(rel) in known or (sources.module_root / rel).is_dir():
continue
found.append(Orphan(stage, str(rel), child,
- sum(1 for f in child.rglob('*') if f.is_file())))
+ sum(1 for f in child.rglob('*') if f.is_file()), kind))
return found
+def unit_orphans(sources) -> list[Orphan]:
+ """Stale unit directories: a problem renamed or deleted, its build left behind."""
+ units = [Path(unit.path) for unit in sources.unit_list]
+ if not units:
+ return [] # no units, so no notion of where a unit belongs
+
+ # Where units live, and which names are theirs. Both come from the sources, which is what
+ # keeps this module-agnostic: naboj puts them under `problems/`, seminar under `1/1`, `1/2`
+ # and so on, and neither is written down here.
+ return _sweep(sources, {unit.parent for unit in units},
+ {str(unit) for unit in units}, 'unit')
+
+
+def language_orphans(sources) -> list[Orphan]:
+ r"""
+ Stale language directories: a translation dropped, its `.tex` left behind.
+
+ The same narrow shape as the unit pass -- a named set of children of one known parent -- and
+ it is here because the unit pass cannot see it. `languages/` is not a place units live, so
+ `build/naboj/phys/29/languages/uk/` is invisible to that sweep however long it sits there.
+
+ **It is self-limiting rather than naboj-specific.** A module with no `languages/` directory
+ beside its scope has no notion of a language directory and yields nothing, which is how
+ seminar and scholar pass through -- monolingual, one language and not one of several.
+
+ This is the half that lets a deleted translation keep building. The language documents in
+ `modules/naboj/module.mk` are pattern rules, so `%` matches any string; a stale `build/`
+ language directory supplies everything they ask for, and `make …/languages/uk/booklet.pdf`
+ for a volume with no Ukrainian produces a booklet out of files whose sources are gone.
+ Volume 29 did exactly that -- a 40-page Ukrainian problems booklet, in a volume that has
+ `en`, `es` and `sk`. The `source/naboj/$*/meta.yaml` prerequisite added there is the other
+ half: it refuses a cold build, and this finds the warm one.
+ """
+ languages = sources.module_root / sources.scope / 'languages'
+ if not languages.is_dir():
+ return []
+
+ parent = Path(sources.scope) / 'languages'
+ known = {str(parent / child.name) for child in languages.iterdir() if child.is_dir()}
+ return _sweep(sources, {parent}, known, 'language')
+
+
+def orphans(sources) -> list[Orphan]:
+ """Every stale directory for one scope -- units, then languages, `render/` before `build/`."""
+ return unit_orphans(sources) + language_orphans(sources)
+
+
def remove(orphan: Orphan) -> int:
"""
Delete one orphan, returning how many files went.
diff --git a/core/builder/context/quantities/physics_quantity.py b/core/builder/context/quantities/physics_quantity.py
index c0ade189..247ba534 100644
--- a/core/builder/context/quantities/physics_quantity.py
+++ b/core/builder/context/quantities/physics_quantity.py
@@ -73,6 +73,18 @@ class PhysicsQuantity:
'electron_volt': r'\electronvolt', # siunitx built-in
}
+ #: What pint emits as a macro **and a bare letter**, which `siunitx.tex` refuses outright:
+ #: this document sets `forbid-literal-units`, so `\qty{25}{\degree F}` raises *Literal units
+ #: disabled* and the unit vanishes from the page. pint 0.26.2 formats Fahrenheit that way
+ #: where 0.26.1 wrote `\degree_Fahrenheit` and so reached `PINT_TO_SIUNITX` by name.
+ #:
+ #: Separate from both tables above because it is neither: the macro is valid TeX, so the
+ #: underscore pass never sees it, and it is a repair rather than a choice of spelling.
+ #: `phys/23/yankees` is the problem that printed `25` with nothing after it.
+ PINT_LITERALS = {
+ r'\degree F': r'\fahrenheit',
+ }
+
#: A `\macro` whose name contains an underscore, i.e. one pint built from a
#: multi-word unit name. `\kilo\meter_per_hour` matches only the second part.
_UNDERSCORE_MACRO = re.compile(r'\\([A-Za-z]+(?:_[A-Za-z0-9]+)+)')
@@ -525,13 +537,16 @@ def latex_unit(self) -> str:
@classmethod
def _latex_unit(cls, unit: str) -> str:
r"""
- Rewrite pint's multi-word unit macros into the siunitx macros DGS declares.
- Raises `UnknownUnitMacroError` for anything unmapped rather than emitting
- an invalid `\foo_bar`.
+ Repair what pint spells as a macro and a literal, then rewrite its multi-word unit
+ macros into the siunitx macros DGS declares. Raises `UnknownUnitMacroError` for
+ anything unmapped rather than emitting an invalid `\foo_bar`.
Then put the two units pint spells American into British, to match the sources.
Multi-word names go first, so a mapping that produces one of them is caught too.
"""
+ for literal, macro in cls.PINT_LITERALS.items():
+ unit = unit.replace(literal, macro)
+
def substitute(match: re.Match) -> str:
name = match.group(1)
if name not in cls.PINT_TO_SIUNITX:
diff --git a/core/latex/siunitx.tex b/core/latex/siunitx.tex
index 486689be..4f43bc49 100644
--- a/core/latex/siunitx.tex
+++ b/core/latex/siunitx.tex
@@ -44,11 +44,22 @@
\DeclareSIUnit{\earthmass}{\ensuremath{\mathrm{M}_{\Earth}}}
\DeclareSIUnit{\horsepower}{hp}
+% Optometry's unit of optical power, which is a reciprocal metre and is never written as one
+% on a prescription. `FKS/42/1/3/05` is the eye-model problem that asked for it.
+\DeclareSIUnit{\dioptre}{D}
+
\DeclareSIUnit{\byte}{B}
\DeclareSIUnit{\litre}{l}
\DeclareSIUnit{\celsius}{\text{°}C}
\DeclareSIUnit{\dcelsius}{\text{°}C}
\DeclareSIUnit{\fahrenheit}{\text{°}F}
+% siunitx ships its own `\degreeCelsius`, and it sets a math ring operator with a space before
+% the C -- `25 ∘ C` where every other temperature in the booklet is `25 °C`. It is redeclared
+% here rather than avoided because pint emits it: up to 0.26.1 a Celsius quantity formatted as
+% `\degree_Celsius`, which is not valid TeX, so `PhysicsQuantity.PINT_TO_SIUNITX` caught it by
+% name and wrote `\celsius`; 0.26.2 emits `\degreeCelsius`, which is valid TeX and therefore
+% never reaches that mapping. 146 source files declare a Celsius quantity.
+\DeclareSIUnit{\degreeCelsius}{\text{°}C}
\DeclareSIUnit{\pound}{lb}
\DeclareSIUnit{\calorie}{cal}
diff --git a/core/tests/test_audit.py b/core/tests/test_audit.py
index 92f5fe83..1532db05 100644
--- a/core/tests/test_audit.py
+++ b/core/tests/test_audit.py
@@ -937,17 +937,23 @@ def test_unlisted_units_sort_last_and_keep_their_place(self, tmp_path):
assert sources.unit_list[-1].order is None
-def make_repo(tmp_path, built=(), beside_the_scope=()):
+def make_repo(tmp_path, built=(), beside_the_scope=(), languages=(), built_languages=()):
"""
A repository shape rather than a bare module root, because `build-orphan` is the one check
that looks outside `source/` and has to find `build/` beside it.
`built` names directories to create under both mirrors at the unit level; `beside_the_scope`
- names directories to create one level up, where the generated aggregates live.
+ names directories to create one level up, where the generated aggregates live. `languages`
+ names the translations the scope has, and `built_languages` the ones its mirrors hold --
+ which is how a dropped translation is staged, by listing it in the second and not the first.
"""
module_root = tmp_path / 'source' / 'naboj'
module_root.mkdir(parents=True)
make_problem(module_root, name='widget')
+ for name in languages:
+ target = module_root / 'phys' / '99' / 'languages' / name
+ target.mkdir(parents=True)
+ (target / 'meta.yaml').write_text('booklet:\n contents:\n problems: True\n')
for stage in ('render', 'build'):
for name in built:
target = tmp_path / stage / 'naboj' / 'phys' / '99' / 'problems' / name
@@ -957,6 +963,10 @@ def make_repo(tmp_path, built=(), beside_the_scope=()):
target = tmp_path / stage / 'naboj' / 'phys' / '99' / name
target.mkdir(parents=True)
(target / 'answers.tex').write_text('generated')
+ for name in built_languages:
+ target = tmp_path / stage / 'naboj' / 'phys' / '99' / 'languages' / name
+ target.mkdir(parents=True)
+ (target / 'booklet.tex').write_text('stale')
return module_root
@@ -1001,6 +1011,37 @@ def test_a_tree_that_was_never_built_is_quiet(self, tmp_path):
assert 'build-orphan' not in ids(audit(module_root, 'naboj', 'phys/99',
['phys/99/problems/widget']))
+ def test_a_dropped_translation_leaves_an_orphan(self, tmp_path):
+ r"""
+ The case the unit sweep cannot see: `languages/` is not a place units live, so a stale
+ `build/naboj/phys/29/languages/uk/` sits there unnoticed -- and the language documents
+ are pattern rules, so it is enough to build a whole booklet from. Volume 29 printed a
+ 40-page Ukrainian problems booklet that way, out of a volume that has `en`, `es`, `sk`.
+ """
+ module_root = make_repo(tmp_path, built=('widget',),
+ languages=('sk',), built_languages=('sk', 'uk'))
+ report = audit(module_root, 'naboj', 'phys/99', ['phys/99/problems/widget'])
+ messages = [f.message for f in report.findings if f.check == 'build-orphan']
+ assert len(messages) == 2 # one per mirror
+ assert all('languages/uk' in m for m in messages)
+ assert not any('languages/sk' in m for m in messages)
+
+ def test_a_translation_that_still_has_its_source_is_quiet(self, tmp_path):
+ module_root = make_repo(tmp_path, built=('widget',),
+ languages=('sk', 'en'), built_languages=('sk', 'en'))
+ assert 'build-orphan' not in ids(audit(module_root, 'naboj', 'phys/99',
+ ['phys/99/problems/widget']))
+
+ def test_a_module_with_no_languages_at_all_is_quiet(self, tmp_path):
+ """
+ Seminar's shape. There is no `source//languages/`, so there is no notion of a
+ language directory here and the pass yields nothing -- it is self-limiting rather than
+ a rule about naboj. The built `languages/` below would otherwise be a tempting orphan.
+ """
+ module_root = make_repo(tmp_path, built=('widget',), beside_the_scope=('languages',))
+ assert 'build-orphan' not in ids(audit(module_root, 'naboj', 'phys/99',
+ ['phys/99/problems/widget']))
+
def test_remove_takes_the_directory_and_reports_what_went(self, tmp_path):
module_root = make_repo(tmp_path, built=('widget', 'old-name'))
sources = read_scope(module_root, 'naboj', 'phys/99', ['phys/99/problems/widget'])
diff --git a/core/tests/test_editor_capabilities.py b/core/tests/test_editor_capabilities.py
index 544a6149..4f34adc4 100644
--- a/core/tests/test_editor_capabilities.py
+++ b/core/tests/test_editor_capabilities.py
@@ -1,7 +1,7 @@
"""
The editor's capability probe, and the TeX target it exposes.
-Everything here runs without pandoc, xelatex or make: the probe's job is to *report* what is
+Everything here runs without pandoc, xelatex or ninja: the probe's job is to *report* what is
missing, so the tests feed it a fake environment rather than the machine's real one. That is also
why these live outside `tools/` -- `pytest.ini` collects `core/tests/`, and a probe that only
worked on a fully installed machine would be exactly the thing this change exists to remove.
@@ -47,7 +47,7 @@ def test_a_module_with_no_render_rule_has_no_tex_rule(self):
assert UnitKind(glob='*').tex == ''
def test_something_that_is_not_a_render_target_is_refused(self):
- """Better no TeX tab than a target make has no rule for."""
+ """Better no TeX tab than a target the build graph has no edge for."""
assert UnitKind(glob='*', render='output/naboj/{unit}.pdf').tex == ''
@pytest.mark.parametrize('descriptor', sorted(ROOT.glob('modules/*/editor.yaml')))
@@ -81,7 +81,7 @@ def fake_run(argv, *, stdin=None, timeout=30):
return (0, f'/texmf/{argv[1]}\n') if argv[1] in present else (1, '')
if tool not in present:
return None
- if tool == 'make':
+ if tool == 'ninja':
return 0, 'GNU Make 4.4\n'
if tool == 'pandoc':
if stdin is None: # `pandoc --version`
@@ -97,11 +97,11 @@ def tiers(self, payload):
return {t['id']: t['ok'] for t in payload['tiers']}
def test_markdown_alone_needs_no_pandoc_and_no_latex(self, monkeypatch):
- payload = self._payload(monkeypatch, present={'make', 'uv'})
+ payload = self._payload(monkeypatch, present={'ninja', 'uv'})
assert self.tiers(payload) == {
'markdown': True, 'tex': False, 'latex': False, 'pdf': False}
- def test_no_make_takes_every_pipeline_tier_with_it(self, monkeypatch):
+ def test_no_ninja_takes_every_pipeline_tier_with_it(self, monkeypatch):
payload = self._payload(monkeypatch, present={'pandoc', 'pandoc-crossref', 'xelatex'})
tiers = self.tiers(payload)
assert tiers['markdown'] is False
@@ -114,7 +114,7 @@ def test_the_macro_sweep_does_not_need_pandoc(self, monkeypatch):
straight to xelatex, so a machine with TeX but no pandoc can still run it.
"""
payload = self._payload(monkeypatch, present={
- 'make', 'uv', 'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'})
+ 'ninja', 'uv', 'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'})
tiers = self.tiers(payload)
assert tiers['latex'] is True
assert tiers['tex'] is False # no pandoc
@@ -123,19 +123,19 @@ def test_the_macro_sweep_does_not_need_pandoc(self, monkeypatch):
def test_xelatex_without_the_class_is_not_a_pdf(self, monkeypatch):
"""`which xelatex` is not enough: dgs.cls and MinionPro.sty decide it."""
payload = self._payload(monkeypatch, present={
- 'make', 'uv', 'pandoc', 'pandoc-crossref', 'xelatex', 'texfot'})
+ 'ninja', 'uv', 'pandoc', 'pandoc-crossref', 'xelatex', 'texfot'})
assert self.tiers(payload)['tex'] is True
assert self.tiers(payload)['pdf'] is False
def test_a_missing_tier_carries_a_reason_and_a_command(self, monkeypatch):
- payload = self._payload(monkeypatch, present={'make', 'uv'})
+ payload = self._payload(monkeypatch, present={'ninja', 'uv'})
tex = next(t for t in payload['tiers'] if t['id'] == 'tex')
assert tex['reason']
assert any('install-pandoc' in line for line in tex['install'])
def test_the_install_hint_is_not_repeated(self, monkeypatch):
"""pandoc and its filter share one script; saying it twice reads like two steps."""
- payload = self._payload(monkeypatch, present={'make', 'uv'})
+ payload = self._payload(monkeypatch, present={'ninja', 'uv'})
tex = next(t for t in payload['tiers'] if t['id'] == 'tex')
assert len(tex['install']) == len(set(tex['install']))
@@ -143,12 +143,12 @@ def test_the_install_hint_is_not_repeated(self, monkeypatch):
class TestTheLauncherDoesNotNeedUv:
def test_uv_is_used_when_it_is_there(self, monkeypatch):
monkeypatch.setattr(capabilities, '_which', lambda name: '/usr/bin/uv' if name == 'uv' else '')
- assert capabilities.probe_python_launcher()['argv'] == ['uv', 'run', 'make']
+ assert capabilities.probe_python_launcher()['argv'] == ['uv', 'run', 'ninja']
def test_without_uv_it_falls_back_to_the_running_interpreter(self, monkeypatch):
monkeypatch.setattr(capabilities, '_which', lambda name: '')
launcher = capabilities.probe_python_launcher()
- assert launcher['argv'] == ['make']
+ assert launcher['argv'] == ['ninja']
# the venv's bin directory, not the base interpreter it symlinks to
assert launcher['path_prefix'] == str(pathlib.Path(sys.executable).parent)
@@ -185,41 +185,52 @@ def test_a_populated_checkout_is_not_reported_as_empty(self):
class TestTheHtmlTargetIsDerived:
- """
- `html` is derived the same way `tex` is, and for a stronger reason: there is exactly one
- `output/%.html: render/%.md` in the root Makefile, shared by every module, so a per-module key
- could only ever drift from it.
+ r"""
+ `html` is derived the same way `tex` is, and lands beside it.
+
+ **A fragment is an intermediate and belongs in `build/`.** It has no ``, so nothing
+ typesets its `\(…\)`, and its `
` point at files that are not next to it; assembling
+ fragments into a page is `core/html/page.py`, and that is the step whose output is in
+ `output/`. This used to say `output/` and was wrong for long enough to matter -- the rule it
+ named became an XFAIL fall-through when the per-module `.html` rules landed, so the editor's
+ HTML tab asked the build for something nothing made.
"""
def test_it_swaps_the_prefix_and_the_extension(self):
kind = UnitKind(glob='*', render='render/naboj/{unit}/{language}/{target}.md')
- assert kind.html == 'output/naboj/{unit}/{language}/{target}.html'
+ assert kind.html == 'build/naboj/{unit}/{language}/{target}.html'
+
+ def test_it_sits_beside_the_tex(self):
+ """The two writers, one rendered Markdown: the only difference is the extension."""
+ kind = UnitKind(glob='*', render='render/naboj/{unit}/{language}/{target}.md')
+ assert kind.html == kind.tex.removesuffix('.tex') + '.html'
def test_a_module_with_no_render_rule_has_no_html_rule(self):
assert UnitKind(glob='*').html == ''
def test_something_that_is_not_a_render_target_is_refused(self):
- """The quiet half: better no HTML tab than a target make has no rule for."""
+ """The quiet half: better no HTML tab than a target the graph has no edge for."""
assert UnitKind(glob='*', render='output/naboj/{unit}.pdf').html == ''
def test_the_tex_override_does_not_leak_into_it(self):
"""
- `tex_override` exists for a module whose `.tex` rule is not the usual one. The HTML rule
- is the root Makefile's for everybody, so an override of the TeX target must not silently
- redirect it.
+ `tex_override` exists for a module whose `.tex` rule is not the usual one. An override of
+ the TeX target must not silently redirect the HTML one, which is a different writer.
"""
kind = UnitKind(glob='*', render='render/x/{target}.md', tex_override='build/odd.tex')
- assert kind.html == 'output/x/{target}.html'
+ assert kind.html == 'build/x/{target}.html'
@pytest.mark.parametrize('descriptor', sorted(ROOT.glob('modules/*/editor.yaml')))
- def test_every_real_descriptor_yields_an_output_path(self, descriptor):
+ def test_every_real_descriptor_yields_a_build_path(self, descriptor):
"""Every module that renders Markdown can show its web fragment."""
import yaml
spec = yaml.safe_load(descriptor.read_text()) or {}
+ module = descriptor.parent.name
for entry in spec.get('units') or []:
kind = UnitKind(glob=entry['glob'], render=entry.get('render', ''))
if kind.render:
- assert kind.html.startswith('output/') and kind.html.endswith('.html')
+ assert kind.html.startswith(f'build/{module}/')
+ assert kind.html.endswith('.html')
class TestAssetVersion:
diff --git a/core/tests/test_ninja_graph.py b/core/tests/test_ninja_graph.py
new file mode 100644
index 00000000..ce8a6d0e
--- /dev/null
+++ b/core/tests/test_ninja_graph.py
@@ -0,0 +1,220 @@
+r"""
+The HTML half of the generated graph, over the helpers that write it.
+
+These are the pure parts of `tools/ninja/generate.py` -- the ones that decide *which* edge a
+file takes -- so they can be exercised with a `Writer` over a `StringIO` and no source tree,
+which is what `core/tests/` is allowed to assume. The edges themselves are checked by building
+a volume and diffing against make; what is pinned here is the shape, because every one of these
+choices has a wrong answer that produces a plausible-looking graph:
+
+- a fragment that goes to only one writer, so the web is built from something else than print;
+- a web picture that is drawn or converted the wrong way, which is a hairline turned into a
+ heavy frame (`dvisvgm`) or three blank figures (a `.gp` setting its own terminal);
+- a page that looks for its fragments one directory off, which for Náboj is the difference
+ between a language and the problem above it.
+"""
+import io
+import pathlib
+
+import pytest
+
+from tools.ninja.generate import (
+ _beside,
+ _fragment,
+ _instagram,
+ _page,
+ _picture_svg,
+ _standalone,
+)
+from tools.ninja.writer import Writer
+
+REPO = pathlib.Path('/repo')
+META = REPO / 'source/naboj/phys/29/problems/drenched/meta.yaml'
+
+
+@pytest.fixture
+def writer():
+ return Writer(io.StringIO())
+
+
+def written(writer) -> str:
+ return writer.out.getvalue()
+
+
+class TestAFragmentGoesToBothWriters:
+ """
+ One Jinja stage, two writers. A second route to the web would be a second chance for the
+ page and the booklet to disagree about what the problem says, which is the whole reason the
+ `.html` hangs off the same `render/…md` as the `.tex`.
+ """
+
+ def test_it_returns_the_tex_and_the_html(self, writer):
+ tex, html = _fragment(writer.build, 'source/naboj/x/sk/solution.md',
+ 'naboj/x/sk/solution', 'sk', META, REPO)
+ assert tex == 'build/naboj/x/sk/solution.tex'
+ assert html == 'build/naboj/x/sk/solution.html'
+
+ def test_both_are_made_from_the_one_rendered_markdown(self, writer):
+ _fragment(writer.build, 'source/naboj/x/sk/solution.md', 'naboj/x/sk/solution',
+ 'sk', META, REPO)
+ text = written(writer)
+ assert 'build/naboj/x/sk/solution.tex: pandoc_tex render/naboj/x/sk/solution.md' in text
+ assert 'build/naboj/x/sk/solution.html: pandoc_html render/naboj/x/sk/solution.md' in text
+
+ def test_the_html_waits_for_the_crossref_table_too(self, writer):
+ """pandoc reads `build/core/i18n/.yaml` whichever writer it is running."""
+ _fragment(writer.build, 'source/naboj/x/sk/solution.md', 'naboj/x/sk/solution',
+ 'sk', META, REPO)
+ line = next(l for l in written(writer).splitlines() if 'pandoc_html' in l)
+ assert '| build/core/i18n/sk.yaml' in line
+
+
+class TestAWebPictureIsTheBookletsPicture:
+ """
+ There is no separate web drawing: every route goes through the artefact the booklet prints,
+ except the two that would cost a conversion to arrive where they started.
+ """
+
+ def test_a_hand_drawn_svg_is_copied(self, writer):
+ """Round-tripping it through a PDF would be two conversions for nothing."""
+ assert _picture_svg(writer.build, writer, 'naboj/x/pic', 'svg') == 'build/naboj/x/pic.svg'
+ assert 'build/naboj/x/pic.svg: copy render/naboj/x/pic.svg' in written(writer)
+
+ def test_a_plot_is_drawn_again_rather_than_converted(self, writer):
+ """One step shorter, and it takes the PDF parser out of the chain for these files."""
+ _picture_svg(writer.build, writer, 'naboj/x/plot', 'gp')
+ assert 'build/naboj/x/plot.svg: gnuplot_svg build/naboj/x/plot.gp' in written(writer)
+
+ @pytest.mark.parametrize('extension', ['tikz', 'pdf'])
+ def test_everything_else_is_converted_from_the_printed_pdf(self, writer, extension):
+ _picture_svg(writer.build, writer, 'naboj/x/pic', extension)
+ assert 'build/naboj/x/pic.svg: pdf_svg build/naboj/x/pic.pdf' in written(writer)
+
+ def test_a_picture_reached_twice_is_declared_once(self, writer):
+ """
+ `seminar/FKS/35/1/2/02` holds both `svah.svg` and `svah.pdf`. The PDF side resolves that
+ by rule order and this side must not then declare a second edge for the same output --
+ Ninja refuses the graph outright, which is how the PDF case was found.
+ """
+ _picture_svg(writer.build, writer, 'seminar/x/svah', 'svg')
+ assert _picture_svg(writer.build, writer, 'seminar/x/svah', 'pdf') \
+ == 'build/seminar/x/svah.svg'
+ assert written(writer).count('build/seminar/x/svah.svg:') == 1
+
+
+class TestWhereAPageLooksForItsFragments:
+ """
+ Náboj holds a directory per language inside a problem; seminar is monolingual and its
+ fragments sit in the unit directory. `page.py` reads that out of the module's descriptor,
+ so the edge has to agree with it or the page is assembled from the wrong directory.
+ """
+
+ def test_naboj_nests_the_language(self, writer):
+ page = _page(writer.build, 'naboj/phys/29/problems/drenched', 'sk', 'naboj',
+ fragments=['build/naboj/phys/29/problems/drenched/sk/solution.html'],
+ pictures=[], nested=True)
+ assert page == 'output/naboj/phys/29/problems/drenched/sk/index.html'
+ assert 'unit = build/naboj/phys/29/problems/drenched' in written(writer)
+ assert 'module = naboj' in written(writer)
+
+ def test_seminar_does_not(self, writer):
+ page = _page(writer.build, 'seminar/FKS/42/1/1/01', 'sk', 'seminar',
+ fragments=['build/seminar/FKS/42/1/1/01/problem.html'],
+ pictures=[], nested=False)
+ assert page == 'output/seminar/FKS/42/1/1/01/index.html'
+
+ def test_it_waits_for_the_maths_the_booklet_is_set_with(self, writer):
+ r"""
+ `mathpage.jtex` `\input`s the language's i18n table and five of `core/latex/`. The
+ document edges learn those from xelatex's `-recorder`; a page runs its xelatex inside
+ `core.html.texmath`, where there is no recorder, so the edge has to name them.
+ """
+ _page(writer.build, 'naboj/x', 'sk', 'naboj', fragments=['build/naboj/x/sk/a.html'],
+ pictures=[], nested=True)
+ line = written(writer).splitlines()[0]
+ for wanted in ('build/core/i18n/sk.tex', 'core/templates/mathpage.jtex',
+ 'core/latex/math.tex', 'core/templates/page.jhtml',
+ 'core/html/page.css'):
+ assert wanted in line, wanted
+
+
+class TestWhichPicturesAPageNames:
+ def test_only_the_ones_in_the_units_own_directory(self):
+ """
+ An exact directory match, not a prefix. Scholar's issue directory *contains* its
+ problems, so a prefix would hand an issue's page every drawing below it.
+ """
+ drawings = ['build/scholar/TA1/2018/handouts/02/cylinder.svg',
+ 'build/scholar/TA1/2018/handouts/02/01/graph.svg']
+ assert _beside(drawings, 'scholar/TA1/2018/handouts/02') == [drawings[0]]
+ assert _beside(drawings, 'scholar/TA1/2018/handouts/02/01') == [drawings[1]]
+
+
+class TestTheEditorsPreview:
+ """
+ `modules//editor.yaml` names `output//standalone.pdf` as its `preview:`, and
+ `/api/compile` asks for that and nothing else -- so a module whose standalone has no edge
+ has an editor that renders and converts and then cannot show the page. Seminar and scholar
+ were in exactly that state while make built both.
+ """
+
+ def test_it_builds_the_target_the_descriptor_names(self, writer):
+ assert _standalone(writer.build, 'seminar', 'seminar/FKS/42/1/3/01',
+ [], [], 'sk') == 'output/seminar/FKS/42/1/3/01/standalone.pdf'
+
+ def test_the_builder_is_given_the_path_below_its_own_source_root(self, writer):
+ """
+ `-l/--launch` defaults to `source//`, so the argument is the unit *below* that.
+ Passing the whole `seminar/FKS/...` would look right and resolve one level too high.
+ """
+ _standalone(writer.build, 'seminar', 'seminar/FKS/42/1/3/01', [], [], 'sk')
+ assert '\n unit = FKS/42/1/3/01\n' in written(writer)
+
+ def test_a_scholar_sheet_and_a_problem_below_it_each_get_one(self, writer):
+ """Both shapes `editor.yaml` declares: the issue holding `text.md`, and a problem."""
+ for rel in ('scholar/TA1/2025/handouts/01', 'scholar/TA1/2025/handouts/01/1-wien'):
+ assert _standalone(writer.build, 'scholar', rel, [], [], 'sk') \
+ == f'output/{rel}/standalone.pdf'
+
+ def test_the_pdf_waits_for_the_tex_and_the_class(self, writer):
+ _standalone(writer.build, 'seminar', 'seminar/FKS/42/1/3/01',
+ ['build/seminar/FKS/42/1/3/01/problem.tex'], [], 'sk')
+ pdf = next(line for line in written(writer).splitlines()
+ if 'document_pdf' in line)
+ for wanted in ('build/seminar/FKS/42/1/3/01/standalone.tex',
+ 'build/seminar/FKS/42/1/3/01/problem.tex',
+ 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'):
+ assert wanted in pdf, wanted
+
+
+class TestTheInstagramPages:
+ """
+ One page per problem is a fact about `instagram.jtex`, not a thing to discover by listing
+ whatever `pdftoppm` happened to write -- so the pages are declared, and a document that
+ does not have that many stops the build instead of being cut into misnumbered images.
+ """
+
+ PREFIX = 'output/seminar/FKS/42/1/3/instagram'
+ COUNTED = 'build/seminar/FKS/42/1/3/instagram.pages'
+
+ def test_there_is_one_image_per_problem(self, writer):
+ assert _instagram(writer.build, self.PREFIX, self.COUNTED, 3) == [
+ f'{self.PREFIX}-01.png', f'{self.PREFIX}-02.png', f'{self.PREFIX}-03.png']
+
+ def test_every_page_waits_for_the_count_to_be_checked(self, writer):
+ _instagram(writer.build, self.PREFIX, self.COUNTED, 3)
+ pages = [line for line in written(writer).splitlines() if 'page_image' in line]
+ assert len(pages) == 3
+ for page in pages:
+ assert f'{self.PREFIX}.pdf | {self.COUNTED}' in page, page
+
+ def test_the_check_is_told_how_many_pages_to_expect(self, writer):
+ _instagram(writer.build, self.PREFIX, self.COUNTED, 7)
+ written_out = written(writer)
+ assert f'build {self.COUNTED}: page_count {self.PREFIX}.pdf' in written_out
+ assert '\n pages = 7\n' in written_out
+
+ def test_a_page_names_itself_as_pdftoppms_prefix(self, writer):
+ """`-singlefile` writes `.png`, so the prefix is the output without its suffix."""
+ _instagram(writer.build, self.PREFIX, self.COUNTED, 1)
+ assert f'\n prefix = {self.PREFIX}-01\n' in written(writer)
diff --git a/core/tests/test_ninja_ink.py b/core/tests/test_ninja_ink.py
new file mode 100644
index 00000000..07ee9991
--- /dev/null
+++ b/core/tests/test_ninja_ink.py
@@ -0,0 +1,78 @@
+r"""
+`tools/ninja/ink.py` -- the Makefile's colours on Ninja's `description` lines.
+
+Two things can go wrong here and neither shows up as a failed build, which is why they are
+pinned: a `{` reaching `str.format` that was meant for the reader, and a line that does not
+end in the default colour, which leaves the next one tinted.
+"""
+import re
+
+import pytest
+
+from tools.ninja import ink
+
+#: What `tput sgr0` writes, with the charset reset `tput` adds for xterm allowed either way.
+RE_ESCAPE = re.compile(r'(?:\x1b\(B)?\x1b\[[0-9;]*m')
+
+
+@pytest.fixture
+def coloured(monkeypatch):
+ monkeypatch.delenv('NO_COLOR', raising=False)
+ monkeypatch.setenv('TERM', 'xterm-256color')
+
+
+class TestTheColours:
+ def test_a_description_ends_in_the_default(self, coloured):
+ """Otherwise the colour of the last thing named bleeds into Ninja's next line."""
+ assert ink.described('{a}Copying {f}$in').endswith(ink.PALETTE['d'])
+
+ def test_ninja_s_own_variables_pass_through(self, coloured):
+ """`$in` is expanded by Ninja when the edge runs, long after `format` has been."""
+ assert '$in' in ink.described('{a}Copying {f}$in')
+ assert '$out' in ink.described('{a}to {f}$out')
+
+ def test_the_text_survives_the_colours(self, coloured):
+ said = ink.described('{a}[jinja] Rendering {x}$kind template{a} {f}$in')
+ assert RE_ESCAPE.sub('', said) == '[jinja] Rendering $kind template $in'
+
+ @pytest.mark.parametrize('environment', [{'NO_COLOR': '1'}, {'TERM': 'dumb'},
+ {'TERM': ''}])
+ def test_a_terminal_that_is_not_one_gets_no_escapes(self, monkeypatch, environment):
+ monkeypatch.delenv('NO_COLOR', raising=False)
+ monkeypatch.setenv('TERM', 'xterm-256color')
+ for key, value in environment.items():
+ monkeypatch.setenv(key, value)
+ said = ink.described('{a}[jinja] Rendering {x}$kind template{a} {f}$in')
+ assert said == '[jinja] Rendering $kind template $in'
+ assert '\x1b' not in said
+
+
+class TestEveryDescriptionInTheGraph:
+ """
+ Over the rules themselves, because a stray brace in one of them is a `KeyError` at
+ generation time and a missing reset is invisible until two lines run together.
+ """
+
+ @staticmethod
+ def descriptions() -> list[str]:
+ import io
+
+ from tools.ninja.generate import rules
+ from tools.ninja.writer import Writer
+
+ out = io.StringIO()
+ rules(Writer(out))
+ return [line.split(' = ', 1)[1]
+ for line in out.getvalue().splitlines() if line.startswith(' description = ')]
+
+ def test_there_are_some(self):
+ assert len(self.descriptions()) > 15
+
+ def test_each_one_ends_in_the_default(self, coloured):
+ for said in self.descriptions():
+ assert said.endswith(ink.PALETTE['d']), said
+
+ def test_none_of_them_leaks_a_brace(self):
+ """A `{` that `format` did not consume is a marker nobody defined."""
+ for said in self.descriptions():
+ assert '{' not in said and '}' not in said, said
diff --git a/core/tests/test_picture_svg.py b/core/tests/test_picture_svg.py
index 7ab77c28..3e23da4f 100644
--- a/core/tests/test_picture_svg.py
+++ b/core/tests/test_picture_svg.py
@@ -121,10 +121,17 @@ class TestGnuplotDrawsTheSamePlotTwice:
def canvas(path: pathlib.Path) -> tuple[float, float]:
if path.suffix == '.svg':
found = re.search(r'width="([\d.]+)"\s+height="([\d.]+)"', path.read_text())
- else: # a PDF's MediaBox, in points
- found = re.search(rb'/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)',
- path.read_bytes())
+ assert found, 'the SVG declares no canvas'
return tuple(float(x) for x in found.groups())
+ # The page size is **asked of poppler**, not read out of the file. cairo puts the page
+ # dictionary into a compressed object stream from 1.17 on, so the `/MediaBox` a regex
+ # over the bytes used to find is simply not there on a newer machine -- which is how
+ # this passed here and failed in CI, with `NoneType has no attribute groups` and
+ # nothing to say that the PDF was perfectly good. `pdfinfo` is poppler, the same
+ # package as the `pdftocairo` this module already requires.
+ out = subprocess.run(['pdfinfo', str(path)], check=True, capture_output=True, text=True)
+ found = re.search(r'Page size:\s+([\d.]+) x ([\d.]+) pts', out.stdout)
+ assert found, f'pdfinfo reported no page size:\n{out.stdout}'
return tuple(float(x) for x in found.groups())
def test_the_canvas_is_the_same_physical_size(self, tmp_path):
diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py
index 0e518e14..1efda1b8 100644
--- a/core/tests/test_quantities.py
+++ b/core/tests/test_quantities.py
@@ -468,10 +468,13 @@ def test_meter_per_second(self):
r'\unit{\metre\per\second}'
def test_celsius(self):
- """Degree-Celsius is converted to siunitx \\celsius."""
+ """
+ Degree-Celsius reaches the page as the macro pint spells it, which `siunitx.tex`
+ declares so that it sets `°C` like every other temperature in the booklet.
+ """
from pint import UnitRegistry as u
T = PhysicsQuantity(u.Quantity(25, 'degree_Celsius'))
- assert T.only_unit() == r'\unit{\celsius}'
+ assert T.only_unit() == r'\unit{\degreeCelsius}'
def test_joule(self):
assert PhysicsQuantity.construct(1.5, 'joule').only_unit() == r'\unit{\joule}'
@@ -1197,8 +1200,11 @@ class TestUnitMacros:
pytest.param('ly', r'\lightyear', id='light_year'),
pytest.param('t', r'\tonne', id='metric_ton'),
pytest.param('eV', r'\electronvolt', id='electron_volt'),
- pytest.param('degC', r'\celsius', id='degree_Celsius'),
- pytest.param('delta_degC', r'\dcelsius', id='delta_degree_Celsius'),
+ # pint 0.26.2 spells all three itself, and in valid TeX, so they no longer reach
+ # `PINT_TO_SIUNITX` -- see the note beside `\degreeCelsius` in `core/latex/siunitx.tex`.
+ # Each still sets as `°C` or `°F`, which is what the entries there are for.
+ pytest.param('degC', r'\degreeCelsius', id='degree_Celsius'),
+ pytest.param('delta_degC', r'\degreeCelsius', id='delta_degree_Celsius'),
pytest.param('degF', r'\fahrenheit', id='degree_Fahrenheit'),
pytest.param('rpm', r'\rpm', id='revolutions_per_minute'),
pytest.param('atm', r'\atmosphere', id='standard_atmosphere'),
diff --git a/core/tests/test_runner.py b/core/tests/test_runner.py
new file mode 100644
index 00000000..86b4f1c7
--- /dev/null
+++ b/core/tests/test_runner.py
@@ -0,0 +1,177 @@
+r"""
+The warm runner, `tools/runner/`.
+
+Each test runs a real client against a real daemon, because what is worth asserting here is
+exactly what a unit test of the pieces would stub out: that descriptors reach the child, that
+an exit status comes back, that the environment and the working directory are the client's and
+not the daemon's. The daemon is given a socket of its own through `DGS_RUNNER_SOCKET`, so a
+test run never restarts -- or inherits the state of -- the one serving a build in the next
+terminal.
+
+The job every test runs is `core.builder.reference`, which is core's own, needs no content
+repository, and is cheap: `--check` reads `docs/filters.md` and says whether it is stale.
+"""
+import os
+import pathlib
+import subprocess
+import sys
+import time
+
+import pytest
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+CLIENT = ('-S', 'tools/runner/client.py')
+#: A job that exists in a bare checkout, writes to stdout, and takes an argument that decides
+#: whether it succeeds.
+JOB = ('-m', 'core.builder.reference', '--check')
+
+
+@pytest.fixture
+def runner(tmp_path):
+ """A client bound to a daemon of this test's own, stopped however the test ends."""
+ environment = dict(os.environ, DGS_RUNNER_SOCKET=str(tmp_path / 'sock'))
+
+ def run(*arguments, **extra):
+ return subprocess.run([sys.executable, *CLIENT, *arguments], check=False,
+ cwd=ROOT, capture_output=True, text=True,
+ env=dict(environment, **extra.pop('env', {})), **extra)
+
+ yield run
+ subprocess.run([sys.executable, '-m', 'tools.runner.server', '--stop'],
+ cwd=ROOT, env=environment, capture_output=True, check=False)
+
+
+def direct(*arguments):
+ return subprocess.run([sys.executable, *arguments], check=False,
+ cwd=ROOT, capture_output=True, text=True)
+
+
+class TestTheWarmRunner:
+ def test_a_job_matches_the_interpreter_it_replaces(self, runner):
+ """The whole contract: same stdout, same stderr, same status, warm or cold."""
+ warm, cold = runner(*JOB), direct(*JOB)
+ assert (warm.stdout, warm.stderr, warm.returncode) \
+ == (cold.stdout, cold.stderr, cold.returncode)
+
+ def test_a_second_job_reuses_the_daemon(self, runner):
+ first = runner(*JOB)
+ second = runner(*JOB)
+ assert first.returncode == second.returncode
+ assert first.stdout == second.stdout
+
+ def test_a_failure_keeps_its_status_and_its_complaint(self, runner):
+ """`--check` is not the point; a status other than 0 surviving the socket is."""
+ warm = runner('-m', 'core.builder.reference', '--nonsense')
+ cold = direct('-m', 'core.builder.reference', '--nonsense')
+ assert warm.returncode == cold.returncode != 0
+ assert warm.stderr == cold.stderr
+
+ def test_the_fallback_is_the_plain_interpreter(self, runner):
+ """`DGS_NO_DAEMON` must leave nothing behind, because it never connects."""
+ warm = runner(*JOB, env={'DGS_NO_DAEMON': '1'})
+ assert warm.returncode == direct(*JOB).returncode
+
+ def test_a_daemon_holding_stale_code_retires_and_the_job_still_runs(self, runner,
+ tmp_path):
+ """
+ The failure this exists to prevent is a daemon answering with code that is no longer
+ on disk. It must refuse instead -- and refusing before the acknowledgement means the
+ client runs the job itself, so the edit costs one slow job and nothing else.
+ """
+ runner(*JOB)
+ socket = tmp_path / 'sock'
+ assert socket.exists()
+
+ watched = ROOT / 'core' / 'builder' / 'jinja.py'
+ stat = watched.stat()
+ try:
+ os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9))
+ done = runner(*JOB)
+ finally:
+ os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns))
+
+ assert done.returncode == direct(*JOB).returncode
+ deadline = time.monotonic() + 5
+ while socket.exists() and time.monotonic() < deadline:
+ time.sleep(0.02)
+ assert not socket.exists(), 'the stale daemon is still listening'
+
+ def test_a_daemon_that_cannot_start_does_not_stop_the_build(self, tmp_path):
+ """
+ The job still runs, and in reasonable time. A Unix socket name is capped near 108
+ bytes, so this one can be neither bound nor connected to; the daemon retires without
+ a word and the client notices it go rather than waiting out `SPAWN_TIMEOUT`.
+ """
+ impossible = tmp_path / ('d' * 120) / 'sock'
+ started = time.monotonic()
+ done = subprocess.run([sys.executable, *CLIENT, *JOB], cwd=ROOT, check=False,
+ capture_output=True, text=True,
+ env=dict(os.environ, DGS_RUNNER_SOCKET=str(impossible)))
+ assert (done.stdout, done.returncode) == (direct(*JOB).stdout, direct(*JOB).returncode)
+ assert time.monotonic() - started < 10
+
+ def test_the_child_takes_the_client_s_directory_and_environment(self, runner, tmp_path):
+ """
+ Not the daemon's. It is started from wherever the first client happened to be and
+ lives for half an hour, so a job that inherited its directory or its environment would
+ be right until the moment somebody built from somewhere else.
+
+ The first call is what starts the daemon, from the repository root; the probe then
+ runs from `tmp_path` and has to report `tmp_path` back.
+ """
+ runner(*JOB)
+
+ probe = tmp_path / 'probe.py'
+ probe.write_text('import os\nprint(os.getcwd())\nprint(os.environ["DGS_PROBE"])\n')
+ answer = subprocess.run(
+ [sys.executable, *CLIENT[:1], str(ROOT / CLIENT[1]), str(probe)], check=False,
+ cwd=tmp_path, capture_output=True, text=True,
+ env=dict(os.environ, DGS_RUNNER_SOCKET=str(tmp_path / 'sock'),
+ DGS_PROBE='from the client'))
+
+ assert answer.returncode == 0, answer.stderr
+ assert answer.stdout.splitlines() == [str(tmp_path), 'from the client']
+
+
+class TestTheFingerprint:
+ def test_it_moves_when_a_watched_file_does(self, tmp_path):
+ """What makes the daemon refuse to answer with code that is no longer on disk."""
+ from tools.runner import server
+
+ before = server.fingerprint()
+ assert before == server.fingerprint()
+
+ watched = ROOT / 'core' / 'builder' / 'jinja.py'
+ stat = watched.stat()
+ try:
+ os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9))
+ assert server.fingerprint() != before
+ finally:
+ os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns))
+ assert server.fingerprint() == before
+
+ def test_it_reads_the_code_and_not_the_sources(self):
+ """A problem is not a dependency of the interpreter. Only what was imported is."""
+ from tools.runner import server
+
+ watched = {pathlib.Path(path) for path in server.watched()}
+ assert ROOT / 'pandoc.py' in watched
+ assert ROOT / 'core' / 'builder' / 'jinja.py' in watched
+ assert not any(path.is_relative_to(ROOT / 'source') for path in watched)
+ assert not any('__pycache__' in path.parts for path in watched)
+ # A template is a dependency of the build graph, not of this process.
+ assert not any(path.suffix == '.jtex' for path in watched)
+
+ def test_it_is_anchored_to_the_checkout_and_not_to_the_directory(self, tmp_path,
+ monkeypatch):
+ """
+ The daemon outlives the terminal it was started from. Taken relative to the working
+ directory, the walk would find nothing from anywhere else and the fingerprint would
+ never move again -- which is the one way this could go on serving deleted code.
+ """
+ from tools.runner import server
+
+ assert all(pathlib.Path(path).is_absolute() for path in server.watched())
+ before = server.fingerprint()
+ monkeypatch.chdir(tmp_path)
+ assert server.fingerprint() == before
diff --git a/dgs-build b/dgs-build
new file mode 100755
index 00000000..03312eba
--- /dev/null
+++ b/dgs-build
@@ -0,0 +1,23 @@
+#!/bin/bash
+#
+# The build, from a terminal: `./dgs-build output/naboj/phys/29/languages/sk/booklet.pdf`.
+#
+# All it does is choose an interpreter and hand over to `tools.ninja.bootstrap`, which generates
+# `build.ninja` if a fresh clone has none and then becomes Ninja. Choosing the interpreter is the
+# one thing that cannot be done from Python, and everything after it is in that module -- so the
+# editor and the command line answer "how is this build run" in one place rather than two.
+#
+# The venv goes first because it is the cheap answer and its existence means somebody has already
+# synced: `uv run` costs an extra process and reaches the same interpreter. uv is still what runs
+# when there is no venv, because it makes one. A bare `python3` is the last resort and may be
+# older than the 3.14 `pyproject.toml` asks for, which is why it is tried last rather than first.
+set -euo pipefail
+cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
+
+if [[ -x .venv/bin/python3 ]]; then
+ exec .venv/bin/python3 -m tools.ninja.bootstrap "$@"
+elif command -v uv > /dev/null 2>&1; then
+ exec uv run python -m tools.ninja.bootstrap "$@"
+else
+ exec python3 -m tools.ninja.bootstrap "$@"
+fi
diff --git a/dgs-container b/dgs-container
index a984b5b5..4f894826 100755
--- a/dgs-container
+++ b/dgs-container
@@ -4,8 +4,8 @@
#
# This exists for two reasons, and brevity is not one of them:
#
-# - forgetting `-v "$PWD/output:/dgs/output"` is *silent*. make builds the booklet into the
-# container's ephemeral layer, prints every success line, and the PDF is gone.
+# - forgetting `-v "$PWD/output:/dgs/output"` is *silent*. The build writes the booklet into
+# the container's ephemeral layer, prints every success line, and the PDF is gone.
# - `build` reads the FontPro commit out of git, because `.git/` is not in the build context
# and the Containerfile's ARG default can only ever be a second copy of it.
set -euo pipefail
@@ -26,7 +26,7 @@ mount=(-v "$PWD:/dgs")
usage() {
sed -n '2,9p' "$0"
echo
- echo "usage: $0 {build|editor|make|test|check|shell} [args...]"
+ echo "usage: $0 {build|editor|ninja|make|test|check|shell} [args...]"
echo " DGS_TARGET=light|full (default full) DGS_PORT (default 5001)"
}
@@ -67,9 +67,24 @@ editor)
python tools/editor/app.py --host 0.0.0.0 --port 5000 "$@"
;;
+ninja)
+ shift
+ mkdir -p build render output
+ # `./dgs-build`, not `ninja`: a fresh mount has no `build.ninja` and nothing inside the
+ # image can have one, since the graph is a function of `source/`, which is bind-mounted
+ # from the host and never baked. The wrapper generates it on the first run.
+ #
+ # `ninja` itself needs no apt package here -- it is a project dependency, so `uv sync`
+ # already put it in /opt/venv/bin, which is first on PATH.
+ exec podman run --rm -it --network=none "${mount[@]}" "$image:$target" ./dgs-build "$@"
+ ;;
+
make)
shift
mkdir -p build render output
+ # The Makefile still builds every tree the Ninja graph does; it is kept while the graph is
+ # new, and this is how you reach it. See `ninja` above for the one that is being moved to.
+ #
# --network=none: nothing in a build needs the network, UV_OFFLINE is already set, and
# -shell-escape is unconditional, so this costs nothing and closes the one real hole.
exec podman run --rm -it --network=none "${mount[@]}" "$image:$target" make "$@"
diff --git a/install.md b/install.md
index b7c88b15..ca7d8990 100644
--- a/install.md
+++ b/install.md
@@ -12,7 +12,7 @@ uv run python tools/editor/capabilities.py
| tier | what you can do | what it needs |
|---|---|---|
| — | open, edit and **save** files; the whole `/audit` page | Python and the dependencies below |
-| Rendered Markdown | see what your Jinja tags expand to | GNU make |
+| Rendered Markdown | see what your Jinja tags expand to | Ninja, which `uv sync` installs |
| TeX | see what pandoc makes of it | + `pandoc` and `pandoc-crossref` |
| PDF | compile and read the page | + TeX Live, `dgs.cls`, and the MinionPro fonts |
@@ -27,7 +27,7 @@ an image:
```
./dgs-container build # or: podman pull ghcr.io/trojstensk/dgs:full
./dgs-container editor # browser at localhost:5001
-./dgs-container make output/naboj/phys/29/languages/sk/booklet.pdf
+./dgs-container ninja output/naboj/phys/29/languages/sk/booklet.pdf
```
`DGS_TARGET=light` gets you a much smaller image with the Markdown and TeX tiers but no PDF —
@@ -87,7 +87,7 @@ against, installs that one, and verifies the result with a probe document.
**The trap:** it installs into `$HOME/.local/bin` and only *warns* if that is not on your `PATH`,
so the install goes green and nothing works. There is a second half the script cannot warn about —
-the editor shells out to make with **its own** environment, so a `PATH` exported in `~/.zshrc` is
+the editor shells out to the build with **its own** environment, so a `PATH` exported in `~/.zshrc` is
invisible to an editor started from a desktop launcher or a systemd unit. The symptom is a TeX tab
greyed out on a machine where `pandoc` works fine in the terminal. `capabilities.py` reports the
PATH the server would actually use.
@@ -115,7 +115,46 @@ It installs the apt packages itself, including `lcdf-typetools` — FontPro's sc
It does **not** install the Python side. Do step 1 as well.
-## 5. Running the editor
+## 5. The build graph
+
+```
+./dgs-build output/naboj/phys/29/languages/sk/booklet.pdf
+```
+
+That is the whole of it, and the first run is the only interesting one. The build is a Ninja
+graph that `tools/ninja/generate.py` writes from the source tree, and **`build.ninja` is
+generated and gitignored, so a fresh clone has none**. Ninja has no bootstrap of its own -- it
+finds no manifest the way `make` finds a `Makefile`, and stops at
+
+```
+ninja: error: loading 'build.ninja': No such file or directory
+```
+
+which names a file nobody has heard of. `./dgs-build` generates it when it is missing and then
+becomes Ninja, so there is nothing to remember; the editor does the same thing from its own
+Build button. Running `ninja` directly is fine once a graph exists.
+
+**It is not part of `install.sh`, deliberately.** The graph is a function of `source/`, which is
+cloned in step 2 and grows a repository whenever you clone another one -- so a graph written at
+install time would be wrong by the end of the install. And `install.sh` is the PDF tier; somebody
+writing or translating never runs it and still needs a build.
+
+After that, regenerating is Ninja's job. `build.ninja` is itself an output of a `generator` edge
+that depends on `generate.py` and on every source *directory* the graph was read from, so adding
+or removing a problem, a language or a picture re-runs the generator mid-build. Three things it
+will not notice, where you run the generator yourself:
+
+```
+uv run python -m tools.ninja.generate --scope all
+```
+
+- **a new volume, round or year.** The scope directories themselves are not tracked;
+- **a new `modules/*/templates/*.jtex`** -- changing one is seen, adding one is not;
+- **a different scope set.** The scopes are baked into the graph, and a target outside them is
+ `unknown target` rather than a reason to regenerate. `--scope phys/29` is a much smaller graph
+ if you want one; `all` is what `./dgs-build` writes and costs about two seconds.
+
+## 6. Running the editor
```
uv run python tools/editor/app.py --port 5001
@@ -126,7 +165,7 @@ path — `core/i18n/__init__.py` reads `core/i18n/default.yaml` at import time
from anywhere else. The app `chdir`s there itself, but the working copy it picks is the one the
file lives in. Port 5000 is the default and is often taken.
-`uv` is not required to run it. Without uv the editor calls `make` directly, with the directory of
+`uv` is not required to run it. Without uv the editor calls `ninja` directly, with the directory of
the interpreter running it prepended to `PATH` — so an editor started from an activated venv works
with no uv installed at all.
@@ -136,8 +175,8 @@ Worth knowing, because two of them fail quietly:
- **A missing `build/core/i18n/.yaml`** does not break the TeX tier; it silently changes it.
pandoc-crossref falls back to English prefixes, so a Slovak booklet says `eq.~\ref{…}` where it
- should say `rovnica~\ref{…}`. The `.tex` rules depend on `build/core/i18n.stamp` so make builds
- it for you; if you ever bypass make, build it first.
+ should say `rovnica~\ref{…}`. Every `.tex` edge depends on `build/core/i18n/.yaml`, so
+ the build makes it for you; if you ever bypass the build, build it first.
- **A font present but lacking a glyph** is not a compile error. xelatex writes
`Missing character:` into the log, exits 0, and ships a hole in the page. `core/tests/test_latex.py`
exists to catch that.
diff --git a/install.sh b/install.sh
index 9a67b876..c28d90e1 100755
--- a/install.sh
+++ b/install.sh
@@ -7,8 +7,10 @@ DIR=$(dirname $0)
#
# `lcdf-typetools` is not optional: FontPro's scripts call otfinfo, otftotfm and cfftot1,
# and die without them. CI has always installed it; this script used to forget.
+# `mupdf-tools` is `mutool`, which dvisvgm needs to read a PDF at all from Ghostscript 10.01.0
+# on -- Debian 13 ships 10.05, and without it every formula on an HTML page fails to convert.
sudo apt install texlive-full texlive-fonts-extra texlive-science librsvg2-bin gnuplot \
- lcdf-typetools
+ lcdf-typetools mupdf-tools
"$DIR/install-pandoc.sh"
TEXHOME=`kpsewhich -var-value=TEXMFHOME`
diff --git a/modules/naboj/module.mk b/modules/naboj/module.mk
index 03ad88c2..d6c6e62e 100644
--- a/modules/naboj/module.mk
+++ b/modules/naboj/module.mk
@@ -429,9 +429,31 @@ build/naboj/%/answers: \
### Languages ###################################
+# `source/naboj/$*/meta.yaml` on every language document below, and it is the only prerequisite
+# here that is not derived from something else. It is what pins `%` to a language that exists.
+#
+# These are pattern rules, so `%` matches any string, and the rest of each list is built with
+# `$(wildcard …)`, which yields nothing for a language that is not there rather than failing.
+# A target with no prerequisites whose file exists is up to date, so `make …/languages/uk/
+# booklet.pdf` for a volume with no Ukrainian would report success over whatever `build/` still
+# held from an older run -- and volume 29 did exactly that, printing a 40-page Ukrainian
+# problems booklet out of intermediates whose sources had been removed. Clearing `build/` was
+# the only thing that turned it back into an error.
+#
+# The meta is the source of truth and make has to find it before anything else can run, so a
+# language that does not exist now fails at once and says which file is missing, whatever is
+# lying in `build/`. The aggregates -- `output/naboj/%/languages` and `booklets` -- already
+# worked this way, wildcarding `source/naboj/$*/languages/*`; it was only the single-document
+# rules that would take any name.
+#
+# `fks-naboj`'s ten language directories have no meta and are now unbuildable by name. They
+# already were: its layout spells languages `slovak` rather than `sk`, so nothing in
+# `SUPPORTED_LANGUAGES` ever matched them.
+
# Full booklet
# % //languages/
output/naboj/%/booklet.pdf: \
+ source/naboj/$$*/meta.yaml \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \
$$(subst $$(cdir),,$$(abspath build/naboj/%/../../problems/$$(word 4,$$(subst /, ,$$*)))) \
@@ -446,6 +468,7 @@ output/naboj/%/booklet.pdf: \
# for this language, the shared pictures, and the rendered statements.
# % //languages/
output/naboj/%/problems.pdf: \
+ source/naboj/$$*/meta.yaml \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \
$$(subst $$(cdir),,$$(abspath build/naboj/%/../../problems/$$(word 4,$$(subst /, ,$$*)))) \
@@ -454,6 +477,7 @@ output/naboj/%/problems.pdf: \
# % //languages/
output/naboj/%/solutions.pdf: \
+ source/naboj/$$*/meta.yaml \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \
$$(subst $$(cdir),,$$(abspath build/naboj/%/../../solutions/$$(word 4,$$(subst /, ,$$*)))) \
@@ -468,6 +492,7 @@ output/naboj/%/booklet-print.pdf: \
mv output/naboj/$*/booklet-book.pdf $@
output/naboj/%/answers.pdf: \
+ source/naboj/$$*/meta.yaml \
$$(subst $$(cdir),,$$(abspath build/naboj/%/../../answers/$$(word 4,$$(subst /, ,$$*)))) \
$$(subst $$(cdir),,$$(abspath build/naboj/%/../../pdf-prerequisites)) \
build/naboj/%/answers.tex
@@ -490,6 +515,7 @@ output/naboj/%/constants.pdf: \
$(call double_xelatex,naboj)
output/naboj/%/evaluation.pdf: \
+ source/naboj/$$*/meta.yaml \
build/naboj/%/evaluators.tex
$(call double_xelatex,naboj)
@@ -501,6 +527,7 @@ output/naboj/%/instructions-online.pdf: \
$(call double_xelatex,naboj)
output/naboj/%/cover.pdf: \
+ source/naboj/$$*/meta.yaml \
build/naboj/%/cover.tex
$(call double_xelatex,naboj)
@@ -511,6 +538,7 @@ output/naboj/%/cover-print.pdf: \
# Tearoffs, three problems per page, aligned for cutting
# //languages/
output/naboj/%/tearoff.pdf: \
+ source/naboj/$$*/meta.yaml \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \
$$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \
build/naboj/%/tearoff.tex
@@ -653,7 +681,3 @@ output/naboj/%/venues: \
output/naboj/%/all: \
output/naboj/%/languages \
output/naboj/%/venues ;
-
-output/naboj/%/copy: \
- output/naboj/%
- rsync -rzvhPL output/naboj/$*/ ago:/home/balaz/public_html/naboj/$(subst output/,,$*)
diff --git a/modules/seminar/module.mk b/modules/seminar/module.mk
index fb96eb79..4c65db05 100644
--- a/modules/seminar/module.mk
+++ b/modules/seminar/module.mk
@@ -271,8 +271,3 @@ output/seminar/%: \
# output/seminar/$$*/instagram ;
.PHONY:
-
-output/seminar/%/copy: \
- output/seminar/%/
- $(eval words := $(subst /, ,$*))
- python ./dgs-copy.py $(word 1,$(words)) $(word 2,$(words)) $(word 3,$(words)) $(word 4,$(words)) $(user)
diff --git a/modules/seminar/templates/solutions-full.jtex b/modules/seminar/templates/solutions-full.jtex
index c0be71d3..938f211a 100644
--- a/modules/seminar/templates/solutions-full.jtex
+++ b/modules/seminar/templates/solutions-full.jtex
@@ -2,5 +2,8 @@
(@ block problem_again @)
\protectedInput{\activeDirectory/problem.tex}%
- \hline
+ % `\hline` stood here and is a *tabular* command, so every `solutions-full.pdf` ever
+ % asked for died on `Misplaced \noalign`. A rule between the restated problem and its
+ % solution is vertical-mode material and wants `\hrule`, with a `\par` to get there.
+ \par\medskip\hrule\medskip
(@ endblock @)
diff --git a/pyproject.toml b/pyproject.toml
index bd430ea1..06797594 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ dependencies = [
"enschema>=0.1.5",
"flask>=3.1.3",
"jinja2>=3.1.6",
+ "ninja>=1.11",
"numpy>=2.5.1",
"pandoc-crossref>=0.1.1",
"pandoc-minted>=0.2.0",
diff --git a/tools/editor/app.py b/tools/editor/app.py
index e9c083a3..d861c4b9 100644
--- a/tools/editor/app.py
+++ b/tools/editor/app.py
@@ -1,5 +1,6 @@
import argparse
import collections
+import functools
import importlib
import os
import re
@@ -46,6 +47,7 @@
from core.builder import reference
from core.builder.jinja import MarkdownJinjaRenderer
from core.builder.renderer import build_render_context, render_to_fixpoint
+from tools.ninja import bootstrap
from core.i18n import languages as LOCALES
@@ -184,7 +186,7 @@ def existing_targets(self):
def meta_path(self):
return self.path / "meta.yaml"
- # -- make targets --------------------------------------------------------
+ # -- build targets -------------------------------------------------------
def _format(self, template):
parent = self.name.rsplit("/", 1)[0] if "/" in self.name else ""
@@ -193,7 +195,8 @@ def _format(self, template):
def render_target(self, target):
"""
- The make target that renders this file. A gnuplot script keeps its own name and extension
+ The build target that renders this file. A gnuplot script keeps its own name and
+ extension
(`render//%.gp`); a declared target is always `.md`.
"""
if self.is_aux(target):
@@ -205,11 +208,12 @@ def render_path(self, target):
def tex_target(self, target):
"""
- The make target that converts this file to TeX, or None when there is not one.
+ The build target that converts this file to TeX, or None when there is not one.
An auxiliary file has none on purpose: a `.gp` becomes a figure through gnuplot, never a
- `.tex`, and asking for `build/.../time.gp.tex` would hit the Makefile's fall-through rule
- and print `Incorrect fall-through rule called`.
+ `.tex`, and `build/.../time.gp.tex` is a target the graph does not declare -- under make
+ it reached the fall-through rule that printed `Incorrect fall-through rule called`,
+ which is the shape of defect a generated graph makes impossible.
"""
if self.is_aux(target) or not self.kind.tex:
return None
@@ -221,7 +225,7 @@ def tex_path(self, target):
def html_target(self, target):
"""
- The make target that converts this file to an HTML fragment, or None when there is not
+ The build target that converts this file to an HTML fragment, or None when there is not
one. Auxiliary files are excluded for the reason `tex_target` excludes them: a `.gp`
becomes a figure, never a page.
"""
@@ -233,6 +237,19 @@ def html_path(self, target):
target = self.html_target(target)
return REPO_ROOT / target if target else None
+ def pictures_dir(self):
+ """
+ Where a relative `
` in this unit's fragments resolves.
+
+ A fragment says `` because that is what the author wrote next to the problem,
+ and the built drawing lands in the unit's own `build/` directory whatever the module --
+ beside the problem for Náboj, whose fragments are a level deeper inside the language
+ directory, and in the unit directory itself for the monolingual two. `page.py` copies
+ the pictures next to the page instead, which is what makes a page self-contained; the
+ HTML pane has no page, so it is pointed here.
+ """
+ return f"build/{self.module.name}/{self.name}/"
+
def preview_target(self):
if not self.kind.preview:
return None
@@ -367,16 +384,16 @@ def api_unit(module, unit):
})
-# --- running make -----------------------------------------------------------
+# --- running the build ------------------------------------------------------
-def make_env():
+def build_env():
"""
- How to invoke make, and in what environment.
+ How to invoke ninja, and in what environment.
- `uv run` is here only to put the project's interpreter on PATH, because the Makefile's
- recipes call a bare `python`. When uv is absent we call make directly and prepend the
- directory of the interpreter running this app -- in a venv that is exactly where `python`
- lives -- so an editor started from an activated venv works with no uv installed at all.
+ `uv run` is here only to put the project's interpreter on PATH, because the generated rules
+ call a bare `python`. When uv is absent we call ninja directly and prepend the directory of
+ the interpreter running this app -- in a venv that is exactly where both `python` and
+ `ninja` live -- so an editor started from an activated venv works with no uv installed.
"""
launcher = capabilities.capabilities(REPO_ROOT)["launcher"]
env = None
@@ -386,15 +403,25 @@ def make_env():
return launcher["argv"], env
-def run_make(target, *, timeout=60):
+#: `build.ninja` present before Ninja is asked to read it. Ninja regenerates the graph when it is
+#: stale -- `tools/ninja/generate.py` is emitted as a `generator` rule depending on the
+#: directories whose listings it was computed from -- so only its *absence* is ours to fix, and
+#: `./dgs-build` fixes it the same way from a terminal.
+ensure_graph = functools.partial(bootstrap.ensure_graph, REPO_ROOT, python=sys.executable)
+
+
+def run_build(target, *, timeout=60):
"""
- Run make and return the same dict `make_result` does -- or say why we could not.
+ Run the build and return the same dict `build_result` does -- or say why we could not.
- Without the guards, a machine with no make and no uv raises `FileNotFoundError` out of the
+ Without the guards, a machine with no ninja and no uv raises `FileNotFoundError` out of the
route, Flask answers 500 with an HTML body, and the front end's `fetchJSON` reports a bare
"HTTP 500", which tells nobody what to install.
"""
- argv, env = make_env()
+ argv, env = build_env()
+ if (failed := ensure_graph(env=env)) is not None:
+ return refusal(target, 'the build graph could not be generated',
+ f'`tools.ninja.generate` failed:\n\n{failed.stderr.strip()}\n')
try:
result = subprocess.run(
[*argv, target], cwd=REPO_ROOT, capture_output=True, text=True,
@@ -407,8 +434,8 @@ def run_make(target, *, timeout=60):
f"See install.md, or run\n\n uv run python tools/editor/capabilities.py\n")
except subprocess.TimeoutExpired:
return refusal(target, f"timed out after {timeout} s",
- f"`make {target}` did not finish within {timeout} seconds.\n")
- return make_result(target, result)
+ f"`ninja {target}` did not finish within {timeout} seconds.\n")
+ return build_result(target, result)
# XeLaTeX runs with `-file-line-error`, so its errors arrive as `file.tex:12: message`.
@@ -445,12 +472,12 @@ def summarise_failure(text):
return None
-def make_result(target, result):
+def build_result(target, result):
combined = ANSI_RE.sub("", result.stdout + result.stderr)
return {
"ok": result.returncode == 0,
"returncode": result.returncode,
- "command": f"make {target}",
+ "command": f"ninja {target}",
"summary": None if result.returncode == 0 else summarise_failure(combined),
"stdout": ANSI_RE.sub("", result.stdout),
"stderr": ANSI_RE.sub("", result.stderr),
@@ -458,8 +485,8 @@ def make_result(target, result):
def refusal(target, summary, explanation):
- """A failure of ours rather than make's: no exit code, and a reason worth reading."""
- return {"ok": False, "returncode": None, "command": f"make {target}",
+ """A failure of ours rather than the build's: no exit code, and a reason worth reading."""
+ return {"ok": False, "returncode": None, "command": f"ninja {target}",
"summary": summary, "stdout": explanation, "stderr": ""}
@@ -479,10 +506,10 @@ def tier_refusal(tier_id, target):
def missing_meta_result(target, unit):
"""
- Every render rule takes the unit's `meta.yaml` as a prerequisite, so without one make cannot
- build the chain at all and reports `No rule to make target`, naming the PDF rather than the
- file that is actually missing. Say what is wrong instead -- an unconverted unit opens in the
- editor precisely so that a meta.yaml can be written for it.
+ A unit with no `meta.yaml` is not in the build graph at all, so Ninja answers
+ `unknown target`, naming the PDF rather than the file that is actually missing. (Make said
+ `No rule to make target` for the same reason.) Say what is wrong instead -- an unconverted
+ unit opens in the editor precisely so that a meta.yaml can be written for it.
"""
return refusal(
target, "meta.yaml does not exist",
@@ -492,10 +519,10 @@ def missing_meta_result(target, unit):
)
-def build(unit, make_target, *, timeout=60):
+def build(unit, build_target, *, timeout=60):
if not unit.meta_path.is_file():
- return missing_meta_result(make_target, unit)
- return run_make(make_target, timeout=timeout)
+ return missing_meta_result(build_target, unit)
+ return run_build(build_target, timeout=timeout)
# --- writing ----------------------------------------------------------------
@@ -552,9 +579,9 @@ def evaluation_context(unit, target, meta_text):
"""
The context a tag in this file would be rendered against, built from the buffer as it stands.
- `build_render_context` is the renderer's own, so what the popup shows and what `make` writes
- come out of one definition. The meta is parsed from `meta_text` rather than read from disk
- because the whole point is to answer for the text on screen, which is usually unsaved.
+ `build_render_context` is the renderer's own, so what the popup shows and what the build
+ writes come out of one definition. The meta is parsed from `meta_text` rather than read from
+ disk because the whole point is to answer for the text on screen, which is usually unsaved.
"""
cli = module_cli(unit.module)
language = unit.lang or DEFAULT_LANGUAGE
@@ -602,8 +629,8 @@ def api_evaluate():
The editor asks this to show, in the reference popup, what the tag under the pointer actually
produces here -- the number, the equation, the word -- rather than the generic example the
- reference table carries. So it is in-process and reads the buffers out of the request: a make
- target would write the file, take seconds, and answer for the last save.
+ reference table carries. So it is in-process and reads the buffers out of the request: a
+ build target would write the file, take seconds, and answer for the last save.
A fragment that fails is reported as a failure of its own; only a context that cannot be
built at all fails the request, because that is one answer for every fragment and it is the
@@ -671,12 +698,12 @@ def api_render():
with BUILD_LOCK:
write_files(unit, body.get("files") or {})
- make_target = unit.render_target(target)
- response = build(unit, make_target)
+ build_target = unit.render_target(target)
+ response = build(unit, build_target)
# The path as well as the text, for the reason `/api/html` returns `html_target`: when
- # make succeeds and the file is not there, only the path tells you whether the rule wrote
- # somewhere else or did not write at all.
- response["render_target"] = make_target
+ # the build succeeds and the file is not there, only the path tells you whether the edge
+ # wrote somewhere else or did not write at all.
+ response["render_target"] = build_target
response["rendered_md"] = (
read_if_exists(unit.render_path(target)) if response["ok"] else None
)
@@ -688,27 +715,27 @@ def api_tex():
"""
The TeX pandoc produces for one file.
- Its make target takes the rendered Markdown as its prerequisite, so asking for TeX exercises
- the Markdown stage in the same call -- and `build()` keeps the `missing_meta_result` answer
- for a unit that has no meta.yaml yet.
+ Its build target takes the rendered Markdown as its prerequisite, so asking for TeX
+ exercises the Markdown stage in the same call -- and `build()` keeps the
+ `missing_meta_result` answer for a unit that has no meta.yaml yet.
"""
body = request.get_json(force=True)
unit = unit_from_body(body)
target = body.get("target")
unit.source_path(target) # validates
- make_target = unit.tex_target(target)
+ build_target = unit.tex_target(target)
with BUILD_LOCK:
write_files(unit, body.get("files") or {})
- if make_target is None:
+ if build_target is None:
response = refusal(
"(none)", "no TeX for this file",
f"{target} is not converted to TeX -- an auxiliary file becomes a figure, "
f"and {unit.module.label} may declare no TeX rule.\n")
elif not capabilities.tier(REPO_ROOT, "tex").ok:
- response = tier_refusal("tex", make_target)
+ response = tier_refusal("tex", build_target)
else:
- response = build(unit, make_target)
+ response = build(unit, build_target)
response["tex"] = (
read_if_exists(unit.tex_path(target)) if response["ok"] else None
)
@@ -733,26 +760,28 @@ def api_html():
unit = unit_from_body(body)
target = body.get("target")
unit.source_path(target) # validates
- make_target = unit.html_target(target)
+ build_target = unit.html_target(target)
with BUILD_LOCK:
write_files(unit, body.get("files") or {})
- if make_target is None:
+ if build_target is None:
response = refusal(
"(none)", "no HTML for this file",
f"{target} is not converted to HTML -- an auxiliary file becomes a figure, "
f"and {unit.module.label} may declare no render rule.\n")
elif not capabilities.tier(REPO_ROOT, "markdown").ok:
- response = tier_refusal("markdown", make_target)
+ response = tier_refusal("markdown", build_target)
else:
- response = build(unit, make_target)
+ response = build(unit, build_target)
response["html"] = (
read_if_exists(unit.html_path(target)) if response["ok"] else None
)
- # Where the fragment lives, so the pane can point a `` at its directory and let the
- # relative pictures resolve. Named here rather than reconstructed in the front end, which
- # would be a second copy of the path rule.
- response["html_target"] = make_target
+ # Where the fragment lives, and where its pictures do -- which for Náboj is not the
+ # same directory, since a fragment sits inside the language and a drawing beside the
+ # problem. Named here rather than reconstructed in the front end, which would be a
+ # second copy of two path rules.
+ response["html_target"] = build_target
+ response["html_base"] = unit.pictures_dir()
return jsonify(response)
@@ -761,14 +790,14 @@ def api_compile():
"""Write every buffer, then build whichever document previews this unit."""
body = request.get_json(force=True)
unit = unit_from_body(body)
- make_target = unit.preview_target()
+ build_target = unit.preview_target()
with BUILD_LOCK:
write_files(unit, body.get("files") or {})
- if make_target is not None and not capabilities.tier(REPO_ROOT, "pdf").ok:
- response = tier_refusal("pdf", make_target)
- elif make_target is None:
+ if build_target is not None and not capabilities.tier(REPO_ROOT, "pdf").ok:
+ response = tier_refusal("pdf", build_target)
+ elif build_target is None:
response = refusal(
"(none)", "no preview for this module",
f"{unit.module.label} declares no preview document in "
@@ -776,8 +805,8 @@ def api_compile():
)
else:
# A whole handout or round takes longer than a single problem.
- response = build(unit, make_target, timeout=600)
- built = REPO_ROOT / make_target
+ response = build(unit, build_target, timeout=600)
+ built = REPO_ROOT / build_target
if response["ok"] and built.is_file():
cached = unit.cached_pdf()
cached.parent.mkdir(parents=True, exist_ok=True)
@@ -824,6 +853,25 @@ def parse_mdcheck_output(stdout):
return violations
+@app.get("/api/build/")
+def api_build(relative):
+ """
+ One file out of `build/`, so the HTML pane's pictures resolve.
+
+ The twin of `api_output` below, and it is needed because a fragment is an intermediate: it
+ lives in `build/` beside the drawings it names, where a page lives in `output/` with its
+ pictures copied in. Serving it the same way keeps the pane honest -- a picture that does
+ not resolve here has not been built, and the pane should show that rather than hide it.
+
+ Path-checked rather than trusted, exactly as `api_output` is.
+ """
+ root = (REPO_ROOT / "build").resolve()
+ path = (root / relative).resolve()
+ if not path.is_relative_to(root) or not path.is_file():
+ raise BadRequest(f"No such build file: {relative}")
+ return send_file(path)
+
+
@app.get("/api/output/")
def api_output(relative):
"""
@@ -1107,9 +1155,9 @@ def api_audit_build(module, scope):
resolved, units = scope_units(module, scope)
sources = run_scope_audit(resolved, scope, units).sources
- def run(make_target, timeout):
+ def run(build_target, timeout):
with BUILD_LOCK:
- result = run_make(make_target, timeout=timeout)
+ result = run_build(build_target, timeout=timeout)
return {"returncode": result["returncode"],
"log": (result["stdout"] or "") + (result["stderr"] or "")}
diff --git a/tools/editor/capabilities.py b/tools/editor/capabilities.py
index 4364be69..148faa6a 100644
--- a/tools/editor/capabilities.py
+++ b/tools/editor/capabilities.py
@@ -4,7 +4,8 @@
The pipeline is three stages and the editor's tabs are those three stages: `render/…md` is
Jinja and needs nothing but Python; `build/…tex` adds pandoc; `output/…pdf` adds the whole TeX
stack and the fonts. A machine that has only the first is still useful to a translator, and this
-module is what lets the app know that rather than shelling out and reporting whatever make printed.
+module is what lets the app know that rather than shelling out and reporting whatever the
+build printed.
Nothing here may raise because a tool is absent -- that is the entire point of it -- so every
probe goes through `_run`, which swallows `FileNotFoundError` and a timeout and returns `None`.
@@ -24,7 +25,7 @@
# One place for install text, quoting the repository's own scripts rather than inventing
# commands that would drift from them.
INSTALL = {
- 'make': ['sudo apt install make'],
+ 'ninja': ['uv sync', 'sudo apt install ninja-build'],
'uv': ['curl -LsSf https://astral.sh/uv/install.sh | sh'],
'python': ['uv sync --dev', '# or activate the venv you installed into'],
'pandoc': ['./install-pandoc.sh', 'export PATH="$HOME/.local/bin:$PATH"'],
@@ -41,6 +42,7 @@
],
'gnuplot': ['sudo apt install gnuplot'],
'rsvg-convert': ['sudo apt install librsvg2-bin'],
+ 'mutool': ['sudo apt install mupdf-tools'],
}
@@ -69,7 +71,17 @@ class Tier:
def _which(name):
- return shutil.which(name) or ''
+ """
+ On `PATH`, or beside the interpreter running this app.
+
+ The second half is not a convenience. `ninja` is a project dependency, so the one the build
+ actually uses is `.venv/bin/ninja`, and that directory is on `PATH` only inside an activated
+ venv -- `probe_python_launcher` prepends it for exactly this reason when uv is absent.
+ Looking only at `PATH`, an editor started as `python tools/editor/app.py` probed its way to
+ "no ninja", and since a tier is never usable when one below it is missing, all three
+ pipeline tiers went dark on a machine that builds perfectly.
+ """
+ return shutil.which(name) or shutil.which(name, path=str(Path(sys.executable).parent)) or ''
def _run(argv, *, stdin=None, timeout=30):
@@ -84,44 +96,46 @@ def _run(argv, *, stdin=None, timeout=30):
# --- individual probes -------------------------------------------------------
-def probe_make():
+def probe_ninja():
"""
- BSD make accepts the invocation and then misparses `.SECONDEXPANSION`, which every rule in
- this repository leans on, so finding *a* make is not enough.
+ Ninja, which the build runs through since `tools/ninja/generate.py` replaced the Makefile.
+
+ Unlike make there is nothing to disambiguate -- there is one Ninja and no dialects -- so this
+ only has to find it, and `_which` is what knows where to look.
+
+ It runs the path it found rather than the bare name, because the two are not the same
+ question: the venv's `ninja` answers `which` only when the venv is activated.
"""
- path = _which('make')
+ path = _which('ninja')
if not path:
- return Tool('make', False)
- result = _run(['make', '--version'], timeout=10)
- first = result[1].splitlines()[0] if result and result[1] else ''
- if 'GNU Make' not in first:
- return Tool('make', False, path, first, 'found, but it is not GNU make')
- return Tool('make', True, path, first)
+ return Tool('ninja', False)
+ result = _run([path, '--version'], timeout=10)
+ return Tool('ninja', True, path, (result[1].strip() if result and result[1] else ''))
def probe_python_launcher():
"""
- How `run_make` should invoke make, and why `uv` is not required.
+ How `run_build` should invoke ninja, and why `uv` is not required.
- `uv` is only there to put the project's interpreter on PATH for the Makefile's bare `python`
- in `define jinja` and `define _pandoc`. This process *is* such an interpreter -- it imported
- jinja2, yaml and all of core.audit to get here -- so when uv is absent we call make directly
- with our own interpreter's directory prepended to PATH. In a venv that directory holds the
- `python` the recipes call.
+ `uv` is only there to put the project's interpreter on PATH, because the generated rules call
+ a bare `python` exactly as the Makefile's recipes did. This process *is* such an interpreter
+ -- it imported jinja2, yaml and all of core.audit to get here -- so when uv is absent we call
+ ninja directly with our own interpreter's directory prepended to PATH. In a venv that
+ directory holds both the `python` the rules call and the `ninja` that calls them.
"""
# Deliberately NOT resolve()d: a venv's `bin/python` is a symlink to the base interpreter,
# so resolving it lands in /usr/local/bin and loses the venv -- which is the one directory
# that matters, because that is where the dependencies are importable from.
bindir = str(Path(sys.executable).parent)
if _which('uv'):
- return {'argv': ['uv', 'run', 'make'], 'via': 'uv', 'path_prefix': '',
+ return {'argv': ['uv', 'run', 'ninja'], 'via': 'uv', 'path_prefix': '',
'note': ''}
if (Path(bindir) / 'python').exists():
- return {'argv': ['make'], 'via': 'python', 'path_prefix': bindir,
- 'note': f'uv is not installed; running make with {bindir} on PATH'}
- return {'argv': ['make'], 'via': 'none', 'path_prefix': bindir,
+ return {'argv': ['ninja'], 'via': 'python', 'path_prefix': bindir,
+ 'note': f'uv is not installed; running ninja with {bindir} on PATH'}
+ return {'argv': ['ninja'], 'via': 'none', 'path_prefix': bindir,
'note': ('uv is not installed and there is no `python` beside this interpreter, so '
- "the Makefile's recipes will not find one")}
+ 'the build rules will not find one')}
def probe_pandoc():
@@ -190,9 +204,26 @@ def probe_latex(repo_root):
def probe_advisories():
- """Needed only by a unit that has a figure, so never a gate -- but worth naming."""
+ """
+ Needed only by some units and some documents, so never a gate -- but worth naming.
+
+ The first four draw a figure. The last three take a finished PDF apart: `pdftk` bursts a
+ volume's online booklet into one file per page, `pdftoppm` renders a seminar round's
+ instagram document to PNGs, and `pdfjam` imposes a semester booklet. Those three were
+ missing from this list, which is how a machine with no `pdftk` -- this one -- could report
+ the PDF tier green and then fail one edge out of 59 355 with nothing having said why.
+
+ **`mutool` is dvisvgm's, not ours.** Ghostscript dropped the PDF interpreter dvisvgm drove
+ in 10.01.0, so from Debian 13 onwards `dvisvgm --pdf` refuses the file outright -- `To
+ process PDF files, either Ghostscript < 10.01.0 or mutool is required` -- and that is the
+ whole of the HTML maths, which goes through dvisvgm for every formula. dvisvgm finds mutool
+ by itself; `DVISVGM_PDF_PROC=gs|mutool` forces the choice. It is listed even where
+ Ghostscript is old enough not to need it, because there is no reading of a version that
+ makes a machine without it safe to upgrade.
+ """
return [Tool(name, bool(_which(name)), _which(name))
- for name in ('gnuplot', 'rsvg-convert', 'dvisvgm', 'pdfcrop')]
+ for name in ('gnuplot', 'rsvg-convert', 'dvisvgm', 'mutool', 'pdfcrop',
+ 'pdftk', 'pdftoppm', 'pdfjam')]
def _first_line(text):
@@ -229,23 +260,23 @@ def capabilities(repo_root, *, refresh=False):
return _CACHE['payload']
launcher = probe_python_launcher()
- make = probe_make()
+ ninja = probe_ninja()
pandoc, crossref = probe_pandoc()
latex = probe_latex(repo_root)
advisories = probe_advisories()
python_ok = launcher['via'] != 'none'
- floor = [make, Tool('python', python_ok, sys.executable, detail=launcher['note'])]
+ floor = [ninja, Tool('python', python_ok, sys.executable, detail=launcher['note'])]
tools = floor + [pandoc, crossref] + latex
- markdown = _tier('markdown', 'Rendered Markdown', tools, {'make', 'python'})
+ markdown = _tier('markdown', 'Rendered Markdown', tools, {'ninja', 'python'})
# What it takes to compile a `.tex` -- no pandoc. The macro sweep needs exactly this and
# nothing more: it writes its own probe document and hands it straight to xelatex.
latex = _tier('latex', 'LaTeX', tools,
{'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'})
- tex = _tier('tex', 'TeX', tools, {'make', 'python', 'pandoc', 'pandoc-crossref'})
+ tex = _tier('tex', 'TeX', tools, {'ninja', 'python', 'pandoc', 'pandoc-crossref'})
pdf = _tier('pdf', 'PDF', tools,
- {'make', 'python', 'pandoc', 'pandoc-crossref',
+ {'ninja', 'python', 'pandoc', 'pandoc-crossref',
'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'})
# A tier is never usable when the one below it is not, whatever its own tools say.
@@ -296,7 +327,7 @@ def main():
for line in t['install']:
print(f' $ {line}')
if report['advisories']:
- print('\n figures may not build without:')
+ print('\n some figures and documents may not build without:')
for t in report['advisories']:
print(f' {t["name"]}')
if report['launcher']['note']:
diff --git a/tools/editor/descriptors.py b/tools/editor/descriptors.py
index b2846669..68a0b07a 100644
--- a/tools/editor/descriptors.py
+++ b/tools/editor/descriptors.py
@@ -33,7 +33,7 @@ class UnitKind:
@property
def tex(self):
"""
- The make target that converts this file to TeX.
+ The build target that converts this file to TeX.
Derived from `render` rather than written out per module, because the relation is the
build system's own: every `.tex` rule in every `module.mk` reads
@@ -49,16 +49,23 @@ def tex(self):
@property
def html(self):
- """
- The make target that converts this file to an HTML fragment.
-
- Derived from `render` exactly as `tex` is, and for the same reason: the root Makefile has
- one `output/%.html: render/%.md` for every module, so the relation belongs to the build
- system and a hand-written key per module could only drift from it.
+ r"""
+ The build target that converts this file to an HTML fragment.
+
+ Derived from `render` exactly as `tex` is, and it lands in **`build/`** beside the
+ `.tex`, because that is what it is: a fragment is to a page what a `.tex` is to a PDF --
+ no ``, nothing to typeset its `\(…\)`, and `
` pointing at files that are not
+ beside it. The page is the `output/` step, and `core/html/page.py` is what makes one.
+
+ This said `output/` until now, which was true of a root-Makefile rule that has since
+ become an XFAIL fall-through: the per-module `.html` rules landed beside the `.tex` ones
+ and this did not follow them, so the editor's HTML tab has been asking for a target
+ nothing builds. Under make that is `Incorrect fall-through rule called`; under Ninja it
+ is `unknown target`, which is how it was found.
"""
if not self.render.startswith("render/") or not self.render.endswith(".md"):
return ""
- return "output/" + self.render[len("render/"):-len(".md")] + ".html"
+ return "build/" + self.render[len("render/"):-len(".md")] + ".html"
@property
def fixed_levels(self):
diff --git a/tools/editor/static/app.js b/tools/editor/static/app.js
index 4e411583..11cd9a01 100644
--- a/tools/editor/static/app.js
+++ b/tools/editor/static/app.js
@@ -912,7 +912,7 @@ function captureEditors() {
/**
* Only dirty buffers go over the wire. Writing every file on every compile would bump
- * their mtimes and make `make` redo the whole problem each time instead of just the part
+ * their mtimes and make the build redo the whole problem each time instead of just the part
* that changed.
*/
function dirtyFiles() {
@@ -1097,22 +1097,23 @@ async function doCompile() {
/* The fragment the web gets, shown as a page rather than as markup.
*
- * `srcdoc` with an absolute `` under `/api/output/`, so the relative pictures resolve
+ * `srcdoc` with an absolute `` under `/api/build/`, so the relative pictures resolve
* exactly as they would on the web -- one that does not load here would not load there either,
* and the pane should show that rather than hide it.
*
* MathJax typesets it, with `static/mathjax-dgs.js` ahead of it supplying the siunitx macros
* MathJax has none of. `allow-scripts` is therefore required, and is given *without*
* `allow-same-origin`: the frame runs on an opaque origin and cannot reach back into the editor. */
-// What the pane shows when make succeeded and the fragment is not where we looked for it.
+// What the pane shows when the build succeeded and the fragment is not where we looked.
// Without this the body is empty, the iframe is white and the status still reads `HTML OK` --
// a success message over a blank page, which reads as the preview being broken rather than as
// a build that did not produce what it claimed. The two paths that reach it are a target whose
-// rule is satisfied without writing the file, and a `html_target` that disagrees with where make
-// put it; naming the path is what tells them apart.
+// edge is satisfied without writing the file, and a `html_target` that disagrees with where
+// the build put it; naming the path is what tells them apart.
const missingFragment = (target) =>
`` +
- `make reported success but wrote no fragment at
${escapeForHtml(target ?? "(no target)")}
`;
+ `the build reported success but wrote no fragment at
` +
+ `${escapeForHtml(target ?? "(no target)")}
`;
// An empty fragment is not that, and must not be reported as a fault: `28/john-doe`'s answer.md
// exists and is deliberately empty, and so is the HTML pandoc makes of it.
@@ -1120,10 +1121,10 @@ const emptyFragment =
`` +
`the source is empty, so the fragment is too
`;
-/* The pane while make is running, and the pane when the call failed.
+/* The pane while the build is running, and the pane when the call failed.
*
* Both exist because the iframe keeps whatever it last held until something replaces it, and
- * `make` can take seconds: a click answered by the previous problem's page -- or, on the first
+ * a build can take seconds: a click answered by the previous problem's page -- or, on the first
* preview of a session, by nothing at all -- reads as the preview being broken, which is the
* one reading the pane must never invite. It is the same reason `missingFragment` exists. */
const frameDocument = (body) =>
@@ -1138,7 +1139,7 @@ async function doHtml() {
const frame = el("html-view");
switchOutputTab("html");
// Only if the wait is long enough to be noticed: a conversion that answers in 80 ms should
- // not flash a message, and one that takes four seconds of `make` must not look like an
+ // not flash a message, and one that takes four seconds of building must not look like an
// empty pane.
const busy = setTimeout(() => {
frame.srcdoc = frameDocument(`converting…`);
@@ -1151,9 +1152,11 @@ async function doHtml() {
if (body.ok) {
// Absolute, because a `srcdoc` document has no URL of its own for a relative one to
- // resolve against.
- const base = `${location.origin}/api/output/` +
- (body.html_target ?? "").replace(/^output\//, "").replace(/[^/]*$/, "");
+ // resolve against. The server says which directory, because it is not the fragment's
+ // own: a Náboj fragment sits inside the language directory and its drawings beside the
+ // problem above it.
+ const base = `${location.origin}/api/build/` +
+ (body.html_base ?? "").replace(/^build\//, "");
frame.srcdoc =
`` +
`` +
@@ -1229,11 +1232,11 @@ async function doRender() {
markSaved();
if (body.ok) {
- // `rendered_md` is null when make succeeded and the file is not where we looked for it --
+ // `rendered_md` is null when the build succeeded and the file is not where we looked --
// the Markdown half of what `missingFragment` says for the HTML pane. Painting that as an
// empty block under `Rendered OK` is the one reading to avoid.
code.innerHTML = body.rendered_md == null
- ? `make reported success but wrote no rendered Markdown at\n`
+ ? `the build reported success but wrote no rendered Markdown at\n`
+ `${escapeForHtml(body.render_target ?? "(no target)")}`
: highlight(body.rendered_md, "dgs-md");
out.classList.toggle("error", body.rendered_md == null);
diff --git a/tools/editor/static/audit.js b/tools/editor/static/audit.js
index 9a584c41..5fa01bfc 100644
--- a/tools/editor/static/audit.js
+++ b/tools/editor/static/audit.js
@@ -812,7 +812,7 @@ function wireErrorReporting() {
}
/**
- * Both buttons here shell out: the build checks run make all the way to a PDF, and the macro
+ * Both buttons here shell out: the build checks go all the way to a PDF, and the macro
* sweep needs xelatex. The source-only checks -- which is every other thing on this page -- need
* no toolchain at all, so the page stays fully useful on a machine that can do neither.
*/
diff --git a/tools/ninja/__init__.py b/tools/ninja/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tools/ninja/bootstrap.py b/tools/ninja/bootstrap.py
new file mode 100644
index 00000000..a11e3cb0
--- /dev/null
+++ b/tools/ninja/bootstrap.py
@@ -0,0 +1,90 @@
+r"""
+`build.ninja` into existence, and then Ninja -- which is the one step Ninja cannot do itself.
+
+The graph is generated and gitignored, so a fresh clone has none and `ninja` stops at
+
+ ninja: error: loading 'build.ninja': No such file or directory
+
+naming a file nobody has heard of and saying nothing about a generator. Unlike `make`, which
+finds a `Makefile` checked into the tree, Ninja has no bootstrap: the `generator = 1` edge keeps
+an *existing* graph current -- it depends on `generate.py`, `writer.py` and every source
+directory the graph was read from, so adding a problem or a language re-runs it mid-build -- but
+it cannot write the first one.
+
+So every entry point calls `ensure_graph` before it calls Ninja. There are two: the editor, from
+`tools/editor/app.py`'s `run_build`, and the command line, through `./dgs-build`.
+
+**The scope is `all`, deliberately.** A graph generated for one volume answers `unknown target`
+for every other, which reads exactly like a broken build to somebody who was never told a graph
+has a scope -- and regenerating the whole repository costs about a second. A narrower graph is
+for someone who has asked for one, by running `tools.ninja.generate` themselves.
+
+**Only absence is ours to fix.** This never regenerates a graph that is merely old: that is the
+generator edge's job, it knows what it read, and second-guessing it here would put two different
+answers to "is the graph stale" in the tree.
+"""
+import os
+import pathlib
+import subprocess
+import sys
+
+#: This file is `/tools/ninja/bootstrap.py`.
+ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
+
+
+def ensure_graph(root=ROOT, *, env=None, python=None):
+ """
+ Generate `build.ninja` if it is not there. `None` when nothing was wrong, else the failure.
+
+ `python` is the interpreter to generate with, and defaults to the one running this. The
+ generator imports nothing outside the standard library, but the project asks for 3.14 and a
+ system `python3` may be older, so the caller that knows better passes it.
+ """
+ root = pathlib.Path(root)
+ graph = root / 'build.ninja'
+ if graph.is_file():
+ return None
+ result = subprocess.run(
+ [python or sys.executable, '-m', 'tools.ninja.generate',
+ '--scope', 'all', '-o', str(graph)],
+ check=False, cwd=root, capture_output=True, text=True, timeout=300, env=env)
+ return None if result.returncode == 0 else result
+
+
+def main(argv=None):
+ """
+ `./dgs-build …`: make sure there is a graph, then become Ninja.
+
+ How to invoke Ninja is `capabilities.probe_python_launcher`'s answer rather than one of our
+ own -- it is the same question the editor asks, and two answers to it would drift. That
+ module is under `tools/editor/` because the editor is what made it worth writing, and it
+ probes the machine rather than serving a page, so this is not a layering violation.
+
+ `exec` rather than a subprocess, so Ninja owns the terminal: its one-line progress display
+ needs a tty, and Ctrl-C has to reach it rather than us.
+ """
+ argv = sys.argv[1:] if argv is None else argv
+ sys.path.insert(0, str(ROOT))
+ from tools.editor import capabilities
+
+ launcher = capabilities.probe_python_launcher()
+ env = dict(os.environ)
+ if launcher['path_prefix']:
+ env['PATH'] = launcher['path_prefix'] + os.pathsep + env.get('PATH', '')
+
+ if (failed := ensure_graph(ROOT, env=env)) is not None:
+ sys.stderr.write(f'could not generate build.ninja:\n{failed.stderr}')
+ return 1
+
+ os.chdir(ROOT)
+ try:
+ os.execvpe(launcher['argv'][0], [*launcher['argv'], *argv], env)
+ except FileNotFoundError:
+ sys.stderr.write(
+ f"{launcher['argv'][0]}: not found. "
+ f"{' or '.join(capabilities.INSTALL['ninja'])}\n")
+ return 127
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/ninja/fls.py b/tools/ninja/fls.py
new file mode 100644
index 00000000..746c87bd
--- /dev/null
+++ b/tools/ninja/fls.py
@@ -0,0 +1,93 @@
+r"""
+Turn xelatex's `.fls` recorder log into a Ninja depfile.
+
+**Why this is the right source.** Every other way of knowing what a document includes is a
+guess. `\protectedInput` resolves its path against `\activeDirectory`, a macro set at run time by
+`\setActive`, and then asks `\IfFileExists`; `\insertPicture` does the same. Nothing upstream can
+predict the answer, which is why `modules/naboj/module.mk` falls back to depending on *directories*
+(`naboj_problem_dirs`, `:114-132`) and on every `.jtex` in the tree at once. With `-recorder`,
+TeX writes down what it actually opened, and the next build is told exactly that.
+
+What is filtered out, and why:
+
+- **Everything outside the repository.** TeX Live is some 1200 files per run -- fonts, `.fmt`,
+ every `.sty` -- and none of it changes without a distribution upgrade, which is not an event
+ this build should try to track. Keeping them would multiply the graph by two orders of
+ magnitude for nothing.
+- **The run's own outputs** (`.aux`, `.log`, `.out`, `.synctex.gz`, the `.pdf` itself). TeX reads
+ its own `.aux` from the previous pass, and a depfile naming the target as its own dependency is
+ a cycle Ninja refuses with `dependency cycle`.
+- **`OUTPUT` lines.** Only `INPUT` is a dependency.
+
+Usage, as the second half of a build command:
+
+ xelatex -recorder … && python -m tools.ninja.fls out.fls out.pdf out.pdf.d
+"""
+import argparse
+import pathlib
+import sys
+
+#: Produced by the run itself, so never a dependency of it.
+OUTPUTS = {'.aux', '.log', '.out', '.pdf', '.synctex', '.gz', '.toc', '.nav', '.snm', '.fls'}
+
+
+def dependencies(fls: pathlib.Path, repo: pathlib.Path, target: pathlib.Path) -> list[str]:
+ """Every file inside the repository that this run read, once each, in a stable order."""
+ seen, out = set(), []
+ target = target.resolve()
+ lines = fls.read_text(errors='replace').splitlines()
+ # A relative INPUT is relative to the directory TeX ran in, which the log states on its
+ # first line -- *not* to wherever the `.fls` itself landed. `-jobname` puts the log under
+ # `output/…` while the run happens at the repository root, so resolving against the file's
+ # own directory turns `build/core/i18n/sk.tex` into
+ # `output/…/languages/sk/build/core/i18n/sk.tex`, a path that exists nowhere.
+ base = fls.parent
+ for line in lines:
+ if line.startswith('PWD '):
+ base = pathlib.Path(line[4:])
+ break
+ for line in lines:
+ if not line.startswith('INPUT '):
+ continue
+ path = pathlib.Path(line[6:])
+ if not path.is_absolute():
+ path = base / path
+ try:
+ path = path.resolve()
+ relative = path.relative_to(repo)
+ except (ValueError, OSError):
+ continue # outside the repository: TeX Live, and it does not move
+ if path == target or path.suffix in OUTPUTS or path in seen:
+ continue
+ seen.add(path)
+ out.append(str(relative))
+ return sorted(out)
+
+
+def write(fls: pathlib.Path, target: str, depfile: pathlib.Path, repo: pathlib.Path) -> int:
+ deps = dependencies(fls, repo, pathlib.Path(target))
+ # Ninja's depfile parser takes one target and a space-separated list; a space in a path is
+ # escaped with a backslash, which is also how make spells it.
+ escaped = ' '.join(d.replace(' ', r'\ ') for d in deps)
+ depfile.write_text(f"{target}: {escaped}\n")
+ return len(deps)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
+ parser.add_argument('fls', type=pathlib.Path)
+ parser.add_argument('target')
+ parser.add_argument('depfile', type=pathlib.Path)
+ parser.add_argument('--repo', type=pathlib.Path, default=pathlib.Path('.'))
+ args = parser.parse_args()
+ if not args.fls.is_file():
+ # No recorder log means the run did not get that far; an empty depfile is correct and
+ # lets Ninja fall back to the declared inputs rather than failing on a missing file.
+ args.depfile.write_text(f'{args.target}:\n')
+ return 0
+ write(args.fls, args.target, args.depfile, args.repo.resolve())
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py
new file mode 100644
index 00000000..30606a54
--- /dev/null
+++ b/tools/ninja/generate.py
@@ -0,0 +1,1313 @@
+r"""
+Generate `build.ninja` for one Náboj volume, as a spike against the Makefile.
+
+**Why this exists.** The make build decides what to do with pattern rules whose prerequisites come
+from `$(wildcard …)`, and a wildcard that matches nothing yields *nothing* rather than failing. A
+rule then either silently does not apply -- and an existing target is declared current -- or applies
+against whatever is left in `build/`. That has hidden three defects on record: `Makefile:20-23`
+(`$(lang)` unset, every picture rule dying on `invalid choice`), `Makefile:146`, and a 40-page
+Ukrainian booklet built for a volume that has no Ukrainian. Ninja has no pattern rules and no
+implicit search: a target either has an edge here or it is `ninja: error: unknown target`.
+
+**Why it is possible.** No `$(wildcard …)` in any of the four makefiles reads `build/` -- every
+`build/` prerequisite is `$(subst source/,build/,…)` of a `source/` glob. The dependency graph is a
+pure function of the source tree, so it can be computed up front.
+
+Run:
+
+ uv run python -m tools.ninja.generate --scope phys/29 -o build.ninja
+ uv run --with ninja ninja -f build.ninja output/naboj/phys/29/languages/sk/booklet.pdf
+
+Nothing in `Makefile` or `modules/*/module.mk` is touched; the two drive the same trees and the
+point of the exercise is to diff their output.
+"""
+import argparse
+import pathlib
+import sys
+
+from tools.ninja.ink import described
+from tools.ninja.writer import Writer
+
+#: Files a problem holds inside its language directory, and beside it. The split is
+#: `modules/naboj/editor.yaml`'s `targets` / `translated`; it is repeated here rather than read
+#: because the spike must not depend on the descriptor being right about a module it is testing.
+#: Which fragments each document actually includes. Make expresses this with the three
+#: per-language stamps `problems/`, `solutions/` and `answers/`
+#: (`modules/naboj/module.mk:321-343`); the sets are the same, named rather than stamped.
+#:
+#: Getting it wrong is not merely wasteful. Declaring every fragment on every document made
+#: `problems.pdf` wait for a `solution.md` it never includes -- and in `FKS/42/1/1` that is a
+#: solution which does not currently convert, so a booklet that builds under make did not here.
+NABOJ_GROUPS = {
+ 'problems': ('problem', 'problem-extra'),
+ 'solutions': ('solution',),
+ 'answers': ('answer', 'answer-also', 'answer-interval', 'answer-extra'),
+}
+NABOJ_DOCUMENT_GROUPS = {
+ 'booklet': ('problems', 'solutions', 'answers'),
+ 'online': ('problems', 'solutions', 'answers'),
+ 'tearoff': ('problems', 'solutions', 'answers'),
+ 'problems': ('problems',),
+ 'solutions': ('solutions',),
+ 'answers': ('answers',),
+ 'constants': (),
+ 'cover': (),
+ 'evaluation': (),
+}
+#: The same, for seminar, from `modules/seminar/module.mk:171-199`.
+SEMINAR_DOCUMENT_GROUPS = {
+ 'problems': ('problem',),
+ 'solutions': ('solution',),
+ 'solutions-full': ('problem', 'solution'),
+ 'instagram': ('problem',),
+}
+
+#: Scholar has two kinds of issue and each has its own builder, writing three documents and two
+#: respectively (`modules/scholar/builder/{handout,homework}.py`). A unit here is an *issue*,
+#: which holds a `text.md` of its own and a directory per problem -- two editable levels, which
+#: `modules/scholar/editor.yaml` declares as two `units:` entries.
+SCHOLAR_KINDS = {
+ 'handouts': ('handout', ('handout-students', 'handout-solutions', 'handout-solved')),
+ 'homework': ('homework', ('homework-students', 'homework-solutions')),
+}
+#: `content` is in neither `modules/scholar/module.mk`'s `RULE_TEMPLATE_SCHOLAR` (which names
+#: `text problem solution`) nor `modules/scholar/editor.yaml` (`targets: [text]`), and 28 files
+#: are called that -- which is why `make output/scholar/…/handout-students.pdf` dies on the
+#: XFAIL fall-through at `Makefile:186` rather than building. Taken from the tree, not from
+#: either declaration.
+SCHOLAR_TARGETS = ('text', 'content', 'problem', 'solution')
+
+NABOJ_RENDERER = 'modules.naboj.builder.renderer'
+SEMINAR_RENDERER = 'modules.seminar.builder.renderer'
+SCHOLAR_RENDERER = 'modules.scholar.builder.renderer'
+
+TRANSLATED = ('problem', 'problem-extra', 'solution', 'answer-extra')
+SHARED = ('answer', 'answer-also', 'answer-interval')
+
+#: What `build/%.pdf` can be made from, in the order the Makefile tries them -- which matters,
+#: because a hand-drawn `.svg` matches two rules and must be copied rather than round-tripped
+#: (`Makefile:227-235`). Here the choice is made once, by extension, and written down.
+PICTURE_SOURCES = ('svg', 'tikz', 'gp')
+PICTURE_COPIES = ('png', 'jpg', 'pdf')
+
+#: The documents one language directory holds. `modules/naboj/builder/language.py:templates`
+#: is the authority; `intro` and `evaluators` come from `i18n_templates` and are skipped when
+#: their source is absent, which is the one place this generator has to guess.
+LANGUAGE_DOCUMENTS = ('booklet', 'answers', 'constants', 'cover', 'online',
+ 'evaluation', 'tearoff', 'problems', 'solutions')
+#: Of those, the ones with a `double_xelatex` rule to a PDF in `output/`.
+LANGUAGE_PDFS = ('booklet', 'problems', 'solutions', 'answers', 'constants', 'cover',
+ 'evaluation', 'tearoff', 'online')
+
+#: What `modules/seminar/builder/round.py` writes, and which of those reach `output/` as a PDF.
+#: `instagram` is built but its `output/` rule is commented out in `modules/seminar/module.mk:270`;
+#: it is kept here as a `.tex` so nothing is lost, and not as a PDF so nothing new appears.
+ROUND_DOCUMENTS = ('problems', 'solutions', 'solutions-full', 'instagram')
+#: What `all` is. `instagram.pdf` is built on request and is deliberately not in it, which is
+#: `modules/seminar/module.mk:270`'s reading too -- its line in the round aggregate is commented
+#: out, and what a round is run for is its problems and its solutions.
+ROUND_PDFS = ('problems', 'solutions', 'solutions-full')
+
+#: A seminar problem's two fragments. Seminar is monolingual -- one language, not one of several
+#: that happens to be checked out -- so there is no language directory and no shared/translated
+#: split to make.
+SEMINAR_TARGETS = ('problem', 'solution')
+
+#: What `modules/naboj/builder/venue.py` writes: two from the template root, and
+#: `instructions-inner.tex` per language, which it skips when the source is absent.
+VENUE_DOCUMENTS = ('instructions', 'answers-modulo')
+
+
+def rules(w: Writer) -> None:
+ """Every command shape the volume needs, taken verbatim from the Makefile's recipes."""
+ # The interpreter that generated this file, by absolute path, rather than a bare `python`
+ # the way the Makefile's recipes spelled it. Bare, every invocation has to go through
+ # `uv run` to put one on PATH -- which costs about 55 ms each time and fails outright if
+ # you type `ninja` directly, including the generator rule rebuilding this very file.
+ w.comment('Commands are the Makefile\'s, unchanged, so the two builds can be diffed.')
+ w.variable('python', sys.executable)
+ # Every Python step goes through the warm runner rather than starting an interpreter of
+ # its own: 92 % of what one of these costs is the import of `core.builder.jinja` and the
+ # pint/numpy stack under it -- 0.46 s of wall and 1.10 s of CPU, against 0.05 s to do the
+ # work. `tools/runner/` carries the argument; what matters here is that the argument list
+ # after the script is exactly what `python` itself would have taken, so deleting
+ # `-S tools/runner/client.py` from any command below runs it the old way. So does
+ # `DGS_NO_DAEMON=1`, and so does any failure to reach a daemon, which falls back by itself.
+ #
+ # `regenerate` is deliberately *not* among them: it rewrites `build.ninja` precisely when
+ # the code a daemon is holding has changed, which is the one moment a warm interpreter is
+ # the wrong thing to ask.
+ w.variable('run', f'{sys.executable} -S tools/runner/client.py')
+ w.newline()
+
+ # **No pool for xelatex.** One was here as a precaution -- two runs in a directory, and
+ # `-shell-escape` lets a document write anything -- and it was wrong: `-jobname` is the full
+ # output path, so no two runs ever share an output name, and nothing else is written to a
+ # shared name. What the precaution cost was every scrap of parallelism on the one expensive
+ # step: a depth-1 pool put all 1549 xelatex edges in single file, so `-j8` bought nothing at
+ # all and a picture-heavy volume ran slower than it had under make.
+ w.rule('jinja',
+ '$run -m $renderer $lang $in $out --context $meta',
+ # `$kind` is bound by every edge, through `_rendered`.
+ description=described('{a}[jinja] Rendering {x}$kind template{a} {f}$in{a} '
+ 'to {x}$kind{a} file {f}$out'))
+ w.rule('pandoc_tex',
+ '$run pandoc.py --format latex $lang $in $out',
+ description=described('{a}[convert] Converting {x}Markdown{a} file {f}$in{a} '
+ 'to {x}TeX{a} file {f}$out'))
+ # The other writer, from the same rendered Markdown. A fragment is to a page what a
+ # `.tex` is to a PDF, so it lands in `build/` and the assembly into `output/` is the `page`
+ # rule below. The two writers disagree -- maths above all -- and where they disagree the web
+ # is the half nobody looks at, which is the reason it is built at all.
+ w.rule('pandoc_html',
+ '$run pandoc.py --format html $lang $in $out',
+ description=described('{a}[convert] Converting {x}Markdown{a} file {f}$in{a} '
+ 'to {x}HTML{a} fragment {f}$out'))
+ w.rule('copy',
+ 'mkdir -p $$(dirname $out) && cp $in $out',
+ # `$kind` is bound by every edge, through `_copied`: a rule block may not carry a
+ # default for it, and an unbound variable would print as nothing.
+ description=described('{a}Copying {x}$kind{a} file {f}$in{a} to {f}$out'))
+ w.rule('i18n',
+ 'mkdir -p build/core/i18n && $run -m core.builder.i18n '
+ "'core/i18n/' 'core/templates/' $lang -o build/core/i18n",
+ description=described('{a}Building {x}i18n{a} files for {f}$lang'))
+ w.rule('copy_static',
+ 'mkdir -p $$(dirname $out) && cp -r $src/.static/ $$(dirname $out)/ && touch $out',
+ description=described('{a}Copying {x}static{a} files for {f}$src'))
+ w.rule('language',
+ "mkdir -p $outdir && $run -m modules.naboj.builder.language 'source/naboj/' "
+ "'modules/naboj/templates/' $competition $volume $lang -o '$outdir'",
+ description=described('{a}Building {x}language{a} documents for '
+ '{f}$competition/$volume/$lang'))
+ w.rule('picture_tex',
+ 'mkdir -p $$(dirname $out) && $run standalone.py $lang $in $out',
+ description=described('{a}[standalone] Rendering {x}picture template{a} {f}$in{a} '
+ 'to {x}TeX{a} file {f}$out'))
+ w.rule('svg_pdf',
+ # `${out}-crop`, not `$out-crop`: a Ninja variable name may contain a hyphen, so the
+ # bare form is read as a variable called `out-crop`, expands to nothing, and `mv` is
+ # left with one operand. The error says `missing destination file operand`, which
+ # names neither the rule nor the variable.
+ 'mkdir -p $$(dirname $out) && rsvg-convert --format pdf --keep-aspect-ratio '
+ '--output $out $in && pdfcrop $out ${out}-crop && mv ${out}-crop $out',
+ description=described('{a}[rsvg-convert] Converting {f}$in{a} '
+ 'to {x}PDF{a} file {f}$out'))
+ w.rule('tikz_pdf',
+ 'mkdir -p $$(dirname $out) && ' + _xelatex('$in', '$out', first=True)
+ + ' && ' + _xelatex('$in', '$out', first=False)
+ + ' && ' + _depfile('$out'),
+ description=described('{a}[xelatex] Rendering {f}$in{a} '
+ 'to {x}PDF{a} file {f}$out'),
+ depfile='$out.d', deps='gcc')
+ # `Makefile:350-354`, written out rather than referenced. `gpterm` carries the terminal *and*
+ # its font because re-issuing `set terminal` resets the font, and the two `-e` options cannot
+ # be one: gnuplot expands a macro while parsing the line it is on.
+ w.rule('gnuplot_pdf',
+ 'mkdir -p $$(dirname $out) && cd $$(dirname $out) && gnuplot '
+ '-e "set macros; gppt = 1.0 / 72; '
+ '''gpterm = 'pdfcairo fontscale 1 font \\"Minion Pro, 6\\"' " '''
+ '-e "set terminal @gpterm size 5, 3; '
+ "set output '$$(basename $out)'; set fit quiet;\" $$(basename $in)",
+ description=described('{a}[gnuplot] Rendering {f}$in{a} '
+ 'to {x}PDF{a} file {f}$out'))
+ w.rule('document_pdf',
+ 'mkdir -p $$(dirname $out) && ' + _xelatex('$tex', '$out', first=True)
+ + ' && ' + _xelatex('$tex', '$out', first=False)
+ + ' && ' + _depfile('$out'),
+ description=described('{a}[XeLaTeX] Compiling {x}PDF{a} file {f}$out'),
+ depfile='$out.d', deps='gcc')
+ # A picture in the one format a browser wants, converted from **the PDF the LaTeX path
+ # already built** -- so the web picture and the printed one cannot drift. `pdftocairo` and
+ # not `dvisvgm --pdf`: dvisvgm reads line widths and embedded rasters wrongly, which is a
+ # hairline turned into a heavy frame and a bitmap smeared into the corner (`Makefile:276-299`
+ # has the whole account). A hand-drawn `.svg` short-circuits this and is copied instead.
+ w.rule('pdf_svg',
+ 'mkdir -p $$(dirname $out) && pdftocairo -svg $in $out',
+ description=described('{a}[pdftocairo] Rendering {f}$in{a} '
+ 'to {x}SVG{a} file {f}$out'))
+ # A gnuplot plot is drawn twice by gnuplot rather than converted, one terminal each. The two
+ # disagree about their units -- the `svg` terminal counts CSS pixels, 96 to the inch, where
+ # `pdfcairo` counts points and hides a `fontscale 0.5` -- so `gppt` is 4/3 here against 1/72
+ # there and the font is 8 against 6. The same type at the same size on the same canvas, said
+ # in each terminal's own units.
+ w.rule('gnuplot_svg',
+ 'mkdir -p $$(dirname $out) && cd $$(dirname $out) && gnuplot '
+ '-e "set macros; gppt = 4.0 / 3; '
+ '''gpterm = 'svg font \\"Minion Pro, 8\\"' " '''
+ '-e "set terminal @gpterm size 480, 288; '
+ "set output '$$(basename $out)'; set fit quiet;\" $$(basename $in) && "
+ # A script that sets a terminal of its own wins over the `-e` above, and gnuplot then
+ # writes a PDF into a file named `.svg` with no error anywhere -- `FKS/42/1/1/03` did
+ # it from a `blocks:` entry, which is a `.gp` no grep over `source/**/*.gp` can see,
+ # and the page showed three blank figures.
+ '{ head -c 5 $$(basename $out) | grep -q "<" || { echo "$out is not an SVG: does '
+ 'its script (or a blocks: entry) set its own terminal?" >&2; '
+ 'rm -f $$(basename $out); exit 1; }; }',
+ description=described('{a}[gnuplot] Rendering {f}$in{a} '
+ 'to {x}SVG{a} file {f}$out'))
+ # One unit's fragments into `/index.html`, with the pictures it names copied in
+ # beside it and its maths typeset, so the directory opens from `file://` with nothing else
+ # present. The pictures it copies are not declared: which ones they are is decided by
+ # reading the fragments, and they are a copy of files this graph already builds.
+ w.rule('page',
+ 'mkdir -p $outdir && $run -m core.html.page $unit $lang $outdir --module $module',
+ description=described('{a}[page] Assembling {f}$unit{a} into {x}HTML{a} page '
+ '{f}$outdir/index.html'))
+ w.rule('standalone_tex',
+ "mkdir -p $outdir && $run -m modules.naboj.builder.standalone "
+ "$competition $volume $problem $lang -o '$outdir'",
+ description=described('{a}Building {x}standalone{a} for {f}$outdir'))
+ # Seminar's and scholar's standalone builders take the unit as one path and find their own
+ # templates, where naboj's takes the competition, volume, problem and language apart. Two
+ # rules rather than one contorted one.
+ w.rule('unit_standalone_tex',
+ "mkdir -p $outdir && $run -m modules.$module.builder.standalone $unit -o '$outdir'",
+ description=described('{a}Building {x}standalone{a} for {f}$outdir'))
+ w.rule('scholar_issue',
+ "mkdir -p $outdir && $run -m modules.scholar.builder.$builder 'source/scholar/' "
+ "'modules/scholar/templates/' $course $year $issue -o '$outdir'",
+ description=described('{a}Building {x}$builder{a} for {f}$outdir'))
+ w.rule('round',
+ "mkdir -p $outdir && $run -m modules.seminar.builder.round 'source/seminar/' "
+ "'modules/seminar/templates/' -c $competition -v $volume -s $semester -r $round "
+ "-o '$outdir'",
+ description=described('{a}Building {x}round{a} documents for {f}$outdir'))
+ w.rule('venue',
+ "mkdir -p $outdir && $run -m modules.naboj.builder.venue 'source/naboj/' "
+ "'modules/naboj/templates/' $competition $volume $venue -o '$outdir'",
+ description=described('{a}Building {x}venue{a} documents for '
+ '{f}$competition/$volume/$venue'))
+ w.rule('standalone_pdf',
+ 'mkdir -p $$(dirname $out) && ' + _xelatex('$tex', '$out', first=True)
+ + ' && ' + _xelatex('$tex', '$out', first=False)
+ + ' && ' + _depfile('$out'),
+ description=described('{a}[XeLaTeX] Compiling {x}PDF{a} file {f}$out'),
+ depfile='$out.d', deps='gcc')
+ # `pdfbook2` names its own output and the recipe renames it; `pdfjam` takes `--outfile`.
+ w.rule('pdfbook',
+ 'mkdir -p $$(dirname $out) && pdfbook2 --paper a4paper --outer-margin=30 '
+ '--inner-margin=50 --top-margin=30 --bottom-margin=30 $in && '
+ 'mv $$(dirname $in)/$$(basename $in .pdf)-book.pdf $out',
+ description=described('{a}[pdfbook2] Imposing {f}$in{a} into {f}$out'))
+ w.rule('pdfjam_nup',
+ 'mkdir -p $$(dirname $out) && pdfjam --quiet --nup "2x1" --landscape '
+ '--outfile $out $in',
+ description=described('{a}[pdfjam] Imposing {f}$in{a} into {f}$out'))
+ # `pdftk burst` writes one PDF per page and the count is only known after xelatex ran, so
+ # the pages are not declared; the stamp is what downstream depends on.
+ w.rule('burst',
+ 'pdftk $in burst output $$(dirname $in)/%02d.pdf && touch $out',
+ description=described('{a}[pdftk] Splitting {f}$in{a} into individual pages'))
+ # One page of a PDF to one PNG. Unlike `burst` above the count is **not** unknowable here:
+ # `instagram.jtex` ends every problem with a `\clearpage`, so the document is one page per
+ # problem and the generator knows how many problems there are. So the pages are declared,
+ # one edge each, and `-f`/`-l`/`-singlefile` let the name be ours rather than pdftoppm's
+ # digit-padding rule. `test -f` is what makes a page that did not come out loud: a page
+ # number past the end of the document is not an error to pdftoppm, it simply writes
+ # nothing, and an undeclared-looking silence is exactly what this build is migrating away
+ # from.
+ # An instagram document is one page per problem -- `instagram.jtex` ends each with a
+ # `\clearpage` -- so the page count is a fact the generator already knows, and a document
+ # that does not have it is a layout defect rather than a thing to render anyway. Checked
+ # once per document; every page then hangs off the check, so no PNG is cut from a page that
+ # belongs to the problem before it.
+ w.rule('page_count',
+ "count=$$(pdfinfo $in | sed -n 's/^Pages: *//p'); "
+ 'if [ "$$count" != "$pages" ]; then '
+ 'echo "$in has $$count pages, one per problem would be $pages" >&2; exit 1; fi; '
+ 'mkdir -p $$(dirname $out) && touch $out',
+ description=described('{a}Checking {f}$in{a} is {x}$pages{a} pages'))
+ w.rule('page_image',
+ 'mkdir -p $$(dirname $out) && pdftoppm -png -r 150 -aa yes -aaVector yes '
+ '-f $page -l $page -singlefile $in $prefix && test -f $out',
+ description=described('{a}[pdftoppm] Rendering page {x}$page{a} of {f}$in'))
+ w.rule('tearoff_zip',
+ 'mkdir -p $$(dirname $out) && rm -f $out && $run -m tools.ninja.zip $out $in',
+ description=described('{a}Packing {x}tear-offs{a} into {f}$out'))
+ w.rule('phony_clean',
+ 'rm -rf $paths',
+ description=described('{a}Removing {f}$paths'))
+ w.rule('prune',
+ '$python tools/prune.py $flags',
+ description=described('{a}Prune: {f}$flags'))
+ w.rule('regenerate',
+ '$python -m tools.ninja.generate --scope $scope -o $out',
+ description=described('{a}[ninja] Regenerating {f}$out'), generator='1')
+ w.newline()
+
+
+#: `Makefile:68-72`. The first pass is allowed wrong cross-references, the second is not.
+TEXFOT_FIRST = ("--no-interactive --ignore 'Underfull.*' "
+ "--ignore 'LaTeX Warning: Hyper reference.*' "
+ "--ignore 'LaTeX Warning: Reference.*' --ignore 'LaTeX Warning: Citation.*'")
+TEXFOT = "--no-interactive --ignore 'Underfull.*'"
+
+
+def _xelatex(source: str, target: str, *, first: bool) -> str:
+ r"""
+ One xelatex pass, spelled as `Makefile:85-90` spells it, plus `-recorder`.
+
+ `-recorder` is the whole of the dependency story for a document. It costs nothing -- TeX
+ writes a `.fls` listing every file it opened -- and it is the only honest answer to
+ `\protectedInput`, whose path is built from a macro at run time and then tested with
+ `\IfFileExists`. `tools/ninja/fls.py` turns the log into a depfile.
+ """
+ return ('max_print_line=1000 error_line=254 half_error_line=238 texfot '
+ f'{TEXFOT_FIRST if first else TEXFOT} xelatex -file-line-error -shell-escape '
+ f'-recorder -jobname=$$(echo {target} | sed s/\\.pdf$$//) -halt-on-error -synctex=1 '
+ f'-interaction=nonstopmode {source}')
+
+
+def _depfile(target: str) -> str:
+ """The command tail that turns this run's `.fls` into the depfile Ninja reads next time."""
+ return (f'$run -m tools.ninja.fls $$(echo {target} | sed s/\\.pdf$$//).fls '
+ f'{target} {target}.d')
+
+
+def languages_of(volume: pathlib.Path) -> list[str]:
+ """
+ The translations this volume has, from `languages/` on disk.
+
+ **This is the whole point of the exercise.** Make matched any string here, so
+ `…/languages/uk/booklet.pdf` was a legal target for a volume with no Ukrainian; a language
+ that is not in this list simply has no edges and Ninja refuses it by name.
+ """
+ root = volume / 'languages'
+ return sorted(child.name for child in root.iterdir()
+ if child.is_dir() and _visible(child)) if root.is_dir() else []
+
+
+def generate(repo: pathlib.Path, scopes: list[str], out) -> dict:
+ """Rules once, then the edges of every scope, then the edge that rebuilds this file."""
+ w = Writer(out)
+ w.comment('GENERATED by `python -m tools.ninja.generate`. Do not edit.')
+ w.comment('A spike. The Makefile still builds the same trees; see tools/ninja/generate.py.')
+ w.newline()
+ rules(w)
+
+ totals = {'edges': 0, 'problems': 0, 'pictures': 0, 'languages': [],
+ 'listed': set(), 'read': set()}
+ needs = {'languages': set(), 'competitions': set()}
+
+ # Two passes over the scopes: the first writes their edges into a buffer and collects what
+ # they share, the second puts the shared edges in front. Ninja does not care about order --
+ # it reads the whole file before deciding anything -- but a person reading `build.ninja`
+ # does, and the core comes first in the Makefile too.
+ import io
+ body = io.StringIO()
+ inner = Writer(body)
+ inner.emitted = w.emitted # one map across both, or a clash across the split is missed
+ for scope in scopes:
+ # `:`; a bare scope is naboj's, which is what the spike started with.
+ module, _, path = scope.rpartition(':')
+ counts = (seminar_edges(repo, path, inner) if module == 'seminar'
+ else scholar_edges(repo, path, inner) if module == 'scholar'
+ else scope_edges(repo, path, inner))
+ for key in ('edges', 'problems', 'pictures'):
+ totals[key] += counts[key]
+ totals['languages'] += counts['languages']
+ totals['listed'] |= counts['listed']
+ totals['read'] |= counts.get('read', set())
+ for key in ('languages', 'competitions'):
+ needs[key] |= counts.get('needs', {}).get(key, set())
+
+ totals['edges'] += core_edges(repo, w, needs)
+ w.out.write(body.getvalue())
+
+ # The names that are not files. `clean` and friends keep their `rm -rf`; `stale` and
+ # `prune` are `tools/prune.py`, which is not about this build system at all -- it exists
+ # because make kept intermediates it could not clean up after, and it still answers the
+ # question "what is in build/ that has no source".
+ w.comment('Housekeeping. `clean` removes `build/`, `distclean` also `render/` and `output/`; '
+ '`stale` lists what has no source left and `prune` removes it.')
+ w.build('clean', 'phony_clean', None, variables={'paths': 'build'})
+ w.build('distclean', 'phony_clean', None, variables={'paths': 'build render output'})
+ w.build('stale', 'prune', None, variables={'flags': ''})
+ w.build('prune', 'prune', None, variables={'flags': '--delete'})
+ totals['edges'] += 4
+ w.newline()
+
+ w.comment('Rerun this generator when the set of files changes -- Ninja does it itself, '
+ 'given the `generator` rule and something to notice the change by.')
+ w.comment('**The directories, not their contents.** A file edited inside one does not change '
+ 'this graph; a problem or a translation added or removed does, and a directory\'s '
+ 'mtime moves on exactly that event and no other. `naboj_problem_dirs` in '
+ 'modules/naboj/module.mk:114-132 is the same trick for the same reason -- the '
+ 'difference is that here it is needed in one place instead of on every document.')
+ w.comment('A handful of *files* are in there too. A venue meta names the language its '
+ 'documents are set in, which decides which i18n table they need, and that is a '
+ 'fact no directory listing holds -- so the file itself is watched, or changing '
+ '`language:` would leave the graph pointing at the locale it used to say.')
+ w.build('build.ninja', 'regenerate', None,
+ implicit=['tools/ninja/generate.py', 'tools/ninja/writer.py',
+ *sorted(totals['listed']), *sorted(totals['read'])],
+ variables={'scope': ' '.join(scopes)})
+ totals['edges'] += 1
+
+ w.newline()
+ w.comment('And a `phony` for each of those directories, which is what makes *removing* one '
+ 'recoverable. An input with no rule that has gone missing is a hard error -- '
+ '`\'source/naboj/phys/29/problems/amusement-speed/es\', needed by '
+ '\'build.ninja\', missing and no known rule to make it` -- and it is '
+ 'unrecoverable, because the edge that would notice the removal is the one Ninja '
+ 'is refusing to run. A phony whose output does not exist is simply dirty, so the '
+ 'generator reruns and the directory leaves the graph. Deleting a language cost a '
+ 'hand-run generator until this was here.')
+ for directory in sorted(totals['listed']):
+ w.build(directory, 'phony', None)
+ totals['edges'] += 1
+
+ w.newline()
+ w.comment('What a bare `ninja` builds. Left unsaid it would be every root of the graph -- '
+ 'the whole repository -- where a bare `make` touched a stamp and stopped. The '
+ 'i18n tables are the nearest honest equivalent: core\'s own, needed by every '
+ 'other edge, and a second or two.')
+ w.build('core', 'phony', None,
+ implicit=[f'build/core/i18n/{lang}.tex' for lang in sorted(needs['languages'])])
+ w.default('core')
+ totals['edges'] += 1
+ return totals
+
+
+def core_edges(repo: pathlib.Path, w: Writer, needs: dict) -> int:
+ r"""
+ The edges every scope shares, emitted once however many scopes there are.
+
+ The i18n tables, and each competition's `.static/` tree. One `core.builder.i18n` invocation
+ writes both the `.tex` the booklet `\input`s and the `.yaml` every pandoc run reads as
+ `crossrefYaml` -- two outputs on one edge, where `Makefile:165` declares only the first and
+ leaves `core/templates/crossref.jyaml` a prerequisite of nothing at all.
+ """
+ w.comment('==== core ' + '=' * 60)
+ edges = 0
+ for lang in sorted(needs['languages']):
+ w.build(f'build/core/i18n/{lang}.tex', 'i18n', [f'core/i18n/{lang}.yaml'],
+ implicit=['core/templates/override.jtex', 'core/templates/crossref.jyaml'],
+ implicit_outputs=[f'build/core/i18n/{lang}.yaml'],
+ variables={'lang': lang})
+ edges += 1
+ for competition in sorted(needs['competitions']):
+ static = repo / 'source/naboj' / competition / '.static'
+ w.build(f'build/naboj/{competition}/copy-static', 'copy_static', None,
+ implicit=sorted(str(p.relative_to(repo)) for p in static.rglob('*')
+ if p.is_file()) if static.is_dir() else None,
+ variables={'src': f'source/naboj/{competition}'})
+ edges += 1
+ w.newline()
+ return edges
+
+
+def scope_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict:
+ counts = {'edges': 0, 'languages': [], 'listed': set(), 'read': set()}
+
+ def listed(directory: pathlib.Path) -> pathlib.Path:
+ """Record a directory whose *listing* this graph depends on, and return it."""
+ if directory.is_dir():
+ counts['listed'].add(str(directory.relative_to(repo)))
+ return directory
+
+ def build(*args, **kwargs):
+ counts['edges'] += 1
+ return w.build(*args, **kwargs)
+
+ competition, volume_id = scope.split('/')
+ volume = repo / 'source' / 'naboj' / scope
+ listed(volume / 'languages')
+ languages = languages_of(volume)
+ problems = sorted(p.parent for p in listed(volume / 'problems').glob('*/meta.yaml')
+ if _visible(p.parent))
+
+ w.comment(f'==== {scope} ' + '=' * 60)
+
+ # The i18n tables and the competition's static files are shared by every scope, so they are
+ # emitted once by `generate()`. Declaring them here instead gave Ninja `multiple rules
+ # generate build/core/i18n/sk.tex` the moment a second volume was added -- which is Ninja
+ # refusing an ambiguity make would have resolved silently by taking whichever rule it saw
+ # last.
+ static = f'build/naboj/{competition}/copy-static'
+ counts['needs'] = {'languages': set(languages), 'competitions': {competition}}
+ templates = sorted(str(q.relative_to(repo))
+ for q in (repo / 'modules/naboj/templates').rglob('*.jtex'))
+
+ # -- pictures --------------------------------------------------------------------------------
+ w.comment('Pictures. A `.svg`, `.tikz` and `.gp` are Jinja templates first; the meta beside '
+ 'them is the context, so a drawing can print the number the meta computes.')
+ pictures, drawings = [], []
+ for problem in problems:
+ rel = listed(problem).relative_to(repo / 'source').as_posix()
+ meta = (problem / 'meta.yaml').resolve()
+ for extension in PICTURE_SOURCES:
+ for picture in sorted(problem.glob(f'*.{extension}')):
+ stem = f'{rel}/{picture.stem}'
+ _rendered(build, f'render/{stem}.{extension}', f'source/{stem}.{extension}',
+ implicit=[str(meta.relative_to(repo))],
+ variables={'lang': 'sk', 'meta': str(meta),
+ 'renderer': NABOJ_RENDERER})
+ printed, shown = _picture(build, w, stem, extension, problem, repo)
+ pictures.append(printed)
+ drawings.append(shown)
+ for extension in PICTURE_COPIES:
+ for picture in sorted(problem.glob(f'*.{extension}')):
+ stem = f'{rel}/{picture.stem}'
+ if _already_drawn(w, stem, extension):
+ continue
+ _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}')
+ pictures.append(f'build/{stem}.{extension}')
+ # A raster goes to the page as it is; a PDF copied in is converted like any
+ # other drawing.
+ drawings.append(_picture_svg(build, w, stem, extension) if extension == 'pdf'
+ else f'build/{stem}.{extension}')
+ w.newline()
+
+ # -- fragments -------------------------------------------------------------------------------
+ w.comment('One fragment per problem per language. A translated file lives inside the language '
+ 'directory; a shared one sits beside the meta and is rendered once per language.')
+ fragments = {lang: {group: [] for group in NABOJ_GROUPS} for lang in languages}
+ pages = {lang: [] for lang in languages}
+ for problem in problems:
+ rel = problem.relative_to(repo / 'source').as_posix()
+ meta = (problem / 'meta.yaml').resolve()
+ for lang in languages:
+ listed(problem / lang)
+ web = []
+ for name in TRANSLATED:
+ if not (problem / lang / f'{name}.md').is_file():
+ continue
+ tex, fragment = _fragment(
+ build, f'source/{rel}/{lang}/{name}.md', f'{rel}/{lang}/{name}', lang, meta,
+ repo)
+ fragments[lang][_group(name)].append(tex)
+ web.append(fragment)
+ for name in SHARED:
+ if not (problem / f'{name}.md').is_file():
+ continue
+ tex, fragment = _fragment(
+ build, f'source/{rel}/{name}.md', f'{rel}/{lang}/{name}', lang, meta, repo)
+ fragments[lang][_group(name)].append(tex)
+ web.append(fragment)
+
+ # The page: the same problem in the same language, for a browser. Only where there
+ # is something to assemble -- `page.py` refuses an empty unit, and rightly, so a
+ # declared edge with no fragments would be an edge that cannot run.
+ if web:
+ pages[lang].append(_page(build, rel, lang, 'naboj', fragments=web,
+ pictures=_beside(drawings, rel), nested=True))
+
+ # The standalone: one problem, one language, one PDF. **This is the editor's
+ # preview** -- `modules/naboj/editor.yaml`'s `preview:` names it and `/api/compile`
+ # builds nothing else -- so leaving it out of the graph took the editor's compile
+ # button with it, which is not slowness but looks like it from the outside.
+ unit = f'{rel.split("/", 1)[1]}/{lang}' # //problems//
+ mine = [f for group in fragments[lang].values() for f in group
+ if f.startswith(f'build/{rel}/{lang}/')]
+ build(f'build/naboj/{unit}/standalone.tex', 'standalone_tex', None,
+ implicit=[*templates, f'source/{rel}/meta.yaml'],
+ variables={'competition': competition, 'volume': volume_id,
+ 'problem': problem.name, 'lang': lang,
+ 'outdir': f'build/naboj/{unit}'})
+ pdf = f'output/naboj/{unit}/standalone.pdf'
+ build(pdf, 'document_pdf', None,
+ implicit=[f'build/naboj/{unit}/standalone.tex', *mine,
+ *[p for p in pictures if p.startswith(f'build/{rel}/')],
+ f'build/core/i18n/{lang}.tex', static, 'core/latex/dgs.cls'],
+ implicit_outputs=[pdf.replace('.pdf', e)
+ for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')],
+ variables={'tex': f'build/naboj/{unit}/standalone.tex'})
+ w.newline()
+
+ # -- documents -------------------------------------------------------------------------------
+ w.comment('The language documents. One builder invocation writes all of them, which Ninja '
+ 'takes as one edge with many outputs -- where make needs a `touch` stamp and a row '
+ 'of empty-recipe rules, whose trap is that an empty recipe marks a target out of '
+ 'date, runs nothing, and leaves the stale file in place.')
+ for lang in languages:
+ outdir = f'build/naboj/{scope}/languages/{lang}'
+ documents = [f'{outdir}/{name}.tex' for name in LANGUAGE_DOCUMENTS]
+ extra = [f'{outdir}/{name}.tex' for name in ('intro', 'evaluators')
+ if (volume / 'languages' / lang / f'{name}.jtex').is_file()]
+ build(documents + extra, 'language', None,
+ implicit=[*templates, static,
+ f'source/naboj/{scope}/meta.yaml',
+ f'source/naboj/{competition}/meta.yaml',
+ f'source/naboj/{scope}/languages/{lang}/meta.yaml',
+ f'source/naboj/{competition}/.static/i18n/{lang}.yaml',
+ *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)],
+ variables={'competition': competition, 'volume': volume_id, 'lang': lang,
+ 'outdir': outdir})
+
+ for name in LANGUAGE_PDFS:
+ tex = f'{outdir}/{name}.tex'
+ pdf = f'output/naboj/{scope}/languages/{lang}/{name}.pdf'
+ included = [f for group in NABOJ_DOCUMENT_GROUPS[name]
+ for f in fragments[lang][group]]
+ build(pdf, 'document_pdf', None,
+ implicit=[tex, *included, *pictures,
+ f'build/core/i18n/{lang}.tex', static,
+ 'core/latex/dgs.cls'],
+ implicit_outputs=[pdf.replace('.pdf', e)
+ for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')],
+ variables={'tex': tex})
+ # Folded for printing, and the cover two-up. Both take a finished PDF and rename it.
+ build(f'output/naboj/{scope}/languages/{lang}/booklet-print.pdf', 'pdfbook',
+ [f'output/naboj/{scope}/languages/{lang}/booklet.pdf'])
+ build(f'output/naboj/{scope}/languages/{lang}/cover-print.pdf', 'pdfjam_nup',
+ [f'output/naboj/{scope}/languages/{lang}/cover.pdf'])
+
+ # `online.pdf` is burst into one PDF per page. The page count is known only after
+ # xelatex has run, so the pages are not declared and a stamp stands for them.
+ build(f'build/naboj/{scope}/languages/{lang}/online.burst', 'burst',
+ [f'output/naboj/{scope}/languages/{lang}/online.pdf'])
+
+ # Everything this language has, as one name to ask for -- `output/naboj/%/languages/`
+ # in the Makefile, which had to be spelled out per language there to beat make's
+ # pattern-tie rules against the venue aggregate. Here it is just a phony edge.
+ w.build(f'output/naboj/{scope}/languages/{lang}/all', 'phony',
+ [f'output/naboj/{scope}/languages/{lang}/{name}.pdf'
+ for name in (*LANGUAGE_PDFS, 'booklet-print', 'cover-print')])
+ # Every page of this volume in this language. Not folded into `all`, which is the
+ # Makefile's reading too: the documents are what a volume is run from, and the pages
+ # are a second output of the same sources rather than part of the first.
+ w.build(f'output/naboj/{scope}/languages/{lang}/pages', 'phony', pages[lang])
+ counts['edges'] += 2
+ counts['languages'].append(f'{scope}:{lang}')
+ w.newline()
+
+ # -- the tearoff archive ---------------------------------------------------------------
+ if languages:
+ build(f'output/naboj/{scope}/languages/tearoffs.zip', 'tearoff_zip',
+ [f'output/naboj/{scope}/languages/{lang}/tearoff.pdf' for lang in languages])
+
+ # -- venues --------------------------------------------------------------------------------
+ w.comment('Venues. One `venue.py` invocation writes `instructions.tex`, `answers-modulo.tex` '
+ 'and, when the source is there, `instructions-inner.tex` for the venue\'s language.')
+ venues = sorted(child.name for child in listed(volume / 'venues').iterdir()
+ if child.is_dir() and _visible(child)) if (volume / 'venues').is_dir() else []
+ for venue in venues:
+ venue_meta = f'source/naboj/{scope}/venues/{venue}/meta.yaml'
+ # **A venue is set in its own language, which need not be one of the volume's.** Volume
+ # 29 has `en` and `sk` and runs in Praha and Olomouc, so its Czech venue documents
+ # `\input{build/core/i18n/cs.tex}` -- a file nothing asked for, because the edges were
+ # taking the *volume's* languages. The whole of `all` failed on it, 34 documents at
+ # once, and it was invisible under make, whose `i18n.stamp` built every locale whether
+ # anything wanted it or not.
+ venue_language = _venue_language(repo / venue_meta)
+ counts['needs']['languages'] |= {venue_language} - {None}
+ counts['read'].add(venue_meta)
+ outdir = f'build/naboj/{scope}/venues/{venue}'
+ documents = [f'{outdir}/{name}.tex' for name in VENUE_DOCUMENTS]
+ build(documents, 'venue', None,
+ implicit=[*templates, static,
+ venue_meta,
+ f'source/naboj/{scope}/meta.yaml',
+ f'source/naboj/{competition}/meta.yaml',
+ *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)],
+ variables={'competition': competition, 'volume': volume_id, 'venue': venue,
+ 'outdir': outdir})
+ for name in VENUE_DOCUMENTS:
+ pdf = f'output/naboj/{scope}/venues/{venue}/{name}.pdf'
+ build(pdf, 'document_pdf', None,
+ implicit=[f'{outdir}/{name}.tex', *pictures, static, 'core/latex/dgs.cls',
+ *([f'build/core/i18n/{venue_language}.tex']
+ if venue_language else [])],
+ implicit_outputs=[pdf.replace('.pdf', e)
+ for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')],
+ variables={'tex': f'{outdir}/{name}.tex'})
+ w.build(f'output/naboj/{scope}/venues/{venue}/all', 'phony',
+ [f'output/naboj/{scope}/venues/{venue}/{name}.pdf' for name in VENUE_DOCUMENTS])
+ counts['edges'] += 1
+ w.newline()
+
+ # -- aggregates ----------------------------------------------------------------------------
+ w.comment('The names a person types. Every one is a `phony` edge over a list computed here, '
+ 'where make had to generate a rule per language and lean on stem length.')
+ w.build(f'output/naboj/{scope}/booklets', 'phony',
+ [f'output/naboj/{scope}/languages/{lang}/booklet.pdf' for lang in languages])
+ w.build(f'output/naboj/{scope}/languages', 'phony',
+ [f'output/naboj/{scope}/languages/{lang}/all' for lang in languages]
+ + ([f'output/naboj/{scope}/languages/tearoffs.zip'] if languages else []))
+ w.build(f'output/naboj/{scope}/pages', 'phony',
+ [f'output/naboj/{scope}/languages/{lang}/pages' for lang in languages])
+ w.build(f'output/naboj/{scope}/venues', 'phony',
+ [f'output/naboj/{scope}/venues/{venue}/all' for venue in venues])
+ w.build(f'output/naboj/{scope}/all', 'phony',
+ [f'output/naboj/{scope}/languages', f'output/naboj/{scope}/venues'])
+ counts['edges'] += 5
+ w.newline()
+
+ counts['problems'] = len(problems)
+ counts['pictures'] = len(pictures)
+ return counts
+
+
+def seminar_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict:
+ """
+ One seminar round: `FKS/42/1/1`.
+
+ Much the smaller half of the build, and the shape is simpler in one way that matters --
+ seminar is monolingual, so there is no language dimension at all and `pathlang` resolves to
+ `sk` everywhere. What it adds is `.py` listings: `(§ include('x.py') §)` inlines a program
+ sitting beside the solution, and `module.mk` copies the same file into `output/` for the
+ reader to download.
+ """
+ counts = {'edges': 0, 'languages': [], 'listed': set()}
+
+ def listed(directory: pathlib.Path) -> pathlib.Path:
+ if directory.is_dir():
+ counts['listed'].add(str(directory.relative_to(repo)))
+ return directory
+
+ def build(*args, **kwargs):
+ counts['edges'] += 1
+ return w.build(*args, **kwargs)
+
+ competition, volume_id, semester, round_id = scope.split('/')
+ root = repo / 'source' / 'seminar' / scope
+ problems = sorted(p.parent for p in listed(root).glob('*/meta.yaml') if _visible(p.parent))
+
+ w.comment(f'==== seminar/{scope} ' + '=' * 50)
+
+ counts['needs'] = {'languages': {'sk'}, 'competitions': set()}
+ templates = sorted(str(p.relative_to(repo))
+ for p in (repo / 'modules/seminar/templates').rglob('*.jtex'))
+
+ # `source/seminar//.static/` is what every round of that competition inputs:
+ # the format override, the logo, and the intro and rules templates. `module.mk` copied the
+ # whole tree with one `cp -r` whose own target it never touched, so it depended on nothing
+ # and ran on every build; here each file is an edge and the documents name what they read.
+ static = []
+ for path in sorted(p for p in listed(repo / 'source/seminar' / competition / '.static')
+ .rglob('*') if p.is_file()):
+ listed(path.parent)
+ source = path.relative_to(repo).as_posix()
+ target = source.replace('source/', 'build/', 1)
+ _copied(build, target, source)
+ static.append(target)
+
+ fragments = {name: [] for name in SEMINAR_TARGETS}
+ web_fragments = {name: [] for name in SEMINAR_TARGETS}
+ pictures, drawings, pages = [], [], []
+ for problem in problems:
+ rel = listed(problem).relative_to(repo / 'source').as_posix()
+ meta = (problem / 'meta.yaml').resolve()
+ # the programs a solution lists, copied for the reader as well as inlined by `include()`
+ for program in sorted(problem.glob('*.py')):
+ _copied(build, f'output/{rel}/{program.name}', f'source/{rel}/{program.name}')
+ for extension in PICTURE_SOURCES:
+ for picture in sorted(problem.glob(f'*.{extension}')):
+ stem = f'{rel}/{picture.stem}'
+ _rendered(build, f'render/{stem}.{extension}', f'source/{stem}.{extension}',
+ implicit=[str(meta.relative_to(repo))],
+ variables={'lang': 'sk', 'meta': str(meta),
+ 'renderer': SEMINAR_RENDERER})
+ printed, shown = _picture(build, w, stem, extension, problem, repo)
+ pictures.append(printed)
+ drawings.append(shown)
+ for extension in PICTURE_COPIES:
+ for picture in sorted(problem.glob(f'*.{extension}')):
+ stem = f'{rel}/{picture.stem}'
+ if _already_drawn(w, stem, extension):
+ continue
+ _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}')
+ pictures.append(f'build/{stem}.{extension}')
+ drawings.append(_picture_svg(build, w, stem, extension) if extension == 'pdf'
+ else f'build/{stem}.{extension}')
+ web, mine = [], []
+ for name in SEMINAR_TARGETS:
+ if (problem / f'{name}.md').is_file():
+ tex, fragment = _fragment(build, f'source/{rel}/{name}.md',
+ f'{rel}/{name}', 'sk', meta, repo,
+ SEMINAR_RENDERER)
+ fragments[name].append(tex)
+ web_fragments[name].append(fragment)
+ mine.append(tex)
+ web.append(fragment)
+
+ # The standalone: one problem, one PDF, and **this is the editor's preview** --
+ # `modules/seminar/editor.yaml`'s `preview:` names it and `/api/compile` asks for
+ # nothing else. It was missing while naboj's was there, so the editor could render and
+ # convert a seminar problem and then not show it, which reads as a broken button.
+ _standalone(build, 'seminar', rel, [*mine, *_beside(pictures, rel), *static],
+ templates, 'sk')
+ # A seminar page sits in the problem directory, not in a language directory under it:
+ # seminar is monolingual, which `modules/seminar/editor.yaml` says by carrying no
+ # `languages:` key and which `page.py` reads from there.
+ if web:
+ pages.append(_page(build, rel, 'sk', 'seminar', fragments=web,
+ pictures=_beside(drawings, rel), nested=False))
+
+ outdir = f'build/seminar/{scope}'
+ build([f'{outdir}/{name}.tex' for name in ROUND_DOCUMENTS], 'round', None,
+ implicit=[*templates,
+ f'source/seminar/{scope}/meta.yaml',
+ *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)],
+ variables={'competition': competition, 'volume': volume_id, 'semester': semester,
+ 'round': round_id, 'outdir': outdir})
+
+ for name in ROUND_DOCUMENTS:
+ pdf = f'output/seminar/{scope}/{name}.pdf'
+ included = [f for kind in SEMINAR_DOCUMENT_GROUPS[name] for f in fragments[kind]]
+ build(pdf, 'document_pdf', None,
+ implicit=[f'{outdir}/{name}.tex', *included, *pictures, *static,
+ 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'],
+ implicit_outputs=[pdf.replace('.pdf', e)
+ for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')],
+ variables={'tex': f'{outdir}/{name}.tex'})
+
+ images = _instagram(build, f'output/seminar/{scope}/instagram',
+ f'{outdir}/instagram.pages', len(problems))
+
+ # The names make offered, kept. `all` and `pages` are this generator's; `pdf`, `problems`
+ # and `solutions` are `modules/seminar/module.mk:258-270`, and the last two reach the HTML
+ # fragments as well, exactly as `html-problems` and `html-solutions` did there.
+ w.build(f'output/seminar/{scope}/all', 'phony',
+ [f'output/seminar/{scope}/{name}.pdf' for name in ROUND_PDFS])
+ w.build(f'output/seminar/{scope}/pdf', 'phony',
+ [f'output/seminar/{scope}/{name}.pdf' for name in ('problems', 'solutions')])
+ for name, kind in (('problems', 'problem'), ('solutions', 'solution')):
+ w.build(f'output/seminar/{scope}/{name}', 'phony',
+ [f'output/seminar/{scope}/{name}.pdf', *web_fragments[kind]])
+ w.build(f'output/seminar/{scope}/instagram', 'phony', images)
+ w.build(f'output/seminar/{scope}/pages', 'phony', pages)
+ counts['edges'] += 6
+ w.newline()
+
+ counts['problems'] = len(problems)
+ counts['pictures'] = len(pictures)
+ return counts
+
+
+def scholar_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict:
+ """
+ One scholar year: `TA1/2018`.
+
+ The shape neither other module has: **two editable levels**. An issue (`handouts/02`) holds
+ its own `text.md` *and* a directory per problem, each with `problem.md` and `solution.md`,
+ and one builder invocation turns the lot into three documents (or two, for homework).
+ """
+ counts = {'edges': 0, 'languages': [], 'listed': set()}
+
+ def listed(directory: pathlib.Path) -> pathlib.Path:
+ if directory.is_dir():
+ counts['listed'].add(str(directory.relative_to(repo)))
+ return directory
+
+ def build(*args, **kwargs):
+ counts['edges'] += 1
+ return w.build(*args, **kwargs)
+
+ course, year = scope.split('/')
+ root = repo / 'source' / 'scholar' / scope
+ w.comment(f'==== scholar/{scope} ' + '=' * 50)
+
+ counts['needs'] = {'languages': {'sk'}, 'competitions': set()}
+ templates = sorted(str(p.relative_to(repo))
+ for p in (repo / 'modules/scholar/templates').rglob('*.jtex'))
+
+ # `source/scholar//.static/`, which every document of that course `\input`s, copied
+ # one file to an edge exactly as seminar's is. It used to be a prerequisite of nothing at
+ # all, on the grounds that scholar built under neither system so a fix could not be checked;
+ # it builds now, and the first thing it did was stop at `File
+ # 'build/scholar/STA/.static/format-override.tex' not found`.
+ static = []
+ for path in sorted(p for p in listed(repo / 'source/scholar' / course / '.static')
+ .rglob('*') if p.is_file()):
+ listed(path.parent)
+ source = path.relative_to(repo).as_posix()
+ target = source.replace('source/', 'build/', 1)
+ _copied(build, target, source)
+ static.append(target)
+
+ # **Fragments but no pages**, which is the one asymmetry with the other two modules.
+ # `page.py` collects the targets `modules/scholar/editor.yaml` declares, and that descriptor
+ # says `targets: [text]` where 28 of this module's issues hold a `content.md` instead -- so
+ # an issue page would be an edge that could only ever fail with `no fragments to assemble`.
+ # The `.html` fragments are still built, because those are what the editor's HTML tab shows
+ # and they are right whatever the descriptor calls them. Settling `content` against `text`
+ # is scholar's own question and is noted at `SCHOLAR_TARGETS`.
+ # **Per issue, not per year.** These used to accumulate over the whole loop and every
+ # document took the lot, so one plot that would not draw stopped the issues *after* it as
+ # well: `2025/homework/03/monte-carlo.gp` took `04` down with it although `04` names no
+ # drawing of 03's. An issue is a document; what it reads is what is under it.
+ units, pictures = 0, []
+ for kind, (builder, documents) in SCHOLAR_KINDS.items():
+ if not (root / kind).is_dir():
+ continue
+ for issue in sorted(i for i in listed(root / kind).iterdir()
+ if i.is_dir() and _visible(i)):
+ units += 1
+ issue_rel = issue.relative_to(repo / 'source').as_posix()
+ drawn = []
+ # the issue's own text, then one directory per problem below it
+ places = [issue, *sorted(c for c in listed(issue).iterdir()
+ if c.is_dir() and _visible(c))]
+ fragments = []
+ for place in places:
+ rel = place.relative_to(repo / 'source').as_posix()
+ meta = place / 'meta.yaml'
+ if not meta.is_file():
+ continue
+ for extension in PICTURE_SOURCES:
+ for picture in sorted(place.glob(f'*.{extension}')):
+ stem = f'{rel}/{picture.stem}'
+ _rendered(build, f'render/{stem}.{extension}',
+ f'source/{stem}.{extension}',
+ implicit=[f'source/{rel}/meta.yaml'],
+ variables={'lang': 'sk', 'meta': str(meta.resolve()),
+ 'renderer': SCHOLAR_RENDERER})
+ # The `.svg` the second half of this emits is unreferenced here and
+ # is still worth having: `build/.svg` is a target anyone may ask
+ # for, and it is the same picture the booklet prints.
+ printed, _ = _picture(build, w, stem, extension, place, repo)
+ drawn.append(printed)
+ for extension in PICTURE_COPIES:
+ for picture in sorted(place.glob(f'*.{extension}')):
+ stem = f'{rel}/{picture.stem}'
+ if _already_drawn(w, stem, extension):
+ continue
+ _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}')
+ drawn.append(f'build/{stem}.{extension}')
+ if extension == 'pdf':
+ _picture_svg(build, w, stem, extension)
+ mine = []
+ for name in SCHOLAR_TARGETS:
+ if (place / f'{name}.md').is_file():
+ tex, _ = _fragment(build, f'source/{rel}/{name}.md',
+ f'{rel}/{name}', 'sk', meta.resolve(), repo,
+ SCHOLAR_RENDERER)
+ fragments.append(tex)
+ mine.append(tex)
+
+ # The editor's preview, for both shapes a scholar unit comes in: the sheet
+ # itself, which holds `text.md`, and a problem below it. `editor.yaml` names
+ # the same target for all four of its unit kinds.
+ _standalone(build, 'scholar', rel, [*mine, *_beside(drawn, rel), *static],
+ templates, 'sk')
+
+ outdir = f'build/{issue_rel}'
+ build([f'{outdir}/{name}.tex' for name in documents], 'scholar_issue', None,
+ implicit=[*templates, f'source/{issue_rel}/meta.yaml'],
+ variables={'builder': builder, 'course': course, 'year': year,
+ 'issue': issue.name, 'outdir': outdir})
+ for name in documents:
+ pdf = f'output/{issue_rel}/{name}.pdf'
+ build(pdf, 'document_pdf', None,
+ implicit=[f'{outdir}/{name}.tex', *fragments, *drawn, *static,
+ 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'],
+ implicit_outputs=[pdf.replace('.pdf', e)
+ for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')],
+ variables={'tex': f'{outdir}/{name}.tex'})
+ w.build(f'output/{issue_rel}/all', 'phony',
+ [f'output/{issue_rel}/{name}.pdf' for name in documents])
+ counts['edges'] += 1
+ pictures += drawn
+
+ w.build(f'output/scholar/{scope}/{kind}', 'phony',
+ [f'output/scholar/{scope}/{kind}/{i.name}/all'
+ for i in sorted(x for x in (root / kind).iterdir()
+ if x.is_dir() and _visible(x))])
+ counts['edges'] += 1
+ w.newline()
+
+ counts['problems'] = units
+ counts['pictures'] = len(pictures)
+ return counts
+
+
+def _already_drawn(w: Writer, stem: str, extension: str) -> bool:
+ """
+ Whether a drawing already produces this path, so the copy beside it must not.
+
+ `seminar/FKS/35/1/2/02` holds both `svah.svg` and `svah.pdf`, and both want to become
+ `build/…/svah.pdf`. Make picked the SVG, silently, by rule order -- `build/%.pdf: render/%.svg`
+ at `Makefile:213` is tried before the `.pdf` copy at `:350`, and make takes the first rule
+ whose prerequisites can be made. Keep that answer, and say so rather than relying on the
+ order two loops happen to run in; Ninja would otherwise refuse the graph outright, which is
+ how this was found.
+ """
+ return extension == 'pdf' and w.has(f'build/{stem}.pdf')
+
+
+def _group(name: str) -> str:
+ """Which of the three per-language sets a fragment belongs to."""
+ return next(group for group, names in NABOJ_GROUPS.items() if name in names)
+
+
+def _fragment(build, source: str, stem: str, lang: str, meta: pathlib.Path,
+ repo: pathlib.Path, renderer: str | None = None) -> tuple[str, str]:
+ """
+ `source/…/x.md` → `render/…/x.md` → `build/…/x.{tex,html}`.
+
+ One Jinja stage, one language, **two writers**. The `.tex` goes to xelatex and the `.html`
+ goes into a page, and both are made from the same rendered Markdown -- which is the whole
+ point of the arrangement, since a second route to the web would be a second chance for the
+ two to disagree about what the problem says.
+ """
+ _rendered(build, f'render/{stem}.md', source,
+ implicit=[str(meta.relative_to(repo))],
+ variables={'lang': lang, 'meta': str(meta),
+ 'renderer': renderer or NABOJ_RENDERER})
+ build(f'build/{stem}.tex', 'pandoc_tex', [f'render/{stem}.md'],
+ implicit=[f'build/core/i18n/{lang}.yaml'],
+ variables={'lang': lang})
+ build(f'build/{stem}.html', 'pandoc_html', [f'render/{stem}.md'],
+ implicit=[f'build/core/i18n/{lang}.yaml'],
+ variables={'lang': lang})
+ return f'build/{stem}.tex', f'build/{stem}.html'
+
+
+#: What a template is called in the line the build prints. The jinja step serves four formats,
+#: and the Makefile's single `jinja` macro called every one of them a "Markdown template" --
+#: true of the 161 fragments in a volume and not of its 30 pictures.
+TEMPLATE_KINDS = {'md': 'Markdown', 'gp': 'gnuplot', 'svg': 'SVG', 'tikz': 'TikZ'}
+
+
+def _rendered(build, target: str, source: str, **kwargs):
+ """A jinja edge that says which format it is rendering. See `_copied`."""
+ kind = TEMPLATE_KINDS.get(pathlib.Path(source).suffix.lstrip('.'), 'template')
+ variables = {'kind': kind, **kwargs.pop('variables', {})}
+ return build(target, 'jinja', [source], variables=variables, **kwargs)
+
+
+def _copied(build, target: str, source: str, **kwargs):
+ """
+ A copy edge that says what kind of file it is copying, which is all the description wants.
+
+ The Makefile spelled that out at each call -- `$(call _copy,PNG)`, `$(call _copy,SVG)` --
+ and the suffix is where it came from every time, so it is taken from the path here.
+ """
+ kind = pathlib.Path(source).suffix.lstrip('.').upper()
+ variables = {'kind': kind or 'source', **kwargs.pop('variables', {})}
+ return build(target, 'copy', [source], variables=variables, **kwargs)
+
+
+def _picture(build, w: Writer, stem: str, extension: str, problem: pathlib.Path,
+ repo: pathlib.Path) -> tuple[str, str]:
+ """
+ The routes a drawing takes to the two formats it is wanted in.
+
+ `build/.pdf` is what the booklet includes and `build/.svg` is what a page
+ shows, and **it is the same picture** -- see `_picture_svg` for why that matters.
+ """
+ if extension == 'svg':
+ build(f'build/{stem}.pdf', 'svg_pdf', [f'render/{stem}.svg'])
+ elif extension == 'tikz':
+ build(f'build/{stem}.tikz.tex', 'picture_tex', [f'render/{stem}.tikz'],
+ implicit=['core/templates/standalone.jtex'], variables={'lang': 'sk'})
+ build(f'build/{stem}.pdf', 'tikz_pdf', [f'build/{stem}.tikz.tex'])
+ else:
+ data = sorted(str(p.relative_to(repo / 'source')) for p in problem.glob('*.dat'))
+ for name in data:
+ _copied(build, f'build/{name}', f'source/{name}')
+ _copied(build, f'build/{stem}.gp', f'render/{stem}.gp',
+ implicit=[f'build/{name}' for name in data])
+ build(f'build/{stem}.pdf', 'gnuplot_pdf', [f'build/{stem}.gp'])
+ return f'build/{stem}.pdf', _picture_svg(build, w, stem, extension)
+
+
+def _picture_svg(build, w: Writer, stem: str, extension: str) -> str:
+ """
+ The same picture, in the one format a browser wants.
+
+ **There is no separate web drawing.** A TikZ drawing, a PDF copied in and a `.gp` all reach
+ the page through the artefact the booklet prints, so the two cannot drift -- the first two
+ by conversion and the plot by being drawn a second time in the `svg` terminal, which is one
+ step shorter and takes the PDF parser out of the chain for the files that provoked the
+ conversion bug. A hand-drawn `.svg` is copied: round-tripping it through a PDF would cost
+ two conversions to arrive where it started.
+
+ `w.has` because a picture with two sources of the same name reaches this twice -- the same
+ guard `_already_drawn` applies on the PDF side, and for the same reason.
+ """
+ target = f'build/{stem}.svg'
+ if w.has(target):
+ return target
+ if extension == 'svg':
+ _copied(build, target, f'render/{stem}.svg')
+ elif extension == 'gp':
+ build(target, 'gnuplot_svg', [f'build/{stem}.gp'])
+ else:
+ build(target, 'pdf_svg', [f'build/{stem}.pdf'])
+ return target
+
+
+#: What a page's maths is set with when the engine is `latex`, which is the default: the
+#: `\input`s of `core/templates/mathpage.jtex`. Every document edge gets these from xelatex's
+#: own `-recorder`; a page runs its xelatex inside `core.html.texmath`, where there is no
+#: recorder to read, so they are named here instead.
+MATHPAGE_INPUTS = ('core/templates/mathpage.jtex', 'core/latex/fonts.tex',
+ 'core/latex/utilities.tex', 'core/latex/math.tex',
+ 'core/latex/symbols.tex', 'core/latex/siunitx.tex')
+
+
+def _page(build, rel: str, lang: str, module: str, *, fragments: list, pictures: list,
+ nested: bool) -> str:
+ """
+ One unit's fragments assembled into a page, which is what goes in `output/`.
+
+ `nested` is the module's own answer to where a fragment lives: Náboj holds a directory per
+ language inside a problem, so its page is `//index.html`; seminar and scholar
+ are monolingual and theirs sits in the unit directory. `page.py` reads the same fact out of
+ `modules//editor.yaml`, which is why the module is passed rather than guessed from
+ the path.
+ """
+ outdir = f'output/{rel}/{lang}' if nested else f'output/{rel}'
+ build(f'{outdir}/index.html', 'page', None,
+ implicit=[*fragments, *pictures, f'build/core/i18n/{lang}.tex',
+ 'core/templates/page.jhtml', 'core/html/page.css', *MATHPAGE_INPUTS],
+ variables={'unit': f'build/{rel}', 'lang': lang, 'module': module, 'outdir': outdir})
+ return f'{outdir}/index.html'
+
+
+def _visible(path: pathlib.Path) -> bool:
+ """
+ A directory whose name begins with a dot is not content, and nothing may treat it as one.
+
+ Two were being built as though they were. `scholar/STA/2023/homework/02/.priors` is a draft
+ kept beside the real problems: it was rendered, and failed on a meta the problem schema
+ refuses. `seminar/FKS/.template` is the scaffolding a new volume is copied from, and
+ `--scope all` made six scopes out of it. Neither is a defect in the sources -- a leading dot
+ is how a filesystem has always said *this is not one of the others*, and `.static/` sits
+ beside the units in every module without anyone mistaking it for one.
+ """
+ return not path.name.startswith('.')
+
+
+def _venue_language(meta: pathlib.Path) -> str | None:
+ """
+ The one thing in this generator read out of a file rather than off a directory listing.
+
+ A venue's documents are set in the venue's language, which decides the i18n table they
+ `\\input`, and no listing anywhere holds that fact -- so it is read here, and the meta goes
+ into the generator edge's inputs so that changing `language:` regenerates the graph.
+
+ A line scan rather than `yaml.safe_load`, because this module imports nothing outside the
+ standard library and `tools/ninja/bootstrap.py` leans on that: its last-resort path runs the
+ generator under whatever `python3` is on PATH, where the project's packages are not
+ importable. `language:` is a plain scalar in every venue meta that has one, and the schema
+ requires it to be one, so there is no YAML here to get wrong.
+
+ **`None` where there is no key, and no guess.** 69 of the 144 venue metas have none -- all
+ of `phys/02` and `20`-`25`, several of them empty files -- and those venues cannot build
+ today for a reason that has nothing to do with this graph: `contexts/buildable.py:66`
+ subscripts `i18n.languages[venue.language]` outright, so the builder raises before anything
+ reads an i18n table. Falling back to the volume's language would paper over that with the
+ wrong locale; raising here would be worse still, because one incomplete venue in a volume
+ nobody builds would stop the generator emitting *any* scope's edges.
+ """
+ for line in meta.read_text(encoding='utf-8').splitlines():
+ key, separator, value = line.partition(':')
+ if (separator and key.strip() == 'language'
+ and (language := value.split('#')[0].strip().strip('\'"'))):
+ return language
+ return None
+
+
+def _standalone(build, module: str, rel: str, inputs: list, templates: list, lang: str) -> str:
+ """
+ One unit, one PDF: the document `modules//editor.yaml` names as its `preview:`.
+
+ naboj's is written at its own call site, because its builder takes the competition, volume,
+ problem and language apart where these two take the unit as one path. What the three share
+ is the reason for existing: `/api/compile` asks for this target and nothing else, so a
+ module whose standalone has no edge has an editor that renders Markdown, converts TeX and
+ then cannot show the page. That was seminar and scholar until now, and make built both.
+
+ The builders are deliberately forgiving -- each reads its meta through a `read_yaml` that
+ answers `{}` to anything unparseable -- because a preview has to compile for the problem in
+ front of you even when the round around it does not. This edge keeps that: the only source
+ it names is the unit's own.
+ """
+ unit = rel.split('/', 1)[1] # the path below `source//`
+ outdir = f'build/{rel}'
+ build(f'{outdir}/standalone.tex', 'unit_standalone_tex', None,
+ implicit=[*templates, f'source/{rel}/meta.yaml'],
+ variables={'module': module, 'unit': unit, 'outdir': outdir})
+ pdf = f'output/{rel}/standalone.pdf'
+ build(pdf, 'document_pdf', None,
+ implicit=[f'{outdir}/standalone.tex', *inputs,
+ f'build/core/i18n/{lang}.tex', 'core/latex/dgs.cls'],
+ implicit_outputs=[pdf.replace('.pdf', e)
+ for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')],
+ variables={'tex': f'{outdir}/standalone.tex'})
+ return pdf
+
+
+def _instagram(build, prefix: str, counted: str, problems: int) -> list:
+ """
+ The instagram pictures: one PNG per problem, under the name make gave them.
+
+ The count is not a thing to discover after the fact. `modules/seminar/templates/instagram.jtex`
+ sets each problem on its own page and ends it with a `\\clearpage`, so the document is one
+ page per problem and the generator knows how many problems a round has -- which is why this
+ is not `burst`, whose page count genuinely is unknowable until pdftk has run. Every PNG is
+ a declared output, and `output//instagram` was a *file* target in make whose recipe
+ never created it, so it re-ran on every build.
+
+ The count is also checked, once per document rather than once per page, and every page hangs
+ off the check. Without it a document that came out longer would be cut into images silently
+ misnumbered -- `instagram-02.png` holding the tail of the first problem rather than the
+ second, which is worse than no image at all.
+ """
+ build(counted, 'page_count', [f'{prefix}.pdf'], variables={'pages': str(problems)})
+ images = []
+ for number in range(1, problems + 1):
+ image = f'{prefix}-{number:02d}.png'
+ build(image, 'page_image', [f'{prefix}.pdf'], implicit=[counted],
+ variables={'page': str(number), 'prefix': image.removesuffix('.png')})
+ images.append(image)
+ return images
+
+
+def _beside(drawings: list, rel: str) -> list:
+ """The pictures that sit in this unit's own directory, which are the ones its page names."""
+ return [picture for picture in drawings if picture.rsplit('/', 1)[0] == f'build/{rel}']
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
+ parser.add_argument('--scope', required=True, nargs='+',
+ help='one or more, e.g. phys/29 seminar:FKS/42/1/1; '
+ '`all` for every scope of every module')
+ parser.add_argument('-o', '--output', type=pathlib.Path, default=pathlib.Path('build.ninja'))
+ parser.add_argument('--repo', type=pathlib.Path, default=pathlib.Path('.'))
+ args = parser.parse_args()
+
+ repo = args.repo.resolve()
+ scopes = args.scope
+ if 'all' in scopes:
+ scopes = [s for s in scopes if s != 'all']
+
+ def named(root: pathlib.Path, pattern: str):
+ """Every directory matching `pattern` under `root`, by its path relative to it.
+
+ `pathlib.glob` is not the `glob` module: `*` matches a leading dot here, which is
+ how `seminar:FKS/.template/1/1` became six scopes of a scaffolding directory. Every
+ part has to be visible, not only the last -- `.template/1/1` ends in `1`.
+ """
+ for path in root.glob(pattern):
+ relative = path.relative_to(root)
+ if path.is_dir() and all(not part.startswith('.') for part in relative.parts):
+ yield path, relative
+
+ scopes += sorted(str(relative.parent)
+ for path, relative in named(repo / 'source/naboj', '*/*/problems')
+ if (path.parent / 'languages').is_dir())
+ scopes += sorted(f'seminar:{relative}'
+ for path, relative in named(repo / 'source/seminar', '*/*/*/*')
+ if any(path.glob('*/meta.yaml')))
+ # `A and (B or C)`. Written without the brackets this was `(A and B) or C`, so a
+ # `homework/` directory got in whether or not its parent was a directory at all.
+ scopes += sorted(f'scholar:{relative}'
+ for path, relative in named(repo / 'source/scholar', '*/*')
+ if ((path / 'handouts').is_dir() or (path / 'homework').is_dir()))
+ with args.output.open('w') as out:
+ counts = generate(repo, scopes, out)
+ print(f"{args.output}: {counts['edges']} edges, {len(scopes)} scopes, "
+ f"{counts['problems']} problems, {counts['pictures']} pictures, "
+ f"{len(counts['languages'])} scope-languages")
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/ninja/ink.py b/tools/ninja/ink.py
new file mode 100644
index 00000000..cba45673
--- /dev/null
+++ b/tools/ninja/ink.py
@@ -0,0 +1,50 @@
+r"""
+The Makefile's colours, for Ninja's `description` lines.
+
+Every recipe in `Makefile` opened with an `@echo -e` in four colours -- a blue sentence, the
+file names in magenta, the format names in bold green -- and Ninja prints a rule's
+`description` where make printed that echo, so the same sentences belong there. They are
+written with `{a}`, `{f}`, `{x}` and `{d}` markers and passed through `described`:
+
+ described('{a}[jinja] Rendering {x}Markdown template{a} {f}$in{a} to {f}$out')
+
+`$in` and `$out` are Ninja's, expanded when the edge runs; the braces are Python's, expanded
+when the graph is written. The two never meet, which is why one string can carry both.
+
+**Plain SGR rather than `tput`.** The Makefile asked terminfo at parse time -- `$(shell tput
+sgr0; tput bold; tput setaf 4)` -- which costs six subprocesses and, more to the point, gives
+a different answer depending on `TERM` in the shell that happened to run `make`. The generator
+is itself usually run *by* Ninja, where there is no terminal at all, so terminfo would answer
+for nothing. These are the codes `tput` emits for every terminal this repository is built on,
+minus the `\033(B` charset reset, which does nothing on a UTF-8 terminal.
+
+Colour is baked into `build.ninja` when it is generated, so `NO_COLOR` takes effect at the next
+regeneration rather than the next build. Ninja 1.13's status line elides the *visible* text and
+re-emits the escapes around it, so a long description truncated to the terminal's width still
+ends in the right colour.
+"""
+import os
+
+#: `c_error` and `c_special` from the Makefile are deliberately absent. The first coloured its
+#: three `Incorrect fall-through rule called on …` recipes, which exist because a pattern rule
+#: can silently fail to apply -- the defect this build does not have, since a target either has
+#: an edge or Ninja says `unknown target`. The second was defined and never used.
+PALETTE = {
+ 'a': '\033[0m\033[1m\033[34m', # the sentence
+ 'f': '\033[0m\033[35m', # a file name
+ 'x': '\033[0m\033[1m\033[32m', # a format, or the kind of thing being built
+ 'd': '\033[0m\033[37m', # back to the default, which every line ends in
+}
+PLAIN = dict.fromkeys(PALETTE, '')
+
+
+def palette() -> dict[str, str]:
+ """Honours `NO_COLOR`, and `TERM=dumb`, which is what a terminal says when it is not one."""
+ if os.environ.get('NO_COLOR') or os.environ.get('TERM', '') in ('', 'dumb'):
+ return PLAIN
+ return PALETTE
+
+
+def described(sentence: str) -> str:
+ """One `description`, coloured, ending in the default so nothing bleeds into the next."""
+ return sentence.format(**palette()) + palette()['d']
diff --git a/tools/ninja/writer.py b/tools/ninja/writer.py
new file mode 100644
index 00000000..851e9518
--- /dev/null
+++ b/tools/ninja/writer.py
@@ -0,0 +1,122 @@
+r"""
+A minimal `build.ninja` writer.
+
+Ninja's own `misc/ninja_syntax.py` is the canonical version of this and is about the same size;
+it is vendored here rather than depended on because `ninja` is a *build-time* tool for this
+repository, not a Python dependency, and `pyproject.toml` should not gain one for 60 lines.
+
+The only subtlety is escaping, and there are two different rules. In a **path** (an output or an
+input) Ninja takes `$ ` for a space, `$:` for a colon and `$$` for a dollar. In a **value** (the
+right-hand side of `command = …`) only `$` and the newline are special. Getting the two confused
+is how a path with a space in it silently becomes two paths.
+"""
+import textwrap
+
+
+def escape_path(word: str) -> str:
+ return word.replace('$ ', '$$ ').replace(' ', '$ ').replace(':', '$:')
+
+
+def escape(value: str) -> str:
+ return value.replace('$', '$$')
+
+
+class Writer:
+ def __init__(self, out, width: int = 100):
+ self.out = out
+ self.width = width
+ #: Every output written so far, to the edge that wrote it. Ninja refuses two rules for
+ #: one output -- `multiple rules generate …` -- and it is right to: make resolved the
+ #: same ambiguity silently by taking whichever rule it saw last, which is how
+ #: `Makefile:377` and `:397` came to be the same rule head with the second unreachable.
+ #:
+ #: Benign repetition is real here, though. Two gnuplot scripts in one directory share a
+ #: `.dat`, so the same copy edge is reached twice; that is the identical edge and is
+ #: skipped. A *different* edge for the same output is a generator bug and raises.
+ self.emitted: dict[str, str] = {}
+
+ def comment(self, text: str) -> None:
+ for line in textwrap.wrap(text, self.width - 2, break_long_words=False,
+ break_on_hyphens=False):
+ self.out.write(f'# {line}\n')
+
+ def newline(self) -> None:
+ self.out.write('\n')
+
+ def variable(self, key: str, value, indent: int = 0) -> None:
+ if value is None:
+ return
+ if isinstance(value, (list, tuple)):
+ value = ' '.join(str(v) for v in value if v)
+ self.out.write(f"{' ' * indent}{key} = {value}\n")
+
+ def pool(self, name: str, depth: int) -> None:
+ self.out.write(f'pool {name}\n')
+ self.variable('depth', depth, 1)
+
+ KNOWN = ('description', 'depfile', 'deps', 'pool', 'generator', 'restat')
+
+ def rule(self, name: str, command: str, **kwargs) -> None:
+ """
+ A rule block takes only the bindings Ninja knows -- an arbitrary one is a parse error,
+ `unexpected variable 'kind'`, not something it ignores. So a `$kind` in a description
+ has to be bound by every edge that uses the rule, and the whitelist here is what turns
+ a misspelt keyword into an error at generation time rather than a silent omission.
+ """
+ if unknown := set(kwargs) - set(self.KNOWN):
+ raise TypeError(f'rule {name}: unknown {", ".join(sorted(unknown))}')
+ self.out.write(f'rule {name}\n')
+ self.variable('command', command, 1)
+ for key in self.KNOWN:
+ if key in kwargs:
+ self.variable(key, kwargs[key], 1)
+
+ def build(self, outputs, rule: str, inputs=None, *, implicit=None, order_only=None,
+ implicit_outputs=None, variables=None) -> list:
+ outputs = [escape_path(str(o)) for o in _listify(outputs)]
+ line = f"build {' '.join(outputs)}"
+ if implicit_outputs:
+ line += ' | ' + ' '.join(escape_path(str(o)) for o in _listify(implicit_outputs))
+ line += f': {rule}'
+ for prefix, items in ((' ', inputs), (' | ', implicit), (' || ', order_only)):
+ items = [escape_path(str(i)) for i in _listify(items)]
+ if items:
+ line += prefix + ' '.join(items)
+ signature = line + ''.join(f'|{k}={v}' for k, v in sorted((variables or {}).items()))
+ first = outputs[0]
+ if (previous := self.emitted.get(first)) is not None:
+ if previous == signature:
+ return [] # the same edge reached twice; write it once
+ raise ValueError(f'two different edges produce {first}:\n {previous}\n {signature}')
+ for output in outputs:
+ self.emitted[output] = signature
+
+ self.out.write(line + '\n')
+ for key, value in (variables or {}).items():
+ self.variable(key, value, 1)
+ return outputs
+
+
+ def default(self, targets) -> None:
+ """
+ What `ninja` with no arguments builds.
+
+ Ninja's own default is every output nothing else depends on, which here is 33 929 of
+ them -- the whole repository, every preview PDF included. `make` with no goal touched a
+ stamp and stopped, so the two spellings of "I typed the command to see what happens"
+ must not differ by four hours of xelatex.
+ """
+ self.out.write('default ' + ' '.join(escape_path(t) for t in _listify(targets)) + '\n')
+
+
+ def has(self, output: str) -> bool:
+ """Whether something already produces this path."""
+ return escape_path(str(output)) in self.emitted
+
+
+def _listify(value) -> list:
+ if value is None:
+ return []
+ if isinstance(value, (list, tuple, set)):
+ return sorted(value) if isinstance(value, set) else list(value)
+ return [value]
diff --git a/tools/ninja/zip.py b/tools/ninja/zip.py
new file mode 100644
index 00000000..d09fecdb
--- /dev/null
+++ b/tools/ninja/zip.py
@@ -0,0 +1,44 @@
+r"""
+The tearoff archive: one PDF per language, each named after its language.
+
+`output/…/languages//tearoff.pdf` is what the build produces, and what a venue wants is an
+archive of those under the names `sk.pdf`, `en.pdf` and so on. The Makefile did it by laying a
+symlink beside each PDF and running `zip --junk-paths` over the links
+(`modules/naboj/module.mk:548-551`), which leaves the links behind permanently -- `core/audit/stale.py`
+carries a note about having to `unlink` rather than follow them -- and which **appends**, so a
+language removed from the volume stayed in the archive for ever.
+
+Writing the archive directly costs nothing and fixes both: no symlinks, and the file is replaced
+rather than added to.
+"""
+import argparse
+import pathlib
+import sys
+import zipfile
+
+
+def language_of(pdf: pathlib.Path) -> str:
+ """`output/naboj/phys/29/languages/sk/tearoff.pdf` → `sk`, from the directory it sits in."""
+ return pdf.parent.name
+
+
+def write(archive: pathlib.Path, pdfs: list[pathlib.Path]) -> int:
+ archive.parent.mkdir(parents=True, exist_ok=True)
+ # `w`, not `a`: the archive is the set of languages that exist now.
+ with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as out:
+ for pdf in sorted(pdfs):
+ out.write(pdf, f'{language_of(pdf)}.pdf')
+ return len(pdfs)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
+ parser.add_argument('archive', type=pathlib.Path)
+ parser.add_argument('pdfs', type=pathlib.Path, nargs='+')
+ args = parser.parse_args()
+ print(f'{args.archive}: {write(args.archive, args.pdfs)} language(s)')
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/runner/__init__.py b/tools/runner/__init__.py
new file mode 100644
index 00000000..b44c40f9
--- /dev/null
+++ b/tools/runner/__init__.py
@@ -0,0 +1,19 @@
+r"""
+A warm interpreter for the build's Python steps.
+
+Every recipe in this build shells out to `python -m something`, and **92 % of what that costs
+is the interpreter coming up**: 445 ms of wall and 1.10 s of CPU to import `core.builder.jinja`
+and the pint/numpy stack underneath it, against 53 ms to do the actual render once those are in
+memory. A volume booklet runs about 320 such steps, so the startup alone is roughly 40 % of the
+whole build's CPU.
+
+`server.py` holds the imports once and forks per job; `client.py` is what the build actually
+invokes, and imports nothing but `os`, `socket`, `struct` and `sys` so that it starts in 14 ms.
+
+**Fork, not a reused worker.** The expensive state -- the pint registry at `core/builder/jinja.py`
+module level, `core.i18n`'s thirteen parsed and schema-validated locales, Jinja's template caches
+-- is shared copy-on-write and read only, while anything a job mutates (`sys.argv`, the working
+directory, the environment, a global some builder sets) dies with the child. That is what
+separates this from a Bazel-style persistent worker, where state leaking from one job into the
+next is the standard failure and the reason such pools rot. The fork costs about 2 ms of the 53.
+"""
diff --git a/tools/runner/client.py b/tools/runner/client.py
new file mode 100644
index 00000000..39eb4d7b
--- /dev/null
+++ b/tools/runner/client.py
@@ -0,0 +1,162 @@
+r"""
+What the build invokes in place of `python -m …`. Run it with `-S`.
+
+ .venv/bin/python3 -S tools/runner/client.py -m modules.naboj.builder.renderer sk …
+ .venv/bin/python3 -S tools/runner/client.py pandoc.py --format latex sk …
+
+The argument list after the script is **exactly what `python` itself would have taken**, so any
+command in `build.ninja` can be run by hand by deleting `-S tools/runner/client.py` from it, and
+`DGS_NO_DAEMON=1` does that for you -- the client `exec`s the real interpreter and nothing else
+about the invocation changes.
+
+It imports four stdlib modules and nothing else, because its own startup is the floor on what
+this whole exercise can save: 14 ms here against 445 ms for the renderer. `-S` skips `site`, so
+the venv's packages are not on the path and must not be wanted. Everything else -- `fcntl`,
+`subprocess`, `time` -- is imported inside `spawn`, which runs once per build at most.
+
+**Every failure before the daemon acknowledges falls back to running the job here.** No daemon,
+a refused socket, one too old to answer, one that died holding the request: each ends in
+`os.execv` of the real interpreter, so this cannot break a build, only slow it back down to
+where it was. After the acknowledgement the opposite rule applies -- see `request`.
+"""
+import os
+import socket
+import struct
+import sys
+
+#: The checkout this file belongs to, so that the client works from a subdirectory and two
+#: checkouts never share a daemon. `tools/runner/client.py` is three directories down.
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+#: Under `build/`, which is already ignored and already swept. A path too long for a Unix
+#: socket -- they are capped near 108 bytes -- simply fails to bind and to connect, and the
+#: build falls back to plain interpreters, which is the right way for that to go wrong. The
+#: override is what lets a test have a daemon of its own rather than restarting yours.
+SOCKET = os.environ.get('DGS_RUNNER_SOCKET') or os.path.join(ROOT, 'build', '.dgsd', 'sock')
+LOCK = SOCKET + '.lock'
+#: Long enough for the daemon to bind, which it does before importing anything; a client that
+#: connects during the import simply waits in `accept`. Not long enough to matter if the
+#: daemon cannot start at all, which `spawn` notices directly anyway.
+SPAWN_TIMEOUT = 20.0
+#: How long a daemon may take to acknowledge before we give up on it and do the job here. The
+#: job itself is then given no limit at all: a volume booklet legitimately takes minutes.
+READY_TIMEOUT = 60.0
+#: The daemon's "I have your descriptors, your directory and your environment, and I am about
+#: to run this". See `request`.
+READY = b'\x01'
+
+
+def direct(argv):
+ """Be `python` after all. Does not return."""
+ os.execv(sys.executable, [sys.executable, *argv])
+
+
+def connect():
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.settimeout(READY_TIMEOUT)
+ sock.connect(SOCKET)
+ return sock
+
+
+def spawn():
+ """
+ Start a daemon, under a lock so that eight parallel clients do not start eight of them.
+
+ Whoever takes the lock tries to connect first, because by then the daemon the previous
+ holder started is usually up and nothing needs starting at all. A daemon that dies on the
+ way up is noticed as it dies rather than waited out, so a broken one costs each client the
+ half second it takes to fail, not `SPAWN_TIMEOUT`.
+ """
+ import fcntl
+ import subprocess
+ import time
+
+ os.makedirs(os.path.dirname(SOCKET) or '.', exist_ok=True)
+ with open(LOCK, 'a+') as lock:
+ fcntl.flock(lock, fcntl.LOCK_EX)
+ try:
+ return connect()
+ except OSError:
+ pass
+ daemon = subprocess.Popen(
+ [sys.executable, '-m', 'tools.runner.server'], cwd=ROOT,
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ start_new_session=True)
+ deadline = time.monotonic() + SPAWN_TIMEOUT
+ while time.monotonic() < deadline:
+ try:
+ return connect()
+ except OSError:
+ if daemon.poll() is not None:
+ return None
+ time.sleep(0.02)
+ return None
+
+
+def request(sock, argv):
+ """
+ One job: the working directory, the environment and the argument list, with our own three
+ streams passed as descriptors so that the child writes straight to them.
+
+ Fields are NUL-separated and the length of `argv` is sent before it, so the environment
+ needs no delimiter of its own. Nothing here can hold a NUL: a command line and an
+ environment are both NUL-terminated strings to begin with.
+
+ **`None` means the job has not started** -- nothing acknowledged, or the daemon died
+ holding the request -- and the caller may safely run it here instead. Once the
+ acknowledgement is in, the rule inverts: anything that goes wrong is the job's own failure
+ and is reported as one, because falling back then would run a half-finished job a second
+ time.
+ """
+ fields = [os.getcwd(), str(len(argv)), *argv,
+ *(f'{key}={value}' for key, value in os.environ.items())]
+ payload = '\0'.join(fields).encode()
+ try:
+ # The length goes in the **same** `sendmsg` as the descriptors, not in a `sendall`
+ # before it. On a stream socket the ancillary data rides on one byte of the stream and
+ # is delivered to whichever `recvmsg` consumes that byte -- so a plain `recv` of a
+ # separate header can coalesce the first payload byte with it, and the descriptors are
+ # closed without a word. One send, one `recv_fds` on the far side, no race.
+ socket.send_fds(sock, [struct.pack(' int:
+ argv = sys.argv[1:]
+ if not argv or os.environ.get('DGS_NO_DAEMON'):
+ direct(argv)
+
+ try:
+ sock = connect()
+ except OSError:
+ sock = spawn()
+ if sock is None:
+ direct(argv)
+
+ with sock:
+ code = request(sock, argv)
+ if code is None:
+ direct(argv)
+ return code
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/runner/server.py b/tools/runner/server.py
new file mode 100644
index 00000000..60bad2a3
--- /dev/null
+++ b/tools/runner/server.py
@@ -0,0 +1,357 @@
+r"""
+The warm interpreter. Started by `client.py` when nothing is listening; never started by hand
+in the course of a build.
+
+ python -m tools.runner.server run in the foreground, to watch it
+ python -m tools.runner.server --stop tell a running one to go away
+
+It imports the build's Python once and forks a child per job. The child inherits every parsed
+locale, the pint registry and whatever Jinja has cached, which is the whole point, and inherits
+them *copy-on-write*, so nothing it does to them outlives it. See `__init__.py`.
+
+**It must hold no threads.** `import numpy` spawns an OpenBLAS pool -- 0.57 s of CPU at 630 %,
+for a build that does no linear algebra -- and forking a process whose threads hold locks is
+the classic way to a child that deadlocks on its first allocation. The variables below are set
+before the first import for that reason: this is a correctness measure and the CPU it saves is
+incidental.
+
+**Staleness is checked in the parent, before every fork.** A hash of every `.py`, `.yaml` and
+`.lua` under `core/`, `modules/` and `tools/` -- 176 files, 1.2 ms -- stands for what was
+imported. When it moves, the daemon unlinks its socket and exits rather than answering with
+code that is no longer on disk; the client whose request met the change runs the job itself,
+and the next one starts a fresh daemon. A job already running when a file changes is a race
+`make` has too.
+
+**One thing a job does not inherit: the build's process group.** The daemon is its own session,
+so interrupting `ninja` kills the clients and leaves their jobs to finish into descriptors
+nobody is reading any more. They exit within a second or two and Ninja re-runs the edges whose
+commands never reported, which is what it does for an interrupted build anyway.
+"""
+from __future__ import annotations
+
+import os
+
+# Before anything imports numpy. See the module docstring: this is what makes the fork safe.
+for _variable in ('OPENBLAS_NUM_THREADS', 'OMP_NUM_THREADS', 'MKL_NUM_THREADS',
+ 'NUMEXPR_NUM_THREADS', 'VECLIB_MAXIMUM_THREADS'):
+ os.environ[_variable] = '1'
+
+import contextlib
+import dataclasses
+import hashlib
+import importlib
+import io
+import locale
+import runpy
+import socket
+import struct
+import sys
+import traceback
+
+#: The checkout this file belongs to. Everything below is anchored to it rather than to the
+#: working directory, because the daemon outlives whichever one it was started from -- and a
+#: fingerprint taken relative to the wrong directory would find no files and never go stale,
+#: which is the one way this could serve code that is no longer on disk.
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+SOCKET = os.environ.get('DGS_RUNNER_SOCKET') or os.path.join(ROOT, 'build', '.dgsd', 'sock')
+
+#: No client for this long and there is no build going on. Cheap to start again.
+IDLE = 1800.0
+#: How often `accept` is interrupted to ask whether the socket is still ours, which is how
+#: `--stop` -- and `ninja -t clean`, and `rm -rf build/` -- is noticed rather than waited out.
+TICK = 5.0
+#: What a request may hold. A command line and an environment, no more.
+LIMIT = 1 << 20
+#: Sent once the child holds the job's descriptors, directory and environment and is about to
+#: run it. It is what divides "the daemon never started this" from "this failed".
+READY = b'\x01'
+
+#: Imported at startup so that the first job is as fast as the second.
+#:
+#: **Libraries only.** Eight of the builders -- `core/builder/i18n.py`, naboj's `language.py`
+#: and `venue.py`, seminar's `round.py`, scholar's three -- end with a bare
+#: `BuilderX().build_templates()` at module level rather than under an `if __name__`, so
+#: importing one *runs* it: the first draft of this list printed an argparse usage message and
+#: took the daemon down with it. What they cost is in the modules underneath, which is what is
+#: named here. A module that is not there -- scholar and seminar are optional checkouts -- is
+#: skipped, and the job that wants it imports it itself, once.
+#:
+#: **Nor the modules the build runs as `__main__`**, the three `builder.renderer` among them.
+#: `runpy` warns when asked to execute a module already in `sys.modules`, and it is right to:
+#: the job would hold two copies of every class in that file. Preloading buys nothing there
+#: anyway -- the expense is `core.builder.renderer` underneath, and executing the entry module
+#: itself is a handful of class statements.
+PRELOAD = (
+ 'core.builder.jinja',
+ 'core.builder.builder',
+ 'core.builder.convertor',
+ 'core.builder.standalone',
+ 'core.builder.renderer',
+ 'modules.naboj.builder.builder',
+ 'modules.naboj.builder.contexts',
+ 'modules.seminar.builder.builder',
+ 'modules.seminar.builder.context',
+ 'modules.scholar.builder.builder',
+ 'modules.scholar.builder.contexts',
+)
+
+#: Where the code is. Templates are deliberately absent: a `.jtex` is a dependency of the build
+#: graph, not of the interpreter, and changing one must not cost a restart.
+SOURCES = ('core', 'modules', 'tools')
+SCRIPTS = ('pandoc.py', 'standalone.py')
+#: Directories whose contents say nothing about what was imported.
+SKIPPED = {'__pycache__', 'tests', 'node_modules'}
+SUFFIXES = ('.py', '.yaml', '.lua')
+
+
+def watched():
+ """Every file whose contents could be in this process's memory."""
+ for name in SOURCES:
+ for directory, children, files in os.walk(os.path.join(ROOT, name)):
+ children[:] = [c for c in children if c not in SKIPPED and not c.startswith('.')]
+ for found in files:
+ if found.endswith(SUFFIXES):
+ yield os.path.join(directory, found)
+ for name in SCRIPTS:
+ yield os.path.join(ROOT, name)
+
+
+def fingerprint() -> str:
+ """What was imported, as far as the file system can say: name, mtime and size of each."""
+ digest = hashlib.blake2b(digest_size=16)
+ for path in sorted(watched()):
+ try:
+ stat = os.stat(path)
+ except OSError:
+ digest.update(f'{path}\0gone\0'.encode())
+ else:
+ digest.update(f'{path}\0{stat.st_mtime_ns}\0{stat.st_size}\0'.encode())
+ return digest.hexdigest()
+
+
+def preload() -> None:
+ """
+ Best effort, and `BaseException` rather than `ImportError`: a module that insists on
+ parsing arguments or calling `sys.exit` at import must cost one slow job, never the daemon.
+ """
+ for name in PRELOAD:
+ with contextlib.suppress(BaseException):
+ importlib.import_module(name)
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class Request:
+ """One job as the client described it, and the three descriptors it sent with it."""
+
+ cwd: str
+ argv: list[str]
+ environment: dict[str, str]
+ descriptors: list[int]
+
+ @classmethod
+ def read(cls, connection) -> Request | None:
+ """
+ `None` for anything malformed, with the descriptors closed on the way out. The child
+ is about to exit either way, but a half-read request must not go on holding the
+ client's three streams open while it does.
+ """
+ held, descriptors, _, _ = socket.recv_fds(connection, 65536, 3)
+ request = cls.parse(connection, held, descriptors)
+ if request is None:
+ for descriptor in descriptors:
+ os.close(descriptor)
+ return request
+
+ @classmethod
+ def parse(cls, connection, held: bytes, descriptors: list[int]) -> Request | None:
+ """
+ The rest of the stream, and the fields in it. The first `recv` is the one that carried
+ the descriptors and may have carried any amount of the payload with them, so what is
+ left is read here -- plainly, the ancillary data being long since delivered.
+ """
+ if len(held) < 4 or len(descriptors) != 3:
+ return None
+ try:
+ (length,) = struct.unpack(' LIMIT:
+ return None
+ held = held[4:]
+ while len(held) < length:
+ piece = connection.recv(length - len(held))
+ if not piece:
+ return None
+ held += piece
+
+ fields = held.decode().split('\0')
+ count = int(fields[1])
+ return cls(cwd=fields[0], argv=fields[2:2 + count],
+ environment=dict(entry.split('=', 1)
+ for entry in fields[2 + count:] if '=' in entry),
+ descriptors=descriptors)
+ except (OSError, UnicodeDecodeError, ValueError, IndexError, struct.error):
+ return None
+
+ def become(self) -> None:
+ """Make this child look like the process the client would otherwise have started."""
+ for target, descriptor in enumerate(self.descriptors):
+ os.dup2(descriptor, target)
+ os.close(descriptor)
+ # Rebuilt rather than reused: the daemon's own `sys.stdout` settled its buffering
+ # against whatever it was started with -- `/dev/null`, so block-buffered -- and a job
+ # whose output is a terminal wants that decision taken again. UTF-8 outright, because
+ # every file in this repository is UTF-8 and the alternative is that a job's output
+ # depends on the locale of the terminal the daemon happened to be started from half an
+ # hour ago, which is the one thing a long-lived process must not let happen.
+ sys.stdin = io.TextIOWrapper(io.FileIO(0, 'r', closefd=False), encoding='utf-8')
+ sys.stdout = io.TextIOWrapper(io.FileIO(1, 'w', closefd=False), encoding='utf-8',
+ line_buffering=os.isatty(1))
+ sys.stderr = io.TextIOWrapper(io.FileIO(2, 'w', closefd=False), encoding='utf-8',
+ errors='backslashreplace', line_buffering=True)
+
+ os.environ.clear()
+ os.environ.update(self.environment)
+ # The client's locale and not the daemon's, for the same reason: `Path.open()`
+ # throughout the builders takes its encoding from it.
+ with contextlib.suppress(locale.Error):
+ locale.setlocale(locale.LC_ALL, '')
+ os.chdir(self.cwd)
+
+ def execute(self) -> int:
+ """`python -m module …` or `python script.py …`, as the interpreter would read it."""
+ try:
+ if self.argv[0] == '-m':
+ sys.argv = [self.argv[1], *self.argv[2:]]
+ runpy.run_module(self.argv[1], run_name='__main__', alter_sys=True)
+ else:
+ sys.argv = list(self.argv)
+ runpy.run_path(self.argv[0], run_name='__main__')
+ except SystemExit as exit:
+ if exit.code is None or isinstance(exit.code, int):
+ return exit.code or 0
+ # What the interpreter does with `sys.exit('…')`, which nothing here writes today.
+ print(exit.code, file=sys.stderr)
+ return 1
+ except KeyboardInterrupt:
+ return 130
+ except Exception: # noqa: BLE001 -- the top level of a process, and it behaves like
+ # one: whatever the job raised is printed and becomes a status, because there is
+ # nobody above to handle it and letting it reach the daemon would end the build.
+ traceback.print_exc()
+ return 1
+ return 0
+
+
+def serve(connection) -> None:
+ """The child, which never returns from here."""
+ code = 70
+ try:
+ request = Request.read(connection)
+ if request is not None:
+ request.become()
+ # In place and about to run, so from here on a client that loses the connection
+ # must report a failure rather than run the job itself. Before this byte it may
+ # safely fall back; after it, falling back would run the job twice.
+ connection.sendall(READY)
+ code = request.execute()
+ finally:
+ for stream in (sys.stdout, sys.stderr):
+ with contextlib.suppress(Exception):
+ stream.flush()
+ with contextlib.suppress(OSError):
+ connection.sendall(struct.pack(' tuple[socket.socket, int] | None:
+ """
+ The listening socket and the inode of the path it is bound to.
+
+ `None` is the answer to every reason there should be no daemon here, and the client reads
+ all of them the same way -- it runs the job itself. Somebody else is already serving, which
+ is how a duplicate from a spawn race retires itself; or the path cannot be bound at all,
+ a Unix socket name being capped near 108 bytes.
+ """
+ os.makedirs(os.path.dirname(SOCKET) or '.', exist_ok=True)
+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ listener.bind(SOCKET)
+ except OSError:
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ probe.connect(SOCKET)
+ except OSError:
+ pass
+ else:
+ probe.close()
+ return None
+ # Nothing was listening, so whatever is there is a corpse and may be cleared.
+ with contextlib.suppress(OSError):
+ os.unlink(SOCKET)
+ try:
+ listener.bind(SOCKET)
+ except OSError:
+ return None
+ listener.listen(64)
+ # The inode of the **path**, not of the socket: `fstat` on a bound socket descriptor
+ # reports its node in the socket filesystem, which says nothing about the path at all.
+ return listener, os.stat(SOCKET).st_ino
+
+
+def ours(inode: int) -> bool:
+ """Is the socket we bound still the socket at that path? `--stop` is what makes it not."""
+ try:
+ return os.stat(SOCKET).st_ino == inode
+ except OSError:
+ return False
+
+
+def reap() -> None:
+ """Children that have finished, collected where no signal handler has to be involved."""
+ with contextlib.suppress(ChildProcessError):
+ while os.waitpid(-1, os.WNOHANG)[0]:
+ pass
+
+
+def main() -> int:
+ if '--stop' in sys.argv[1:]:
+ with contextlib.suppress(OSError):
+ os.unlink(SOCKET)
+ return 0
+
+ bound = listen()
+ if bound is None:
+ return 0
+ listener, inode = bound
+ held = fingerprint()
+ preload()
+ listener.settimeout(TICK)
+
+ try:
+ idle = 0.0
+ while True:
+ try:
+ connection, _ = listener.accept()
+ except TimeoutError:
+ reap()
+ idle += TICK
+ if idle >= IDLE or not ours(inode):
+ return 0
+ continue
+ idle = 0.0
+ reap()
+ with connection:
+ if fingerprint() != held:
+ return 0
+ if os.fork() == 0:
+ listener.close()
+ serve(connection)
+ finally:
+ if ours(inode):
+ with contextlib.suppress(OSError):
+ os.unlink(SOCKET)
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/uv.lock b/uv.lock
index 79c24a25..fbcd90e6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -61,6 +61,7 @@ dependencies = [
{ name = "enschema" },
{ name = "flask" },
{ name = "jinja2" },
+ { name = "ninja" },
{ name = "numpy" },
{ name = "pandoc-crossref" },
{ name = "pandoc-minted" },
@@ -86,6 +87,7 @@ requires-dist = [
{ name = "enschema", specifier = ">=0.1.5" },
{ name = "flask", specifier = ">=3.1.3" },
{ name = "jinja2", specifier = ">=3.1.6" },
+ { name = "ninja", specifier = ">=1.11" },
{ name = "numpy", specifier = ">=2.5.1" },
{ name = "pandoc-crossref", specifier = ">=0.1.1" },
{ name = "pandoc-minted", specifier = ">=0.2.0" },
@@ -126,16 +128,16 @@ wheels = [
[[package]]
name = "flake8"
-version = "7.4.1"
+version = "7.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mccabe" },
{ name = "pycodestyle" },
{ name = "pyflakes" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/94/85870fc02dfe4ab0885c440e7e41f6a986b10e43eca6717e90f8ce1215cc/flake8-7.4.1.tar.gz", hash = "sha256:84ea5afcaf344487b0ea5baaebb8100f4cfaebc01f755998f75876664029f587", size = 47897, upload-time = "2026-09-23T12:21:59.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/da/b0d00f8c4c676212284edd3cc3d371588ce0e36a349373c3a01e188b5639/flake8-7.5.0.tar.gz", hash = "sha256:0dab7566341aca0d80aa29b5812bdadf256caa6a63e6068e49ac2649918236a4", size = 47811, upload-time = "2026-10-28T20:17:22.128Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b5/d4/8e58a7221419bf8cb194f362ab26abf504c983135975e4c785fac0852886/flake8-7.4.1-py2.py3-none-any.whl", hash = "sha256:78480274a6d7289d9cb8eafeda241fac57d4ea687d26e32dfdca37b72cdeddad", size = 57449, upload-time = "2026-09-23T12:21:58.085Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/57/a3adc5f380f50ef3d01014cb9d3b8c17b25de9b89def20d559def4fc3c4e/flake8-7.5.0-py2.py3-none-any.whl", hash = "sha256:637ea1a443a78ed6ee461725cdf71ca93bc5cde0abf25436dbebdc3880b4e078", size = 57487, upload-time = "2026-10-28T20:17:21.061Z" },
]
[[package]]
@@ -181,11 +183,11 @@ wheels = [
[[package]]
name = "iniconfig"
-version = "2.3.0"
+version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
@@ -211,32 +213,80 @@ wheels = [
[[package]]
name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+version = "3.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
+ { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
+ { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
+ { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
+ { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
+ { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
+ { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
+ { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
+ { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
+ { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
+ { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
+ { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
+ { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
+ { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
+ { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
+ { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
+ { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
+ { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
+ { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
+ { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
+ { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
+ { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
]
[[package]]
@@ -248,55 +298,81 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
+[[package]]
+name = "ninja"
+version = "1.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/92/410b7917d16ab54c04b05cc32b9284803671d91cf79d33be6009c28d4ea8/ninja-1.13.2.tar.gz", hash = "sha256:525bfa3fc88aa30a4467df270fd5be6f9fcae8061d54d4df74ea1dc5abd5a975", size = 243739, upload-time = "2026-08-30T15:49:51.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/b8/90a9518f2264637084d6199bc20c2f3fb97fefc4c474d277720150c3bd6d/ninja-1.13.2-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fd82e26c0706ad4ab88e5fdd26f3fab0a987a90f810160f6c322e752c6af298b", size = 306611, upload-time = "2026-08-30T15:49:28.267Z" },
+ { url = "https://files.pythonhosted.org/packages/35/54/7368ce188625e39acc03ee362bb86cc9bfa6ad15e25c50889ae36e2889b3/ninja-1.13.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d775a5e43e9088f507a6250d57fcf5678eb31268c545feb5064ffeee33735622", size = 177180, upload-time = "2026-08-30T15:49:29.59Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1a/0b5601ece2a5de97253e7c7c442b70315333955593c2b55616fd17f1706f/ninja-1.13.2-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:81d95081c0ad7c95f67bf682220361ed2a32659d7861b4766b03433c58f22516", size = 199485, upload-time = "2026-08-30T15:49:30.72Z" },
+ { url = "https://files.pythonhosted.org/packages/48/23/fcbe234a66966e35928c47b86336f92a7612db4781665f4e5f5fddef9630/ninja-1.13.2-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:227cbc3ae3e5e429692388103cae8c09451df086cd2d342dae0795af0d162547", size = 197676, upload-time = "2026-08-30T15:49:32.026Z" },
+ { url = "https://files.pythonhosted.org/packages/24/eb/a6ca97ef0ff7bb8bdcb395ec65a716e65d7c1f40896c3afe0090bb3e1535/ninja-1.13.2-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1684c60d031c54c1d049541b64243c0c567dca5463dbd77682a8901780af293d", size = 187980, upload-time = "2026-08-30T15:49:33.372Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/53/ebfed7b689c338dd8ebeec9c0730c8d56821292f14e2536e5f3ef1a05744/ninja-1.13.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65a24341b5ac09fcadcc37082660be40a94174e51a937fabf6e2cae26225fa2c", size = 183365, upload-time = "2026-08-30T15:49:34.53Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/d6/dcf06d7ab44ade992ae5aa1228feff317684b463a1bd47e8642b30ac922e/ninja-1.13.2-py3-none-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa3d2ae5706a2c4d1e93edc951d1c6cbb45107413c404f8fde1741239efbc9a0", size = 155089, upload-time = "2026-08-30T15:49:35.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/6b/6513c09c33382b17c05b4349b8e81437b18680d0d7ec6fb8f7edc29adda1/ninja-1.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:919572cbc3f233261ecd41fe1f3efc9d44aa02464a4588867e06a8b4f6f416ea", size = 152149, upload-time = "2026-08-30T15:49:36.971Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/70/d59fa4261f5ce586f43d8716de1da33d813afc116f0bf9173bf4cdbfdb2a/ninja-1.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e083700470c02ca154a855ae6d692d03564f5064cae52e113896f9ccc078418", size = 525392, upload-time = "2026-08-30T15:49:38.136Z" },
+ { url = "https://files.pythonhosted.org/packages/37/04/c8c2dc5b2f5fee79a1691d490256b178b7e1af97d56769117422ae8a23cc/ninja-1.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:59d71c3e15b6b6f3d903eb0c27285544e0747ca59925ada7037bb1af781ad4b3", size = 466268, upload-time = "2026-08-30T15:49:39.479Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6d/fce288647e53e96e0f0929a3c5b23986aae1b7101ea8889a3090237c5d13/ninja-1.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f90f84affc441e219f15fe52532806c1c9dbd22fb66c3addddce88a3deaabab7", size = 605088, upload-time = "2026-08-30T15:49:40.826Z" },
+ { url = "https://files.pythonhosted.org/packages/10/a2/d8eedd25d0ae80b9e874aea362013e67416877c8f732eb4d5e7c971fdb9c/ninja-1.13.2-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f687437fac460b27b7eadc99039b1163016fb4ba7276e2782a192d9f24ee0e", size = 610806, upload-time = "2026-08-30T15:49:42.168Z" },
+ { url = "https://files.pythonhosted.org/packages/14/0f/696d96821fad1b5767fd311c1569dde8881a57412369bfe7b11bcbfde036/ninja-1.13.2-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:09de9ab04f7352f51570c73fd4913acb1e6c24be0a72cd8b80243d4d3ed04925", size = 533978, upload-time = "2026-08-30T15:49:43.451Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/69/28844ca579156776a202217a7cd66f60d06a0710a935e879bb89ce396ecc/ninja-1.13.2-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:6a87bf42b123abe2f37737300185f0a303a891899da85d73a3613ee80547e578", size = 653822, upload-time = "2026-08-30T15:49:44.724Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5f/c511f2952f94ab2966d60edd9c34e744ea32f2724b1184b62270bde55b3a/ninja-1.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:915bd482c4be41c75120fd67a22e0bb3f0fbb3bbc5f95b89787deadd59e27ef2", size = 544460, upload-time = "2026-08-30T15:49:46.29Z" },
+ { url = "https://files.pythonhosted.org/packages/79/e7/fb0e828e89ac77ef77183a0834f17c4108e66088732fed86a3cc3c776a7a/ninja-1.13.2-py3-none-win32.whl", hash = "sha256:792cadbb9decfd1f776d4d0a6930feb46d08302eb57c176bcf26b09de5748e9f", size = 270319, upload-time = "2026-08-30T15:49:47.942Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/dd/3766b5f4d32e8a9b97d195496b0b01fbbe2e1a41669dab0cd6492a6ce199/ninja-1.13.2-py3-none-win_amd64.whl", hash = "sha256:1293f4078278b70d0ee4b6cc8f3a9e030656c9b2f59909970343c4fe76070118", size = 311798, upload-time = "2026-08-30T15:49:49.351Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8d/59a31fa508070d042571d9d226b541a21000756817313f397da22287ad34/ninja-1.13.2-py3-none-win_arm64.whl", hash = "sha256:1db9852e528efa7702f5123969f86678663e46d57ff28ab13f5fd84d64a85fb1", size = 290620, upload-time = "2026-08-30T15:49:50.639Z" },
+]
+
[[package]]
name = "numpy"
-version = "2.5.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
- { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
- { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
- { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
- { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
- { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
- { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
- { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
- { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
- { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
- { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
- { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
- { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
- { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
- { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
- { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
- { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
- { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
- { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
- { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
- { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
- { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
- { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
- { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
- { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
- { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
- { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
- { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
- { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
- { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
- { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
- { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
- { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
- { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
- { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
- { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
- { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
- { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
- { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
- { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
- { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
- { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
+version = "2.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" },
+ { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" },
+ { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" },
+ { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" },
+ { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" },
+ { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" },
+ { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" },
+ { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" },
+ { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" },
+ { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" },
+ { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" },
+ { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" },
+ { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" },
]
[[package]]
@@ -334,7 +410,7 @@ wheels = [
[[package]]
name = "pint"
-version = "0.26.1"
+version = "0.26.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flexcache" },
@@ -342,27 +418,27 @@ dependencies = [
{ name = "platformdirs" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/2c38c32e0fb1f966d3695f4493a3f3ce2cc0cca1bbe7c958b92261b31af9/pint-0.26.1.tar.gz", hash = "sha256:1bbde36eae57a5a289cd05081c6405618a5899814940752064ce351cd0204f71", size = 273631, upload-time = "2026-09-10T21:16:49.712Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/b0/cef9c8913ebe959d97b2d57b1fc2a77a4291c4f2bd4dc3fe4d3313c938ea/pint-0.26.2.tar.gz", hash = "sha256:391e5d2fc01fdb121c113b8eb47386688575542c1f5144c465ff318bf5133573", size = 295834, upload-time = "2026-10-19T19:49:31.845Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/5c/9507ea3c732f8a259f45ddf190a741e9bf7d3fe229b5d473917ee6efbf1e/pint-0.26.1-py3-none-any.whl", hash = "sha256:e982b129415c09c63308f314ae44697d83e5c96f253bcc0d5b833fc3329eb6d4", size = 326478, upload-time = "2026-09-10T21:16:48.316Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/d5/01ea71591a29e88926582e3ca7ed32f8b933e9ebc99187a7ab100587242d/pint-0.26.2-py3-none-any.whl", hash = "sha256:c94124cdb19baa1a84bb65c8bb9a620a9636b9b9cd80f32f30ad7ff795ea2eb8", size = 351853, upload-time = "2026-10-19T19:49:29.966Z" },
]
[[package]]
name = "platformdirs"
-version = "4.11.13"
+version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/0e/7be2983c52622d6c0899300f760e31ba143355cedf1afcb3e65689d5ca85/platformdirs-4.11.13.tar.gz", hash = "sha256:6985eefdc2298693e4ce1fe124645524cb967428eb6384e0ce5b49767e7ea8ba", size = 40415, upload-time = "2026-09-25T04:44:25.484Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/886dc7c9d21e2d95aceb696f1e31271742033aeef3661f6e9c8921493592/platformdirs-4.14.3.tar.gz", hash = "sha256:021add1ab113483edbcd4694f5fbb2d688b893ea30d35e1155bb371cf4be0be8", size = 71124, upload-time = "2026-11-01T16:27:32.928Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/29/a6/76a492f6a937e196641b3abd1588c504c71438e243daf1bebde4d3bb8ee9/platformdirs-4.11.13-py3-none-any.whl", hash = "sha256:228b2283af5c1f32673cdf55271216a52bbd8258cb31374e302ad7bd26cd7513", size = 25054, upload-time = "2026-09-25T04:44:23.975Z" },
+ { url = "https://files.pythonhosted.org/packages/93/41/f0aa003909ee77a9e7e55f8659abb635ca23ab0d022a449bbb18da87a1ef/platformdirs-4.14.3-py3-none-any.whl", hash = "sha256:cba2d05fe5e18b26b1fa6f376b8e6234c012abe1f8696c486fface7d0054e931", size = 35276, upload-time = "2026-11-01T16:27:31.498Z" },
]
[[package]]
name = "pluggy"
-version = "1.6.0"
+version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
@@ -376,11 +452,11 @@ wheels = [
[[package]]
name = "pyflakes"
-version = "4.0.0"
+version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6e/07/587d938ce8ffea54aa23337c4a827a72c27a3a994b4346899682704af588/pyflakes-4.0.0.tar.gz", hash = "sha256:492b27735181e3d4a6acfc08738948b666bf3e696781854ea6e0d8540d566d52", size = 66927, upload-time = "2026-09-22T22:37:01.881Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/43/f4566b992ab97abd18426aeba516a83c7a833230614a2c3f4d3e9d8ff0f0/pyflakes-4.1.0.tar.gz", hash = "sha256:592beb57058eb82eb190fb7514f2081c555c51000b029ff71286a681e6cf2f83", size = 67243, upload-time = "2026-10-28T20:10:02.469Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3e/f3/2664f53ab17a9249c58317a00aabf76acdd18d4b1b98b3a0a36af7763950/pyflakes-4.0.0-py2.py3-none-any.whl", hash = "sha256:0f7b7a78e8fcffd78b205200f045a70911f4179be1b42eac82c3323e0bf5c8aa", size = 65907, upload-time = "2026-09-22T22:37:00.744Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/52/98c9a0e4718abe887121279457bc7140115df22d714b7183880eb61e359f/pyflakes-4.1.0-py2.py3-none-any.whl", hash = "sha256:b2e05e6bac8e49b67be2a6ffa63422c60f7193fc72e8180255907b41ec997dc1", size = 66258, upload-time = "2026-10-28T20:10:01.109Z" },
]
[[package]]
@@ -394,7 +470,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.1.1"
+version = "9.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -403,9 +479,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
]
[[package]]
@@ -442,99 +518,99 @@ sdist = { url = "https://files.pythonhosted.org/packages/f1/a7/8fac2e213db8108d8
[[package]]
name = "regex"
-version = "2026.9.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b9/5c/f403115361de25809e8f785686ec7096e30fef73be9ae35aa51da4e80abb/regex-2026.9.10.tar.gz", hash = "sha256:1e321e2c84f0e52c457f5ea5944f796d6e8e09cb99738ea98dcc1bfe402a128d", size = 417072, upload-time = "2026-09-09T21:00:21.521Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/ed/98e9b07d8bb9c765d07774f0b2c19b301b96d51f44630fea48951051c94e/regex-2026.9.10-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd6bd89b9fc06018d35851cab0240adb7dd84d51941b19f6574ac90cd54e3ae5", size = 496662, upload-time = "2026-09-09T20:58:05.118Z" },
- { url = "https://files.pythonhosted.org/packages/ee/a8/9dfe9be48378b47c5a8f04b0f200ea225f9ee0f8e93f010433f661a37878/regex-2026.9.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ef4c0a9dfdc90581b90b1b95a8c3d1557f8ff8f5a2a53536d26314de699d1468", size = 297115, upload-time = "2026-09-09T20:58:06.822Z" },
- { url = "https://files.pythonhosted.org/packages/2b/e4/5d1f005a3825ec49842ad349061c1c26e6d42f47ccf105e6e5aa6aeed392/regex-2026.9.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14caa05ce39ec70437af5aac8814c50ee6628f4a90353871c059692f448a164f", size = 291896, upload-time = "2026-09-09T20:58:08.675Z" },
- { url = "https://files.pythonhosted.org/packages/be/15/44ce83fca50c6058f42b62fa8300a8030eea7e4e5a973a2dd33db0f557fb/regex-2026.9.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3264132d576847ab5f88bb83e7debe67854bf165b3ea613bd467312b6099536a", size = 800534, upload-time = "2026-09-09T20:58:10.339Z" },
- { url = "https://files.pythonhosted.org/packages/af/6e/a62e070a5a033643b287489576f02ae6a9c584c337d62349e340a2b4d001/regex-2026.9.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cef9f3d14796500ea834c41dbe688f1f6b23c7024dc23e8a794d7ebaf5d71d0", size = 872038, upload-time = "2026-09-09T20:58:12.186Z" },
- { url = "https://files.pythonhosted.org/packages/f3/06/8b8e2483949b1329df10c5b615e85d332066deb426b11332b799629b9201/regex-2026.9.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d278ad30ec83b6b9202685b0f80b741a51ea3ca7f0595ebda96e7628b6398876", size = 918927, upload-time = "2026-09-09T20:58:13.903Z" },
- { url = "https://files.pythonhosted.org/packages/bb/64/9b56f69100d3afdbc9c4fa6e302764f9cb717fbc06a9d50558d98ca89cd2/regex-2026.9.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb018cfe7144585fc83882405906ff84994a2d154afc2509ecc7752c51f864", size = 803694, upload-time = "2026-09-09T20:58:15.949Z" },
- { url = "https://files.pythonhosted.org/packages/07/43/d00d59a7c8fd0e070ae8457a8743597f45ad9682b100f57b9c9c405fbdfd/regex-2026.9.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fd555fc9abef50c530869690b2daca054c8811a7aff632d11f9a7b2590b2742", size = 777770, upload-time = "2026-09-09T20:58:17.628Z" },
- { url = "https://files.pythonhosted.org/packages/46/6b/a11d0446484efbc9eb67abec133f254c6d66a1568b8f3fb36d39a73a1129/regex-2026.9.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8d5c4518235a2ec1611e57af85fa488d529c1106aacff12adadcedf8687012cd", size = 791234, upload-time = "2026-09-09T20:58:19.476Z" },
- { url = "https://files.pythonhosted.org/packages/12/09/bcd24e78b373fd4f98090caa43eade439223f6703b909be79b9efd9ab0ab/regex-2026.9.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:175cf49ce7a994c88b8f15e3cb17cdb66a48ebb2d36de736b8205033db950f89", size = 866259, upload-time = "2026-09-09T20:58:21.683Z" },
- { url = "https://files.pythonhosted.org/packages/8b/c6/c57ba5e94222a813260af4c17ced92d40cdb44737eb6b50979688310b6a3/regex-2026.9.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b71649169a9fcf30b395ee01047fa7ad6654a4c900ca75b23c04dedcce6a1f8c", size = 768219, upload-time = "2026-09-09T20:58:23.842Z" },
- { url = "https://files.pythonhosted.org/packages/7b/a2/3820037587d00901ace96c5864335c2cd1b899d5263ea0dd2261359e0bee/regex-2026.9.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ba1f78bd4fef2d8f84b894ec28ac3481afe6cc07aaa253ad4717ef7b3fe6bcb", size = 858582, upload-time = "2026-09-09T20:58:25.607Z" },
- { url = "https://files.pythonhosted.org/packages/47/f0/f9a838ca6219ae4821de0175e4548db73ef56de5ec08d032fb427732fa07/regex-2026.9.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:217e98ba5fc8908ed8ffd4ebac04753a0c831067cbfb495b9821b94cc61eaa76", size = 791405, upload-time = "2026-09-09T20:58:27.406Z" },
- { url = "https://files.pythonhosted.org/packages/f7/bf/67d71cc4e13ae2e0022d21243ca069e0868701a99eb29cdecd13d356e694/regex-2026.9.10-cp314-cp314-win32.whl", hash = "sha256:b298cdc33c5cc6969ff07f0fba19cc73e0fd8576373c50935feadaca2f6b4405", size = 272702, upload-time = "2026-09-09T20:58:29.116Z" },
- { url = "https://files.pythonhosted.org/packages/c1/38/40a93e72703a741235115ed1b1e5f6b869917677b7643005034ce1611d70/regex-2026.9.10-cp314-cp314-win_amd64.whl", hash = "sha256:c32818b28bcd153b25b63038348a9fe9b9fbcddb60df43f204c3ab55eeb57f77", size = 281170, upload-time = "2026-09-09T20:58:30.899Z" },
- { url = "https://files.pythonhosted.org/packages/bf/ac/e95387f00617c16bb41b786d900bacd17eab88afa81b4f263df532b3a731/regex-2026.9.10-cp314-cp314-win_arm64.whl", hash = "sha256:75242f44a3e283106077be4ab717bc535e4701c9d54ad69e195945c22f137a1d", size = 281511, upload-time = "2026-09-09T20:58:32.594Z" },
- { url = "https://files.pythonhosted.org/packages/39/e5/a4b12262edc488a8a7a95b672db317dd8aa9bf2fab98297f9c91bb11ad4d/regex-2026.9.10-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2dd9286093c71afc8f55ef035c5b9d2776641fd72c6535f1febc92d0b0be9666", size = 501128, upload-time = "2026-09-09T20:58:34.306Z" },
- { url = "https://files.pythonhosted.org/packages/55/c8/9ca31c0fa5197ded8614c8ae0e105bcff2d979025ffa3c75580baa334e2f/regex-2026.9.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:71879292c9c7ac67b1680345b16daba1be937cb027362cfa04e68f65db2dcfdd", size = 299428, upload-time = "2026-09-09T20:58:36.296Z" },
- { url = "https://files.pythonhosted.org/packages/2b/d1/ee7662561735f90475443c3ca1977e5cecaeaa8f29620dec75580aebc839/regex-2026.9.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ccd139b2061132e7b265cfb4b4721baeb9f8928b81415304abf1ec7e3181c26", size = 294494, upload-time = "2026-09-09T20:58:37.964Z" },
- { url = "https://files.pythonhosted.org/packages/d9/b1/333168e45ed6cfe71f6d17e21e5f54725f44d171f84edd12469a2f739227/regex-2026.9.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7327795089ddb44912dce1434e1d7244be2e9fb48fcc2d6782936af7a3062db", size = 814925, upload-time = "2026-09-09T20:58:40.438Z" },
- { url = "https://files.pythonhosted.org/packages/bc/a5/df1b38536d0a3b24a030eb4130ce98b403cc925220ff530f5313a7c436eb/regex-2026.9.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff4d7b14ea19e50c8d9d6d83f45bd9b45cbb624c07ac1fa54db0a019049abed7", size = 873323, upload-time = "2026-09-09T20:58:42.349Z" },
- { url = "https://files.pythonhosted.org/packages/93/ea/aa71fe62dd63a8336bbdec1ff002a6c53b40ccfca95961c18ee4f09bf03c/regex-2026.9.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f0407474ffac8e5e89d93ca41d60891e29f0ab8423eb66ff292d850a86a0843", size = 923080, upload-time = "2026-09-09T20:58:44.488Z" },
- { url = "https://files.pythonhosted.org/packages/86/38/49f8d6fd34fc1a9c75b5b96364ebdde702a8144e5ed63d2213c58d454c27/regex-2026.9.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ad10a135fa0b4e4a462a61d07c6654d7518cfdb5cb8da08f9ff7d61384af1fe", size = 821284, upload-time = "2026-09-09T20:58:46.56Z" },
- { url = "https://files.pythonhosted.org/packages/32/45/91a977c96d4be13d1ade8208c260c26841c836d4c91745e1828a30589070/regex-2026.9.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9fbd2e5d8002dc49a6129fb321ec51c57a025e752ed525ddce0ba9223c4350a7", size = 789256, upload-time = "2026-09-09T20:58:48.473Z" },
- { url = "https://files.pythonhosted.org/packages/2d/79/4d110bf01bf9651bf9b3f88a6d9fa7e643e0586921a18432b25a478edbfa/regex-2026.9.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4a761ea45f2ad74c575ef5850ea514cef97302a552d3c7c9d1a1a870d4661d6c", size = 803722, upload-time = "2026-09-09T20:58:50.286Z" },
- { url = "https://files.pythonhosted.org/packages/e7/67/b587a0d3bbac2635309ed9c40c197120c39e1ec0afb8813cbed1338fdd75/regex-2026.9.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:75aa39d3f4f1650eea84e46b0d8cefe77dd5478c10e3d0aaf0b0f00493475a7a", size = 870085, upload-time = "2026-09-09T20:58:52.224Z" },
- { url = "https://files.pythonhosted.org/packages/07/fc/0827bca20ddba1d70fa5111a2a64e6b6b38bfdab43fc435022b54172a111/regex-2026.9.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f5c629df03adec31ee505dda3c8988f106c9390e4cbd343600036eb8b3d6724f", size = 776970, upload-time = "2026-09-09T20:58:54.478Z" },
- { url = "https://files.pythonhosted.org/packages/9b/03/ab9d08d30568ca868791bfb99551db60b947f05e2e65dcd1261e87083c21/regex-2026.9.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3a66e40a1a20de96a2fee00ed67e11012b62d85b277688258677fd19997addb7", size = 863611, upload-time = "2026-09-09T20:58:56.432Z" },
- { url = "https://files.pythonhosted.org/packages/5e/6d/8063ae86b543ae878a7d6e7ba21ebf0af4af06161230e0012e2d652320c6/regex-2026.9.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:968c1e33edd9a104d1bf24c8d476c72de7e3839ae7f894b37e9e4f4739fdeeca", size = 804064, upload-time = "2026-09-09T20:58:59.066Z" },
- { url = "https://files.pythonhosted.org/packages/c5/63/b19305c4b8d3d7867699f7d83c43550273d91a2f31793452d87edb1d259f/regex-2026.9.10-cp314-cp314t-win32.whl", hash = "sha256:fbc4e2f3cb7ce8436154e6483079e7d35eeb321a952fa936e180300630d8b873", size = 274607, upload-time = "2026-09-09T20:59:00.942Z" },
- { url = "https://files.pythonhosted.org/packages/32/b8/1695072a512a49060294024e23b945eeb87675b02c06e53a92a1b42b0bfd/regex-2026.9.10-cp314-cp314t-win_amd64.whl", hash = "sha256:c37fa93bf18bf4f90b01c0fa9f11ea567ee4b7dd8bf96e63663e5edc37aa38cf", size = 283944, upload-time = "2026-09-09T20:59:02.876Z" },
- { url = "https://files.pythonhosted.org/packages/a9/9f/65bac17f39991a67e22f8b3c849fdc02a56147c97029b5351494341959b4/regex-2026.9.10-cp314-cp314t-win_arm64.whl", hash = "sha256:ffc2da104e43db716ce30cef9f28049a1faa6aca385dd8771b033268d0730b07", size = 283780, upload-time = "2026-09-09T20:59:05.009Z" },
- { url = "https://files.pythonhosted.org/packages/67/ca/1d1f83bc2f8fff4f186266ac82d73254e686565530cec9ab5228fb5c63dc/regex-2026.9.10-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6afcad14310f1311d077553ed374b42a5e538f85a8c884b4e38e52de091c8077", size = 496869, upload-time = "2026-09-09T20:59:07.051Z" },
- { url = "https://files.pythonhosted.org/packages/33/42/4217510286501a2ebcd372b781b4754ac961e043fa13ef8dce803c44d89c/regex-2026.9.10-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:3fb4ae8cf83ef4e9addd43b2da31a9f45be816a8036fae8af59c8998b72718e2", size = 297121, upload-time = "2026-09-09T20:59:09.007Z" },
- { url = "https://files.pythonhosted.org/packages/55/a7/595468ed0bbccd94be92c6b5d67736ba128b204d942429a8485c2693914d/regex-2026.9.10-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f7d4656e17ab736e9415a6442a345bfc97bb8b7dcce47884bb74a37f70f08d0c", size = 292139, upload-time = "2026-09-09T20:59:10.859Z" },
- { url = "https://files.pythonhosted.org/packages/29/1c/ac92c123e0ab9bea75a904272171b356940bd6139e4a35de44f2254dca8f/regex-2026.9.10-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ba3bab0c45079735f55ac61526774de1d84bc4a0333cc554e1a4ab74913924", size = 802375, upload-time = "2026-09-09T20:59:12.823Z" },
- { url = "https://files.pythonhosted.org/packages/8d/16/d349f6fa9f908162359004e4f067353a0146e8074d24989490778244be44/regex-2026.9.10-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff6b3267318661dfddf6b3628663e00e5946bd0a5c8fa678537a1401f0388f91", size = 872328, upload-time = "2026-09-09T20:59:15.352Z" },
- { url = "https://files.pythonhosted.org/packages/df/81/251b5aef23147057e926346fdb7c8c352d0568f65a492e4e9eb6120f6446/regex-2026.9.10-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030fa9e23624e39b3b94e46b90a5abd1a1678eb2f58fcdd3fd6c27526bf91c7e", size = 919594, upload-time = "2026-09-09T20:59:17.31Z" },
- { url = "https://files.pythonhosted.org/packages/d0/6e/1f25319dc1b9cf4b7ffa303f3d16ca53260fe92aff45e81aa1b3c6c7cba2/regex-2026.9.10-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fbc8314436353e097c050e11b01a6c11433579437ed0579730157676ef59e2f", size = 807394, upload-time = "2026-09-09T20:59:19.328Z" },
- { url = "https://files.pythonhosted.org/packages/cc/fc/3da6b3dffddf12d5e96cbbe6f5e65ebcd64f92e3388a6690a53e0878232d/regex-2026.9.10-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e6c0b5ec6ddee4032247585dc491b0fa58627745b66a705728703a3f0331231", size = 786018, upload-time = "2026-09-09T20:59:21.592Z" },
- { url = "https://files.pythonhosted.org/packages/bb/6f/1cc86dafddc912ef44c5ccd4f729060be8c6735600932664eb6d0e25469b/regex-2026.9.10-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bf29611e5376fec8f795879bb5c6153a76c3a292573d173c26784042b01eb840", size = 793504, upload-time = "2026-09-09T20:59:23.734Z" },
- { url = "https://files.pythonhosted.org/packages/b1/cb/11692e29388d006211163627fcefa0c79917ab9f94d46a1b2254fe5efc81/regex-2026.9.10-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:ec8855f08c17895a26fbf5f19ed829722e19b34a96629e49a43c92974924026b", size = 866766, upload-time = "2026-09-09T20:59:25.798Z" },
- { url = "https://files.pythonhosted.org/packages/5d/78/3631df969830f94d83fcfc5fc71a7b39caad905e18f7b17350a94135d625/regex-2026.9.10-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94c5ce3bc41d226b4eb89ca3f842b2e28c031487fb1f34eb2153d98235831325", size = 775825, upload-time = "2026-09-09T20:59:28.51Z" },
- { url = "https://files.pythonhosted.org/packages/4e/8a/762892a8e3e21d119eacaffde848aa247e6cf4e1d90c46011671e86b1b9e/regex-2026.9.10-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:9ce239acb15843ab03976626af810a4424b0409689ec2bbc52088ab5479ab487", size = 858901, upload-time = "2026-09-09T20:59:30.656Z" },
- { url = "https://files.pythonhosted.org/packages/3f/f0/78048fada5c2f61d795efb26ea24f820a5564c4aa26574920284d45cd656/regex-2026.9.10-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c22df8dd6373bbe3898e77429ffc85594300e39d752fd0e68a31e59d37899376", size = 796208, upload-time = "2026-09-09T20:59:32.852Z" },
- { url = "https://files.pythonhosted.org/packages/aa/58/632681f7b9aaa3d83b40e5862ba46364a453c8eb2bc7d43fece3dc31f972/regex-2026.9.10-cp315-cp315-win32.whl", hash = "sha256:1aa309ab7ba89a62d6cf70dbd38d4176440bce3c7001ab86256704cf4c18c6eb", size = 272704, upload-time = "2026-09-09T20:59:34.927Z" },
- { url = "https://files.pythonhosted.org/packages/5d/64/81cce28754c37037b1fe740b6d7a556d51d97cf935ea4547bfab104f43f7/regex-2026.9.10-cp315-cp315-win_amd64.whl", hash = "sha256:58da726d3e766c0b3f5a3997dfaf0275898a1107b8191cdd6b0437fe45fd817d", size = 281182, upload-time = "2026-09-09T20:59:36.86Z" },
- { url = "https://files.pythonhosted.org/packages/33/28/5a13a340c9c759e863a0e7f765d323601d03d6538c8a627ed628662e083b/regex-2026.9.10-cp315-cp315-win_arm64.whl", hash = "sha256:75f9297b16fcb588a1f8d8a55dabef3c0c20b0c7bac43c87ceaaaf1a825c12f4", size = 281512, upload-time = "2026-09-09T20:59:38.875Z" },
- { url = "https://files.pythonhosted.org/packages/f7/38/a3caebcd5105be90708071db20bd261b0961b8ea4fe5e8be45c2632519b5/regex-2026.9.10-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1270cdec69248592bbe38a0b263ed58d907b891bd2b93703e225c317e421bda1", size = 501336, upload-time = "2026-09-09T20:59:40.987Z" },
- { url = "https://files.pythonhosted.org/packages/ee/b5/ec8887b2658bf0a5df143c7c1fcd562b0abd2fa208c04ebf15c6607c9bb2/regex-2026.9.10-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:681ed38664b64c6617d3c3c332018d1948c77e139c5ea667c1886efa671e426f", size = 299318, upload-time = "2026-09-09T20:59:43.039Z" },
- { url = "https://files.pythonhosted.org/packages/d5/58/84724a9eccf6e8cd46f7e4534576093e2476a5eeb55e2453aa6606e86059/regex-2026.9.10-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8e127d9a80cbf1c3276bb465c6d047e8705e97b58c2b8f2f0c0a69c336b44b37", size = 294847, upload-time = "2026-09-09T20:59:44.954Z" },
- { url = "https://files.pythonhosted.org/packages/50/03/70ccc5e53905984abf8eab63eebd3ce740522ef8c8d392622b64d79ef290/regex-2026.9.10-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:990797e765d89a423880052c68b61c31afe701de94a8c060f61c40605ca6c727", size = 814359, upload-time = "2026-09-09T20:59:47.194Z" },
- { url = "https://files.pythonhosted.org/packages/29/53/40f7a11ec547e4a947883c9d5e8a075f6d4f59af2b8dbcaa8bf5b504aca0/regex-2026.9.10-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e5e4a6e0734a685d13b9685622bb503bdbb2927f8b0df025a5085f0ea067475b", size = 875586, upload-time = "2026-09-09T20:59:49.537Z" },
- { url = "https://files.pythonhosted.org/packages/0b/9d/83f3e022d99ce601727c4ef5f7b527753901ce4509ed89a1bb6a2263380a/regex-2026.9.10-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63bb62cf62217dc38c8a6b2b61b165b0e4eb8fa93b0aba12139251c0986a8fa3", size = 920990, upload-time = "2026-09-09T20:59:51.776Z" },
- { url = "https://files.pythonhosted.org/packages/b2/64/dfdb367d8f4f5c9b8ccb4b59789c2ce996f2c69c3b8192aa986fe7d92ec4/regex-2026.9.10-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb76a9c4e07a6a47849726af0ed14c41741a182f097f134a8cf29c1bc0f4dde8", size = 818660, upload-time = "2026-09-09T20:59:54.428Z" },
- { url = "https://files.pythonhosted.org/packages/88/a6/fb7d0b64487913845834f319aa84f8377d55305958a5db7e19c128798366/regex-2026.9.10-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31e4df2b11d48f61d511019bc1ee9b477055f17c352b68fe72db7a98b14d603c", size = 794976, upload-time = "2026-09-09T20:59:56.799Z" },
- { url = "https://files.pythonhosted.org/packages/3e/1c/23484edaae387ea0d31f4d414463211e3516c8aa210ce8d0640f5aff3502/regex-2026.9.10-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cf377960d2ac37d987394a9dbaa75e91338c41a46d41e1d25e90125e7b3ee2dc", size = 804081, upload-time = "2026-09-09T20:59:59.39Z" },
- { url = "https://files.pythonhosted.org/packages/d5/e1/e30d138f13aecec99ea9aecef7e31563de6ec6a2f5ce1d65fc506aee33fe/regex-2026.9.10-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8fbd9cb30c68c1686b94029b9ef845d5870d3d65baf66cb126b676849b9d72b", size = 870430, upload-time = "2026-09-09T21:00:03.304Z" },
- { url = "https://files.pythonhosted.org/packages/53/dc/81f9ce86f7ae4f57901543597c95751fa01c41a672ec1636dd913f8a000a/regex-2026.9.10-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:53e182b6b04d0011909b47d51a2d72d908de07c7b1c7f16b3adda2204d723bc1", size = 783327, upload-time = "2026-09-09T21:00:05.68Z" },
- { url = "https://files.pythonhosted.org/packages/a9/1b/d7bf8f91534740f6a8ca17e5ac9c5337903baf527c8de240fbac1bbedfd0/regex-2026.9.10-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:0aa7589394230e0f0a422ab6b90841ff12c87e855e7aaf75d192a54a5f124548", size = 860874, upload-time = "2026-09-09T21:00:08.41Z" },
- { url = "https://files.pythonhosted.org/packages/9a/1d/52cc88364aca7c9013dfe9abe0fea67f8d394efe384d07798300a6e2f27d/regex-2026.9.10-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1b891f77554bff991804cee24b78b40789f7d5993a24c7907bc7025fd2a70c8d", size = 806705, upload-time = "2026-09-09T21:00:10.768Z" },
- { url = "https://files.pythonhosted.org/packages/cb/51/37a194df7707f92f33173260ba7b221d4ec376419a53babaec50019a2804/regex-2026.9.10-cp315-cp315t-win32.whl", hash = "sha256:5bef622850cf760154719d4e0d74b0a855962432995168e250069899ae12fe8f", size = 274780, upload-time = "2026-09-09T21:00:14.003Z" },
- { url = "https://files.pythonhosted.org/packages/77/12/3227a52970d90908b230f15b2c86df903f49ac72c9eedf4f6e8b5bb5e1a7/regex-2026.9.10-cp315-cp315t-win_amd64.whl", hash = "sha256:07b45ba5c94b8fcb30cb6c56a11f715c57533a3017964504322ea52690a27b72", size = 283936, upload-time = "2026-09-09T21:00:16.275Z" },
- { url = "https://files.pythonhosted.org/packages/f3/bc/c567c5a61671f04d30e83f20b496b465432879196574d051007285576205/regex-2026.9.10-cp315-cp315t-win_arm64.whl", hash = "sha256:f70b9f0e39c2dba1d9da6bf7ef7c377cad7277f8440e9a69be05ede529ff024c", size = 283747, upload-time = "2026-09-09T21:00:19.113Z" },
+version = "2026.9.29"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" },
+ { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" },
+ { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" },
+ { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" },
+ { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" },
+ { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" },
+ { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" },
+ { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" },
+ { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" },
+ { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" },
+ { url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" },
+ { url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" },
+ { url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" },
+ { url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" },
+ { url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" },
+ { url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" },
+ { url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" },
+ { url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" },
+ { url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" },
+ { url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" },
+ { url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" },
+ { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" },
]
[[package]]
name = "ruff"
-version = "0.16.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
- { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
- { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
- { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
- { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
- { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
- { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
- { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
- { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
- { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
- { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
- { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
- { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
- { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
- { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
- { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
- { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
+version = "0.17.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
+ { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
+ { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
+ { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
+ { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
+ { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
+ { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
]
[[package]]
@@ -557,12 +633,12 @@ wheels = [
[[package]]
name = "werkzeug"
-version = "3.1.8"
+version = "3.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" },
]