From 8759ee6287cbf4cb01fd7f94d05bae87064ee726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 23 Apr 2026 19:04:48 +0000 Subject: [PATCH 01/46] Tidying up with Claude --- core/builder/context/context.py | 7 +++- core/builder/context/quantities/constant.py | 2 +- core/builder/context/quantities/quantity.py | 44 +++++++++------------ core/builder/convertor.py | 3 +- core/builder/renderer.py | 4 +- modules/naboj/builder/venue.py | 5 ++- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/core/builder/context/context.py b/core/builder/context/context.py index ba50fa79..d8305012 100644 --- a/core/builder/context/context.py +++ b/core/builder/context/context.py @@ -4,15 +4,18 @@ import pprint import yaml -from typing import Any, Self, Optional +from typing import Any, Self, Optional, TypeVar from pathlib import Path -from enschema import Schema, SchemaError +from enschema import Schema, SchemaError, Regex from core.utilities import colour as c log = logging.getLogger('dgs') +ValidIdentifier = Regex(r'[A-Za-z_][A-Za-z_0-9]*]') + + class Context(abc.ABC): _defaults: dict[str, Any] = {} # Defaults for every instance _schema: Optional[Schema] = None # Validation schema for the context, or None if it is not to be validated diff --git a/core/builder/context/quantities/constant.py b/core/builder/context/quantities/constant.py index 5f11abca..94e2b45b 100644 --- a/core/builder/context/quantities/constant.py +++ b/core/builder/context/quantities/constant.py @@ -58,7 +58,7 @@ def full_exact(self): @property def full_approx(self): - return self._format(self.approximate(self.digits)) + return self.approximate(self.digits)._format(f'{self.digits}g') def __str__(self): return self.full diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index be6f1ed7..b935e9d1 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -3,7 +3,7 @@ import math import numbers import re -from typing import Optional, Self +from typing import Optional, Self, Callable, Union, Any import numpy as np import pint @@ -39,35 +39,29 @@ def construct(magnitude, unit, **kwargs): """ return PhysicsQuantity(u.Quantity(magnitude, unit), **kwargs) - def __add__(self, other): + def _binop(self, other, op: Callable[[Self, Union[Self, numbers.Number, u.Quantity]], Any]) -> Self: if isinstance(other, PhysicsQuantity): - return PhysicsQuantity(self._quantity + other._quantity) - elif isinstance(other, numbers.Number): - return PhysicsQuantity(self._quantity + other) + return PhysicsQuantity(op(self._quantity, other._quantity)) + elif isinstance(other, numbers.Number) or isinstance(other, pint.registry.Quantity): + return PhysicsQuantity(op(self._quantity, other)) else: - raise TypeError(f"Cannot __add__ with {type(other)} ({other})") + raise TypeError(f"Cannot perform {op} with {type(other)} ({other})") + + + def __add__(self, other): + return self._binop(other, operator.add) def __radd__(self, other): return self + other def __sub__(self, other): - if isinstance(other, PhysicsQuantity): - return PhysicsQuantity(self._quantity - other._quantity) - elif isinstance(other, numbers.Number): - return PhysicsQuantity(self._quantity - other) - else: - raise TypeError(f"Cannot __sub__ with {type(other)} ({other})") + return self._binop(other, operator.sub) def __rsub__(self, other): return -(self - other) def __mul__(self, other): - if isinstance(other, PhysicsQuantity): - return PhysicsQuantity(self._quantity * other._quantity) - elif isinstance(other, numbers.Number): - return PhysicsQuantity(self._quantity * other) - else: - return NotImplemented + return self._binop(other, operator.mul) def __rmul__(self, other): return self * other @@ -76,12 +70,7 @@ def __pow__(self, exponent): return PhysicsQuantity(self._quantity ** exponent) def __truediv__(self, other): - if isinstance(other, PhysicsQuantity): - return PhysicsQuantity(self._quantity / other._quantity) - elif isinstance(other, numbers.Number) or isinstance(other, pint.registry.Quantity): - return PhysicsQuantity(self._quantity / other) - else: - raise TypeError(f"Cannot __truediv__ type {type(other)} ({other})") + return self._binop(other, operator.truediv) def __rtruediv__(self, other): return PhysicsQuantity(other / self._quantity) @@ -140,7 +129,7 @@ def to(self, what): return PhysicsQuantity(self._quantity.to(what), symbol=self._symbol, si_extra=self.si_extra) def simplify(self): - return PhysicsQuantity(self._quantity.to_base_units()) + return PhysicsQuantity(self._quantity.to_base_units(), symbol=self._symbol, si_extra=self.si_extra) def sin(self): return PhysicsQuantity(np.sin(self._quantity)) @@ -170,13 +159,14 @@ def approximate(self, digits: int): Note that this representation might not be exact due to machine precision, and will have to be passed through `format` again to render correctly. """ + assert digits > 0 and isinstance(digits, int), \ + "Digits must be a positive integer" if self._quantity.magnitude == 0: logarithm = 1 else: logarithm = math.floor(math.log10(abs(self._quantity.magnitude))) precision = digits - logarithm - 1 - #magnitude = math.trunc(self._quantity.magnitude * (10 ** precision) + 0.5) / (10 ** precision) magnitude = round(self._quantity.magnitude, precision) return PhysicsQuantity(u.Quantity(magnitude, self._quantity.units), symbol=self._symbol, si_extra=self.si_extra) @@ -318,6 +308,8 @@ class QuantityList: def __init__(self, *qs: PhysicsQuantity): # First, try to force same units everywhere. If it works, good, if it does not, a pint error will be raised. + assert len(qs) > 0, \ + f"{self.__class__.__name__} must have at least one quantity" self.qs = [q.to(qs[0].unit) for q in qs] self.si_extra = functools.reduce(operator.or_, [q.si_extra for q in self.qs]) diff --git a/core/builder/convertor.py b/core/builder/convertor.py index 7a5bd89e..994e8f6f 100644 --- a/core/builder/convertor.py +++ b/core/builder/convertor.py @@ -31,7 +31,7 @@ class Convertor: RegexReplacement(r'\\bottomrule\\noalign\{}\n\\endlastfoot', r'\\endlastfoot', purpose="Remove bottom rule from endlastfoot (moved to end of table)"), - # Claude's fix for missing bottom rules + # Claude's fix for missing bottom rules (currently does not do anything) RegexReplacement(r'\\end{longtable}', r'\\end{longtable}', purpose="Restore missing bottom rule"), @@ -108,6 +108,7 @@ class Convertor: RegexReplacement(r"^@L\s*(.*)$", r"", purpose="Remove LaTeX-only lines"), RegexReplacement(r"^@H\s*(.*)$", r"\g<1>", purpose="Keep HTML-only tag"), RegexReplacement(r"^@T([Oo][Dd][Oo])?\s*(.*)$", r"TODO: \g<2>", purpose="Replace TODO tag"), + # FixMe: These two are harmful workarounds of downstream problems RegexReplacement(r"\\qty", r"\\SI", purpose="Revert to old siunitx syntax for old failing web"), RegexReplacement(r"\\unit", r"\\si", purpose="Revert to old siunitx syntax for old failing web"), ], diff --git a/core/builder/renderer.py b/core/builder/renderer.py index adc11e4e..73d7be64 100755 --- a/core/builder/renderer.py +++ b/core/builder/renderer.py @@ -99,9 +99,9 @@ class StandaloneContext(FileContext): """ _schema = Schema({ 'id': str, - Opt('values'): dict[str, Or(str, float, int, PhysicsConstant)], # Values + Opt('values'): dict[ValidIdentifier, Or(str, float, int, PhysicsConstant)], # Values # Equations have to be strings, 'eq' and 'const' are reserved - Opt('eq'): dict[And(str, lambda x: x != 'eq' and x != 'const', Regex(r'^[a-z][a-zA-Z0-9_]+$')), str], + Opt('eq'): dict[And(ValidIdentifier, lambda x: x != 'eq' and x != 'const', Regex(r'^[a-z][a-zA-Z0-9_]+$')), str], }) diff --git a/modules/naboj/builder/venue.py b/modules/naboj/builder/venue.py index 2c7b3d93..cd26c375 100644 --- a/modules/naboj/builder/venue.py +++ b/modules/naboj/builder/venue.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional import core.builder.jinja as jinja from modules.naboj.builder.builder import BuilderNaboj @@ -31,8 +32,8 @@ def path(self) -> tuple: def language_path(self) -> tuple: return self.args.competition, f'{self.args.volume:02d}', 'languages', self.context.data['language']['id'] - def build_templates(self): - super().build_templates() + def build_templates(self, *, new_name: Optional[str] = None) -> None: + super().build_templates(new_name=new_name) language_renderer = jinja.StaticRenderer(Path('/home/kvik/dgs/source/naboj') / Path(*self.language_path())) for template in self.language_templates: From e52ce52e2a95eaa8742aee140c75f10f863f2733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 23 Apr 2026 20:17:57 +0000 Subject: [PATCH 02/46] Fixed one more bug --- core/builder/context/context.py | 4 ++-- core/builder/context/quantities/quantity.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/builder/context/context.py b/core/builder/context/context.py index d8305012..1e92724e 100644 --- a/core/builder/context/context.py +++ b/core/builder/context/context.py @@ -4,7 +4,7 @@ import pprint import yaml -from typing import Any, Self, Optional, TypeVar +from typing import Any, Self, Optional from pathlib import Path from enschema import Schema, SchemaError, Regex @@ -13,7 +13,7 @@ log = logging.getLogger('dgs') -ValidIdentifier = Regex(r'[A-Za-z_][A-Za-z_0-9]*]') +ValidIdentifier = Regex(r'^[A-Za-z_][A-Za-z_0-9]*$') class Context(abc.ABC): diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index b935e9d1..4eb8d032 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -310,8 +310,8 @@ def __init__(self, # First, try to force same units everywhere. If it works, good, if it does not, a pint error will be raised. assert len(qs) > 0, \ f"{self.__class__.__name__} must have at least one quantity" - self.qs = [q.to(qs[0].unit) for q in qs] + self.qs = [q.to(qs[0].unit) for q in qs] self.si_extra = functools.reduce(operator.or_, [q.si_extra for q in self.qs]) def __format__(self, fmt: str): From 0d3cf21d963bfc1e21a6f108fae14515a450758d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 23 Apr 2026 21:31:50 +0000 Subject: [PATCH 03/46] Vibecoding frenzy --- core/builder/context/quantities/constant.py | 8 +- core/builder/context/quantities/quantity.py | 62 ++-- core/builder/convertor.py | 2 +- core/builder/jinja.py | 5 +- core/builder/renderer.py | 2 +- core/cli.py | 2 +- core/latex/math.tex | 4 +- core/tests/test_quantities.py | 386 ++++++++++++++++++++ 8 files changed, 439 insertions(+), 32 deletions(-) diff --git a/core/builder/context/quantities/constant.py b/core/builder/context/quantities/constant.py index 94e2b45b..ba94f200 100644 --- a/core/builder/context/quantities/constant.py +++ b/core/builder/context/quantities/constant.py @@ -29,7 +29,7 @@ def format(self, fmt: str = None): elif fmt is None: fmt = f'.{self.digits}g' - return self._format(fmt) + return format(self, fmt) @property def approx(self): @@ -42,7 +42,7 @@ def approx(self): def _full(self, kind: str, precision: int = None) -> str: if precision is None: precision = self.digits - return self._format(f'.{precision}{kind}') + return f'{self:.{precision}{kind}}' def fullf(self, precision: int = None) -> str: """ Full, with f formatting """ @@ -54,11 +54,11 @@ def fullg(self, precision: int = None) -> str: @property def full_exact(self): - return self._format('99g') + return f'{self:99g}' @property def full_approx(self): - return self.approximate(self.digits)._format(f'{self.digits}g') + return f'{self.approximate(self.digits):{self.digits}g}' def __str__(self): return self.full diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 4eb8d032..62d4426a 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -82,10 +82,23 @@ def __neg__(self): return PhysicsQuantity(-self._quantity) def __str__(self): - return self._format('g') + return format(self, 'g') def __format__(self, fmt): - return self._format(fmt) + """ + Format the quantity as a siunitx command (\\num or \\qty). + The format spec is forwarded to the underlying magnitude formatting: + empty spec prints the magnitude with Python's default for its type + (plain decimal for ints, repr-like for floats), which is usually + what callers want for verbatim output. Pass 'g', '.3f' etc. for + specific formatting. + """ + fragments = self.format_struct(fmt=fmt) + cmd = fragments['cmd'] + si_extra = self.format_si_extra(self.si_extra) + magnitude = f"{{{fragments['magnitude']}}}" + unit = f"{{{fragments['unit']}}}" if fragments['unit'] else '' + return rf'\{cmd}{si_extra}{magnitude}{unit}' def __repr__(self): return f"{self.__class__.__name__} ({self._quantity})" @@ -152,6 +165,12 @@ def log(self): def degrees(self): return PhysicsQuantity(np.degrees(self._quantity)) + def ceil(self): + return PhysicsQuantity(np.ceil(self._quantity)) + + def floor(self): + return PhysicsQuantity(np.floor(self._quantity)) + def approximate(self, digits: int): """ Return an approximate value of the constant (not just formatted output, but truly rounded). @@ -197,15 +216,6 @@ def format_si_extra(si_extra) -> str: siextraf = f'[{siextraf}]' if len(siextraf) >= 1 else siextraf return siextraf - def _format(self, fmt: str = 'g'): - """Return a formatted string representation, by default a `g` one.""" - fragments = self.format_struct(fmt=fmt) - cmd = fragments['cmd'] - si_extra = self.format_si_extra(self.si_extra) - magnitude = f"{{{fragments['magnitude']}}}" - unit = '' if fragments['unit'] is None else f"{{{fragments['unit']}}}" - return rf'\{cmd}{si_extra}{magnitude}{unit}' - @property def full(self): r""" @@ -219,7 +229,7 @@ def full(self): ``` as \qty{1.23e-6}{\kilo\gram}. """ - return self._format() + return f'{self:g}' @property def equals(self) -> str: @@ -241,14 +251,14 @@ def equals_float(self, precision: Optional[int]) -> str: Full form with symbol and equal sign, ` = ` """ - return rf"{self._symbol} = {self._format(f'.{precision}f')}" + return rf"{self._symbol} = {self:.{precision}f}" def equals_general(self, precision: Optional[int]) -> str: """ Full form with symbol and equal sign, ` = ` """ - return rf"{self._symbol} = {self._format(f'.{precision}g')}" + return rf"{self._symbol} = {self:.{precision}g}" def construct_quantity(magnitude, unit, *, symbol: Optional[str] = None): @@ -281,9 +291,14 @@ def __format__(self, fmt: str): si_extraf = PhysicsQuantity.format_si_extra(self.si_extra) minf = f"{{{minr['magnitude']}}}" maxf = f"{{{maxr['magnitude']}}}" - unitf = f"{{{minr['unit']}}}" - cmd = 'qtyrange' + # Use \numrange for dimensionless quantities, \qtyrange otherwise. + if minr['unit']: + cmd = 'qtyrange' + unitf = f"{{{minr['unit']}}}" + else: + cmd = 'numrange' + unitf = '' return rf'\{cmd}{si_extraf}{minf}{maxf}{unitf}' def widen(self, value: float) -> Self: @@ -297,7 +312,7 @@ def widen(self, value: float) -> Self: return QuantityRange(self.minimum * (1 - value), self.maximum * (1 + value)) def __str__(self): - return self.__format__('g') + return format(self, 'g') class QuantityList: @@ -310,20 +325,25 @@ def __init__(self, # First, try to force same units everywhere. If it works, good, if it does not, a pint error will be raised. assert len(qs) > 0, \ f"{self.__class__.__name__} must have at least one quantity" - self.qs = [q.to(qs[0].unit) for q in qs] + self.si_extra = functools.reduce(operator.or_, [q.si_extra for q in self.qs]) def __format__(self, fmt: str): - cmd = 'qtylist' fqs = [q.format_struct(fmt) for q in self.qs] self.magnitudes = ';'.join([fq['magnitude'] for fq in fqs]) - unitf = f"{{{fqs[0]['unit']}}}" si_extraf = PhysicsQuantity.format_si_extra(self.si_extra) magf = f'{{{self.magnitudes}}}' + # Use \numlist for dimensionless quantities, \qtylist otherwise. + if fqs[0]['unit']: + cmd = 'qtylist' + unitf = f"{{{fqs[0]['unit']}}}" + else: + cmd = 'numlist' + unitf = '' return rf'\{cmd}{si_extraf}{magf}{unitf}' def __str__(self): - return self.__format__('g') \ No newline at end of file + return format(self, 'g') diff --git a/core/builder/convertor.py b/core/builder/convertor.py index 994e8f6f..60d3ca8f 100644 --- a/core/builder/convertor.py +++ b/core/builder/convertor.py @@ -229,7 +229,7 @@ def call_pandoc(self): "--to", self.output_format, "--filter", "pandoc-crossref", "-M", f"crossrefYaml=build/core/i18n/{self.locale_code}.yaml", - "--filter", "pandoc-include", + #"--filter", "pandoc-include", "-M", f"include-entry={Path(self.infile.name).parent}/", "-M", f"rewrite-path=false", "--filter", "pandoc-minted", diff --git a/core/builder/jinja.py b/core/builder/jinja.py index 6417116b..006b1a3c 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -185,6 +185,7 @@ def __init__(self, **kwargs): 'eg': latex.equals_general, 'w': QuantityRange.widen, 'widen': QuantityRange.widen, + 'mag': PhysicsQuantity.mag, } | self.__generate_format_functions(numbers.format_float, 'f') | self.__generate_format_functions(numbers.format_general, 'g') | @@ -207,8 +208,8 @@ def __init__(self, **kwargs): 'acos': np.acos, 'atan': np.atan, 'atan2': np.atan2, - 'ceil': np.ceil, - 'floor': np.floor, + 'ceil': PhysicsQuantity.ceil, + 'floor': PhysicsQuantity.floor, 'sqrt': lambda x: (x ** 0.5), 'cbrt': np.cbrt, 'rad': np.radians, diff --git a/core/builder/renderer.py b/core/builder/renderer.py index 73d7be64..5869d4f9 100755 --- a/core/builder/renderer.py +++ b/core/builder/renderer.py @@ -15,7 +15,7 @@ from enschema import Schema, Optional as Opt, Or, And, Regex from core import cli -from core.builder.context.context import Context +from core.builder.context.context import Context, ValidIdentifier from core.builder.context.file import FileContext from core.builder.context.quantities.math import MathObject from core.builder.jinja import MarkdownJinjaRenderer diff --git a/core/cli.py b/core/cli.py index 64ab56da..96a977d8 100644 --- a/core/cli.py +++ b/core/cli.py @@ -52,7 +52,7 @@ def run(self) -> None: def fail(self, e): log.error(f"{c.err('convert: failure on ')}{c.path(self.args.infile.name)}: {e}") - sys.exit(1) + raise e def success(self): if self.args.verbose: diff --git a/core/latex/math.tex b/core/latex/math.tex index c4cd7c02..172cb533 100644 --- a/core/latex/math.tex +++ b/core/latex/math.tex @@ -17,8 +17,8 @@ \NewDocumentCommand{\Paren}{m}{\dgs_paren@*{#1}} \NewDocumentCommand{\Abs}{m}{\dgs_abs@*{#1}} % Shorthands for floor and ceiling functions -\NewDocumentCommand{\Floor}{m}{\dgs_floor@{#1}} -\NewDocumentCommand{\Ceil}{m}{\dgs_ceil@{#1}} +\NewDocumentCommand{\Floor}{m}{\dgs_floor@*{#1}} +\NewDocumentCommand{\Ceil}{m}{\dgs_ceil@*{#1}} % List of differentials \NewDocumentCommand{\cdiff}{O{,} m m}{\dgs_split_diff:nnn {#1} {#2} {#3}} diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index 5194f0b7..3cd01486 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -99,3 +99,389 @@ def test_lengths(self, length1, length2): def test_incommensurate(self, length1, mass2): with pytest.raises(pint.errors.DimensionalityError): _ = QuantityList(length1, mass2) + + +# --- Metadata propagation ------------------------------------------------ +# +# Physical reasoning: a "symbol" names a specific quantity and does not +# compose under arithmetic (c - 1000 km/s is not c). Same for si_extra: +# formatting directives attached to one quantity should not silently propagate +# to a derived one. Operations that yield the *same* quantity in a different +# form (unit conversion, simplification, rounding) do preserve metadata. + + +class TestMetadataPreserved: + """Operations that yield the same quantity must keep symbol and si_extra.""" + + @pytest.fixture + def labelled(self): + return PhysicsQuantity.construct( + 5000, 'gram', symbol='m', si_extra={'round-mode': 'figures'}, + ) + + def test_to_preserves(self, labelled): + converted = labelled.to('kg') + assert converted.symbol == 'm' + assert converted.si_extra == {'round-mode': 'figures'} + + def test_simplify_preserves(self, labelled): + simplified = labelled.simplify() + assert simplified.symbol == 'm' + assert simplified.si_extra == {'round-mode': 'figures'} + + def test_approximate_preserves(self, labelled): + approx = labelled.approximate(2) + assert approx.symbol == 'm' + assert approx.si_extra == {'round-mode': 'figures'} + + +class TestMetadataDropped: + """Operations that yield a *new* quantity must drop symbol and si_extra.""" + + @pytest.fixture + def labelled(self): + return PhysicsQuantity.construct( + 5, 'kg', symbol='m', si_extra={'round-mode': 'figures'}, + ) + + @pytest.fixture + def other(self): + return PhysicsQuantity.construct(3, 'kg', symbol='n') + + def test_add_drops(self, labelled, other): + result = labelled + other + assert result.symbol is None + assert result.si_extra == {} + + def test_sub_drops(self, labelled, other): + result = labelled - other + assert result.symbol is None + assert result.si_extra == {} + + def test_mul_drops(self, labelled, other): + result = labelled * other + assert result.symbol is None + assert result.si_extra == {} + + def test_truediv_drops(self, labelled, other): + result = labelled / other + assert result.symbol is None + assert result.si_extra == {} + + def test_neg_drops(self, labelled): + result = -labelled + assert result.symbol is None + assert result.si_extra == {} + + def test_pow_drops(self, labelled): + result = labelled ** 2 + assert result.symbol is None + assert result.si_extra == {} + + def test_scalar_mul_drops(self, labelled): + """Multiplying by a plain number is still a new quantity.""" + result = labelled * 2 + assert result.symbol is None + assert result.si_extra == {} + + def test_sin_drops(self): + import numpy as np + angle = PhysicsQuantity.construct( + np.pi / 2, 'radian', symbol=r'\theta', + si_extra={'round-mode': 'figures'}, + ) + result = angle.sin() + assert result.symbol is None + assert result.si_extra == {} + + def test_log_drops(self): + q = PhysicsQuantity.construct( + 2.718, '', symbol='x', si_extra={'round-mode': 'figures'}, + ) + result = q.log() + assert result.symbol is None + assert result.si_extra == {} + + def test_degrees_drops(self): + import numpy as np + angle = PhysicsQuantity.construct( + np.pi, 'radian', symbol='a', si_extra={'round-mode': 'figures'}, + ) + result = angle.degrees() + assert result.symbol is None + assert result.si_extra == {} + + +# --- Pint interop -------------------------------------------------------- + + +class TestPintInterop: + """Binary ops with bare pint.Quantity operands must stay in the PhysicsQuantity world.""" + + def test_mul_with_pint_quantity(self): + from pint import UnitRegistry as u + m = PhysicsQuantity.construct(5, 'kg') + v = u.Quantity(2, 'meter / second') + result = m * v + assert isinstance(result, PhysicsQuantity), ( + f"expected PhysicsQuantity, got {type(result).__name__}" + ) + + def test_truediv_with_pint_quantity(self): + from pint import UnitRegistry as u + m = PhysicsQuantity.construct(5, 'kg') + v = u.Quantity(2, 'meter / second') + result = m / v + assert isinstance(result, PhysicsQuantity) + + def test_add_with_plain_number_errors(self): + """5 kg + 3 is a dimensionality error, not a silent pass-through.""" + m = PhysicsQuantity.construct(5, 'kg') + with pytest.raises(pint.errors.DimensionalityError): + _ = m + 3 + + +# --- Approximate rounding ------------------------------------------------ + + +class TestApproximate: + """Sanity checks on `approximate()` rounding.""" + + def test_positive_digits_required(self): + m = PhysicsQuantity.construct(123.456, 'kg') + with pytest.raises(AssertionError): + m.approximate(0) + + def test_negative_digits_rejected(self): + m = PhysicsQuantity.construct(123.456, 'kg') + with pytest.raises(AssertionError): + m.approximate(-1) + + def test_non_integer_digits_rejected(self): + m = PhysicsQuantity.construct(123.456, 'kg') + with pytest.raises(AssertionError): + m.approximate(2.5) + + def test_symmetric_around_zero(self): + """Positive and negative magnitudes must round symmetrically.""" + pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag + neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag + assert pos == -neg, f"asymmetric rounding: {pos} vs {neg}" + + def test_zero_magnitude(self): + m = PhysicsQuantity.construct(0.0, 'kg') + assert m.approximate(3).mag == 0.0 + + def test_significant_figures_one(self): + m = PhysicsQuantity.construct(123.456, 'kg') + assert m.approximate(1).mag == 100.0 + + def test_significant_figures_three(self): + m = PhysicsQuantity.construct(123.456, 'kg') + assert m.approximate(3).mag == 123.0 + + +# --- Range guardrails ---------------------------------------------------- + + +class TestRangeGuardrails: + """Ranges with invalid bounds should be rejected at construction.""" + + @pytest.fixture + def m1(self): + return PhysicsQuantity.construct(1, 'kg') + + @pytest.fixture + def m3(self): + return PhysicsQuantity.construct(3, 'kg') + + @pytest.mark.xfail(reason="swapped endpoints currently accepted silently") + def test_swapped_endpoints_rejected(self, m1, m3): + with pytest.raises(ValueError): + QuantityRange(m3, m1) + + def test_widen_sane(self, m1, m3): + r = QuantityRange(m1, m3).widen(0.1) + assert r.minimum.mag == pytest.approx(0.9) + assert r.maximum.mag == pytest.approx(3.3) + + @pytest.mark.xfail(reason="widen(value>=1) produces negative minimum") + def test_widen_out_of_range_rejected(self, m1, m3): + with pytest.raises((ValueError, AssertionError)): + QuantityRange(m1, m3).widen(1.5) + + def test_widen_identity(self, m1, m3): + """widen(0) returns an equivalent range.""" + r = QuantityRange(m1, m3).widen(0) + assert r.minimum.mag == 1 + assert r.maximum.mag == 3 + + +# --- si_extra clash detection -------------------------------------------- + + +class TestSiExtraClash: + """ + When two quantities with conflicting si_extra keys are combined into a + QuantityRange or QuantityList, the conflict should be surfaced rather + than silently resolved. + """ + + @pytest.fixture + def m_figures(self): + return PhysicsQuantity.construct(1, 'kg', si_extra={'round-mode': 'figures'}) + + @pytest.fixture + def m_places(self): + return PhysicsQuantity.construct(2, 'kg', si_extra={'round-mode': 'places'}) + + @pytest.fixture + def m_plain(self): + return PhysicsQuantity.construct(3, 'kg') + + def test_range_compatible_keys_merge(self, m_figures, m_plain): + """Non-conflicting si_extra should merge cleanly.""" + r = QuantityRange(m_figures, m_plain) + assert r.si_extra == {'round-mode': 'figures'} + + def test_list_compatible_keys_merge(self, m_figures, m_plain): + ql = QuantityList(m_figures, m_plain) + assert ql.si_extra == {'round-mode': 'figures'} + + @pytest.mark.xfail(reason="si_extra conflicts in QuantityRange are currently silent (last-wins)") + def test_range_conflicting_keys_raises(self, m_figures, m_places): + with pytest.raises(ValueError, match="round-mode"): + QuantityRange(m_figures, m_places) + + @pytest.mark.xfail(reason="si_extra conflicts in QuantityList are currently silent (last-wins)") + def test_list_conflicting_keys_raises(self, m_figures, m_places): + with pytest.raises(ValueError, match="round-mode"): + QuantityList(m_figures, m_places) + + +# --- QuantityList edge cases --------------------------------------------- + + +class TestQuantityListEdgeCases: + def test_empty_rejected(self): + with pytest.raises(AssertionError): + QuantityList() + + def test_single_element(self): + m = PhysicsQuantity.construct(5, 'kg') + assert rf'{QuantityList(m)}' == r'\qtylist{5}{\kilo\gram}' + + def test_unit_of_first_wins(self): + """ + QuantityList coerces all elements to the first element's unit. + Note: pint's conversion may change int→float, so '1' becomes '1.0' + when it is the result of a conversion rather than a literal. + """ + g = PhysicsQuantity.construct(1000, 'gram') + kg = PhysicsQuantity.construct(1, 'kilogram') + assert rf'{QuantityList(g, kg)}' == r'\qtylist{1000;1000.0}{\gram}' + assert rf'{QuantityList(kg, g)}' == r'\qtylist{1;1.0}{\kilo\gram}' + + +# --- Formatting ---------------------------------------------------------- + + +class TestFormatting: + def test_dimensionless_uses_num(self): + d = PhysicsQuantity.construct(3.14, '') + assert str(d).startswith(r'\num{') + + def test_dimensionless_has_no_trailing_braces(self): + """\num{3.14} is the correct form; \num{3.14}{} has a stray empty group.""" + d = PhysicsQuantity.construct(3.14, '') + assert str(d) == r'\num{3.14}' + + def test_dimensionless_range_uses_numrange(self): + a = PhysicsQuantity.construct(0.1, '') + b = PhysicsQuantity.construct(0.5, '') + assert rf'{QuantityRange(a, b)}' == r'\numrange{0.1}{0.5}' + + def test_dimensionless_list_uses_numlist(self): + a = PhysicsQuantity.construct(0.1, '') + b = PhysicsQuantity.construct(0.5, '') + assert rf'{QuantityList(a, b)}' == r'\numlist{0.1;0.5}' + + def test_with_unit_uses_qty(self): + m = PhysicsQuantity.construct(5, 'kg') + assert str(m).startswith(r'\qty{') + + def test_si_extra_appears_in_output(self): + m = PhysicsQuantity.construct( + 5, 'kg', si_extra={'round-mode': 'figures'}, + ) + assert 'round-mode=figures' in str(m) + + +# --- Equality semantics -------------------------------------------------- + + +class TestEquality: + """Equality compares physical values only; metadata is ignored by design.""" + + def test_same_value_different_symbol(self): + a = PhysicsQuantity.construct(5, 'kg', symbol='X') + b = PhysicsQuantity.construct(5, 'kg', symbol='Y') + assert a == b + + def test_same_value_different_si_extra(self): + a = PhysicsQuantity.construct(5, 'kg', si_extra={'round-mode': 'figures'}) + b = PhysicsQuantity.construct(5, 'kg') + assert a == b + + def test_same_value_different_units(self): + """pint's equality handles unit conversion under the hood.""" + a = PhysicsQuantity.construct(1, 'kilogram') + b = PhysicsQuantity.construct(1000, 'gram') + assert a == b + + def test_different_values_not_equal(self): + a = PhysicsQuantity.construct(5, 'kg') + b = PhysicsQuantity.construct(7, 'kg') + assert a != b + + def test_not_equal_to_non_quantity(self): + """Comparison with non-PhysicsQuantity returns False, does not crash.""" + m = PhysicsQuantity.construct(5, 'kg') + assert (m == 5) is False + assert (m == "5 kg") is False + assert (m == None) is False + + def test_unhashable(self): + """Defining __eq__ without __hash__ makes instances unhashable.""" + m = PhysicsQuantity.construct(5, 'kg') + with pytest.raises(TypeError): + hash(m) + + +# --- PhysicsConstant ----------------------------------------------------- + + +class TestPhysicsConstant: + """Smoke tests for the PhysicsConstant subclass.""" + + @pytest.fixture + def g(self): + from core.builder.context.quantities.constant import PhysicsConstant + from pint import UnitRegistry as u + return PhysicsConstant( + 'gforce', u.Quantity(9.80665, 'meter / second^2'), digits=2, + ) + + def test_full(self, g): + assert r'\qty{' in g.full + assert r'\meter' in g.full + + def test_approx(self, g): + """g with 2 digits rounds to 9.8 m/s^2.""" + assert g.approx.mag == pytest.approx(9.8) + + def test_full_approx(self, g): + """Previously crashed with `ValueError: Invalid format specifier`.""" + rendered = g.full_approx + assert r'\qty{' in rendered + assert '9.8' in rendered From 3ba896fbfda65c925840219d6f20600f8757f86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 23 Apr 2026 22:15:28 +0000 Subject: [PATCH 04/46] Removed property from mag --- core/builder/context/quantities/quantity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 62d4426a..271f8aa1 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -118,7 +118,6 @@ def quantity(self): def quantity(self, value): raise TypeError(f"{self.__class__.__name__} ({value}) is immutable") - @property def mag(self): """ Return the internal magnitude. """ return self._quantity.magnitude From 1cc59f869ebca0582543e93dd22310b1c934a9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 23 Apr 2026 22:16:32 +0000 Subject: [PATCH 05/46] Tests now passing too --- core/tests/test_quantities.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index 3cd01486..fc15d162 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -264,21 +264,21 @@ def test_non_integer_digits_rejected(self): def test_symmetric_around_zero(self): """Positive and negative magnitudes must round symmetrically.""" - pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag - neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag + pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag() + neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag() assert pos == -neg, f"asymmetric rounding: {pos} vs {neg}" def test_zero_magnitude(self): m = PhysicsQuantity.construct(0.0, 'kg') - assert m.approximate(3).mag == 0.0 + assert m.approximate(3).mag()== 0.0 def test_significant_figures_one(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(1).mag == 100.0 + assert m.approximate(1).mag()== 100.0 def test_significant_figures_three(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(3).mag == 123.0 + assert m.approximate(3).mag()== 123.0 # --- Range guardrails ---------------------------------------------------- @@ -302,8 +302,8 @@ def test_swapped_endpoints_rejected(self, m1, m3): def test_widen_sane(self, m1, m3): r = QuantityRange(m1, m3).widen(0.1) - assert r.minimum.mag == pytest.approx(0.9) - assert r.maximum.mag == pytest.approx(3.3) + assert r.minimum.mag()== pytest.approx(0.9) + assert r.maximum.mag()== pytest.approx(3.3) @pytest.mark.xfail(reason="widen(value>=1) produces negative minimum") def test_widen_out_of_range_rejected(self, m1, m3): @@ -313,8 +313,8 @@ def test_widen_out_of_range_rejected(self, m1, m3): def test_widen_identity(self, m1, m3): """widen(0) returns an equivalent range.""" r = QuantityRange(m1, m3).widen(0) - assert r.minimum.mag == 1 - assert r.maximum.mag == 3 + assert r.minimum.mag()== 1 + assert r.maximum.mag()== 3 # --- si_extra clash detection -------------------------------------------- @@ -478,7 +478,7 @@ def test_full(self, g): def test_approx(self, g): """g with 2 digits rounds to 9.8 m/s^2.""" - assert g.approx.mag == pytest.approx(9.8) + assert g.approx.mag()== pytest.approx(9.8) def test_full_approx(self, g): """Previously crashed with `ValueError: Invalid format specifier`.""" From 11f3b49dc9fa3f49e21238548ce93485fa4118a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 27 Apr 2026 21:36:43 +0000 Subject: [PATCH 06/46] Quantity operations added --- core/builder/context/quantities/quantity.py | 11 ++- core/tests/test_dicts.py | 100 ++++++++++++++++++++ core/tests/test_quantities.py | 22 ++--- core/utilities/dicts.py | 32 +++++++ 4 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 core/tests/test_dicts.py create mode 100644 core/utilities/dicts.py diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 271f8aa1..7251ad9f 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -1,7 +1,6 @@ -import functools -import operator import math import numbers +import operator import re from typing import Optional, Self, Callable, Union, Any @@ -10,6 +9,7 @@ from pint import UnitRegistry as u from core.filters.hacks import cut_extra_one +from core.utilities.dicts import strict_merge class PhysicsQuantity: @@ -118,6 +118,7 @@ def quantity(self): def quantity(self, value): raise TypeError(f"{self.__class__.__name__} ({value}) is immutable") + @property def mag(self): """ Return the internal magnitude. """ return self._quantity.magnitude @@ -276,7 +277,7 @@ def __init__(self, maximum: PhysicsQuantity): self.minimum = minimum self.maximum = maximum - self.si_extra = self.minimum.si_extra | self.maximum.si_extra + self.si_extra = strict_merge(self.minimum.si_extra, self.maximum.si_extra) # Try to coerce to the same unit (minimum takes precedence). # If it works, fine, if not, let pint raise the appropriate exception. @@ -326,7 +327,7 @@ def __init__(self, f"{self.__class__.__name__} must have at least one quantity" self.qs = [q.to(qs[0].unit) for q in qs] - self.si_extra = functools.reduce(operator.or_, [q.si_extra for q in self.qs]) + self.si_extra = strict_merge(*(q.si_extra for q in self.qs)) def __format__(self, fmt: str): fqs = [q.format_struct(fmt) for q in self.qs] @@ -345,4 +346,4 @@ def __format__(self, fmt: str): return rf'\{cmd}{si_extraf}{magf}{unitf}' def __str__(self): - return format(self, 'g') + return format(self, 'g') \ No newline at end of file diff --git a/core/tests/test_dicts.py b/core/tests/test_dicts.py new file mode 100644 index 00000000..d2a2fae6 --- /dev/null +++ b/core/tests/test_dicts.py @@ -0,0 +1,100 @@ +import pytest + +from core.utilities.dicts import strict_merge + + +class TestStrictMergeBasic: + def test_empty(self): + assert strict_merge() == {} + + def test_single_dict(self): + assert strict_merge({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} + + def test_disjoint_keys(self): + assert strict_merge({'a': 1}, {'b': 2}) == {'a': 1, 'b': 2} + + def test_three_disjoint(self): + assert strict_merge({'a': 1}, {'b': 2}, {'c': 3}) == {'a': 1, 'b': 2, 'c': 3} + + def test_does_not_mutate_inputs(self): + a = {'a': 1} + b = {'b': 2} + _ = strict_merge(a, b) + assert a == {'a': 1} + assert b == {'b': 2} + + +class TestStrictMergeAgreement: + """Same key with the same value across dicts should be silently coalesced.""" + + def test_identical_values(self): + assert strict_merge({'a': 1}, {'a': 1}) == {'a': 1} + + def test_identical_values_three_dicts(self): + assert strict_merge({'a': 1}, {'a': 1}, {'a': 1}) == {'a': 1} + + def test_identical_string_values(self): + assert strict_merge( + {'mode': 'figures'}, {'mode': 'figures'} + ) == {'mode': 'figures'} + + def test_identical_nested_values(self): + """Nested structures compare by value (dict ==).""" + assert strict_merge( + {'a': {'x': 1, 'y': 2}}, {'a': {'x': 1, 'y': 2}} + ) == {'a': {'x': 1, 'y': 2}} + + def test_identical_list_values(self): + assert strict_merge({'k': [1, 2, 3]}, {'k': [1, 2, 3]}) == {'k': [1, 2, 3]} + + +class TestStrictMergeConflict: + """Same key with different values should raise.""" + + def test_simple_conflict(self): + with pytest.raises(ValueError, match="key 'a'"): + strict_merge({'a': 1}, {'a': 2}) + + def test_conflict_message_carries_both_values(self): + with pytest.raises(ValueError, match="1.*2"): + strict_merge({'a': 1}, {'a': 2}) + + def test_string_conflict(self): + with pytest.raises(ValueError, match="round-mode"): + strict_merge({'round-mode': 'figures'}, {'round-mode': 'places'}) + + def test_nested_conflict(self): + with pytest.raises(ValueError, match="key 'a'"): + strict_merge({'a': {'x': 1}}, {'a': {'x': 2}}) + + def test_partial_overlap_with_conflict(self): + """Disjoint keys are no problem; only the conflict raises.""" + with pytest.raises(ValueError, match="key 'b'"): + strict_merge({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) + + def test_third_dict_introduces_conflict(self): + with pytest.raises(ValueError, match="key 'a'"): + strict_merge({'a': 1}, {'a': 1}, {'a': 2}) + + +class TestStrictMergeEdgeCases: + def test_none_value_does_not_conflict_with_none(self): + assert strict_merge({'a': None}, {'a': None}) == {'a': None} + + def test_none_conflicts_with_other(self): + with pytest.raises(ValueError): + strict_merge({'a': None}, {'a': 0}) + + def test_zero_and_false_treated_as_distinct(self): + """0 == False in Python but the values should still be considered equal here.""" + # 0 == False is True; strict_merge uses ==, so they are considered the same. + # This is the documented behavior. + assert strict_merge({'a': 0}, {'a': False}) == {'a': 0} + + def test_first_occurrence_wins_in_result(self): + """When values are equal, the first one written is the one stored.""" + a = {'k': (1, 2)} + b = {'k': (1, 2)} # equal but possibly different object identity + result = strict_merge(a, b) + # Either is acceptable; we just want to confirm no error and right value. + assert result['k'] == (1, 2) \ No newline at end of file diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index fc15d162..e27cb41b 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -264,21 +264,21 @@ def test_non_integer_digits_rejected(self): def test_symmetric_around_zero(self): """Positive and negative magnitudes must round symmetrically.""" - pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag() - neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag() + pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag + neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag assert pos == -neg, f"asymmetric rounding: {pos} vs {neg}" def test_zero_magnitude(self): m = PhysicsQuantity.construct(0.0, 'kg') - assert m.approximate(3).mag()== 0.0 + assert m.approximate(3).mag == 0.0 def test_significant_figures_one(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(1).mag()== 100.0 + assert m.approximate(1).mag == 100.0 def test_significant_figures_three(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(3).mag()== 123.0 + assert m.approximate(3).mag == 123.0 # --- Range guardrails ---------------------------------------------------- @@ -302,8 +302,8 @@ def test_swapped_endpoints_rejected(self, m1, m3): def test_widen_sane(self, m1, m3): r = QuantityRange(m1, m3).widen(0.1) - assert r.minimum.mag()== pytest.approx(0.9) - assert r.maximum.mag()== pytest.approx(3.3) + assert r.minimum.mag == pytest.approx(0.9) + assert r.maximum.mag == pytest.approx(3.3) @pytest.mark.xfail(reason="widen(value>=1) produces negative minimum") def test_widen_out_of_range_rejected(self, m1, m3): @@ -313,8 +313,8 @@ def test_widen_out_of_range_rejected(self, m1, m3): def test_widen_identity(self, m1, m3): """widen(0) returns an equivalent range.""" r = QuantityRange(m1, m3).widen(0) - assert r.minimum.mag()== 1 - assert r.maximum.mag()== 3 + assert r.minimum.mag == 1 + assert r.maximum.mag == 3 # --- si_extra clash detection -------------------------------------------- @@ -348,12 +348,10 @@ def test_list_compatible_keys_merge(self, m_figures, m_plain): ql = QuantityList(m_figures, m_plain) assert ql.si_extra == {'round-mode': 'figures'} - @pytest.mark.xfail(reason="si_extra conflicts in QuantityRange are currently silent (last-wins)") def test_range_conflicting_keys_raises(self, m_figures, m_places): with pytest.raises(ValueError, match="round-mode"): QuantityRange(m_figures, m_places) - @pytest.mark.xfail(reason="si_extra conflicts in QuantityList are currently silent (last-wins)") def test_list_conflicting_keys_raises(self, m_figures, m_places): with pytest.raises(ValueError, match="round-mode"): QuantityList(m_figures, m_places) @@ -478,7 +476,7 @@ def test_full(self, g): def test_approx(self, g): """g with 2 digits rounds to 9.8 m/s^2.""" - assert g.approx.mag()== pytest.approx(9.8) + assert g.approx.mag == pytest.approx(9.8) def test_full_approx(self, g): """Previously crashed with `ValueError: Invalid format specifier`.""" diff --git a/core/utilities/dicts.py b/core/utilities/dicts.py new file mode 100644 index 00000000..e4466312 --- /dev/null +++ b/core/utilities/dicts.py @@ -0,0 +1,32 @@ +""" +Dict utilities. Made by Claude. +""" + + +def strict_merge(*dicts: dict) -> dict: + """ + Merge multiple dicts, raising ValueError if two of them assign different + values to the same key. Identical assignments are silently coalesced; + non-overlapping keys merge cleanly. + + Comparison uses ``==``, so nested structures are compared by value. + + >>> strict_merge({'a': 1}, {'b': 2}) + {'a': 1, 'b': 2} + >>> strict_merge({'a': 1}, {'a': 1}) + {'a': 1} + >>> strict_merge({'a': 1}, {'a': 2}) + Traceback (most recent call last): + ... + ValueError: Conflicting values for key 'a': 1 vs 2 + """ + result: dict = {} + for source in dicts: + for key, value in source.items(): + if key in result and result[key] != value: + raise ValueError( + f"Conflicting values for key {key!r}: " + f"{result[key]!r} vs {value!r}" + ) + result[key] = value + return result \ No newline at end of file From 1de55e1d930ad1d26c67b569ae47d43feeacb84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 27 Apr 2026 21:59:00 +0000 Subject: [PATCH 07/46] More Claude work --- core/builder/context/quantities/quantity.py | 48 +++++++-- core/builder/jinja.py | 4 +- core/tests/test_quantities.py | 109 ++++++++++++++++++-- 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 7251ad9f..f5a8b811 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -144,6 +144,27 @@ def to(self, what): def simplify(self): return PhysicsQuantity(self._quantity.to_base_units(), symbol=self._symbol, si_extra=self.si_extra) + def widen(self, value: float) -> "QuantityRange": + """ + Construct a tolerance range from this quantity: + ``[(1 - v) * x, (1 + v) * x]``. + + For positive ``x`` the smaller endpoint is ``(1 - v) * x`` and the + larger is ``(1 + v) * x``. For negative ``x`` the order flips so the + returned range still has minimum <= maximum. + + ``value`` must be non-negative; pass ``0`` for a degenerate range. + Values >= 1 are allowed and produce a range that crosses zero + (e.g. ``100.widen(1.5) -> [-50, 250]``). + """ + assert value >= 0, f"widen factor must be non-negative, got {value}" + low = self * (1 - value) + high = self * (1 + value) + if self._quantity.magnitude >= 0: + return QuantityRange(low, high) + else: + return QuantityRange(high, low) + def sin(self): return PhysicsQuantity(np.sin(self._quantity)) @@ -303,13 +324,28 @@ def __format__(self, fmt: str): def widen(self, value: float) -> Self: """ - Widen the interval by value: - minimum := (1 - value) * minimum - maximum := (1 + value) * maximum + Return a new range whose width is multiplied by ``(1 + value)``, + expanded symmetrically around the centre. + + For a range ``[a, b]`` with centre ``c = (a + b) / 2`` and half-width + ``h = (b - a) / 2``, the result is ``[c - h*(1+v), c + h*(1+v)]``. + + This always widens (never narrows) regardless of sign: + [1, 3] widen(0.1) -> [0.9, 3.1] + [-3, -1] widen(0.1) -> [-3.1, -0.9] + [-1, 1] widen(0.5) -> [-1.5, 1.5] - This should be useful for specifying ranges of acceptable results in Náboj. + A degenerate range (a == b) stays degenerate, since its half-width + is zero. Construct an explicit non-degenerate range first if you + want a tolerance band around a single value. + + This should be useful for specifying ranges of acceptable results + in Náboj. """ - return QuantityRange(self.minimum * (1 - value), self.maximum * (1 + value)) + assert value >= 0, f"widen factor must be non-negative, got {value}" + centre = (self.minimum + self.maximum) * 0.5 + half_width = (self.maximum - self.minimum) * 0.5 * (1 + value) + return QuantityRange(centre - half_width, centre + half_width) def __str__(self): return format(self, 'g') @@ -346,4 +382,4 @@ def __format__(self, fmt: str): return rf'\{cmd}{si_extraf}{magf}{unitf}' def __str__(self): - return format(self, 'g') \ No newline at end of file + return format(self, 'g') diff --git a/core/builder/jinja.py b/core/builder/jinja.py index 006b1a3c..cb32ea7b 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -183,8 +183,8 @@ def __init__(self, **kwargs): 'ng': latex.num_general, 'ef': latex.equals_float, 'eg': latex.equals_general, - 'w': QuantityRange.widen, - 'widen': QuantityRange.widen, + 'w': lambda obj, value: obj.widen(value), + 'widen': lambda obj, value: obj.widen(value), 'mag': PhysicsQuantity.mag, } | self.__generate_format_functions(numbers.format_float, 'f') | diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index e27cb41b..f9ff3341 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -281,6 +281,66 @@ def test_significant_figures_three(self): assert m.approximate(3).mag == 123.0 +# --- PhysicsQuantity.widen ----------------------------------------------- + + +class TestQuantityWiden: + """ + PhysicsQuantity.widen(v) constructs an asymmetric tolerance range + [(1-v)*x, (1+v)*x] from a single value, with min/max ordered correctly + regardless of sign. + """ + + def test_positive_value(self): + m = PhysicsQuantity.construct(100, 'meter') + r = m.widen(0.05) + assert r.minimum.mag == pytest.approx(95) + assert r.maximum.mag == pytest.approx(105) + + def test_negative_value(self): + """For x < 0, the result is still ordered (min < max).""" + m = PhysicsQuantity.construct(-100, 'meter') + r = m.widen(0.05) + assert r.minimum.mag == pytest.approx(-105) + assert r.maximum.mag == pytest.approx(-95) + + def test_large_factor_crosses_zero(self): + """value >= 1 produces a range crossing zero.""" + m = PhysicsQuantity.construct(100, 'meter') + r = m.widen(1.5) + assert r.minimum.mag == pytest.approx(-50) + assert r.maximum.mag == pytest.approx(250) + + def test_zero_value(self): + """widen on x == 0 gives a degenerate range at zero.""" + m = PhysicsQuantity.construct(0, 'meter') + r = m.widen(0.1) + assert r.minimum.mag == 0 + assert r.maximum.mag == 0 + + def test_zero_factor(self): + """widen(0) gives a degenerate range at the original value.""" + m = PhysicsQuantity.construct(42, 'meter') + r = m.widen(0) + assert r.minimum.mag == pytest.approx(42) + assert r.maximum.mag == pytest.approx(42) + + def test_negative_factor_rejected(self): + m = PhysicsQuantity.construct(100, 'meter') + with pytest.raises(AssertionError, match="non-negative"): + m.widen(-0.05) + + def test_unit_preserved(self): + m = PhysicsQuantity.construct(100, 'meter') + r = m.widen(0.1) + assert r.minimum.unit == r.maximum.unit + assert str(r.minimum.unit) == 'meter' + + def test_returns_quantity_range(self): + m = PhysicsQuantity.construct(5, 'kg') + assert isinstance(m.widen(0.1), QuantityRange) + + # --- Range guardrails ---------------------------------------------------- @@ -300,21 +360,52 @@ def test_swapped_endpoints_rejected(self, m1, m3): with pytest.raises(ValueError): QuantityRange(m3, m1) - def test_widen_sane(self, m1, m3): + def test_widen_positive_range(self, m1, m3): + """[1, 3] widened by 0.1: width grows from 2 to 2.2, symmetric around centre 2.""" r = QuantityRange(m1, m3).widen(0.1) assert r.minimum.mag == pytest.approx(0.9) - assert r.maximum.mag == pytest.approx(3.3) - - @pytest.mark.xfail(reason="widen(value>=1) produces negative minimum") - def test_widen_out_of_range_rejected(self, m1, m3): - with pytest.raises((ValueError, AssertionError)): - QuantityRange(m1, m3).widen(1.5) + assert r.maximum.mag == pytest.approx(3.1) + + def test_widen_negative_range(self): + """A negative-valued range must widen, not narrow.""" + a = PhysicsQuantity.construct(-3, 'kg') + b = PhysicsQuantity.construct(-1, 'kg') + r = QuantityRange(a, b).widen(0.1) + assert r.minimum.mag == pytest.approx(-3.1) + assert r.maximum.mag == pytest.approx(-0.9) + + def test_widen_zero_centred_range(self): + """Range straddling zero widens symmetrically.""" + a = PhysicsQuantity.construct(-1, 'kg') + b = PhysicsQuantity.construct(1, 'kg') + r = QuantityRange(a, b).widen(0.5) + assert r.minimum.mag == pytest.approx(-1.5) + assert r.maximum.mag == pytest.approx(1.5) + + def test_widen_large_factor_allowed(self, m1, m3): + """value >= 1 is now permitted; the range simply grows substantially.""" + r = QuantityRange(m1, m3).widen(1.5) + # Centre 2, half-width 1, scaled by 2.5 -> half-width 2.5 + assert r.minimum.mag == pytest.approx(-0.5) + assert r.maximum.mag == pytest.approx(4.5) + + def test_widen_negative_value_rejected(self, m1, m3): + """widen(-0.1) would contract; reject so 'widen' stays honest about its name.""" + with pytest.raises(AssertionError, match="non-negative"): + QuantityRange(m1, m3).widen(-0.1) def test_widen_identity(self, m1, m3): """widen(0) returns an equivalent range.""" r = QuantityRange(m1, m3).widen(0) - assert r.minimum.mag == 1 - assert r.maximum.mag == 3 + assert r.minimum.mag == pytest.approx(1) + assert r.maximum.mag == pytest.approx(3) + + def test_widen_degenerate_range(self): + """A range with min==max stays degenerate; half-width is zero.""" + a = PhysicsQuantity.construct(5, 'kg') + r = QuantityRange(a, a).widen(0.1) + assert r.minimum.mag == pytest.approx(5) + assert r.maximum.mag == pytest.approx(5) # --- si_extra clash detection -------------------------------------------- From 386032164fb75f29da87968c35bb1cf6c6da7380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 27 Apr 2026 22:02:01 +0000 Subject: [PATCH 08/46] More tests --- core/tests/test_quantities.py | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index f9ff3341..e07f5b75 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -281,6 +281,127 @@ def test_significant_figures_three(self): assert m.approximate(3).mag == 123.0 +# --- PhysicsQuantity.mag ------------------------------------------------- + + +class TestMag: + """ + `mag` is a thin accessor for the underlying pint magnitude. It returns + whatever numeric type pint stores (int, float, or numpy scalar after + operations like np.floor/np.ceil). + """ + + def test_int_construction(self): + assert PhysicsQuantity.construct(7, 'kg').mag == 7 + + def test_float_construction(self): + assert PhysicsQuantity.construct(5.5, 'kg').mag == pytest.approx(5.5) + + def test_negative(self): + assert PhysicsQuantity.construct(-3.7, 'kg').mag == pytest.approx(-3.7) + + def test_dimensionless(self): + assert PhysicsQuantity.construct(2.5, '').mag == pytest.approx(2.5) + + def test_after_unit_conversion(self): + """`to()` may change int -> float as a side effect of conversion.""" + kg = PhysicsQuantity.construct(1, 'kilogram') + assert kg.to('gram').mag == pytest.approx(1000) + + def test_mag_does_not_carry_units(self): + """The bare magnitude is a number, not a pint Quantity.""" + m = PhysicsQuantity.construct(5, 'kg') + assert not hasattr(m.mag, 'units') + + +# --- PhysicsQuantity.floor / .ceil -------------------------------------- + + +class TestFloorCeil: + """ + `floor` and `ceil` round the magnitude toward -inf and +inf respectively, + keeping the unit. They return a new PhysicsQuantity; metadata is dropped + because the result is a different quantity from the input. + """ + + def test_floor_positive(self): + m = PhysicsQuantity.construct(5.7, 'kg') + assert m.floor().mag == pytest.approx(5) + + def test_ceil_positive(self): + m = PhysicsQuantity.construct(5.3, 'kg') + assert m.ceil().mag == pytest.approx(6) + + def test_floor_negative_rounds_toward_neg_inf(self): + """floor(-2.3) is -3, not -2 (toward -inf, not toward zero).""" + m = PhysicsQuantity.construct(-2.3, 'meter') + assert m.floor().mag == pytest.approx(-3) + + def test_ceil_negative_rounds_toward_pos_inf(self): + """ceil(-2.3) is -2, not -3 (toward +inf, not toward zero).""" + m = PhysicsQuantity.construct(-2.3, 'meter') + assert m.ceil().mag == pytest.approx(-2) + + def test_floor_of_integer(self): + m = PhysicsQuantity.construct(5.0, 'kg') + assert m.floor().mag == pytest.approx(5) + + def test_ceil_of_integer(self): + m = PhysicsQuantity.construct(5.0, 'kg') + assert m.ceil().mag == pytest.approx(5) + + def test_floor_of_zero(self): + m = PhysicsQuantity.construct(0, 'kg') + assert m.floor().mag == pytest.approx(0) + + def test_ceil_of_zero(self): + m = PhysicsQuantity.construct(0, 'kg') + assert m.ceil().mag == pytest.approx(0) + + def test_floor_preserves_unit(self): + m = PhysicsQuantity.construct(5.7, 'kg') + assert m.floor().unit == m.unit + + def test_ceil_preserves_unit(self): + m = PhysicsQuantity.construct(5.7, 'kg') + assert m.ceil().unit == m.unit + + def test_floor_returns_physics_quantity(self): + m = PhysicsQuantity.construct(5.7, 'kg') + assert isinstance(m.floor(), PhysicsQuantity) + + def test_ceil_returns_physics_quantity(self): + m = PhysicsQuantity.construct(5.7, 'kg') + assert isinstance(m.ceil(), PhysicsQuantity) + + def test_floor_drops_symbol(self): + """floor produces a new quantity, so the original symbol does not carry over.""" + m = PhysicsQuantity.construct(5.7, 'kg', symbol='m') + assert m.floor().symbol is None + + def test_floor_drops_si_extra(self): + m = PhysicsQuantity.construct(5.7, 'kg', si_extra={'round-mode': 'figures'}) + assert m.floor().si_extra == {} + + def test_ceil_drops_symbol(self): + m = PhysicsQuantity.construct(5.7, 'kg', symbol='m') + assert m.ceil().symbol is None + + def test_ceil_drops_si_extra(self): + m = PhysicsQuantity.construct(5.7, 'kg', si_extra={'round-mode': 'figures'}) + assert m.ceil().si_extra == {} + + def test_floor_dimensionless_renders_with_num(self): + """Dimensionless results should still use \\num, not \\qty.""" + d = PhysicsQuantity.construct(2.7, '') + assert str(d.floor()).startswith(r'\num{') + + def test_floor_then_ceil_idempotent_on_floored_value(self): + """ceil of a floored integer is the same value.""" + m = PhysicsQuantity.construct(5.7, 'kg') + assert m.floor().ceil().mag == pytest.approx(5) + + # --- PhysicsQuantity.widen ----------------------------------------------- From d3654f52d8a44b77329a047265008d724de8eb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 28 Apr 2026 07:57:34 +0000 Subject: [PATCH 09/46] =?UTF-8?q?Fixed=20N=C3=A1boj=20template=20list,=20n?= =?UTF-8?q?ewline=20after=20preamble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/builder/renderer.py | 5 ++++- core/cli.py | 2 +- modules/naboj/templates/blocks/booklet/footer.jtex | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/core/builder/renderer.py b/core/builder/renderer.py index 5869d4f9..03d6da71 100755 --- a/core/builder/renderer.py +++ b/core/builder/renderer.py @@ -69,7 +69,10 @@ def prepare_template(self, Currently just prepends the preamble, if available """ - return (self.preamble or "") + template + if self.preamble is not None: + return self.preamble + "\n" + template + else: + return template def run(self): # First pass: expand all equations and values diff --git a/core/cli.py b/core/cli.py index 96a977d8..44524a32 100644 --- a/core/cli.py +++ b/core/cli.py @@ -51,7 +51,7 @@ def run(self) -> None: self.fail(e) def fail(self, e): - log.error(f"{c.err('convert: failure on ')}{c.path(self.args.infile.name)}: {e}") + log.error(f"{c.err('convert: failure on ')}{c.path(self.args.infile.name)}") raise e def success(self): diff --git a/modules/naboj/templates/blocks/booklet/footer.jtex b/modules/naboj/templates/blocks/booklet/footer.jtex index 835f9914..f36be747 100644 --- a/modules/naboj/templates/blocks/booklet/footer.jtex +++ b/modules/naboj/templates/blocks/booklet/footer.jtex @@ -6,7 +6,7 @@ (@ for person in people @) \item (* person *) (@ endfor @) - (@ if people|length % 3 == 1 @)\item(@ endif @) + (@ if people|length % 3 == 1 @)\item\item(@ endif @) (@ if people|length % 3 == 2 @)\item(@ endif @) \end{itemize} \end{multicols} From 0fefbb8c880745e71bf077a4716f4e5d00a46545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 28 Apr 2026 12:01:53 +0000 Subject: [PATCH 10/46] Math objects upgraded --- core/builder/context/quantities/math.py | 21 ++-- core/builder/context/quantities/quantity.py | 1 - core/tests/test_mathobject.py | 117 ++++++++++++++++++++ 3 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 core/tests/test_mathobject.py diff --git a/core/builder/context/quantities/math.py b/core/builder/context/quantities/math.py index b8c28ea8..ebdae2c2 100644 --- a/core/builder/context/quantities/math.py +++ b/core/builder/context/quantities/math.py @@ -1,5 +1,3 @@ -from typing import Optional - import regex as re @@ -19,13 +17,22 @@ def __str__(self): def __repr__(self): return repr(self.__str__()) - def __format__(self, spec: Optional[str] = None): + _INTERPUNCTION = '.,;?!' + + def __format__(self, spec: str = ''): + interpunction = '' + if len(spec) > 0 and spec[-1] in self._INTERPUNCTION: + interpunction = spec[-1] + spec = spec[:-1] + match spec: - case None: - return self.__str__() + case '': + return f"${self.content}{interpunction}$" case 'disp': content = re.sub(r'^(?!\Z)', ' ', self.content, flags=re.MULTILINE) - return f"""$$\n{content}\n$$ {{#eq:{self.id}}}""" + return f"$$\n{content}{interpunction}\n$$ {{#eq:{self.id}}}" case 'align': content = re.sub(r'^(?!\Z)', ' ', self.content, flags=re.MULTILINE) - return f"""$${{\n{content}\n}}$$ {{#eq:{self.id}}}""" + return f"$${{\n{content}{interpunction}\n}}$$ {{#eq:{self.id}}}" + case _: + raise NotImplementedError(f"Unknown format spec {spec!r} for MathObject") diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index f5a8b811..c1c0f477 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -47,7 +47,6 @@ def _binop(self, other, op: Callable[[Self, Union[Self, numbers.Number, u.Quanti else: raise TypeError(f"Cannot perform {op} with {type(other)} ({other})") - def __add__(self, other): return self._binop(other, operator.add) diff --git a/core/tests/test_mathobject.py b/core/tests/test_mathobject.py new file mode 100644 index 00000000..3aa3dcf2 --- /dev/null +++ b/core/tests/test_mathobject.py @@ -0,0 +1,117 @@ +import pytest + +from core.builder.context.quantities.math import MathObject + + +@pytest.fixture +def inline(): + return MathObject('e1', 'a + b = c') + + +@pytest.fixture +def multiline(): + return MathObject('e2', 'a &= b + c \\\\\nb &= 2c') + + +class TestMathObjectInline: + """Default formatting wraps content in $...$.""" + + def test_str(self, inline): + assert str(inline) == '$a + b = c$' + + def test_format_no_spec(self, inline): + assert f'{inline}' == '$a + b = c$' + + def test_format_explicit_empty_spec(self, inline): + assert format(inline, '') == '$a + b = c$' + + +class TestMathObjectDisplay: + """`:disp` renders a numbered display equation with pandoc-crossref label.""" + + def test_basic(self, inline): + result = f'{inline:disp}' + assert result.startswith('$$\n') + assert ' a + b = c' in result # 4-space indent + assert result.endswith('{#eq:e1}') + + def test_label_uses_id(self): + m = MathObject('mass-energy', 'E = mc^2') + assert '{#eq:mass-energy}' in f'{m:disp}' + + def test_indents_each_line(self, multiline): + result = f'{multiline:disp}' + assert ' a &= b + c' in result + assert ' b &= 2c' in result + + +class TestMathObjectAlign: + """`:align` renders an aligned display equation.""" + + def test_structure(self, multiline): + result = f'{multiline:align}' + assert result.startswith('$${\n') + assert '\n}$$' in result + assert result.endswith('{#eq:e2}') + + def test_indents_each_line(self, multiline): + result = f'{multiline:align}' + assert ' a &= b + c' in result + assert ' b &= 2c' in result + + +class TestMathObjectInterpunction: + """ + A trailing punctuation character in the spec is appended to the math + content, so the equation can end a sentence cleanly. + """ + + @pytest.mark.parametrize("punct", list('.,;?!')) + def test_inline_with_punctuation(self, inline, punct): + assert f'{inline:{punct}}' == f'$a + b = c{punct}$' + + @pytest.mark.parametrize("punct", list('.,;?!')) + def test_disp_with_punctuation(self, inline, punct): + result = f'{inline:disp{punct}}' + # Punctuation lands at end of math content, before the closing $$. + assert f'a + b = c{punct}\n$$' in result + + @pytest.mark.parametrize("punct", list('.,;?!')) + def test_align_with_punctuation(self, multiline, punct): + result = f'{multiline:align{punct}}' + # Punctuation lands after the last content line, before `\n}$$`. + assert f'b &= 2c{punct}\n}}$$' in result + + def test_punctuation_only_spec_treated_as_inline(self, inline): + """`:.` strips the period and falls through to the empty-spec branch.""" + assert f'{inline:.}' == '$a + b = c.$' + + def test_non_punctuation_char_not_stripped(self, inline): + """A trailing letter is not interpunction; the spec is rejected.""" + with pytest.raises(NotImplementedError): + f'{inline:dispx}' + + +class TestMathObjectErrors: + def test_unknown_spec(self, inline): + with pytest.raises(NotImplementedError, match="foo"): + f'{inline:foo}' + + def test_unknown_spec_with_punctuation(self, inline): + """Unknown specs with valid punctuation suffix still raise on the prefix.""" + with pytest.raises(NotImplementedError, match="foo"): + f'{inline:foo.}' + + +class TestMathObjectConstruction: + def test_strips_trailing_newline(self): + """Constructor strips a single trailing newline from content.""" + m = MathObject('e', 'a + b\n') + assert m.content == 'a + b' + + def test_preserves_internal_newlines(self): + m = MathObject('e', 'a\nb\nc') + assert m.content == 'a\nb\nc' + + def test_repr(self, inline): + assert repr(inline) == "'$a + b = c$'" \ No newline at end of file From 2f327871079781dee6c28edc2468629b936e1ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 28 Apr 2026 12:23:39 +0000 Subject: [PATCH 11/46] Better display and bug hunting with Claude --- core/builder/context/quantities/math.py | 30 ++++++++++++- core/builder/context/quantities/quantity.py | 22 +++++---- core/filters/latex.py | 33 +++++++++----- core/tests/test_jinja.py | 49 +++++++++++++++++++++ core/tests/test_mathobject.py | 31 ++++++++----- core/tests/test_quantities.py | 5 +-- 6 files changed, 133 insertions(+), 37 deletions(-) diff --git a/core/builder/context/quantities/math.py b/core/builder/context/quantities/math.py index ebdae2c2..d98732f6 100644 --- a/core/builder/context/quantities/math.py +++ b/core/builder/context/quantities/math.py @@ -18,6 +18,8 @@ def __repr__(self): return repr(self.__str__()) _INTERPUNCTION = '.,;?!' + _BASE_SPECS = {'', 'disp', 'align'} + _SPECS_ACCEPTING_PUNCTUATION = {'disp', 'align'} def __format__(self, spec: str = ''): interpunction = '' @@ -25,9 +27,33 @@ def __format__(self, spec: str = ''): interpunction = spec[-1] spec = spec[:-1] + # Distinguish "unknown base spec" from "valid base spec with invalid + # trailing character," because the latter is the much more common + # author mistake. + if spec not in self._BASE_SPECS: + if len(spec) > 1 and spec[:-1] in self._BASE_SPECS: + raise ValueError( + f"Invalid trailing character {spec[-1]!r} in MathObject " + f"format spec; expected one of {''.join(self._INTERPUNCTION)} " + f"or no trailing character" + ) + raise NotImplementedError( + f"Unknown format spec {spec!r} for MathObject; " + f"expected one of {sorted(self._BASE_SPECS - {''})} or empty" + ) + + # Inline math doesn't need in-math punctuation — authors can simply + # type the punctuation outside, after the closing $. + if interpunction and spec not in self._SPECS_ACCEPTING_PUNCTUATION: + raise ValueError( + f"Inline math does not accept trailing punctuation; " + f"write the punctuation outside the math instead: " + f"`(* eq | inline *){interpunction}`" + ) + match spec: case '': - return f"${self.content}{interpunction}$" + return f"${self.content}$" case 'disp': content = re.sub(r'^(?!\Z)', ' ', self.content, flags=re.MULTILINE) return f"$$\n{content}{interpunction}\n$$ {{#eq:{self.id}}}" @@ -35,4 +61,4 @@ def __format__(self, spec: str = ''): content = re.sub(r'^(?!\Z)', ' ', self.content, flags=re.MULTILINE) return f"$${{\n{content}{interpunction}\n}}$$ {{#eq:{self.id}}}" case _: - raise NotImplementedError(f"Unknown format spec {spec!r} for MathObject") + raise NotImplementedError(f"Unknown format spec {spec!r} for MathObject") \ No newline at end of file diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index c1c0f477..8761eb29 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -289,20 +289,26 @@ def construct_quantity(magnitude, unit, *, symbol: Optional[str] = None): class QuantityRange: """ Represents a range of two magnitudes of commensurate quantities. - Primarily meant to be useful for result tolerances. + Also meant to be useful for result tolerances. """ def __init__(self, minimum: PhysicsQuantity, maximum: PhysicsQuantity): + # Coerce to a common unit before comparing magnitudes, so ranges like + # QuantityRange(1 kg, 500 g) work correctly. Incompatible units raise + # the underlying pint DimensionalityError. self.minimum = minimum - self.maximum = maximum - self.si_extra = strict_merge(self.minimum.si_extra, self.maximum.si_extra) + self.unit = minimum.unit + self.maximum = maximum.to(self.unit) + + if self.minimum.mag > self.maximum.mag: + raise ValueError( + f"QuantityRange minimum ({minimum}) " + f"must not exceed maximum ({maximum})" + ) - # Try to coerce to the same unit (minimum takes precedence). - # If it works, fine, if not, let pint raise the appropriate exception. - self.unit = self.minimum.unit - self.maximum = self.maximum.to(self.unit) + self.si_extra = strict_merge(self.minimum.si_extra, self.maximum.si_extra) def __format__(self, fmt: str): minr = self.minimum.format_struct(fmt) @@ -381,4 +387,4 @@ def __format__(self, fmt: str): return rf'\{cmd}{si_extraf}{magf}{unitf}' def __str__(self): - return format(self, 'g') + return format(self, 'g') \ No newline at end of file diff --git a/core/filters/latex.py b/core/filters/latex.py index 6e09fa37..c7f9ebe1 100644 --- a/core/filters/latex.py +++ b/core/filters/latex.py @@ -30,7 +30,6 @@ def identity(x: Any) -> Any: return x - def upnth(x: int) -> str: """ Superscripted nth for LaTeX @@ -89,8 +88,8 @@ def process_people(people: Union[list[dict[str, str]], dict[str, str]]) -> list[ def format_gender_suffix(people: dict[str, dict[str, str]], *, func: Callable = identity) -> str: """ Format people metadata: - - if it is a dict, it should have name and gender, display that - - if it is a list of dicts, use plural and display a list of names + - if it is a dict, it should have name and gender, display that + - if it is a list of dicts, use plural and display a list of names Returns ------- @@ -149,22 +148,32 @@ def equals_general(q: PhysicsQuantity, precision: Optional[int] = None): return q.equals_general(precision) -def math_inline(math: MathObject): +def math_inline(math: MathObject) -> str: """ - Display as inline math with no frills. + Display as inline math. No punctuation argument: write any sentence + punctuation outside the math, e.g. `(* eq | inline *).` """ - return f"{math!s}" + return f"{math}" -def math_display(math: MathObject): +def math_display(math: MathObject, punct: str = '') -> str: """ - Display as block math with a label. + Display as block math with a label, optionally with trailing punctuation. + + Usage in templates: + (* eq | disp *) → $$\\n a + b\\n$$ {#eq:id} + (* eq | disp(',') *) → $$\\n a + b,\\n$$ {#eq:id} """ - return f"{math:disp}" + return f"{math:disp{punct}}" -def math_aligned(math: MathObject): +def math_aligned(math: MathObject, punct: str = '') -> str: r""" - Display inside an \aligned{} environment with a label + Display inside an \aligned{} environment with a label, optionally with + trailing punctuation. + + Usage in templates: + (* eq | align *) → $${\n a &= b\n}$$ {#eq:id} + (* eq | align('.') *) → $${\n a &= b.\n}$$ {#eq:id} """ - return f"{math:align}" + return f"{math:align{punct}}" diff --git a/core/tests/test_jinja.py b/core/tests/test_jinja.py index 4cae9e8f..b5624e9f 100644 --- a/core/tests/test_jinja.py +++ b/core/tests/test_jinja.py @@ -174,3 +174,52 @@ def test_default_filter_mixed_with_real_miss(self): with pytest.raises(MissingVariablesError) as exc: renderer.render('(§ a|default("") §) (§ b §)', {}) assert exc.value.missing == ['b'] + + +class TestMathFilters: + """ + The inline/disp/align filters delegate to MathObject.__format__. + disp and align accept an optional punctuation argument; inline does not. + """ + + @pytest.fixture + def renderer(self): + return MarkdownJinjaRenderer() + + @pytest.fixture + def context(self): + from core.builder.context.quantities.math import MathObject + return { + 'eq': MathObject('e1', 'a + b = c'), + 'multi': MathObject('e2', 'a &= b + c \\\\\nb &= 2c'), + } + + def test_inline_no_arg(self, renderer, context): + assert renderer.render('(§ eq | inline §)', context) == '$a + b = c$' + + def test_inline_does_not_accept_punctuation(self, renderer, context): + """The inline filter takes no arguments; punctuation goes outside.""" + with pytest.raises(TypeError): + renderer.render('(§ eq | inline(".") §)', context) + + def test_disp_no_arg(self, renderer, context): + result = renderer.render('(§ eq | disp §)', context) + assert ' a + b = c\n$$' in result + assert '{#eq:e1}' in result + + def test_disp_with_comma(self, renderer, context): + result = renderer.render('(§ eq | disp(",") §)', context) + assert ' a + b = c,\n$$' in result + + def test_align_no_arg(self, renderer, context): + result = renderer.render('(§ multi | align §)', context) + assert ' b &= 2c\n}$$' in result + + def test_align_with_period(self, renderer, context): + result = renderer.render('(§ multi | align(".") §)', context) + assert ' b &= 2c.\n}$$' in result + + def test_invalid_punctuation_rejected(self, renderer, context): + """An unsupported punctuation char is reported with a friendly message.""" + with pytest.raises(ValueError, match="Invalid trailing character 'x'"): + renderer.render('(§ eq | disp("x") §)', context) diff --git a/core/tests/test_mathobject.py b/core/tests/test_mathobject.py index 3aa3dcf2..ab7f8b92 100644 --- a/core/tests/test_mathobject.py +++ b/core/tests/test_mathobject.py @@ -62,13 +62,17 @@ def test_indents_each_line(self, multiline): class TestMathObjectInterpunction: """ - A trailing punctuation character in the spec is appended to the math - content, so the equation can end a sentence cleanly. + For block specs (disp, align), a trailing punctuation character in the + spec is appended to the math content, so the equation can end a sentence + cleanly. Inline math doesn't accept punctuation — authors should write + it outside the closing `$`. """ @pytest.mark.parametrize("punct", list('.,;?!')) - def test_inline_with_punctuation(self, inline, punct): - assert f'{inline:{punct}}' == f'$a + b = c{punct}$' + def test_inline_punctuation_rejected(self, inline, punct): + """Inline math should not accept in-math punctuation.""" + with pytest.raises(ValueError, match="Inline math does not accept"): + f'{inline:{punct}}' @pytest.mark.parametrize("punct", list('.,;?!')) def test_disp_with_punctuation(self, inline, punct): @@ -82,15 +86,18 @@ def test_align_with_punctuation(self, multiline, punct): # Punctuation lands after the last content line, before `\n}$$`. assert f'b &= 2c{punct}\n}}$$' in result - def test_punctuation_only_spec_treated_as_inline(self, inline): - """`:.` strips the period and falls through to the empty-spec branch.""" - assert f'{inline:.}' == '$a + b = c.$' - - def test_non_punctuation_char_not_stripped(self, inline): - """A trailing letter is not interpunction; the spec is rejected.""" - with pytest.raises(NotImplementedError): + def test_invalid_punctuation_after_valid_base(self, inline): + """ + A non-punctuation char following a recognised base spec is reported + specifically as bad punctuation, not as an unknown spec. + """ + with pytest.raises(ValueError, match="Invalid trailing character 'x'"): f'{inline:dispx}' + def test_invalid_punctuation_after_align(self, inline): + with pytest.raises(ValueError, match="Invalid trailing character 'q'"): + f'{inline:alignq}' + class TestMathObjectErrors: def test_unknown_spec(self, inline): @@ -114,4 +121,4 @@ def test_preserves_internal_newlines(self): assert m.content == 'a\nb\nc' def test_repr(self, inline): - assert repr(inline) == "'$a + b = c$'" \ No newline at end of file + assert repr(inline) == "'$a + b = c$'" diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index e07f5b75..f530415d 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -476,9 +476,8 @@ def m1(self): def m3(self): return PhysicsQuantity.construct(3, 'kg') - @pytest.mark.xfail(reason="swapped endpoints currently accepted silently") def test_swapped_endpoints_rejected(self, m1, m3): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="must not exceed"): QuantityRange(m3, m1) def test_widen_positive_range(self, m1, m3): @@ -694,4 +693,4 @@ def test_full_approx(self, g): """Previously crashed with `ValueError: Invalid format specifier`.""" rendered = g.full_approx assert r'\qty{' in rendered - assert '9.8' in rendered + assert '9.8' in rendered \ No newline at end of file From e1e0b8f153c8e6ebf0d18d9aebaa6eae41bf46de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 28 Apr 2026 13:08:47 +0000 Subject: [PATCH 12/46] Converted to new format --- core/builder/context/quantities/quantity.py | 13 ++- core/builder/jinja.py | 4 +- core/tests/test_quantities.py | 88 ++++++++++----------- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 8761eb29..96d42cc6 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -18,12 +18,18 @@ class PhysicsQuantity: """ def __init__(self, - quantity: u.Quantity, + quantity: pint.Quantity | int | float, *, symbol: str = None, si_extra: dict[str, str] = None, force_f: bool = False): - self._quantity = quantity + if isinstance(quantity, pint.Quantity): + self._quantity = quantity + elif isinstance(quantity, numbers.Number): + self._quantity = u.Quantity(quantity, '1') + else: + raise TypeError(f"Cannot construct a {self.__class__.__qualname__} object from {quantity}") + self._symbol = symbol self.si_extra = {} if si_extra is None else si_extra @@ -117,7 +123,6 @@ def quantity(self): def quantity(self, value): raise TypeError(f"{self.__class__.__name__} ({value}) is immutable") - @property def mag(self): """ Return the internal magnitude. """ return self._quantity.magnitude @@ -302,7 +307,7 @@ def __init__(self, self.unit = minimum.unit self.maximum = maximum.to(self.unit) - if self.minimum.mag > self.maximum.mag: + if self.minimum.mag() > self.maximum.mag(): raise ValueError( f"QuantityRange minimum ({minimum}) " f"must not exceed maximum ({maximum})" diff --git a/core/builder/jinja.py b/core/builder/jinja.py index cb32ea7b..1aa137a4 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -208,8 +208,8 @@ def __init__(self, **kwargs): 'acos': np.acos, 'atan': np.atan, 'atan2': np.atan2, - 'ceil': PhysicsQuantity.ceil, - 'floor': PhysicsQuantity.floor, + 'ceil': np.ceil, + 'floor': np.floor, 'sqrt': lambda x: (x ** 0.5), 'cbrt': np.cbrt, 'rad': np.radians, diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index f530415d..08a60245 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -264,24 +264,24 @@ def test_non_integer_digits_rejected(self): def test_symmetric_around_zero(self): """Positive and negative magnitudes must round symmetrically.""" - pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag - neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag + pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag() + neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag() assert pos == -neg, f"asymmetric rounding: {pos} vs {neg}" def test_zero_magnitude(self): m = PhysicsQuantity.construct(0.0, 'kg') - assert m.approximate(3).mag == 0.0 + assert m.approximate(3).mag() == 0.0 def test_significant_figures_one(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(1).mag == 100.0 + assert m.approximate(1).mag() == 100.0 def test_significant_figures_three(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(3).mag == 123.0 + assert m.approximate(3).mag() == 123.0 -# --- PhysicsQuantity.mag ------------------------------------------------- +# --- PhysicsQuantity.mag() ------------------------------------------------- class TestMag: @@ -292,26 +292,26 @@ class TestMag: """ def test_int_construction(self): - assert PhysicsQuantity.construct(7, 'kg').mag == 7 + assert PhysicsQuantity.construct(7, 'kg').mag() == 7 def test_float_construction(self): - assert PhysicsQuantity.construct(5.5, 'kg').mag == pytest.approx(5.5) + assert PhysicsQuantity.construct(5.5, 'kg').mag() == pytest.approx(5.5) def test_negative(self): - assert PhysicsQuantity.construct(-3.7, 'kg').mag == pytest.approx(-3.7) + assert PhysicsQuantity.construct(-3.7, 'kg').mag() == pytest.approx(-3.7) def test_dimensionless(self): - assert PhysicsQuantity.construct(2.5, '').mag == pytest.approx(2.5) + assert PhysicsQuantity.construct(2.5, '').mag() == pytest.approx(2.5) def test_after_unit_conversion(self): """`to()` may change int -> float as a side effect of conversion.""" kg = PhysicsQuantity.construct(1, 'kilogram') - assert kg.to('gram').mag == pytest.approx(1000) + assert kg.to('gram').mag() == pytest.approx(1000) def test_mag_does_not_carry_units(self): """The bare magnitude is a number, not a pint Quantity.""" m = PhysicsQuantity.construct(5, 'kg') - assert not hasattr(m.mag, 'units') + assert not hasattr(m.mag(), 'units') # --- PhysicsQuantity.floor / .ceil -------------------------------------- @@ -326,37 +326,37 @@ class TestFloorCeil: def test_floor_positive(self): m = PhysicsQuantity.construct(5.7, 'kg') - assert m.floor().mag == pytest.approx(5) + assert m.floor().mag() == pytest.approx(5) def test_ceil_positive(self): m = PhysicsQuantity.construct(5.3, 'kg') - assert m.ceil().mag == pytest.approx(6) + assert m.ceil().mag() == pytest.approx(6) def test_floor_negative_rounds_toward_neg_inf(self): """floor(-2.3) is -3, not -2 (toward -inf, not toward zero).""" m = PhysicsQuantity.construct(-2.3, 'meter') - assert m.floor().mag == pytest.approx(-3) + assert m.floor().mag() == pytest.approx(-3) def test_ceil_negative_rounds_toward_pos_inf(self): """ceil(-2.3) is -2, not -3 (toward +inf, not toward zero).""" m = PhysicsQuantity.construct(-2.3, 'meter') - assert m.ceil().mag == pytest.approx(-2) + assert m.ceil().mag() == pytest.approx(-2) def test_floor_of_integer(self): m = PhysicsQuantity.construct(5.0, 'kg') - assert m.floor().mag == pytest.approx(5) + assert m.floor().mag() == pytest.approx(5) def test_ceil_of_integer(self): m = PhysicsQuantity.construct(5.0, 'kg') - assert m.ceil().mag == pytest.approx(5) + assert m.ceil().mag() == pytest.approx(5) def test_floor_of_zero(self): m = PhysicsQuantity.construct(0, 'kg') - assert m.floor().mag == pytest.approx(0) + assert m.floor().mag() == pytest.approx(0) def test_ceil_of_zero(self): m = PhysicsQuantity.construct(0, 'kg') - assert m.ceil().mag == pytest.approx(0) + assert m.ceil().mag() == pytest.approx(0) def test_floor_preserves_unit(self): m = PhysicsQuantity.construct(5.7, 'kg') @@ -399,7 +399,7 @@ def test_floor_dimensionless_renders_with_num(self): def test_floor_then_ceil_idempotent_on_floored_value(self): """ceil of a floored integer is the same value.""" m = PhysicsQuantity.construct(5.7, 'kg') - assert m.floor().ceil().mag == pytest.approx(5) + assert m.floor().ceil().mag() == pytest.approx(5) # --- PhysicsQuantity.widen ----------------------------------------------- @@ -415,36 +415,36 @@ class TestQuantityWiden: def test_positive_value(self): m = PhysicsQuantity.construct(100, 'meter') r = m.widen(0.05) - assert r.minimum.mag == pytest.approx(95) - assert r.maximum.mag == pytest.approx(105) + assert r.minimum.mag() == pytest.approx(95) + assert r.maximum.mag() == pytest.approx(105) def test_negative_value(self): """For x < 0, the result is still ordered (min < max).""" m = PhysicsQuantity.construct(-100, 'meter') r = m.widen(0.05) - assert r.minimum.mag == pytest.approx(-105) - assert r.maximum.mag == pytest.approx(-95) + assert r.minimum.mag() == pytest.approx(-105) + assert r.maximum.mag() == pytest.approx(-95) def test_large_factor_crosses_zero(self): """value >= 1 produces a range crossing zero.""" m = PhysicsQuantity.construct(100, 'meter') r = m.widen(1.5) - assert r.minimum.mag == pytest.approx(-50) - assert r.maximum.mag == pytest.approx(250) + assert r.minimum.mag() == pytest.approx(-50) + assert r.maximum.mag() == pytest.approx(250) def test_zero_value(self): """widen on x == 0 gives a degenerate range at zero.""" m = PhysicsQuantity.construct(0, 'meter') r = m.widen(0.1) - assert r.minimum.mag == 0 - assert r.maximum.mag == 0 + assert r.minimum.mag() == 0 + assert r.maximum.mag() == 0 def test_zero_factor(self): """widen(0) gives a degenerate range at the original value.""" m = PhysicsQuantity.construct(42, 'meter') r = m.widen(0) - assert r.minimum.mag == pytest.approx(42) - assert r.maximum.mag == pytest.approx(42) + assert r.minimum.mag() == pytest.approx(42) + assert r.maximum.mag() == pytest.approx(42) def test_negative_factor_rejected(self): m = PhysicsQuantity.construct(100, 'meter') @@ -483,31 +483,31 @@ def test_swapped_endpoints_rejected(self, m1, m3): def test_widen_positive_range(self, m1, m3): """[1, 3] widened by 0.1: width grows from 2 to 2.2, symmetric around centre 2.""" r = QuantityRange(m1, m3).widen(0.1) - assert r.minimum.mag == pytest.approx(0.9) - assert r.maximum.mag == pytest.approx(3.1) + assert r.minimum.mag() == pytest.approx(0.9) + assert r.maximum.mag() == pytest.approx(3.1) def test_widen_negative_range(self): """A negative-valued range must widen, not narrow.""" a = PhysicsQuantity.construct(-3, 'kg') b = PhysicsQuantity.construct(-1, 'kg') r = QuantityRange(a, b).widen(0.1) - assert r.minimum.mag == pytest.approx(-3.1) - assert r.maximum.mag == pytest.approx(-0.9) + assert r.minimum.mag() == pytest.approx(-3.1) + assert r.maximum.mag() == pytest.approx(-0.9) def test_widen_zero_centred_range(self): """Range straddling zero widens symmetrically.""" a = PhysicsQuantity.construct(-1, 'kg') b = PhysicsQuantity.construct(1, 'kg') r = QuantityRange(a, b).widen(0.5) - assert r.minimum.mag == pytest.approx(-1.5) - assert r.maximum.mag == pytest.approx(1.5) + assert r.minimum.mag() == pytest.approx(-1.5) + assert r.maximum.mag() == pytest.approx(1.5) def test_widen_large_factor_allowed(self, m1, m3): """value >= 1 is now permitted; the range simply grows substantially.""" r = QuantityRange(m1, m3).widen(1.5) # Centre 2, half-width 1, scaled by 2.5 -> half-width 2.5 - assert r.minimum.mag == pytest.approx(-0.5) - assert r.maximum.mag == pytest.approx(4.5) + assert r.minimum.mag() == pytest.approx(-0.5) + assert r.maximum.mag() == pytest.approx(4.5) def test_widen_negative_value_rejected(self, m1, m3): """widen(-0.1) would contract; reject so 'widen' stays honest about its name.""" @@ -517,15 +517,15 @@ def test_widen_negative_value_rejected(self, m1, m3): def test_widen_identity(self, m1, m3): """widen(0) returns an equivalent range.""" r = QuantityRange(m1, m3).widen(0) - assert r.minimum.mag == pytest.approx(1) - assert r.maximum.mag == pytest.approx(3) + assert r.minimum.mag() == pytest.approx(1) + assert r.maximum.mag() == pytest.approx(3) def test_widen_degenerate_range(self): """A range with min==max stays degenerate; half-width is zero.""" a = PhysicsQuantity.construct(5, 'kg') r = QuantityRange(a, a).widen(0.1) - assert r.minimum.mag == pytest.approx(5) - assert r.maximum.mag == pytest.approx(5) + assert r.minimum.mag() == pytest.approx(5) + assert r.maximum.mag() == pytest.approx(5) # --- si_extra clash detection -------------------------------------------- @@ -687,7 +687,7 @@ def test_full(self, g): def test_approx(self, g): """g with 2 digits rounds to 9.8 m/s^2.""" - assert g.approx.mag == pytest.approx(9.8) + assert g.approx.mag() == pytest.approx(9.8) def test_full_approx(self, g): """Previously crashed with `ValueError: Invalid format specifier`.""" From e649e0f47264735ca751746a260cc5ab665dc311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 28 Apr 2026 14:13:00 +0000 Subject: [PATCH 13/46] Fixed resurfacing bugs --- core/builder/jinja.py | 5 ++--- core/latex/math.tex | 44 +++++++++++++++++++++---------------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/core/builder/jinja.py b/core/builder/jinja.py index 1aa137a4..f18405b5 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -56,7 +56,6 @@ def __str__(self): return CollectUndefined - class JinjaRenderer: """ A wrapper class for rendering Jinja2 templates. @@ -208,8 +207,8 @@ def __init__(self, **kwargs): 'acos': np.acos, 'atan': np.atan, 'atan2': np.atan2, - 'ceil': np.ceil, - 'floor': np.floor, + 'ceil': lambda x: PhysicsQuantity.ceil if isinstance(x, PhysicsQuantity) else np.ceil(x), + 'floor': lambda x: PhysicsQuantity.floor if isinstance(x, PhysicsQuantity) else np.floor(x), 'sqrt': lambda x: (x ** 0.5), 'cbrt': np.cbrt, 'rad': np.radians, diff --git a/core/latex/math.tex b/core/latex/math.tex index 172cb533..1986d8e7 100644 --- a/core/latex/math.tex +++ b/core/latex/math.tex @@ -1,6 +1,5 @@ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Operators %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -\ExplSyntaxOn \makeatletter % Patch \left and \right with package mleftright @@ -8,19 +7,26 @@ \let\right\mright % Internal parentheses -\DeclarePairedDelimiter{\dgs_paren@}{(}{)} -\DeclarePairedDelimiter{\dgs_abs@}{\lvert}{\rvert} -\DeclarePairedDelimiter{\dgs_floor@}{\lfloor}{\rfloor} -\DeclarePairedDelimiter{\dgs_ceil@}{\lceil}{\rceil} +\DeclarePairedDelimiter{\dgsparen@}{(}{)} +\DeclarePairedDelimiter{\dgsbracket@}{[}{]} +\DeclarePairedDelimiter{\dgsabs@}{\lvert}{\rvert} +\DeclarePairedDelimiter{\dgsfloor@}{\lfloor}{\rfloor} +\DeclarePairedDelimiter{\dgsceil@}{\lceil}{\rceil} +\DeclarePairedDelimiter{\dgschevrons@}{\langle}{\rangle} % Nice parentheses -\NewDocumentCommand{\Paren}{m}{\dgs_paren@*{#1}} -\NewDocumentCommand{\Abs}{m}{\dgs_abs@*{#1}} +\NewDocumentCommand{\Paren}{m}{\dgsparen@*{#1}} +\NewDocumentCommand{\Abs}{m}{\dgsabs@*{#1}} % Shorthands for floor and ceiling functions -\NewDocumentCommand{\Floor}{m}{\dgs_floor@*{#1}} -\NewDocumentCommand{\Ceil}{m}{\dgs_ceil@*{#1}} +\NewDocumentCommand{\Floor}{m}{\dgsfloor@*{#1}} +\NewDocumentCommand{\Ceil}{m}{\dgsceil@*{#1}} + +\NewDocumentCommand{\ExpectedChevrons}{m o}{\dgschevrons@*{#1}\IfNoValueTF{#2}{}{_#2}} +\NewDocumentCommand{\ExpectedE}{m o}{\mathrm{E}\dgsbrackets@*{#1}\IfNoValueTF{#2}{}{_#2}} +\NewDocumentCommand{\Expected}{m o}{\ExpectedE{#1}[#2]} % List of differentials +\ExplSyntaxOn \NewDocumentCommand{\cdiff}{O{,} m m}{\dgs_split_diff:nnn {#1} {#2} {#3}} \cs_new_protected:Npn \dgs_split_diff:nnn #1 #2 #3 { @@ -169,28 +175,27 @@ }% -\ExplSyntaxOn % Generic 1D integral % [lower limit] % [upper limit] % {integrand} % {differential} % [operation] between integrand and differential, such as \cdot or \times -\NewDocumentCommand{\dgs_int@}{O{} O{} O{} m O{}}{\int\limits_{#1}^{#2} #3#5\diff@#4} +\NewDocumentCommand{\dgsint}{O{} O{} O{} m O{}}{\int\limits_{#1}^{#2} #3#5\diff@#4} % Basic integral from #1 to #2 of #3 with respect to #4 % Usage \Int[0][1]{x^2}{x} -\NewDocumentCommand{\Int}{O{} O{} m m}{\dgs_int@[#1][#2][#3]{#4}[]} -\NewDocumentCommand{\IntP}{O{} O{} m m}{\dgs_int@[#1][#2][\left(#3\right)]{#4}[]} +\NewDocumentCommand{\Int}{O{} O{} m m}{\dgsint[#1][#2][#3]{#4}[]} +\NewDocumentCommand{\IntP}{O{} O{} m m}{\dgsint[#1][#2][\left(#3\right)]{#4}[]} % Empty integral for operations on expressions \NewDocumentCommand{\IntX}{O{} O{} m}{\int\limits_{#1}^{#2}{#3}} -\NewDocumentCommand{\IntE}{O{} O{} m}{\dgs_int@[#1][#2][]{#3}[]} +\NewDocumentCommand{\IntE}{O{} O{} m}{\dgsint[#1][#2][]{#3}[]} % Integral from #1 to #2 of dot product of #3 and d#4 -\NewDocumentCommand{\IntD}{O{} O{} m m}{\dgs_int@[#1][#2][#3]{#4}[\cdot]} +\NewDocumentCommand{\IntD}{O{} O{} m m}{\dgsint[#1][#2][#3]{#4}[\cdot]} % ...auto-vectorized version \NewDocumentCommand{\IntDV}{O{} O{} m m}{\IntD[#1][#2]{\vec{#3}}{\vec{#4}}} % Integral from #1 to #2 of cross product of #3 and d#4 -\NewDocumentCommand{\IntC}{O{} O{} m m}{\dgs_int@[#1][#2][#3]{#4}[\times]} +\NewDocumentCommand{\IntC}{O{} O{} m m}{\dgsint[#1][#2][#3]{#4}[\times]} % ...auto-vectorized version \NewDocumentCommand{\IntCV}{O{} O{} m m}{\IntC[#1][#2]{\vec{#3}}{\vec{#4}}} @@ -246,9 +251,6 @@ \DeclareMathOperator{\del}{\raisebox{0.06em}{\ensuremath{\vec{\nabla}}}} -\ExplSyntaxOff - - \DeclareMathOperator{\Grad}{\del\!} \DeclareMathOperator{\GradT}{grad} \NewDocumentCommand{\GradV}{m}{\Grad{\vec{#1}}} @@ -324,8 +326,6 @@ \NewDocumentCommand{\Distribution}{m o m}{#1\left(\IfNoValueTF{#2}{}{#2 \mid}#3\right)} % Statistics -\NewDocumentCommand{\ExpectedChevrons}{m}{\left\langle#1\right\rangle} -\NewDocumentCommand{\ExpectedE}{m}{\mathrm{E}\left[#1\right]} \NewDocumentCommand{\Mean}{m}{\overline{#1}} \NewDocumentCommand{\Var}{m}{\mathrm{Var}\left(#1\right)} \NewDocumentCommand{\MSE}{m}{\mathrm{MSE}\left({#1}\right)} @@ -447,7 +447,6 @@ \NewDocumentCommand{\kth}{m}{\ensuremath{#1^{\text{th}}}} % Defaults for multi-notation -\NewDocumentCommand{\Expected}{m}{\ExpectedE{#1}} \NewDocumentCommand{\OneHalf}{}{\text{½}} \NewDocumentCommand{\OneThird}{}{\text{⅓}} @@ -456,3 +455,4 @@ \NewDocumentCommand{\OneQuarter}{}{\text{¼}} \NewDocumentCommand{\ThreeQuarters}{}{\text{¾}} % (and maybe more but later) +\makeatother \ No newline at end of file From 611d49f211cb94f40ec0e0a9d61583598123b7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 28 Apr 2026 21:56:36 +0000 Subject: [PATCH 14/46] Microfixes on train --- core/builder/jinja.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/builder/jinja.py b/core/builder/jinja.py index f18405b5..aed31da1 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -7,7 +7,7 @@ import os import numpy as np -from typing import Any +from typing import Any, Callable from core.builder.context.quantities import PhysicsQuantity, QuantityRange from core.utilities import colour as c, logger @@ -160,7 +160,7 @@ class MarkdownJinjaRenderer(JinjaRenderer): Includes mathematical functions, basic constants, and numerous formatting filters. """ @staticmethod - def __generate_format_functions(func, tag): + def __generate_format_functions(func: Callable[[Any], Callable], tag: str): """ Generate formatting function shorthands for a particular format and all precisions between 0 and 9. """ @@ -182,7 +182,7 @@ def __init__(self, **kwargs): 'ng': latex.num_general, 'ef': latex.equals_float, 'eg': latex.equals_general, - 'w': lambda obj, value: obj.widen(value), + 'w': lambda obj, value: obj.widen(value), # This is so that we can call it on both Quantity and Range 'widen': lambda obj, value: obj.widen(value), 'mag': PhysicsQuantity.mag, } | @@ -223,9 +223,6 @@ def __init__(self, **kwargs): 'pi': np.pi, 'tau': math.tau, 'euler': math.e, - } | { - 'KtoC': lambda x: x - 273.15, - 'CtoK': lambda x: x + 273.15, } def _render(self, From d23b30dab7235a31a76ca960c3510b6a82b873e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Fri, 1 May 2026 11:14:24 +0000 Subject: [PATCH 15/46] Added only_unit, reverted unit from property --- core/builder/context/quantities/quantity.py | 11 ++++++++--- core/builder/jinja.py | 2 ++ core/data/constants.yaml | 3 ++- core/tests/test_quantities.py | 8 ++++---- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 96d42cc6..2d1631bf 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -127,7 +127,6 @@ def mag(self): """ Return the internal magnitude. """ return self._quantity.magnitude - @property def unit(self): """ Return the internal unit. """ return self._quantity.units @@ -148,6 +147,12 @@ def to(self, what): def simplify(self): return PhysicsQuantity(self._quantity.to_base_units(), symbol=self._symbol, si_extra=self.si_extra) + def only_unit(self): + fragments = self.format_struct(fmt='f') + si_extra = self.format_si_extra(self.si_extra) + unit = f"{{{fragments['unit']}}}" if fragments['unit'] else '1' + return rf'\unit{si_extra}{unit}' + def widen(self, value: float) -> "QuantityRange": """ Construct a tolerance range from this quantity: @@ -304,7 +309,7 @@ def __init__(self, # QuantityRange(1 kg, 500 g) work correctly. Incompatible units raise # the underlying pint DimensionalityError. self.minimum = minimum - self.unit = minimum.unit + self.unit = minimum.unit() self.maximum = maximum.to(self.unit) if self.minimum.mag() > self.maximum.mag(): @@ -371,7 +376,7 @@ def __init__(self, # First, try to force same units everywhere. If it works, good, if it does not, a pint error will be raised. assert len(qs) > 0, \ f"{self.__class__.__name__} must have at least one quantity" - self.qs = [q.to(qs[0].unit) for q in qs] + self.qs = [q.to(qs[0].unit()) for q in qs] self.si_extra = strict_merge(*(q.si_extra for q in self.qs)) diff --git a/core/builder/jinja.py b/core/builder/jinja.py index aed31da1..daf5b69b 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -185,6 +185,8 @@ def __init__(self, **kwargs): 'w': lambda obj, value: obj.widen(value), # This is so that we can call it on both Quantity and Range 'widen': lambda obj, value: obj.widen(value), 'mag': PhysicsQuantity.mag, + 'unit': PhysicsQuantity.only_unit, + 'sim': PhysicsQuantity.simplify, } | self.__generate_format_functions(numbers.format_float, 'f') | self.__generate_format_functions(numbers.format_general, 'g') | diff --git a/core/data/constants.yaml b/core/data/constants.yaml index 72120180..b090303f 100644 --- a/core/data/constants.yaml +++ b/core/data/constants.yaml @@ -38,7 +38,7 @@ gravity: symbol: "G" aliases: ['G'] magnitude: 6.67430e-11 - unit: "newton metre squared / kilogram squared" + unit: "kg^(-1) m^3 s^(-2)" digits: 3 ### Imperial @@ -202,6 +202,7 @@ boltzmann: symbol: "k_B" magnitude: 1.380649e-23 unit: "joule / kelvin" + aliases: ['k_B'] digits: 3 stefan_boltzmann: diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index 08a60245..4c4f6dcc 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -360,11 +360,11 @@ def test_ceil_of_zero(self): def test_floor_preserves_unit(self): m = PhysicsQuantity.construct(5.7, 'kg') - assert m.floor().unit == m.unit + assert m.floor().unit() == m.unit() def test_ceil_preserves_unit(self): m = PhysicsQuantity.construct(5.7, 'kg') - assert m.ceil().unit == m.unit + assert m.ceil().unit() == m.unit() def test_floor_returns_physics_quantity(self): m = PhysicsQuantity.construct(5.7, 'kg') @@ -454,8 +454,8 @@ def test_negative_factor_rejected(self): def test_unit_preserved(self): m = PhysicsQuantity.construct(100, 'meter') r = m.widen(0.1) - assert r.minimum.unit == r.maximum.unit - assert str(r.minimum.unit) == 'meter' + assert r.minimum.unit() == r.maximum.unit() + assert str(r.minimum.unit()) == 'meter' def test_returns_quantity_range(self): m = PhysicsQuantity.construct(5, 'kg') From 6bc435334e7b4b6e413fa2ed921ff98978b336c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Fri, 1 May 2026 11:16:24 +0000 Subject: [PATCH 16/46] Docstrings --- core/builder/context/quantities/quantity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 2d1631bf..2cace9b8 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -121,6 +121,7 @@ def quantity(self): @quantity.setter def quantity(self, value): + """ No setter: PhysicsQuantity is immutable. """ raise TypeError(f"{self.__class__.__name__} ({value}) is immutable") def mag(self): @@ -148,6 +149,8 @@ def simplify(self): return PhysicsQuantity(self._quantity.to_base_units(), symbol=self._symbol, si_extra=self.si_extra) def only_unit(self): + """ Return a nicely formatted unit (\unit{...} in siunitx format) + """ fragments = self.format_struct(fmt='f') si_extra = self.format_si_extra(self.si_extra) unit = f"{{{fragments['unit']}}}" if fragments['unit'] else '1' From b31e33948d4335bed4f9fe5493b5c29602374648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Fri, 1 May 2026 11:25:56 +0000 Subject: [PATCH 17/46] Claude's tests --- core/builder/context/quantities/quantity.py | 7 +- core/tests/test_quantities.py | 72 ++++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index 2cace9b8..a46f3512 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -149,11 +149,10 @@ def simplify(self): return PhysicsQuantity(self._quantity.to_base_units(), symbol=self._symbol, si_extra=self.si_extra) def only_unit(self): - """ Return a nicely formatted unit (\unit{...} in siunitx format) - """ - fragments = self.format_struct(fmt='f') + r""" Return a nicely formatted unit (\unit{...} in siunitx format) """ + fragments = self.format_struct(fmt='g') si_extra = self.format_si_extra(self.si_extra) - unit = f"{{{fragments['unit']}}}" if fragments['unit'] else '1' + unit = f"{{{fragments['unit']}}}" if fragments['unit'] else '{1}' return rf'\unit{si_extra}{unit}' def widen(self, value: float) -> "QuantityRange": diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index 4c4f6dcc..e320ea4b 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -284,6 +284,76 @@ def test_significant_figures_three(self): # --- PhysicsQuantity.mag() ------------------------------------------------- +class TestOnlyUnit: + """ + `only_unit()` returns the unit of a quantity formatted as a siunitx + `\\unit{...}` command, without the magnitude. The output can be used + in templates where you want to print the unit separately from the value. + """ + + def test_kilogram(self): + assert PhysicsQuantity.construct(5, 'kg').only_unit() == r'\unit{\kilo\gram}' + + def test_meter_per_second_squared(self): + assert PhysicsQuantity.construct(9.8, 'meter/second^2').only_unit() == \ + r'\unit{\meter\per\second\squared}' + + def test_meter_per_second(self): + assert PhysicsQuantity.construct(10, 'meter/second').only_unit() == \ + r'\unit{\meter\per\second}' + + def test_celsius(self): + """Degree-Celsius is converted to siunitx \\celsius.""" + from pint import UnitRegistry as u + T = PhysicsQuantity(u.Quantity(25, 'degree_Celsius')) + assert T.only_unit() == r'\unit{\celsius}' + + def test_joule(self): + assert PhysicsQuantity.construct(1.5, 'joule').only_unit() == r'\unit{\joule}' + + def test_pascal(self): + assert PhysicsQuantity.construct(101325, 'Pa').only_unit() == r'\unit{\pascal}' + + def test_dimensionless_gives_unit_one(self): + """A dimensionless quantity produces \\unit{1}.""" + d = PhysicsQuantity.construct(3.14, '') + assert d.only_unit() == r'\unit{1}' + + def test_si_extra_included(self): + """si_extra options appear between \\unit and the braces.""" + m = PhysicsQuantity.construct(5, 'kg', si_extra={'round-mode': 'figures'}) + assert m.only_unit() == r'\unit[round-mode=figures]{\kilo\gram}' + + def test_symbol_ignored(self): + """The symbol is a display label for the quantity, not part of its unit.""" + m = PhysicsQuantity.construct(5, 'meter', symbol='d') + assert m.only_unit() == r'\unit{\meter}' + + def test_after_unit_conversion(self): + """Unit reflects the converted unit, not the original.""" + g = PhysicsQuantity.construct(1000, 'gram').to('kilogram') + assert g.only_unit() == r'\unit{\kilo\gram}' + + def test_magnitude_does_not_appear(self): + """Sanity check: the output contains no numeric characters.""" + m = PhysicsQuantity.construct(12345.678, 'kg') + result = m.only_unit() + assert not any(c.isdigit() for c in result), \ + f"Magnitude appeared in unit output: {result!r}" + + def test_starts_with_unit_command(self): + m = PhysicsQuantity.construct(5, 'kg') + assert m.only_unit().startswith(r'\unit') + + def test_jinja_filter(self): + """The `| unit` Jinja filter wires correctly to only_unit().""" + from core.builder.jinja import MarkdownJinjaRenderer + renderer = MarkdownJinjaRenderer() + ctx = {'q': PhysicsQuantity.construct(9.8, 'meter/second^2')} + result = renderer.render('(§ q | unit §)', ctx) + assert result == r'\unit{\meter\per\second\squared}' + + class TestMag: """ `mag` is a thin accessor for the underlying pint magnitude. It returns @@ -693,4 +763,4 @@ def test_full_approx(self, g): """Previously crashed with `ValueError: Invalid format specifier`.""" rendered = g.full_approx assert r'\qty{' in rendered - assert '9.8' in rendered \ No newline at end of file + assert '9.8' in rendered From bcd4091a3ae9ff654217632e2f4bf0481e921352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Fri, 1 May 2026 12:16:48 +0000 Subject: [PATCH 18/46] Restored properties, added .minted to .gitignore --- .gitignore | 1 + core/builder/context/quantities/quantity.py | 10 ++- core/builder/jinja.py | 6 +- core/tests/test_quantities.py | 96 ++++++++++----------- 4 files changed, 58 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 82123e09..a105c62e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ source/ _*/ .idea/* svg-inkscape/ +*.minted diff --git a/core/builder/context/quantities/quantity.py b/core/builder/context/quantities/quantity.py index a46f3512..6c22a448 100644 --- a/core/builder/context/quantities/quantity.py +++ b/core/builder/context/quantities/quantity.py @@ -124,10 +124,12 @@ def quantity(self, value): """ No setter: PhysicsQuantity is immutable. """ raise TypeError(f"{self.__class__.__name__} ({value}) is immutable") + @property def mag(self): """ Return the internal magnitude. """ return self._quantity.magnitude + @property def unit(self): """ Return the internal unit. """ return self._quantity.units @@ -311,10 +313,10 @@ def __init__(self, # QuantityRange(1 kg, 500 g) work correctly. Incompatible units raise # the underlying pint DimensionalityError. self.minimum = minimum - self.unit = minimum.unit() + self.unit = minimum.unit self.maximum = maximum.to(self.unit) - if self.minimum.mag() > self.maximum.mag(): + if self.minimum.mag > self.maximum.mag: raise ValueError( f"QuantityRange minimum ({minimum}) " f"must not exceed maximum ({maximum})" @@ -378,7 +380,7 @@ def __init__(self, # First, try to force same units everywhere. If it works, good, if it does not, a pint error will be raised. assert len(qs) > 0, \ f"{self.__class__.__name__} must have at least one quantity" - self.qs = [q.to(qs[0].unit()) for q in qs] + self.qs = [q.to(qs[0].unit) for q in qs] self.si_extra = strict_merge(*(q.si_extra for q in self.qs)) @@ -399,4 +401,4 @@ def __format__(self, fmt: str): return rf'\{cmd}{si_extraf}{magf}{unitf}' def __str__(self): - return format(self, 'g') \ No newline at end of file + return format(self, 'g') diff --git a/core/builder/jinja.py b/core/builder/jinja.py index daf5b69b..d8614b4a 100644 --- a/core/builder/jinja.py +++ b/core/builder/jinja.py @@ -174,7 +174,8 @@ def __init__(self, **kwargs): variable_end_string='§)', **kwargs) - self.env.filters |= ({ + self.env.filters |= ( + { 'f': numbers.format_float, 'g': numbers.format_general, 'n': latex.num, @@ -184,7 +185,7 @@ def __init__(self, **kwargs): 'eg': latex.equals_general, 'w': lambda obj, value: obj.widen(value), # This is so that we can call it on both Quantity and Range 'widen': lambda obj, value: obj.widen(value), - 'mag': PhysicsQuantity.mag, + 'mag': lambda q: q.mag, 'unit': PhysicsQuantity.only_unit, 'sim': PhysicsQuantity.simplify, } | @@ -231,4 +232,3 @@ def _render(self, template: str, context: dict[str, Any]): return self.env.from_string(template).render(**context) - diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index e320ea4b..1e7bcfd6 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -264,24 +264,24 @@ def test_non_integer_digits_rejected(self): def test_symmetric_around_zero(self): """Positive and negative magnitudes must round symmetrically.""" - pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag() - neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag() + pos = PhysicsQuantity.construct(1.55, 'kg').approximate(2).mag + neg = PhysicsQuantity.construct(-1.55, 'kg').approximate(2).mag assert pos == -neg, f"asymmetric rounding: {pos} vs {neg}" def test_zero_magnitude(self): m = PhysicsQuantity.construct(0.0, 'kg') - assert m.approximate(3).mag() == 0.0 + assert m.approximate(3).mag == 0.0 def test_significant_figures_one(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(1).mag() == 100.0 + assert m.approximate(1).mag == 100.0 def test_significant_figures_three(self): m = PhysicsQuantity.construct(123.456, 'kg') - assert m.approximate(3).mag() == 123.0 + assert m.approximate(3).mag == 123.0 -# --- PhysicsQuantity.mag() ------------------------------------------------- +# --- PhysicsQuantity.mag ------------------------------------------------- class TestOnlyUnit: @@ -362,26 +362,26 @@ class TestMag: """ def test_int_construction(self): - assert PhysicsQuantity.construct(7, 'kg').mag() == 7 + assert PhysicsQuantity.construct(7, 'kg').mag == 7 def test_float_construction(self): - assert PhysicsQuantity.construct(5.5, 'kg').mag() == pytest.approx(5.5) + assert PhysicsQuantity.construct(5.5, 'kg').mag == pytest.approx(5.5) def test_negative(self): - assert PhysicsQuantity.construct(-3.7, 'kg').mag() == pytest.approx(-3.7) + assert PhysicsQuantity.construct(-3.7, 'kg').mag == pytest.approx(-3.7) def test_dimensionless(self): - assert PhysicsQuantity.construct(2.5, '').mag() == pytest.approx(2.5) + assert PhysicsQuantity.construct(2.5, '').mag == pytest.approx(2.5) def test_after_unit_conversion(self): """`to()` may change int -> float as a side effect of conversion.""" kg = PhysicsQuantity.construct(1, 'kilogram') - assert kg.to('gram').mag() == pytest.approx(1000) + assert kg.to('gram').mag == pytest.approx(1000) def test_mag_does_not_carry_units(self): """The bare magnitude is a number, not a pint Quantity.""" m = PhysicsQuantity.construct(5, 'kg') - assert not hasattr(m.mag(), 'units') + assert not hasattr(m.mag, 'units') # --- PhysicsQuantity.floor / .ceil -------------------------------------- @@ -396,45 +396,45 @@ class TestFloorCeil: def test_floor_positive(self): m = PhysicsQuantity.construct(5.7, 'kg') - assert m.floor().mag() == pytest.approx(5) + assert m.floor().mag == pytest.approx(5) def test_ceil_positive(self): m = PhysicsQuantity.construct(5.3, 'kg') - assert m.ceil().mag() == pytest.approx(6) + assert m.ceil().mag == pytest.approx(6) def test_floor_negative_rounds_toward_neg_inf(self): """floor(-2.3) is -3, not -2 (toward -inf, not toward zero).""" m = PhysicsQuantity.construct(-2.3, 'meter') - assert m.floor().mag() == pytest.approx(-3) + assert m.floor().mag == pytest.approx(-3) def test_ceil_negative_rounds_toward_pos_inf(self): """ceil(-2.3) is -2, not -3 (toward +inf, not toward zero).""" m = PhysicsQuantity.construct(-2.3, 'meter') - assert m.ceil().mag() == pytest.approx(-2) + assert m.ceil().mag == pytest.approx(-2) def test_floor_of_integer(self): m = PhysicsQuantity.construct(5.0, 'kg') - assert m.floor().mag() == pytest.approx(5) + assert m.floor().mag == pytest.approx(5) def test_ceil_of_integer(self): m = PhysicsQuantity.construct(5.0, 'kg') - assert m.ceil().mag() == pytest.approx(5) + assert m.ceil().mag == pytest.approx(5) def test_floor_of_zero(self): m = PhysicsQuantity.construct(0, 'kg') - assert m.floor().mag() == pytest.approx(0) + assert m.floor().mag == pytest.approx(0) def test_ceil_of_zero(self): m = PhysicsQuantity.construct(0, 'kg') - assert m.ceil().mag() == pytest.approx(0) + assert m.ceil().mag == pytest.approx(0) def test_floor_preserves_unit(self): m = PhysicsQuantity.construct(5.7, 'kg') - assert m.floor().unit() == m.unit() + assert m.floor().unit == m.unit def test_ceil_preserves_unit(self): m = PhysicsQuantity.construct(5.7, 'kg') - assert m.ceil().unit() == m.unit() + assert m.ceil().unit == m.unit def test_floor_returns_physics_quantity(self): m = PhysicsQuantity.construct(5.7, 'kg') @@ -469,7 +469,7 @@ def test_floor_dimensionless_renders_with_num(self): def test_floor_then_ceil_idempotent_on_floored_value(self): """ceil of a floored integer is the same value.""" m = PhysicsQuantity.construct(5.7, 'kg') - assert m.floor().ceil().mag() == pytest.approx(5) + assert m.floor().ceil().mag == pytest.approx(5) # --- PhysicsQuantity.widen ----------------------------------------------- @@ -485,36 +485,36 @@ class TestQuantityWiden: def test_positive_value(self): m = PhysicsQuantity.construct(100, 'meter') r = m.widen(0.05) - assert r.minimum.mag() == pytest.approx(95) - assert r.maximum.mag() == pytest.approx(105) + assert r.minimum.mag == pytest.approx(95) + assert r.maximum.mag == pytest.approx(105) def test_negative_value(self): """For x < 0, the result is still ordered (min < max).""" m = PhysicsQuantity.construct(-100, 'meter') r = m.widen(0.05) - assert r.minimum.mag() == pytest.approx(-105) - assert r.maximum.mag() == pytest.approx(-95) + assert r.minimum.mag == pytest.approx(-105) + assert r.maximum.mag == pytest.approx(-95) def test_large_factor_crosses_zero(self): """value >= 1 produces a range crossing zero.""" m = PhysicsQuantity.construct(100, 'meter') r = m.widen(1.5) - assert r.minimum.mag() == pytest.approx(-50) - assert r.maximum.mag() == pytest.approx(250) + assert r.minimum.mag == pytest.approx(-50) + assert r.maximum.mag == pytest.approx(250) def test_zero_value(self): """widen on x == 0 gives a degenerate range at zero.""" m = PhysicsQuantity.construct(0, 'meter') r = m.widen(0.1) - assert r.minimum.mag() == 0 - assert r.maximum.mag() == 0 + assert r.minimum.mag == 0 + assert r.maximum.mag == 0 def test_zero_factor(self): """widen(0) gives a degenerate range at the original value.""" m = PhysicsQuantity.construct(42, 'meter') r = m.widen(0) - assert r.minimum.mag() == pytest.approx(42) - assert r.maximum.mag() == pytest.approx(42) + assert r.minimum.mag == pytest.approx(42) + assert r.maximum.mag == pytest.approx(42) def test_negative_factor_rejected(self): m = PhysicsQuantity.construct(100, 'meter') @@ -524,8 +524,8 @@ def test_negative_factor_rejected(self): def test_unit_preserved(self): m = PhysicsQuantity.construct(100, 'meter') r = m.widen(0.1) - assert r.minimum.unit() == r.maximum.unit() - assert str(r.minimum.unit()) == 'meter' + assert r.minimum.unit == r.maximum.unit + assert str(r.minimum.unit) == 'meter' def test_returns_quantity_range(self): m = PhysicsQuantity.construct(5, 'kg') @@ -553,31 +553,31 @@ def test_swapped_endpoints_rejected(self, m1, m3): def test_widen_positive_range(self, m1, m3): """[1, 3] widened by 0.1: width grows from 2 to 2.2, symmetric around centre 2.""" r = QuantityRange(m1, m3).widen(0.1) - assert r.minimum.mag() == pytest.approx(0.9) - assert r.maximum.mag() == pytest.approx(3.1) + assert r.minimum.mag == pytest.approx(0.9) + assert r.maximum.mag == pytest.approx(3.1) def test_widen_negative_range(self): """A negative-valued range must widen, not narrow.""" a = PhysicsQuantity.construct(-3, 'kg') b = PhysicsQuantity.construct(-1, 'kg') r = QuantityRange(a, b).widen(0.1) - assert r.minimum.mag() == pytest.approx(-3.1) - assert r.maximum.mag() == pytest.approx(-0.9) + assert r.minimum.mag == pytest.approx(-3.1) + assert r.maximum.mag == pytest.approx(-0.9) def test_widen_zero_centred_range(self): """Range straddling zero widens symmetrically.""" a = PhysicsQuantity.construct(-1, 'kg') b = PhysicsQuantity.construct(1, 'kg') r = QuantityRange(a, b).widen(0.5) - assert r.minimum.mag() == pytest.approx(-1.5) - assert r.maximum.mag() == pytest.approx(1.5) + assert r.minimum.mag == pytest.approx(-1.5) + assert r.maximum.mag == pytest.approx(1.5) def test_widen_large_factor_allowed(self, m1, m3): """value >= 1 is now permitted; the range simply grows substantially.""" r = QuantityRange(m1, m3).widen(1.5) # Centre 2, half-width 1, scaled by 2.5 -> half-width 2.5 - assert r.minimum.mag() == pytest.approx(-0.5) - assert r.maximum.mag() == pytest.approx(4.5) + assert r.minimum.mag == pytest.approx(-0.5) + assert r.maximum.mag == pytest.approx(4.5) def test_widen_negative_value_rejected(self, m1, m3): """widen(-0.1) would contract; reject so 'widen' stays honest about its name.""" @@ -587,15 +587,15 @@ def test_widen_negative_value_rejected(self, m1, m3): def test_widen_identity(self, m1, m3): """widen(0) returns an equivalent range.""" r = QuantityRange(m1, m3).widen(0) - assert r.minimum.mag() == pytest.approx(1) - assert r.maximum.mag() == pytest.approx(3) + assert r.minimum.mag == pytest.approx(1) + assert r.maximum.mag == pytest.approx(3) def test_widen_degenerate_range(self): """A range with min==max stays degenerate; half-width is zero.""" a = PhysicsQuantity.construct(5, 'kg') r = QuantityRange(a, a).widen(0.1) - assert r.minimum.mag() == pytest.approx(5) - assert r.maximum.mag() == pytest.approx(5) + assert r.minimum.mag == pytest.approx(5) + assert r.maximum.mag == pytest.approx(5) # --- si_extra clash detection -------------------------------------------- @@ -757,7 +757,7 @@ def test_full(self, g): def test_approx(self, g): """g with 2 digits rounds to 9.8 m/s^2.""" - assert g.approx.mag() == pytest.approx(9.8) + assert g.approx.mag == pytest.approx(9.8) def test_full_approx(self, g): """Previously crashed with `ValueError: Invalid format specifier`.""" From 4c3c2828d79f97115dfa5a859920c49cfd93bbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 14 Jul 2026 13:07:23 +0000 Subject: [PATCH 19/46] Switched to uv --- Pipfile | 30 -- Pipfile.lock | 902 -------------------------------------- core/builder/convertor.py | 4 +- pyproject.toml | 25 ++ uv.lock | 499 +++++++++++++++++++++ 5 files changed, 526 insertions(+), 934 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/Pipfile b/Pipfile deleted file mode 100644 index c073ef6b..00000000 --- a/Pipfile +++ /dev/null @@ -1,30 +0,0 @@ -[[source]] -verify_ssl = true -name = "pypi" -url = "https://pypi.org/simple" - -[requires] -python_version = "3.13" - -[dev-packages] -pytest = "*" -"flake8" = "*" -ipython = "*" - -[packages] -pyyaml = "*" -colorama = ">=0.4.6" -ply = ">=3.11" -"Jinja2" = ">=2.10.1" -PyYAML = ">=5.4" -pandoc-crossref = ">=0.1.1" -pandoc-minted = "*" -pandoc-include = "*" -dotmap = "*" -argparsedirs = "*" -schema = "*" -pygments = "*" -enschema = "*" -regex = "*" -pint = "*" -numpy = "*" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 6a809c08..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,902 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "88edaf5e623379497ab693f75ced356968216a8106dd201d048d56753f0090a9" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.13" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "argparse": { - "hashes": [ - "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", - "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314" - ], - "version": "==1.4.0" - }, - "argparsedirs": { - "hashes": [ - "sha256:3db491a9a6735c25cb46aab161551b1f910a1747e2768b469a92189ed65a5ea9", - "sha256:c9a243110a8af10e297deb5a3602d8860909563ab240de0753f4a1275965163c" - ], - "index": "pypi", - "markers": "python_version >= '3.10' and python_version < '4.0'", - "version": "==0.2.1" - }, - "click": { - "hashes": [ - "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", - "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" - ], - "markers": "python_version >= '3.10'", - "version": "==8.3.2" - }, - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", - "version": "==0.4.6" - }, - "dotmap": { - "hashes": [ - "sha256:5821a7933f075fb47563417c0e92e0b7c031158b4c9a6a7e56163479b658b368", - "sha256:bd9fa15286ea2ad899a4d1dc2445ed85a1ae884a42effb87c89a6ecce71243c6" - ], - "index": "pypi", - "version": "==1.3.30" - }, - "enschema": { - "hashes": [ - "sha256:a4f37c04ec192cb8d33ac2aaeb2620008a09994a3c88e2a56f6676370da77ad4", - "sha256:e128802c2832fbd40db1b51feed83d6a3592573f73ef50332228bce2848b80d1" - ], - "index": "pypi", - "markers": "python_version >= '3.10' and python_version < '4.0'", - "version": "==0.1.5" - }, - "flexcache": { - "hashes": [ - "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", - "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32" - ], - "markers": "python_version >= '3.9'", - "version": "==0.3" - }, - "flexparser": { - "hashes": [ - "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", - "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846" - ], - "markers": "python_version >= '3.9'", - "version": "==0.4" - }, - "jinja2": { - "hashes": [ - "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", - "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==3.1.6" - }, - "lxml": { - "hashes": [ - "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", - "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", - "sha256:045e387d1f4f42a418380930fa3f45c73c9b392faf67e495e58902e68e8f44a7", - "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", - "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", - "sha256:08950a23f296b3f83521577274e3d3b0f3d739bf2e68d01a752e4288bc50d286", - "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", - "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", - "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", - "sha256:11a873c77a181b4fef9c2e357d08ed399542c2af1390101da66720a19c7c9618", - "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", - "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", - "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", - "sha256:1bc4cc83fb7f66ffb16f74d6dd0162e144333fc36ebcce32246f80c8735b2551", - "sha256:1dd6a1c3ad4cb674f44525d9957f3e9c209bb6dd9213245195167a281fcc2bdc", - "sha256:20cf4d0651987c906a2f5cba4e3a8d6ba4bfdf973cfe2a96c0d6053888ea2ecd", - "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", - "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", - "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", - "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", - "sha256:2593a0a6621545b9095b71ad74ed4226eba438a7d9fc3712a99bdb15508cf93a", - "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", - "sha256:26c5272c6a4bf4cf32d3f5a7890c942b0e04438691157d341616d02cca74d4bd", - "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", - "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", - "sha256:29f5c00cb7d752bce2c70ebd2d31b0a42f9499ffdd3ecb2f31a5b73ee43031ad", - "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", - "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", - "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", - "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", - "sha256:37448bf9c7d7adfc5254763901e2bbd6bb876228dfc1fc7f66e58c06368a7544", - "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", - "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", - "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", - "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", - "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", - "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", - "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec", - "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", - "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", - "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", - "sha256:468479e52ecf3ec23799c863336d02c05fc2f7ffd1a1424eeeb9a28d4eb69d13", - "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", - "sha256:481d6e2104285d9add34f41b42b247b76b61c5b5c26c303c2e9707bbf8bd9a64", - "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", - "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", - "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", - "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", - "sha256:4e2c54d6b47361d0f1d3bc8d4e082ad87201e56ccdcca4d3b9ee3644ff595ec8", - "sha256:52b0ac6903cf74ebf997eb8c682d2fbac7d1ab7e4c552413eec55868a9b73f39", - "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", - "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", - "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", - "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", - "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", - "sha256:6262b87f9e5c1e5fe501d6c153247289af42eb44ad7660b9b3de17baaf92d6f6", - "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", - "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", - "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", - "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", - "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", - "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", - "sha256:76b958b4ea3104483c20f74866d55aa056546e15ebe83dd7aecd63698f43b755", - "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", - "sha256:7ba11752e346bd804ea312ec2eea2532dfa8b8d3261d81a32ef9e6ab16256280", - "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", - "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", - "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", - "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", - "sha256:81ff55c70b67d19d52b6fd118a114c0a4c97d799cd3089ff9bd9e2ff4b414ee2", - "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", - "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", - "sha256:8c11b984b5ce6add4dccc7144c7be5d364d298f15b0c6a57da1991baedc750ce", - "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", - "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", - "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", - "sha256:920354904d1cb86577d4b3cfe2830c2dbe81d6f4449e57ada428f1609b5985f7", - "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", - "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", - "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", - "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", - "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", - "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", - "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", - "sha256:9f93d5b8b07f73e8c77e3c6556a3db269918390c804b5e5fcdd4858232cc8f16", - "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", - "sha256:a02ca8fe48815bddcfca3248efe54451abb9dbf2f7d1c5744c8aa4142d476919", - "sha256:a1d9b99e5b2597e4f5aed2484fef835256fa1b68a19e4265c97628ef4bf8bcf4", - "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", - "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec", - "sha256:a86f06f059e22a0d574990ee2df24ede03f7f3c68c1336293eee9536c4c776cd", - "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", - "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", - "sha256:b6c2f225662bc5ad416bdd06f72ca301b31b39ce4261f0e0097017fc2891b940", - "sha256:bb40648d96157f9081886defe13eac99253e663be969ff938a9289eff6e47b72", - "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", - "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", - "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", - "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", - "sha256:c08da09dc003c9e8c70e06b53a11db6fb3b250c21c4236b03c7d7b443c318e7a", - "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", - "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", - "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", - "sha256:c53fa3a5a52122d590e847a57ccf955557b9634a7f99ff5a35131321b0a85317", - "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", - "sha256:c748ebcb6877de89f48ab90ca96642ac458fff5dec291a2b9337cd4d0934e383", - "sha256:c871299c595ee004d186f61840f0bfc4941aa3f17c8ba4a565ead7e4f4f820ee", - "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", - "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", - "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", - "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", - "sha256:d0d799ff958655781296ec870d5e2448e75150da2b3d07f13ff5b0c2c35beefd", - "sha256:d1392c569c032f78a11a25d1de1c43fff13294c793b39e19d84fade3045cbbc3", - "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", - "sha256:d3829a6e6fd550a219564912d4002c537f65da4c6ae4e093cc34462f4fa027ad", - "sha256:d43aa26dcda363f21e79afa0668f5029ed7394b3bb8c92a6927a3d34e8b610ea", - "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", - "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", - "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", - "sha256:de550d129f18d8ab819651ffe4f38b1b713c7e116707de3c0c6400d0ef34fbc1", - "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", - "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", - "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", - "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", - "sha256:e80807d72f96b96ad5588cb85c75616e4f2795a7737d4630784c51497beb7776", - "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", - "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", - "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", - "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", - "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", - "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", - "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", - "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", - "sha256:ffb34ea45a82dd637c2c97ae1bbb920850c1e59bcae79ce1c15af531d83e7215" - ], - "markers": "python_version >= '3.8'", - "version": "==6.1.0" - }, - "markupsafe": { - "hashes": [ - "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", - "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", - "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", - "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", - "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", - "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", - "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", - "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", - "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", - "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", - "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", - "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", - "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", - "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", - "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", - "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", - "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", - "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", - "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", - "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", - "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", - "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", - "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", - "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", - "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", - "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", - "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", - "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", - "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", - "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", - "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", - "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", - "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", - "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", - "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", - "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", - "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", - "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", - "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", - "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", - "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", - "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", - "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", - "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", - "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", - "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", - "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", - "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", - "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", - "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", - "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", - "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", - "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", - "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", - "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", - "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", - "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", - "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", - "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", - "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", - "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", - "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", - "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", - "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", - "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", - "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", - "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", - "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", - "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", - "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", - "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", - "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", - "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", - "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", - "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", - "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", - "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", - "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", - "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", - "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", - "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", - "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", - "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", - "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", - "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", - "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", - "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", - "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", - "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" - ], - "markers": "python_version >= '3.9'", - "version": "==3.0.3" - }, - "natsort": { - "hashes": [ - "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", - "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c" - ], - "markers": "python_version >= '3.7'", - "version": "==8.4.0" - }, - "numpy": { - "hashes": [ - "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", - "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", - "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", - "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", - "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", - "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", - "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", - "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", - "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", - "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", - "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", - "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", - "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", - "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", - "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", - "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", - "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", - "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", - "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", - "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", - "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", - "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", - "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", - "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", - "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", - "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", - "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", - "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", - "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", - "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", - "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", - "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", - "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", - "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", - "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", - "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", - "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", - "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", - "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", - "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", - "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", - "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", - "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", - "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", - "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", - "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", - "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", - "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", - "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", - "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", - "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", - "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", - "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", - "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", - "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", - "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", - "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", - "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", - "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", - "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", - "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", - "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", - "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", - "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", - "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", - "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", - "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", - "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", - "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", - "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", - "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", - "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e" - ], - "index": "pypi", - "markers": "python_version >= '3.11'", - "version": "==2.4.4" - }, - "pandoc-crossref": { - "hashes": [ - "sha256:de738ffc9789feb13d16b5f7ac9c0298aff187ae958c0472167d26cec0ad9714" - ], - "index": "pypi", - "version": "==0.1.1" - }, - "pandoc-include": { - "hashes": [ - "sha256:242cd4e4ddd327c6cb528ce82e736b0ec1c31de68011a4fc02046dc04ae7bf2c", - "sha256:d92829a79bdc1ba4655effc43c945c63273b8829bd1f426993bf29c536dfdb36" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==1.4.3" - }, - "pandoc-minted": { - "hashes": [ - "sha256:0cc0a2c279c514df2512a4ffe59766b02b59fe68e4ee85c4437e2e00d40bcaa6" - ], - "index": "pypi", - "version": "==0.2.0" - }, - "pandocfilters": { - "hashes": [ - "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", - "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.1" - }, - "panflute": { - "hashes": [ - "sha256:5f1bd02a34ef3982ee025ec5b58fb3a6eedfc31d994b8ae39d8dc9915a2d8f1f", - "sha256:e44afd875b7b17ffebbbe58282849df06d9f1b20a45a2f933cd51bdcf4e89130" - ], - "markers": "python_version >= '3.7'", - "version": "==2.3.1" - }, - "pint": { - "hashes": [ - "sha256:27eb25143bd5de9fcc4d5a4b484f16faf6b4615aa93ece6b3373a8c1a3c1b97d", - "sha256:f8f5df6cf65314d74da1ade1bf96f8e3e4d0c41b51577ac53c49e7d44ca5acee" - ], - "index": "pypi", - "markers": "python_version >= '3.11'", - "version": "==0.25.3" - }, - "platformdirs": { - "hashes": [ - "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", - "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917" - ], - "markers": "python_version >= '3.10'", - "version": "==4.9.6" - }, - "ply": { - "hashes": [ - "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", - "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce" - ], - "index": "pypi", - "version": "==3.11" - }, - "pygments": { - "hashes": [ - "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", - "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==2.20.0" - }, - "pyyaml": { - "hashes": [ - "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", - "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", - "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", - "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", - "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", - "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", - "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", - "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", - "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", - "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", - "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", - "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", - "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", - "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", - "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", - "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", - "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", - "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", - "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", - "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", - "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", - "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", - "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", - "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", - "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", - "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", - "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", - "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", - "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", - "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", - "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", - "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", - "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", - "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", - "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", - "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", - "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", - "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", - "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", - "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", - "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", - "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", - "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", - "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", - "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", - "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", - "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", - "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", - "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", - "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", - "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", - "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", - "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", - "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", - "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", - "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", - "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", - "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", - "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", - "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", - "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", - "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", - "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", - "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", - "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", - "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", - "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", - "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", - "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", - "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", - "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", - "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", - "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==6.0.3" - }, - "regex": { - "hashes": [ - "sha256:011bb48bffc1b46553ac704c975b3348717f4e4aa7a67522b51906f99da1820c", - "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", - "sha256:0540e5b733618a2f84e9cb3e812c8afa82e151ca8e19cf6c4e95c5a65198236f", - "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", - "sha256:0709f22a56798457ae317bcce42aacee33c680068a8f14097430d9f9ba364bee", - "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", - "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", - "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", - "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", - "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", - "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", - "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", - "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", - "sha256:1b9a00b83f3a40e09859c78920571dcb83293c8004079653dd22ec14bbfa98c7", - "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", - "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", - "sha256:2895506ebe32cc63eeed8f80e6eae453171cfccccab35b70dc3129abec35a5b8", - "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", - "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", - "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", - "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", - "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", - "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", - "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", - "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", - "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", - "sha256:33bfda9684646d323414df7abe5692c61d297dbb0530b28ec66442e768813c59", - "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", - "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", - "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", - "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", - "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", - "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", - "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", - "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", - "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", - "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", - "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", - "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", - "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", - "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", - "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", - "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", - "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", - "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", - "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", - "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", - "sha256:6780f008ee81381c737634e75c24e5a6569cc883c4f8e37a37917ee79efcafd9", - "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", - "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", - "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", - "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", - "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", - "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", - "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", - "sha256:74fa82dcc8143386c7c0392e18032009d1db715c25f4ba22d23dc2e04d02a20f", - "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", - "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", - "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", - "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", - "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", - "sha256:8512fcdb43f1bf18582698a478b5ab73f9c1667a5b7548761329ef410cd0a760", - "sha256:867bddc63109a0276f5a31999e4c8e0eb7bbbad7d6166e28d969a2c1afeb97f9", - "sha256:88e9b048345c613f253bea4645b2fe7e579782b82cac99b1daad81e29cc2ed8e", - "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", - "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", - "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", - "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", - "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", - "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", - "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", - "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", - "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", - "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", - "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", - "sha256:a85b620a388d6c9caa12189233109e236b3da3deffe4ff11b84ae84e218a274f", - "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", - "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", - "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", - "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", - "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", - "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", - "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", - "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", - "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", - "sha256:be061028481186ba62a0f4c5f1cc1e3d5ab8bce70c89236ebe01023883bc903b", - "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", - "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", - "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", - "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", - "sha256:cf9b1b2e692d4877880388934ac746c99552ce6bf40792a767fd42c8c99f136d", - "sha256:d2228c02b368d69b724c36e96d3d1da721561fb9cc7faa373d7bf65e07d75cb5", - "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", - "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", - "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", - "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", - "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", - "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", - "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", - "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", - "sha256:e355be718caf838aa089870259cf1776dc2a4aa980514af9d02c59544d9a8b22", - "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", - "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", - "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", - "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", - "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", - "sha256:ee9627de8587c1a22201cb16d0296ab92b4df5cdcb5349f4e9744d61db7c7c98", - "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", - "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", - "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", - "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", - "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", - "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", - "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e" - ], - "index": "pypi", - "markers": "python_version >= '3.10'", - "version": "==2026.4.4" - }, - "schema": { - "hashes": [ - "sha256:00bd977fadc7d9521bf289850cd8a8aa5f4948f575476b8daaa5c1b57af2dce1", - "sha256:e86cc08edd6fe6e2522648f4e47e3a31920a76e82cce8937535422e310862ab5" - ], - "index": "pypi", - "version": "==0.7.8" - }, - "typing-extensions": { - "hashes": [ - "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", - "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" - ], - "markers": "python_version >= '3.9'", - "version": "==4.15.0" - } - }, - "develop": { - "asttokens": { - "hashes": [ - "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", - "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" - ], - "markers": "python_version >= '3.8'", - "version": "==3.0.1" - }, - "decorator": { - "hashes": [ - "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", - "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" - ], - "markers": "python_version >= '3.8'", - "version": "==5.2.1" - }, - "executing": { - "hashes": [ - "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", - "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" - ], - "markers": "python_version >= '3.8'", - "version": "==2.2.1" - }, - "flake8": { - "hashes": [ - "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", - "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==7.3.0" - }, - "iniconfig": { - "hashes": [ - "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", - "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" - ], - "markers": "python_version >= '3.10'", - "version": "==2.3.0" - }, - "ipython": { - "hashes": [ - "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", - "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d" - ], - "index": "pypi", - "markers": "python_version >= '3.12'", - "version": "==9.12.0" - }, - "ipython-pygments-lexers": { - "hashes": [ - "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", - "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" - ], - "markers": "python_version >= '3.8'", - "version": "==1.1.1" - }, - "jedi": { - "hashes": [ - "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", - "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9" - ], - "markers": "python_version >= '3.6'", - "version": "==0.19.2" - }, - "matplotlib-inline": { - "hashes": [ - "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", - "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe" - ], - "markers": "python_version >= '3.9'", - "version": "==0.2.1" - }, - "mccabe": { - "hashes": [ - "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", - "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.0" - }, - "packaging": { - "hashes": [ - "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", - "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de" - ], - "markers": "python_version >= '3.8'", - "version": "==26.1" - }, - "parso": { - "hashes": [ - "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", - "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff" - ], - "markers": "python_version >= '3.6'", - "version": "==0.8.6" - }, - "pexpect": { - "hashes": [ - "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", - "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" - ], - "markers": "sys_platform != 'win32' and sys_platform != 'emscripten'", - "version": "==4.9.0" - }, - "pluggy": { - "hashes": [ - "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", - "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" - ], - "markers": "python_version >= '3.9'", - "version": "==1.6.0" - }, - "prompt-toolkit": { - "hashes": [ - "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", - "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" - ], - "markers": "python_version >= '3.8'", - "version": "==3.0.52" - }, - "ptyprocess": { - "hashes": [ - "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", - "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" - ], - "version": "==0.7.0" - }, - "pure-eval": { - "hashes": [ - "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", - "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" - ], - "version": "==0.2.3" - }, - "pycodestyle": { - "hashes": [ - "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", - "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d" - ], - "markers": "python_version >= '3.9'", - "version": "==2.14.0" - }, - "pyflakes": { - "hashes": [ - "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", - "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f" - ], - "markers": "python_version >= '3.9'", - "version": "==3.4.0" - }, - "pygments": { - "hashes": [ - "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", - "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==2.20.0" - }, - "pytest": { - "hashes": [ - "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", - "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" - ], - "index": "pypi", - "markers": "python_version >= '3.10'", - "version": "==9.0.3" - }, - "stack-data": { - "hashes": [ - "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", - "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" - ], - "version": "==0.6.3" - }, - "traitlets": { - "hashes": [ - "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", - "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f" - ], - "markers": "python_version >= '3.8'", - "version": "==5.14.3" - }, - "wcwidth": { - "hashes": [ - "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", - "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159" - ], - "markers": "python_version >= '3.8'", - "version": "==0.6.0" - } - } -} diff --git a/core/builder/convertor.py b/core/builder/convertor.py index 60d3ca8f..72542e34 100644 --- a/core/builder/convertor.py +++ b/core/builder/convertor.py @@ -221,13 +221,13 @@ def call_pandoc(self): self.file.seek(0) args = [ - "pandoc", + "./external/pandoc", "--metadata", f"lang={self.locale.id}", "-V", "csquotes=true", "--from", "markdown+smart", "--pdf-engine", "xelatex", "--to", self.output_format, - "--filter", "pandoc-crossref", + "--filter", "./external/pandoc-crossref", "-M", f"crossrefYaml=build/core/i18n/{self.locale_code}.yaml", #"--filter", "pandoc-include", "-M", f"include-entry={Path(self.infile.name).parent}/", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5aaa5fcc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "dgs" +version = "0.1.0" +description = "Add your description here" +readme = "readme.md" +requires-python = ">=3.12" +dependencies = [ + "argparsedirs>=0.2.1", + "colorama>=0.4.6", + "dotmap>=1.3.30", + "enschema>=0.1.5", + "jinja2>=3.1.6", + "numpy>=2.5.1", + "pandoc-crossref>=0.1.1", + "pandoc-minted>=0.2.0", + "pint>=0.25.3", + "pyyaml>=6.0.3", + "regex>=2026.7.10", + "schema>=0.7.8", +] + +[dependency-groups] +dev = [ + "pytest (>=9.0.1,<10.0.0)" +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..72236bd3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,499 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "argparse" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", size = 70508, upload-time = "2015-09-12T20:22:16.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000, upload-time = "2015-09-14T16:03:16.137Z" }, +] + +[[package]] +name = "argparsedirs" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/1c/1d37d36110e15a2fbc2d5b67d943d94aa6507c2a608cc990ec620eaff2a3/argparsedirs-0.2.1.tar.gz", hash = "sha256:3db491a9a6735c25cb46aab161551b1f910a1747e2768b469a92189ed65a5ea9", size = 1465, upload-time = "2025-12-03T16:02:46.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/5c/f13a1c0e338143824bc24964dfccf0622047ef21e20cb35e1a0fdd620155/argparsedirs-0.2.1-py3-none-any.whl", hash = "sha256:c9a243110a8af10e297deb5a3602d8860909563ab240de0753f4a1275965163c", size = 2167, upload-time = "2025-12-03T16:02:44.833Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dgs" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "argparsedirs" }, + { name = "colorama" }, + { name = "dotmap" }, + { name = "enschema" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "pandoc-crossref" }, + { name = "pandoc-minted" }, + { name = "pint" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "schema" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "argparsedirs", specifier = ">=0.2.1" }, + { name = "colorama", specifier = ">=0.4.6" }, + { name = "dotmap", specifier = ">=1.3.30" }, + { name = "enschema", specifier = ">=0.1.5" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "numpy", specifier = ">=2.5.1" }, + { name = "pandoc-crossref", specifier = ">=0.1.1" }, + { name = "pandoc-minted", specifier = ">=0.2.0" }, + { name = "pint", specifier = ">=0.25.3" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "regex", specifier = ">=2026.7.10" }, + { name = "schema", specifier = ">=0.7.8" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.1,<10.0.0" }] + +[[package]] +name = "dotmap" +version = "1.3.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/68/c186606e4f2bf731abd18044ea201e70c3c244bf468f41368820d197fca5/dotmap-1.3.30.tar.gz", hash = "sha256:5821a7933f075fb47563417c0e92e0b7c031158b4c9a6a7e56163479b658b368", size = 12391, upload-time = "2022-04-06T16:26:49.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/f9/976d6813c160d6c89196d81e9466dca1503d20e609d8751f3536daf37ec6/dotmap-1.3.30-py3-none-any.whl", hash = "sha256:bd9fa15286ea2ad899a4d1dc2445ed85a1ae884a42effb87c89a6ecce71243c6", size = 11464, upload-time = "2022-04-06T16:26:47.103Z" }, +] + +[[package]] +name = "enschema" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "schema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/06/4fd3b6ff63e4bb050cf509dbcc0e03f304de0ffa69ec9a603be1157305f0/enschema-0.1.5.tar.gz", hash = "sha256:a4f37c04ec192cb8d33ac2aaeb2620008a09994a3c88e2a56f6676370da77ad4", size = 1964, upload-time = "2023-12-09T18:11:32.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9d/626df3356d0059ab1c283700560b871160d44a20787dd71246c771e7bb5a/enschema-0.1.5-py3-none-any.whl", hash = "sha256:e128802c2832fbd40db1b51feed83d6a3592573f73ef50332228bce2848b80d1", size = 2289, upload-time = "2023-12-09T18:11:29.148Z" }, +] + +[[package]] +name = "flexcache" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" }, +] + +[[package]] +name = "flexparser" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +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" } +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" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[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/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { 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" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandoc-crossref" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/9037c2bc7f08565a6a5196ecdc5ca416fd64323dde2f3d3f1d580555ffba/pandoc-crossref-0.1.1.tar.gz", hash = "sha256:de738ffc9789feb13d16b5f7ac9c0298aff187ae958c0472167d26cec0ad9714", size = 1477, upload-time = "2019-02-08T05:54:58.321Z" } + +[[package]] +name = "pandoc-minted" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pandocfilters" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/ba/83dfdca8067b84caef8239bc1babc2787f5f17b6d93787a66d9aede20a2d/pandoc-minted-0.2.0.tar.gz", hash = "sha256:0cc0a2c279c514df2512a4ffe59766b02b59fe68e4ee85c4437e2e00d40bcaa6", size = 12660, upload-time = "2017-01-18T21:39:47.882Z" } + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "pint" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/9d/b1379cdbd33a49d17d627bc24e2b63cca06a1c5343b38072d2889499e82e/pint-0.25.3.tar.gz", hash = "sha256:f8f5df6cf65314d74da1ade1bf96f8e3e4d0c41b51577ac53c49e7d44ca5acee", size = 255106, upload-time = "2026-03-19T21:57:08.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl", hash = "sha256:27eb25143bd5de9fcc4d5a4b484f16faf6b4615aa93ece6b3373a8c1a3c1b97d", size = 307488, upload-time = "2026-03-19T21:57:07.022Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.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" } +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" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { 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" } +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" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/9c/2503d4ccf3452dc323f8baa3cf3ee10406037d52735c76cfced81423f183/regex-2026.7.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4", size = 497114, upload-time = "2026-07-10T19:47:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/04534f4263a4f658cd20a511e9d6124350044f2214eb24fee2db96acf318/regex-2026.7.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6", size = 297422, upload-time = "2026-07-10T19:47:17.794Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2d/35809de392ab66ba439b58c3187ae3b8b53c883233f284b59961e5725c99/regex-2026.7.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181", size = 292110, upload-time = "2026-07-10T19:47:19.188Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1e/5ce0fbe9aab071893ce2b7df020d0f561f7b411ec334124302468d587884/regex-2026.7.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38", size = 796800, upload-time = "2026-07-10T19:47:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/c1ccbada395c10e334763b583e1039b1660b142303ebb941d4269130b22f/regex-2026.7.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6", size = 865509, upload-time = "2026-07-10T19:47:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/0e/06/f0b31afc16c1208f945b66290eb2a9936ab8becdfb23bbcedb91cc5f9d9b/regex-2026.7.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d", size = 912395, upload-time = "2026-07-10T19:47:24.128Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/8687de3a6c3220f4f872a9bf4bcd8dc249f2a96e7dddfa93de8bd4d16399/regex-2026.7.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f", size = 801308, upload-time = "2026-07-10T19:47:25.696Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e3/60a40ec02a2315d826414a125640aceb6f30450574c530c8f352110ece0e/regex-2026.7.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a", size = 777120, upload-time = "2026-07-10T19:47:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9a/ec579b4f840ac59bc7c192b56e66abd4cbf385615300d59f7c94bf6863ae/regex-2026.7.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68", size = 785164, upload-time = "2026-07-10T19:47:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1c/60d88afd5f98d4b0fb1f8b8969270628140dc01c7ff93a939f2aa83f31a6/regex-2026.7.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0", size = 860161, upload-time = "2026-07-10T19:47:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/08ae3ba45fe79e48c9a888a3389a7ee7e2d8c580d2d996da5ece02dfdcb9/regex-2026.7.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4", size = 765829, upload-time = "2026-07-10T19:47:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/e6/e613c6755d19aca9d977cdc3418a1991ffc8f386779752dd8fdfa888ea89/regex-2026.7.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402", size = 852170, upload-time = "2026-07-10T19:47:33.567Z" }, + { url = "https://files.pythonhosted.org/packages/03/33/89072f2060e6b844b4916d5bc40ef01e973640c703025707869264ec75ab/regex-2026.7.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb", size = 789550, upload-time = "2026-07-10T19:47:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/4bc8be9a155035e63780ccac1da101f36194946fdc3f6fce90c7179fc6df/regex-2026.7.10-cp312-cp312-win32.whl", hash = "sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d", size = 267151, upload-time = "2026-07-10T19:47:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/9f5aade65bb98cc6e99c336e45a49a658300720c16721f3e687f8d754fec/regex-2026.7.10-cp312-cp312-win_amd64.whl", hash = "sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f", size = 277751, upload-time = "2026-07-10T19:47:38.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/6f/d069dd12872ea1d50e17319d342f89e2072cae4b62f4245009a1108c74d8/regex-2026.7.10-cp312-cp312-win_arm64.whl", hash = "sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe", size = 277063, upload-time = "2026-07-10T19:47:40.023Z" }, + { url = "https://files.pythonhosted.org/packages/e0/88/0c977b9f3ba9b08645516eca236388c340f56f7a87054d41a187a04e134c/regex-2026.7.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c", size = 496868, upload-time = "2026-07-10T19:47:41.675Z" }, + { url = "https://files.pythonhosted.org/packages/f6/51/600882cd5d9a3cf083fd66a4064f5b7f243ba2a7de2437d42823e286edaf/regex-2026.7.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1", size = 297306, upload-time = "2026-07-10T19:47:43.521Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/48a912054ffcb756e374207bb8f4430c5c3e0ffa9627b3c7b6661844b30a/regex-2026.7.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d", size = 291950, upload-time = "2026-07-10T19:47:45.267Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c8/8e1c3c86ebcee7effccbd1f7fc54fe3af22aa0e9204503e2baea4a6ff001/regex-2026.7.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983", size = 796817, upload-time = "2026-07-10T19:47:48.054Z" }, + { url = "https://files.pythonhosted.org/packages/65/39/3e49d9ff0e0737eb8180a00569b47aabb59b84611f48392eba4d998d91a0/regex-2026.7.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197", size = 865513, upload-time = "2026-07-10T19:47:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/70/57/6511ad809bb3122c65bbeeffa5b750652bb03d273d29f3acb0754109b183/regex-2026.7.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e", size = 912391, upload-time = "2026-07-10T19:47:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/a1b0c109c9e878cb04b931bfe4c54332d692b93c322e127b5ae9f25b0d9e/regex-2026.7.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644", size = 801338, upload-time = "2026-07-10T19:47:53.38Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/171c3dad4d77000e1befeff2883ca88734696dfd97b2951e5e074f32e4dd/regex-2026.7.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e", size = 777149, upload-time = "2026-07-10T19:47:54.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/61/41ab0de0e4574da1071c151f67d1eb9db3d92c43e31d64d2e6863c3d89bf/regex-2026.7.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8", size = 785216, upload-time = "2026-07-10T19:47:56.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/372859ea693736f07cf7023247c7eca8f221d9c6df8697ff9f93371cca08/regex-2026.7.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775", size = 860229, upload-time = "2026-07-10T19:47:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/50/b1/e1d32cd944b599534ae655d35e8640d0ec790c0fa12e1fb29bf434d50f55/regex-2026.7.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da", size = 765797, upload-time = "2026-07-10T19:48:00.291Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/79a2cd9556a3329351e370929743ef4f0ccc0aaff6b3dc414ae5fa4a1302/regex-2026.7.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1", size = 852130, upload-time = "2026-07-10T19:48:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/76fec29898cf5d359ab63face50f9d4f7135cc2eca3477139227b1d09952/regex-2026.7.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963", size = 789644, upload-time = "2026-07-10T19:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/f6/06/3c7cec7817bda293e13c8f88aed227bbcf8b37e5990936ff6442a8fdf11a/regex-2026.7.10-cp313-cp313-win32.whl", hash = "sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e", size = 267130, upload-time = "2026-07-10T19:48:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/e2a6f9a6a905f923cfc912298a5949737e9504b1ca24f29eda8d04d05ece/regex-2026.7.10-cp313-cp313-win_amd64.whl", hash = "sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927", size = 277722, upload-time = "2026-07-10T19:48:07.318Z" }, + { url = "https://files.pythonhosted.org/packages/00/a6/9d8935aaa940c388496aa1a0c82669cc4b5d06291c2712d595e3f0cf16d3/regex-2026.7.10-cp313-cp313-win_arm64.whl", hash = "sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08", size = 277059, upload-time = "2026-07-10T19:48:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e9/26decfd3e85c09e42ff7b0d23a6f51085ca4c268db15f084928ca33459c6/regex-2026.7.10-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b", size = 501508, upload-time = "2026-07-10T19:48:10.668Z" }, + { url = "https://files.pythonhosted.org/packages/38/a5/5b167cebde101945690219bf34361481c9f07e858a4f46d9996b80ec1490/regex-2026.7.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8", size = 299705, upload-time = "2026-07-10T19:48:12.544Z" }, + { url = "https://files.pythonhosted.org/packages/f6/20/7909be4b9f449f8c282c14b6762d59aa722aeaeebe7ee4f9bb623eeaa5e0/regex-2026.7.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc", size = 294605, upload-time = "2026-07-10T19:48:14.495Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/e52550185d6fda68f549b01239698697de47320fd599f5e880b1986b7673/regex-2026.7.10-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200", size = 811747, upload-time = "2026-07-10T19:48:16.197Z" }, + { url = "https://files.pythonhosted.org/packages/06/98/16c255c909714de1ee04da6ae30f3ee04170f300cdc0dcf57a314ee4816a/regex-2026.7.10-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e", size = 871203, upload-time = "2026-07-10T19:48:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/423ed27c9bae2092a453e853da2b6628a658d08bb5a6117db8d591183d85/regex-2026.7.10-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8", size = 917334, upload-time = "2026-07-10T19:48:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/74dac8efb500db31cb000fda6bae2be45fc2fbf1fa9412f445fbb8acbe37/regex-2026.7.10-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e", size = 816379, upload-time = "2026-07-10T19:48:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/1859403654e3e030b288f06d49233c6a4f889d62b84c4ef3f3a28653173d/regex-2026.7.10-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6", size = 785563, upload-time = "2026-07-10T19:48:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/35d30d6bdf1ef6a5430e8982607b3a6db4df1ddedbe001e43435585d88ba/regex-2026.7.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192", size = 801415, upload-time = "2026-07-10T19:48:25.499Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/630f31f5ea4826167b2b064d9cac2093a5b3222af380aa432cfe1a5dabcd/regex-2026.7.10-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851", size = 866560, upload-time = "2026-07-10T19:48:27.789Z" }, + { url = "https://files.pythonhosted.org/packages/8d/14/f5914a6d9c5bc63b9bed8c9a1169fb0be35dbe05cdc460e17d953031a366/regex-2026.7.10-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812", size = 772877, upload-time = "2026-07-10T19:48:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/0f/7c13999eef3e4186f7c79d4950fa56f041bf4de107682fb82c80db605ff9/regex-2026.7.10-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef", size = 856648, upload-time = "2026-07-10T19:48:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/a48e43909b6450fb48fa94e783bef2d9a37179258bc32ef2283955df7be7/regex-2026.7.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682", size = 803520, upload-time = "2026-07-10T19:48:33.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/f037d1bf2c133cb24ceb6e7d81d08417080390eddab6ddfd701aa7091874/regex-2026.7.10-cp313-cp313t-win32.whl", hash = "sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1", size = 269168, upload-time = "2026-07-10T19:48:35.353Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/eaac34f8452a838956e7e89852ad049678cdc1af5d14f72d3b3b658b1ea5/regex-2026.7.10-cp313-cp313t-win_amd64.whl", hash = "sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344", size = 280004, upload-time = "2026-07-10T19:48:37.106Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a9/e22e997587bc1d588b0b2cd0572027d39dd3a006216e40bbf0361688c51c/regex-2026.7.10-cp313-cp313t-win_arm64.whl", hash = "sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837", size = 279308, upload-time = "2026-07-10T19:48:38.907Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" }, + { url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" }, + { url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" }, + { url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" }, + { url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" }, + { url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" }, + { url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" }, + { url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" }, +] + +[[package]] +name = "schema" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/2e/8da627b65577a8f130fe9dfa88ce94fcb24b1f8b59e0fc763ee61abef8b8/schema-0.7.8.tar.gz", hash = "sha256:e86cc08edd6fe6e2522648f4e47e3a31920a76e82cce8937535422e310862ab5", size = 45540, upload-time = "2025-10-11T13:15:40.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/75/aad85817266ac5285c93391711d231ca63e9ae7d42cd3ca37549e24ebe52/schema-0.7.8-py2.py3-none-any.whl", hash = "sha256:00bd977fadc7d9521bf289850cd8a8aa5f4948f575476b8daaa5c1b57af2dce1", size = 19108, upload-time = "2025-10-11T17:13:07.323Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From f9e3de156f2ea1037fc703dfe7767c39ca68c0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Sun, 26 Jul 2026 09:59:43 +0000 Subject: [PATCH 20/46] Claude and constants --- .claude/skills/naboj-authoring/SKILL.md | 94 +++++++ .../references/jinja-templating.md | 182 +++++++++++++ .../references/latex-macros.md | 245 ++++++++++++++++++ .../naboj-authoring/references/layout.md | 208 +++++++++++++++ .../references/markdown-extensions.md | 210 +++++++++++++++ .../references/quantities-and-constants.md | 206 +++++++++++++++ core/data/constants.yaml | 16 ++ 7 files changed, 1161 insertions(+) create mode 100644 .claude/skills/naboj-authoring/SKILL.md create mode 100644 .claude/skills/naboj-authoring/references/jinja-templating.md create mode 100644 .claude/skills/naboj-authoring/references/latex-macros.md create mode 100644 .claude/skills/naboj-authoring/references/layout.md create mode 100644 .claude/skills/naboj-authoring/references/markdown-extensions.md create mode 100644 .claude/skills/naboj-authoring/references/quantities-and-constants.md diff --git a/.claude/skills/naboj-authoring/SKILL.md b/.claude/skills/naboj-authoring/SKILL.md new file mode 100644 index 00000000..fc0e7f88 --- /dev/null +++ b/.claude/skills/naboj-authoring/SKILL.md @@ -0,0 +1,94 @@ +--- +name: naboj-authoring +description: Author, edit, and debug Náboj competition problem sources (source/naboj/**) using DGS's Markdown+Jinja+LaTeX pipeline. Use whenever the user is creating a new problem, editing problem.md / solution.md / answer.md / preamble.md / meta.yaml under source/naboj/phys/*/problems/** or source/naboj/chem/*/problems/**, debugging mdcheck violations, Jinja MissingVariablesError, pandoc/XeLaTeX errors, or extending core/ or modules/naboj (new filters, new physical constants in core/data/constants.yaml, new LaTeX macros in core/latex/*.tex, new templates in modules/naboj/templates). Applies to all Náboj volumes (phys/26, phys/27, phys/28, chem/*, ...) — the format is stable across volumes with only minor differences. +--- + +# Náboj problem authoring (DGS) + +DGS renders each problem through this pipeline: + +``` +source/naboj///problems///{problem,solution}.md (Markdown + Jinja) + + source/naboj///problems//{preamble,answer,answer-also,answer-interval}.md + + source/naboj///problems//meta.yaml + → render/naboj///problems///*.md (pure Markdown, no Jinja) + → build/naboj///problems///*.tex (pandoc → XeLaTeX) + → PDF (booklet, tearoff, answers, solutions, ...) +``` + +Preamble + meta.yaml are prepended into the Jinja context; the Jinja renderer runs **twice** so +substituted equations get their inner tags expanded on the second pass. Then the DGS Markdown +style linter (`core/mdcheck`) runs, and pandoc converts to TeX using DGS's custom class +(`core/latex/dgs.cls`, plus `math.tex`, `symbols.tex`, `siunitx.tex`, `hacks.tex`). + +## Where to start + +**Route by task:** + +- Authoring / editing a problem (`problem.md`, `solution.md`, `meta.yaml`, `preamble.md`, + `answer*.md`) → read `references/layout.md`, then `references/markdown-extensions.md`. +- Using `(§ … §)` templating, `@J set …`, math filters, `Q(…)`, `const.g`, etc. → + `references/jinja-templating.md`. +- Defining `values:` in meta.yaml, using `PhysicsQuantity`, `.eq`, `.approx`, `.widen`, ranges, + formatting filters (`|f2`, `|g3`, `|w(0.05)`) → `references/quantities-and-constants.md`. +- Using / adding custom LaTeX macros (`\Int`, `\Sum`, `\Ceil`, `\Nuclide`, `\Implies`, …) → + `references/latex-macros.md`. +- Style linter failures (missing spaces around `=`, `\SI` vs `\qty`, label conventions, + `\frac` in answers, …) → `references/markdown-extensions.md` §Style checker. + +## Ground rules + +1. **Do not invent macros.** Every math command not in vanilla amsmath comes from + `core/latex/*.tex`. Grep `core/latex/symbols.tex` and `core/latex/math.tex` before + introducing a new one. +2. **Use `\qty` / `\num` / `\ang` — never `\SI`.** `mdcheck` fails the build on `\SI`. +3. **Use `\Implies`, not `\implies` or `\Rightarrow`.** Same rule for `\Int`/`\Sum` over + `\int`/`\sum`, `\Ceil{…}` over `\lceil…\rceil`, `\ang{…}` over `^\circ`. +4. **Preserve spaces around binary operators** in math: `= \approx \doteq \geq \leq \gg \ll + + \cdot`. `mdcheck` flags each violation with a caret pointing at the column. +5. **Labels start with the problem id.** In `solution.md` you must add a + sublabel (`{#eq:archery:hd}`). In `problem.md` you can go bare + (`{#eq:archery}`) or sub-labelled (`{#fig:archery:diagram}`) — most + `problem.md` equations get no label at all. +6. **Answers use `\dfrac`, not `\frac`.** `mdcheck` enforces this on `answer.md` / + `answer-also.md` / `answer-interval.md`. +7. **Two-pass rendering matters.** If a value expands into a `\qty{…}` that itself contains + Jinja tags (e.g. from a MathObject), it still gets rendered on the second pass. Don't + try to escape or defer — just write the natural thing. + +## Rendering pipeline entry points + +- Convertor for problems: `modules.naboj.builder.renderer` (subclass of + `core.builder.renderer.CLIInterface`). Meta.yaml + preamble.md become the context. +- Jinja setup: `core/builder/jinja.py` — see `MarkdownJinjaRenderer` for the exact filter / + global table. +- Style linter: `core/markdown-check.py` runs `core/mdcheck/check.py` rules per line. +- Templates that assemble PDFs: `modules/naboj/templates/*.jtex` (these use `(* … *)` for + variables, unlike `.md` files which use `(§ … §)`). +- Build orchestration: root `Makefile` + `modules/naboj/module.mk` (rules + `NABOJ_TRANSLATABLE` for problem/solution, `NABOJ_NONTRANSLATABLE` for + answer/answer-also/answer-interval). + +## Volume differences to be aware of + +- `phys/28` uses `authors:` (plural, list). `phys/27` mostly uses `author:` (singular). + Both are tolerated; new problems should use `authors: [...]`. +- Older volumes often lack `values:` entirely; numbers are hard-coded in the Markdown. +- `chem/*` volumes use a flatter layout (`source/naboj/chem//problems//…`) + and use chemistry macros (`\ce{…}`, `\Nuclide{…}`, `\chemfig{…}`). Their `sk/` may be + the only language directory. + +## Environment / build + +The project uses **uv** (see recent commit "Switched to uv"). To build one problem: + +``` +uv run python -m modules.naboj.builder.renderer \ + -C source/naboj/phys/28/problems/archery/meta.yaml \ + -P source/naboj/phys/28/problems/archery/preamble.md \ + source/naboj/phys/28/problems/archery/en/solution.md \ + /tmp/out.md +``` + +Or via Make targets defined in `modules/naboj/module.mk`. Do not commit the `_minted-output` +directory (`.gitignore`d). diff --git a/.claude/skills/naboj-authoring/references/jinja-templating.md b/.claude/skills/naboj-authoring/references/jinja-templating.md new file mode 100644 index 00000000..ddd4eee4 --- /dev/null +++ b/.claude/skills/naboj-authoring/references/jinja-templating.md @@ -0,0 +1,182 @@ +# Jinja2 templating in DGS + +DGS uses **two** Jinja environments with different delimiters. Get the file type +right and everything follows. + +## Delimiters by file type + +### Markdown files (`.md` under `source/`) + +Custom delimiters chosen so as not to clash with Markdown / TeX syntax: + +| Purpose | Delimiter | Example | +| ----------------------- | ---------------- | ----------------------------- | +| Variable expression | `(§ … §)` | `(§ v0|f2 §)` | +| Block statement | `(@ … @)` | `(@ if fig @)…(@ endif @)` | +| Comment | `(# … #)` | `(# note: dead code #)` | +| Line statement prefix | `@J ` (with space)| `@J set result = v0 + v1` | +| Line comment prefix | `%#` | `%# throwaway comment` | + +`trim_blocks=True`, `autoescape=False`. Missing variables are **collected and +raised together** as `MissingVariablesError` after render — you'll see them all at +once. Filters may still catch undefined via `|default(...)` without triggering the +error. + +### Static TeX templates (`.jtex` under `modules/naboj/templates/` and `core/templates/`) + +Different delimiters — variables use `(* … *)`: + +| Purpose | Delimiter | Example | +| ----------------------- | ---------------- | ----------------------------- | +| Variable expression | `(* … *)` | `(* problem.number *)` | +| Block statement | `(@ … @)` | `(@ if target=='booklet' @)…` | +| Comment | `(# … #)` | | +| Line statement prefix | `@J ` | | + +Everything else about the environments is identical. + +## Rendering flow for `.md` files + +`core/builder/renderer.py::JinjaConvertor.run` does **two passes**: + +1. Prepend `preamble.md` (if any). +2. First render pass: expands values, equations, `@J set …`. +3. Second render pass: re-renders the intermediate so that any Jinja tags that + appear **inside expanded content** (e.g. inside a MathObject) get expanded too. + +This is why you can write things like `(§ result|f0 §)` in `answer.md` and have +`result` come from `preamble.md` — the preamble is prepended before the first pass. + +The second pass matters most for `eq:` fragments: `(§ eq.foo|disp §)` expands +on pass 1 to a display equation whose body still contains raw `(§ … §)` tags +(the ones defined in the `eq:` YAML value); pass 2 evaluates those inner tags. +Without the second pass, `eq:` would be useless. + +## Context available in Markdown Jinja + +Provided by `core/builder/renderer.py::CLIInterface.build_context`: + +- `id` — the problem id (from directory name). +- **Every key under `values:` in `meta.yaml`** becomes a variable in the local + scope. If the value is a dict with `magnitude:` and `unit:`, it becomes a + `PhysicsQuantity`; if a bare string/number, it's used as-is. +- **Every key under `eq:` in `meta.yaml`** becomes a `MathObject` accessible as + `eq.`. The `MathObject`'s `.id` is set to `:` so + `|disp` and `|align` emit a label automatically. See + `source/naboj/chem/04/problems/maliari/` for the canonical example — the + content of each `eq:` entry can itself contain `(§ … §)` tags, which get + expanded on the **second** render pass (see below). +- `const` — the physics constants dict from `core/data/constants.yaml`. Access as + `const.g`, `const.G`, `const.c`, `const.gforce`, ... aliases are also mapped. + +## Filters (registered in `core/builder/jinja.py::MarkdownJinjaRenderer`) + +Number formatting — precision-parameterised versions are pre-generated for 0–9 +digits. Apply to `PhysicsQuantity`, `QuantityRange`, `QuantityList`, or a raw +number: + +| Filter | What it does | +| -------------------- | --------------------------------------------------------- | +| `|f`, `|f0`, ..., `|f9` | Fixed-decimal formatting. `q|f2` → `\qty{50.00}{...}` | +| `|g`, `|g0`, ..., `|g9` | General formatting (may use scientific). `q|g3` | +| `|n` | Wrap raw value in `\num{…}` (no unit). | +| `|nf`, `|nf0..9` | `\num{…}` with fixed precision. | +| `|ng`, `|ng0..9` | `\num{…}` with general precision. | +| `|ef`, `|ef0..9` | `symbol = \qty{…}` fixed. Uses `PhysicsQuantity.symbol`. | +| `|eg`, `|eg0..9` | Same, general precision. | +| `|mag` | Extract raw magnitude (pint magnitude, not a string). | +| `|unit` | Just the unit, formatted as `\unit{…}`. | +| `|sim` | `.simplify()` — convert to base SI units. | +| `|w(value)`, `|widen(value)` | Construct a tolerance range: `x|w(0.05)` → ±5%. | + +For a `MathObject`: + +| Filter | What it does | +| ------------- | ---------------------------------------------------------------- | +| `|inline` | Renders `$…$`. Write sentence punctuation **outside** the tag. | +| `|disp` | Renders `$$\n …\n$$ {#eq:}`. | +| `|disp('.')` | Same with trailing punctuation inside the math. | +| `|align` | `$${\n …\n}$$ {#eq:}` (aligned environment). | +| `|align(',')` | Same with punctuation. | + +## Globals + +Math functions available in Jinja expressions: + +``` +sin cos tan asin acos atan atan2 +sqrt cbrt log log10 log2 exp pow +ceil floor +rad deg # radians ↔ degrees +gamma beta # Γ and B(x,y) = Γ(x)Γ(y)/Γ(x+y) +pi tau euler # constants +``` + +These accept both raw numbers and `PhysicsQuantity` values (pint routes through). + +Constructor: + +``` +Q(magnitude, unit) # ad-hoc PhysicsQuantity, e.g. Q(100, '%') +``` + +## `@J set` — the everyday case + +Used almost exclusively inside `preamble.md`: + +``` +@J set result = v0**2 / const.g.approx - sqrt((v0**4 / const.g.approx**2) - D**2) +@J set result_exact = v0**2 / const.g - sqrt((v0**4 / const.g**2) - D**2) +``` + +The `@J` (line-statement prefix) form is preferred over `(@ set … @)` for +readability. Idiomatic pattern: compute a rounded-constant version for `answer.md` +and an exact-constant version for `solution.md`. + +## Control flow (`.jtex` templates) + +Standard Jinja `if / for` blocks with the DGS delimiters: + +``` +(@ for vid, venue in volume.venues.items() @)% + (* venue.head *) ((* i18n[language.id].venues[vid] *))% +(@ endfor @)% +``` + +Whitespace control follows Jinja: `(@- … -@)` trims surrounding whitespace. + +Path helpers available in static templates: + +- `path_exists('some/path')` — check whether a file exists (used to conditionally + include `answer-extra.md`, etc.). +- `file_size('some/path')` — file size in bytes. + +## MissingVariablesError + +If any Jinja variable used at render time is not found, the collector aggregates +them and raises `MissingVariablesError`. The message lists every missing name in +insertion order. Typical causes: + +- Typo in `(§ v_0 §)` when the value is defined as `v0`. +- Value defined in preamble but preamble file not prepended (check that + `preamble.md` exists at the problem level — the Makefile falls back to a + no-preamble rule if it's missing). +- Cross-problem reference. Values are scoped to a single problem. + +## Common pitfalls + +- **Space around `@J`.** It's a line statement *prefix*, not a delimiter. Write + `@J set …` (with a space). No leading whitespace either — must be column 0. +- **`(§ … §)` in `.jtex` won't work.** The `.jtex` environment uses `(* … *)` for + variables. Conversely, `(* … *)` in `.md` is a literal `(*` `*)`, not a + variable. +- **Numeric arithmetic on `PhysicsQuantity` returns `PhysicsQuantity`.** So + `(§ v0 * 2 §)` becomes `\qty{100}{\metre\per\second}`. If you want a bare number, + use `.mag` or `|mag`. +- **Ranges (`QuantityRange`) via `%`.** `q1 % q2` constructs a range with `q1` as + minimum and `q2` as maximum. If `q1 > q2`, pint raises. Prefer the + `.widen(fraction)` method when you want a symmetric tolerance band. +- **Constants inside math.** `const.g.approx` gives a rounded-magnitude + `PhysicsQuantity`; use it in expressions. `const.g.symbol` (or `const.g.sym`) + gives the TeX symbol string. `const.g.full` gives the printable full-precision + form. diff --git a/.claude/skills/naboj-authoring/references/latex-macros.md b/.claude/skills/naboj-authoring/references/latex-macros.md new file mode 100644 index 00000000..87668370 --- /dev/null +++ b/.claude/skills/naboj-authoring/references/latex-macros.md @@ -0,0 +1,245 @@ +# Custom LaTeX macros (DGS) + +Every non-vanilla-amsmath command you see in Náboj Markdown comes from +`core/latex/*.tex` and is loaded via `core/latex/dgs.cls`. Grep those files +before assuming a macro exists or defining a new one. + +Files: + +- `core/latex/dgs.cls` — class definition; package loads; document parameters. +- `core/latex/fonts.tex` — Minion Pro + math font setup. +- `core/latex/math.tex` — differentials, derivatives, integrals, gradient/div/rot, + vectors, sums/products, intervals, statistics. +- `core/latex/symbols.tex`— planets, nuclides. +- `core/latex/siunitx.tex`— siunitx global setup + custom units. +- `core/latex/hacks.tex` — pandoc/lists fixes, `\phi ↔ \varphi` swap, jigsaw, + `\st` for markdown strikeout. +- `core/latex/utilities.tex` — `\URL`, `\errorMessage`, `\protectedInput`, + `\exampleIO`. + +## Delimiters and brackets + +`\left(...\right)` is redefined via `mleftright` (`\mleft`, `\mright`) so spacing +around parentheses no longer requires manual `\!` corrections. + +Sized delimiters: + +- `\Paren{expr}` → `\left(expr\right)` +- `\Abs{expr}` → `\left| expr \right|` +- `\Floor{expr}` → `\lfloor…\rfloor` +- `\Ceil{expr}` → `\lceil…\rceil` (also enforced in answer files — see + `Adam_ISIC` result `\Ceil{t}`) +- `\ExpectedChevrons{X}[condition]` → `\langle X \mid condition \rangle` + +Tuples and coordinates (with a configurable inner delimiter, default `;`): + +- `\Tuple{a;b;c}` → `(a; b; c)` — round brackets +- `\Coord{x;y;z}` → `[x; y; z]` — square brackets + +## Differentials and derivatives + +Base differentials (each includes the standard math-space adjustment): + +- `\Diff x` → `\mathrm{d}\, x` +- `\PDiff x` → `\partial\, x` +- `\FDiff x` → `\Delta\, x` +- `\UDiff x` → `\delta\, x` +- With power: `\Diff[2] x` → `\mathrm{d}^{2}\, x` + +Derivatives (fraction style controlled by an optional ``): + +- `\Derivative[order]{f}{x}` (aliases `\Drv`) +- `\PDerivative[order]{f}{x}` (`\PDrv`) +- `\FDerivative`, `\UDerivative` — for Δ- and δ-based derivatives +- `\Derivative[2]{f}{x}` — with display-style fraction +- `\DerivativeParen[order]{f}{x}` — d/dx (f) form (`\DrvP`, `\PDrvP`, ...) +- `\DerivativeEmpty[order]{x}` — d/dx alone (`\DrvE`, `\PDrvE`, ...) +- `\DerivativeEval[order]{f}{x}{a}` — evaluated at x=a via `\Eval{…}{…}` + +## Integrals + +The naming scheme: `\Int` is the base 1-D form; the modifiers are +- `I` = integrand takes a differential-of-power operand +- `D` = dot product with `d…` +- `C` = cross product with `d…` +- `V` = auto-vectorise inputs (`\vec{}`) +- `O` = closed loop (single-integral) — `\oint` +- `II` = double integral — `\iint` +- `III`= triple integral — `\iiint` +- `OII`= closed surface (double) — `\oiint` + +1-D: +- `\Int[a][b]{f(x)}{x}` → `\int_a^b f(x) \diff x` +- `\IntP[a][b]{f(x)}{x}` → adds `\left(…\right)` around integrand +- `\IntX[a][b]{expr}` → no `\diff`, for algebraic manipulation +- `\IntE[a][b]{}{x}` → `\int_a^b \diff x` (empty integrand) +- `\IntD[a][b]{\vec E}{\vec r}` → dot product with `d\vec r` +- `\IntDV[a][b]{E}{r}` → auto-vec: same as IntD with `\vec{}` +- `\IntC`, `\IntCV` → cross product variants + +Loop: +- `\OInt[C]{f}{x}` → `\oint_C f \diff x` +- `\OIntD[C]{\vec E}{\vec r}`, `\OIntDV[C]{E}{r}` +- `\OIntC`, `\OIntCV` → cross product + +Surface (double): +- `\IIntI[S][…]{f}[power]{x}` → `\iint_S f \diff^{power} x` +- `\IIntD[S][…]{\vec E}{\vec S}`, `\IIntDV[S][…]{E}{S}` +- `\IIntC`, `\IIntCV` +- `\OIIntD`, `\OIIntDV`, `\OIIntC`, `\OIIntCV` — closed surface + +Volume (triple): +- `\IIInt[V][…]{f}{x}{y}{z}` → `\iiint_V f \diff x \diff y \diff z` +- `\IIIntV[V][…]{f}{r}` → `\iiint_V f \diff^{3} r` +- `\IIIntPV[V][…]{f}{r}` → with `\left(…\right)` around integrand + +## Vector calculus + +- `\Grad`, `\Div`, `\Rot`, `\Laplacian` — with `∇`. +- `\GradT`, `\DivT`, `\RotT` — text forms (`grad`, `div`, `rot`). +- `\GradV{X}`, `\DivV{X}`, `\RotV{X}` — auto-vectorised argument. + +## Vectors + +- `\ArrowVector{X}` / `\vv{X}` — small arrow (esvect). +- `\LongVector{X}` — long arrow (`\overrightarrow`). +- `\BoldVector{X}` — bold. +- `\UnitVector{X}` — `\hat{\vec{X}}`. +- `\UnitBoldVector`, `\UnitArrowVector`. + +`\vec{…}` is the default in DGS; the alternatives are for specific styles. + +## Aggregates (Σ, Π, ⋃, ⋂) + +Use these instead of raw `\sum`, `\prod`, `\bigcup`, `\bigcap`, `\bigtimes` +(`mdcheck` rules `sum` and `int` enforce it): + +- `\Sum[lo][hi]{elt}` — Σ with limits +- `\SumP[lo][hi]{elt}` — same with `\left(…\right)` around body +- `\Product[lo][hi]{elt}` — Π +- `\CartesianProduct[lo][hi]{elt}` — big × +- `\Union[lo][hi]{elt}` — ⋃ +- `\Intersection[lo][hi]{elt}` — ⋂ + +Non-aggregate: + +- `\Uni` (∪), `\Intersect` (∩), `\union`, `\intersection`. + +## Max/min operators + +- `\Max[cond]{expr}`, `\Min[cond]{expr}` — with `\underset`-style condition. + +## Sets, sequences, intervals + +- `\Set{a,b,c}[cond][power]` — `\{ … \}` with optional sub/superscript. +- `\Seq{a,b,c}[cond][power]` — parenthesised sequence. +- `\Natural`, `\NaturalZero`, `\Integer`, `\Rational`, `\Real`, `\RealPos`, + `\RealNeg`, `\RealNonneg`, `\RealNonpos`, `\Complex`, `\Quaternions`. +- `\IntervalCC{a}{b}` → `[a; b]` — closed–closed +- `\IntervalCO{a}{b}` → `[a; b)` — closed–open +- `\IntervalOC{a}{b}` → `(a; b]` +- `\IntervalOO{a}{b}` → `(a; b)` + +Note the semicolon separator inside intervals (Slovak convention). Consistent +with `\Tuple`, `\Coord`. + +## Text-in-math and phrase spacing + +- `\Text{…}` — one small space on each side. +- `\QText{…}` — `\quad` on each side. +- `\QQText{…}` — `\qquad` on each side. +- `\Operation{op}` — right-side annotation (`& \qquad / op`) for + aligned equations. + +Do **not** wrap punctuation in `\text{}` (linter rule `pun`). + +## Common relations + +- `\Implies` → `\quad\Rightarrow\quad` (use everywhere instead of `\implies` / + `\Rightarrow`). +- `\Iff` → `\quad\Leftrightarrow\quad`. +- `\ImpliedBy` → `\quad\Leftarrow\quad`. +- `\MustEq`, `\MustL`, `\MustLL`, `\MustG`, `\MustGG`, `\MustLeq`, `\MustGeq` + — over-stacked with `!` (must-equal etc.). +- `\DefEqual` → `\stackrel{\mathrm{def}}{=}`. +- `\Assign` → `\coloneqq`. +- `\Must{X}` — generic must-something (over-stacked `!`). + +## Statistics + +- `\Mean{X}` → `\overline{X}`. +- `\Var{X}`, `\MSE{X}`, `\Bias{X}`. +- `\Binomial{n}{k}` → `\binom{n}{k}`. +- `\Distribution{Name}[var]{params}` → `Name(var \mid params)`. +- `\Dimen{X}` → `[X]` (dimension brackets). + +## Number literals + +Fractions and units (safe to write in math or inline text): + +- `\OneHalf`, `\OneThird`, `\TwoThirds`, `\OneQuarter`, `\ThreeQuarters` — use + these instead of Unicode `½`, `⅓`, etc. + +Asymptotics: + +- `\SmallO{n^2}`, `\BigO{n^2}`, `\BigTheta{n^2}`. + +## Symbols + +Planets and celestial bodies (see `symbols.tex`): + +- `\Sun`, `\Mercury`, `\Venus`, `\Earth`, `\Moon`, `\Mars`, `\Jupiter`, `\Saturn`, + `\Uranus`, `\Neptune`, `\Pluto`. + +Nuclides (via mhchem): + +- `\Nuclide[A][Z]{sym}` — e.g. `\Nuclide[99m]{Tc}`, `\Nuclide[235][92]{U}`. + +## Chemistry (chem module) + +Loaded in `dgs.cls`: + +- `\ce{H2O}` — reactions and formulas (mhchem, version 4 with `formula=mhchem`). +- `\chemfig{…}` — structural formulas (chemfig package). +- `\chemsetup{formula=mhchem}` is set globally. + +## Font swap (`hacks.tex`) + +DGS **swaps** `\phi ↔ \varphi` and `\epsilon ↔ \varepsilon` at load time — Minion +Pro's default `\phi` and `\epsilon` glyphs are ugly. Consequences: + +- Write `\phi` when you mean φ (looks like `\varphi` in Computer Modern). +- Write `\epsilon` when you mean ε. +- The linter forbids `\varepsilon` outright (rule `vep`) — the swap makes it + unnecessary. + +## Utility macros for content + +- `\URL{https://…}` — `\href{…}{\texttt{…}}` combo. +- `\errorMessage{msg}` — bright red highlight (used for missing files etc.). +- `\todoMessage{msg}` — orange highlight. +- `\protectedInput{path}` — `\input` if it exists, else emit `\errorMessage`. +- `\tryInput{path}` — `\input` if it exists, else nothing. +- `\cutHere` — scissors line for tearoff sheets. +- `\exampleIO{input}{output}` — side-by-side verbatim boxes (programming problems). + +`\insertPicture` is **legacy** and forbidden by `mdcheck.lip` — use pandoc +image syntax instead. + +## Extending macros + +- **New physical constants**: `core/data/constants.yaml`. Add `magnitude`, `unit`, + `symbol`, `digits`, optional `aliases`, `exact`, `force_f`. No LaTeX changes + needed. +- **New math macro**: prefer `core/latex/symbols.tex` for symbols, `math.tex` for + operators / delimiters. Use `\NewDocumentCommand` (LaTeX3 syntax) for + consistency with the existing style. +- **New siunitx unit**: `core/latex/siunitx.tex`, `\DeclareSIUnit{…}{…}`. +- **New Jinja filter**: register in `core/builder/jinja.py::MarkdownJinjaRenderer` + under `self.env.filters` (define the function in `core/filters/`). +- **New Jinja global**: same file, `self.env.globals`. +- **New style-checker rule**: `core/mdcheck/check.py`, subclass `LineChecker` (or + add a `check.FailIfFound` entry with a short 3-letter key in + `core/markdown-check.py::StyleEnforcer.line_errors`). +- **New template block**: `modules/naboj/templates/blocks/`. Remember `.jtex` + uses `(* … *)` for variables, not `(§ … §)`. diff --git a/.claude/skills/naboj-authoring/references/layout.md b/.claude/skills/naboj-authoring/references/layout.md new file mode 100644 index 00000000..dfc2f16a --- /dev/null +++ b/.claude/skills/naboj-authoring/references/layout.md @@ -0,0 +1,208 @@ +# File layout of a Náboj volume + +## Volume directory + +``` +source/naboj/// +├── meta.yaml # volume-level metadata (see below) +├── languages/ +│ └── / +│ ├── meta.yaml # per-language booklet contents flags +│ ├── intro.jtex # editor's letter (uses (* … *) tags) +│ ├── instructions-inner.jtex +│ └── evaluators.jtex +├── venues/ +│ └── / +│ └── meta.yaml +└── problems/ + └── / + ├── meta.yaml # per-problem metadata (see below) + ├── preamble.md # (optional) Jinja preamble with @J set … + ├── answer.md # the answer expression (Jinja, non-translatable) + ├── answer-also.md # (optional) accepted alternative + ├── answer-interval.md # (optional) accepted interval + ├──
.svg / .tikz / .png # (optional) referenced by both problem and solution + └── / + ├── problem.md # the problem statement (Jinja + Markdown) + ├── problem-extra.md # (optional, rare) extra content appended after problem + └── solution.md # the worked solution +``` + +`` is `phys` or `chem`; `` is a two-digit numeric volume id. + +## Volume `meta.yaml` + +Fields observed in `source/naboj/phys/28/meta.yaml`: + +```yaml +date: 2025-11-07 +start: '11:30' +problems: [gravity-sudoku, train-mirror, balance-me, ...] # ordered! +workshop: 'chata Alexa, Oravská Lesná' +authors: + problems: [ "Martin ‚Kvík‘ Baláž", ... ] + pictures: [...] + editors: [...] + head: "Jaroslav Valovčan" +venues: + ba: { name: Bratislava, head: "Katarína Nedeľková" } + ke: { name: Košice, head: "Marián Kireš" } + # ... +table: 8 # answer table columns +constants: # constants that appear in the printed table + generic: [gforce, avogadro, universal_gas, boltzmann] + astronomy: [speed_light, gravity, radius_earth, ...] + elmag: [permittivity, mass_electron, elementary_charge] + misc: [stefan_boltzmann, density_water, ...] +``` + +`problems:` fixes the **order** in which problems appear (numbering follows this list). +Every id listed must exist as `problems//`. + +## Problem `meta.yaml` + +Minimal (no computed values, hard-coded numbers in the Markdown): + +```yaml +authors: ['Kvík'] +tags: ['kinematics'] +``` + +With values (used as Jinja variables inside `problem.md`, `solution.md`, `preamble.md`, +`answer.md`, ...): + +```yaml +authors: ['Kvík'] +tags: ['kinematics', 'oblique-throw'] +values: + v0: + magnitude: 50 + unit: 'metre / second' # pint syntax; see quantities-and-constants.md + symbol: 'v_0' # TeX symbol used by |eq / .eq + D: + magnitude: 70 + unit: 'metre' + symbol: 'd' +``` + +Dimensionless values: use `unit: ~` or `unit: '1'`. + +Percentages: `unit: '%'`. Angles: `unit: 'degree'` or `'radian'`. + +`siunitx` extras (rare, e.g. force `per-mode`): + +```yaml +values: + acc: + magnitude: 2.6 + unit: "kilometre / hour / second" + si_extra: + per-mode: "repeated-symbol" + symbol: a +``` + +A value can also be a bare string or number — no Quantity is constructed then. + +`author:` (singular) is accepted in older volumes; new problems use `authors: [...]`. + +### `eq:` — named math fragments (chem) + +Alongside `values:`, `meta.yaml` can carry an `eq:` top-level dict. Each entry +becomes a `MathObject` addressable in Jinja as `eq.`. Its content is a +LaTeX fragment that may itself contain `(§ … §)` tags — those are expanded on +the second render pass (see `jinja-templating.md` §Two-pass rendering). + +Canonical example: `source/naboj/chem/04/problems/maliari/meta.yaml`. + +```yaml +values: + m: { magnitude: 140, unit: 'g' } + w: { magnitude: 0.88, unit: '1' } +eq: + m: | + m(\ce{Zn}) = + m(\text{kov}) \cdot w(\ce{Zn}) = + (§ m|f0 §) \cdot (§ (w.to('1'))|f2 §) = + (§ (m * w)|f1 §). + Mr: | + M_r(\ce{ZnSO4}) + &= A_r(\ce{Zn}) + A_r(\ce{S}) + 4 A_r(\ce{O}) \\ + &= (§ const.M_Zn §) + (§ const.M_S §) + 4 \cdot (§ const.M_O §) = + (§ M_ZnSO4|f2 §). +``` + +Then in `solution.md`: + +``` +Hmotnosť rozpusteného zinku preto bude +(§ eq.m|disp §) + +Molekulová hmotnosť síranu zinočnatého je +(§ eq.Mr|align §) +``` + +`|disp` produces `$$\n …\n$$ {#eq:maliari:m}` and `|align` produces the +`aligned` variant (`&`-alignment supported). The label is generated +automatically as `{#eq::}` — do **not** add your own. + +Constraints: +- The name must match `^[a-z][a-zA-Z0-9_]+$` and cannot be `eq` or `const`. +- Names are validated by `StandaloneContext._schema` in `renderer.py`. +- More common in `chem` than `phys`; useful when the same computation is + displayed *and* re-used later as a labelled equation. + +## `preamble.md` + +Optional. Executes Jinja before problem/solution rendering; **prepended** to the file being +rendered. Almost always contains `@J set` lines: + +``` +@J set result = v0**2 / const.g.approx - sqrt((v0**4 / const.g.approx**2) - D**2) +@J set result_exact = v0**2 / const.g - sqrt((v0**4 / const.g**2) - D**2) +``` + +Convention: compute a rounded-`const.g` variant (`.approx`) alongside an exact-`const.g` +variant so both `answer.md` (rounded target for competitors) and `solution.md` (exact +expression) get sensible values. + +## `answer.md` / `answer-also.md` / `answer-interval.md` + +Single line (no trailing newline needed), typically referencing a preamble variable: + +``` +(§ result|f0 §) +``` + +- `answer.md` — the canonical answer displayed on the answer sheet. +- `answer-also.md` — an alternative accepted answer, typeset after "also". +- `answer-interval.md` — an interval, typeset after "interval". Usually `(§ (a % b)|f2 §)` + where `%` constructs a `QuantityRange`, or `(§ x|w(0.05)|f2 §)` for a ±5% tolerance. + +All three are **non-translatable** (same file across languages). If wrapped in `$…$` they +render as inline math on the answer sheet. + +The linter forbids `\frac` here — use `\dfrac` because answer cells are typeset small. + +## Language directory (`/`) + +- `problem.md` — problem statement in that language. +- `solution.md` — worked solution. +- `problem-extra.md` — rarely used, gets appended in the booklet after the problem. +- `answer-extra.md` — rarely used, gets appended after the answer. + +Supported language codes (see `Makefile`): `sk en cs hu pl es de fr ru fa uk pt`. + +## Figures + +- `.svg` — rendered via `rsvg-convert` / `dvisvgm`. +- `.tikz` — inline TikZ, wrapped by the pipeline. +- `.png`, `.jpg` — embedded directly. + +Figures live at the **problem level** (not per-language). Reference from Markdown: + +``` +![Caption text](my-figure.svg){#fig::