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 `![](x.svg)` 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" }, ]