diff --git a/.github/bump_version.py b/.github/bump_version.py new file mode 100644 index 0000000..779a82e --- /dev/null +++ b/.github/bump_version.py @@ -0,0 +1,77 @@ +"""Infer semver bump from towncrier fragment types and update version.""" + +import re +import sys +from pathlib import Path + + +def get_current_version(pyproject_path: Path) -> str: + text = pyproject_path.read_text() + match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE) + if not match: + print( + "Could not find version in pyproject.toml", + file=sys.stderr, + ) + sys.exit(1) + return match.group(1) + + +def infer_bump(changelog_dir: Path) -> str: + fragments = [ + f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep" + ] + if not fragments: + print("No changelog fragments found", file=sys.stderr) + sys.exit(1) + + categories = {f.suffix.lstrip(".") for f in fragments} + for f in fragments: + parts = f.stem.split(".") + if len(parts) >= 2: + categories.add(parts[-1]) + + if "breaking" in categories: + return "major" + if "added" in categories or "removed" in categories: + return "minor" + return "patch" + + +def bump_version(version: str, bump: str) -> str: + major, minor, patch = (int(x) for x in version.split(".")) + if bump == "major": + return f"{major + 1}.0.0" + elif bump == "minor": + return f"{major}.{minor + 1}.0" + else: + return f"{major}.{minor}.{patch + 1}" + + +def update_file(path: Path, old_version: str, new_version: str): + text = path.read_text() + updated = text.replace( + f'version = "{old_version}"', + f'version = "{new_version}"', + ) + if updated != text: + path.write_text(updated) + print(f" Updated {path}") + + +def main(): + root = Path(__file__).resolve().parent.parent + pyproject = root / "pyproject.toml" + changelog_dir = root / "changelog.d" + + current = get_current_version(pyproject) + bump = infer_bump(changelog_dir) + new = bump_version(current, bump) + + print(f"Version: {current} -> {new} ({bump})") + + update_file(pyproject, current, new) + + +if __name__ == "__main__": + main() diff --git a/.github/fetch_version.py b/.github/fetch_version.py new file mode 100644 index 0000000..57ca571 --- /dev/null +++ b/.github/fetch_version.py @@ -0,0 +1,10 @@ +"""Print the package version from pyproject.toml (used to tag releases).""" + +import re +from pathlib import Path + +text = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text() +match = re.search(r'^version\s*=\s*"(.+?)"', text, re.MULTILINE) +if match is None: + raise SystemExit("Could not find version in pyproject.toml") +print(match.group(1)) diff --git a/.github/publish-git-tag.sh b/.github/publish-git-tag.sh new file mode 100755 index 0000000..9437a66 --- /dev/null +++ b/.github/publish-git-tag.sh @@ -0,0 +1,4 @@ +#! /usr/bin/env bash + +git tag `python .github/fetch_version.py` # create a new tag +git push --tags || true # update the repository version diff --git a/.github/workflows/changelog_entry.yaml b/.github/workflows/changelog_entry.yaml new file mode 100644 index 0000000..4cf6327 --- /dev/null +++ b/.github/workflows/changelog_entry.yaml @@ -0,0 +1,21 @@ +name: Changelog + +on: + pull_request: + branches: [ main ] + +jobs: + check-changelog: + name: Check changelog fragment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for changelog fragment + run: | + FRAGMENTS=$(find changelog.d -type f ! -name '.gitkeep' | wc -l) + if [ "$FRAGMENTS" -eq 0 ]; then + echo "::error::No changelog fragment found in changelog.d/" + echo "Add one with: echo 'Description.' > changelog.d/\$(git branch --show-current)..md" + echo "Types: added, changed, fixed, removed, breaking" + exit 1 + fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..94df578 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,30 @@ +name: CI +on: + push: + branches: [ main ] + +jobs: + Test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv pip install -e ".[dev]" --system + - name: Run tests with coverage + run: make test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + files: ./coverage.xml + fail_ci_if_error: false + verbose: true diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..1e54fef --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,62 @@ +name: Pull request +on: + pull_request: + branches: [ main ] + +jobs: + Lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Install ruff + run: uv pip install ruff --system + - name: Check formatting and lint + run: make check-format + + Test: + strategy: + matrix: + python-version: ["3.11", "3.13", "3.14"] + fail-fast: false + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv pip install -e ".[dev]" --system + - name: Run tests with coverage + run: make test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + files: ./coverage.xml + fail_ci_if_error: false + verbose: true + + Build: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: uv pip install -e ".[dev]" --system + - name: Build package + run: make build diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml new file mode 100644 index 0000000..0bb0c7e --- /dev/null +++ b/.github/workflows/versioning.yaml @@ -0,0 +1,70 @@ +# Workflow that runs on versioning metadata updates. + +name: Versioning updates +on: + push: + branches: + - main + + paths: + - changelog.d/** + - "!pyproject.toml" + +jobs: + Versioning: + runs-on: ubuntu-latest + if: | + (!(github.event.head_commit.message == 'Update package version')) + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Checkout repo + uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: 3.13 + - name: Build changelog + run: | + pip install towncrier + python .github/bump_version.py + towncrier build --yes --version $(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") + - name: Update changelog + uses: EndBug/add-and-commit@v10 + with: + add: "." + message: Update package version + github_token: ${{ steps.app-token.outputs.token }} + fetch: false + publish-to-pypi: + name: Publish to PyPI + if: (github.event.head_commit.message == 'Update package version') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 # Fetch all history for all tags and branches + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.13 + - name: Install package + run: make install + - name: Build package + run: python -m build + - name: Publish a git tag + run: ".github/publish-git-tag.sh || true" + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI }} + skip-existing: true diff --git a/.gitignore b/.gitignore index dbb6cfa..7ff536b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ dist/ build/ *.egg-info/ +.coverage +coverage.xml +htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..14e279d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bdb2af6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PolicyEngine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..18d96b9 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +install: + pip install -e ".[dev]" + +test: + pytest tests/ --cov=microunit --cov-report=xml --maxfail=0 -v + +check-format: + ruff check . + ruff format --check . + +format: + ruff check --fix . + ruff format . + +build: + pip install build + python -m build + +changelog: + python .github/bump_version.py + towncrier build --yes --version $$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") + +clean: + rm -rf dist/ build/ *.egg-info/ diff --git a/README.md b/README.md index e2c7720..3f5e765 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,86 @@ partition = assign_spm_partition(persons) print(partition.to_frame()) ``` +## Rules-based tax-unit construction + +`microunit` includes the rules-based tax-unit / filing-status construction +engine extracted from +[`policyengine-us-data`](https://github.com/PolicyEngine/policyengine-us-data). +It applies federal filing and dependency rules to assign people into tax +units, infer each person's role (head / spouse / dependent), and infer a +filing status per unit. It is the same engine reused across the CPS and ACS +pipelines there, and is **source-agnostic**: it operates on +already-normalized, CPS-like person frames. It is consumed by +`policyengine-us-data` and `microplex-us`. + +```python +import pandas as pd +from microunit import construct_tax_units + +# person uses CPS-like column names (see "Input contract" below). +person_assignments, tax_unit = construct_tax_units(person, year=2024) +``` + +`construct_tax_units(person, year, mode="policyengine")` returns: + +- **`person_assignments`** (indexed like the input): `TAX_ID` (`int64`, + dense 1-based id), `tax_unit_role_input` (bytes: `HEAD` / `SPOUSE` / + `DEPENDENT`), `is_related_to_head_or_spouse` (bool). +- **`tax_unit`** (one row per `TAX_ID`): `filing_status_input` (bytes: + `JOINT` / `HEAD_OF_HOUSEHOLD` / `SURVIVING_SPOUSE` / `SEPARATE` / + `SINGLE`). + +The string columns are byte strings (the HDF5-friendly encoding used by the +source pipeline); decode with `.decode()`. + +A `UnitPartition` adapter is also provided: + +```python +from microunit.units import construct_tax_partition + +partition = construct_tax_partition(person, year=2024) # UnitPartition(unit_type="tax") +``` + +### Modes + +- **`"policyengine"`** (default, `microunit.POLICYENGINE_MODE`): PolicyEngine's + dependency/filing-rule flow. +- **`"census_documented"`** (`microunit.CENSUS_DOCUMENTED_MODE`): the publicly + documented Census tax-model flow. + +### Input contract + +Required CPS columns (raises `KeyError` if missing): `PH_SEQ`, `A_LINENO`, +`A_AGE`, `A_MARITL`, `A_SPOUSE`, `PEPAR1`, `PEPAR2`, `A_EXPRRP`. + +Optional evidence columns (used when present, safely defaulted otherwise): +income components (`WSAL_VAL`, `SEMP_VAL`, `FRSE_VAL`, `INT_VAL`, `DIV_VAL`, +`RNT_VAL`, `CAP_VAL`, `UC_VAL`, `OI_VAL`, `ANN_VAL`, `PNSN_VAL`, `SS_VAL`), +total money income (`PTOTVAL`), enrollment (`A_ENRLW`, `A_FTPT`, `A_HSCOL`), +and disability flags (`PEDISDRS`, `PEDISEAR`, `PEDISEYE`, `PEDISOUT`, +`PEDISPHY`, `PEDISREM`). Relationship codes follow the CPS ASEC `A_EXPRRP` +recode, exposed as `microunit.CPSRelationshipCode`. + +### ACS column mapping is the consumer's responsibility + +The ACS PUMS -> CPS column mapping (`acs_to_cps_columns.py` in +`policyengine-us-data`) is **not** part of `microunit`. That ~500-line module +is ACS-PUMS-specific (`RELSHIPP`/`RELP` translation, marital-status recoding, +and heuristic spouse/parent-pointer inference, since ACS provides no universal +spouse or parent pointers) and belongs with the ACS reader. Consumers reading +ACS should map their PUMS columns onto the CPS-like contract above and then +call `construct_tax_units`. Accordingly, the ACS-specific tests from +`policyengine-us-data` remain there; the full CPS construction test suite is +ported here. + +### Packaged data + +The qualifying-relative gross income limit (the personal/dependent exemption +amount under IRC 151(d), used by the IRC 152(d)(1)(B) gross income test) ships +as package data at `microunit/data/dependent_gross_income_limit.yaml` and is +loaded via `importlib.resources`, so the engine does not depend on +`policyengine-us` being installed. + ## Scope This package should construct unit assignments and explain them. It should not @@ -65,7 +145,7 @@ Near-term roadmap: 1. Move reusable SPM unit assignment out of `spm-calculator`. 2. Move reusable tax-unit construction out of `policyengine-us-data` / - `policyengine-us`. + `policyengine-us`. (Done -- see "Rules-based tax-unit construction" above.) 3. Add CPS and ACS source adapters for Microplex. 4. Use SPM units as the temporary simplification for SNAP, Medicaid/MAGI, and other program units. diff --git a/changelog.d/.gitkeep b/changelog.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/changelog.d/init-microunit-package.added.md b/changelog.d/init-microunit-package.added.md new file mode 100644 index 0000000..02dd4fb --- /dev/null +++ b/changelog.d/init-microunit-package.added.md @@ -0,0 +1 @@ +Initial release. Rules-based tax-unit construction engine (`construct_tax_units`, `policyengine` and `census_documented` modes) extracted from policyengine-us-data, conservative SPM/SNAP/Medicaid-MAGI unit adapters, partition primitives (`UnitPartition`, `EgoUnitMembership`), and partition-match diagnostics. diff --git a/pyproject.toml b/pyproject.toml index 9bb2ece..1747a83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,9 @@ build-backend = "hatchling.build" [project] name = "microunit" -version = "0.1.0" +# Base version; CI (.github/workflows/versioning.yaml) bumps this from the +# changelog.d fragments on merge to main, so the first release publishes 0.1.0. +version = "0.0.0" description = "Policy unit assignment for PolicyEngine's microdata stack" readme = "README.md" license = "MIT" @@ -32,13 +34,18 @@ classifiers = [ ] requires-python = ">=3.11" dependencies = [ + "numpy>=1.24", "pandas>=2.0", + "pyyaml>=6.0", ] [project.optional-dependencies] dev = [ "pytest>=7.0", + "pytest-cov>=4.0", "ruff>=0.1", + "build>=1.0", + "towncrier>=24.8.0", ] [project.urls] @@ -46,6 +53,17 @@ Repository = "https://github.com/PolicyEngine/microunit" [tool.hatch.build.targets.wheel] packages = ["src/microunit"] +# Ship packaged rule data (the qualifying-relative gross income limit YAML) +# alongside the Python modules. +artifacts = ["src/microunit/data/*.yaml"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/microunit", + "tests", + "README.md", + "CHANGELOG.md", +] [tool.pytest.ini_options] testpaths = ["tests"] @@ -59,3 +77,36 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] ignore = ["E501"] + +[tool.towncrier] +package = "microunit" +directory = "changelog.d" +filename = "CHANGELOG.md" +title_format = "## [{version}] - {project_date}" +issue_format = "" +underlines = ["", "", ""] + +[[tool.towncrier.type]] +directory = "breaking" +name = "Breaking changes" +showcontent = true + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true diff --git a/src/microunit/__init__.py b/src/microunit/__init__.py index 5421dae..4bf25c6 100644 --- a/src/microunit/__init__.py +++ b/src/microunit/__init__.py @@ -3,8 +3,34 @@ from microunit.core import EgoUnitMembership, UnitPartition from microunit.diagnostics import PartitionMatchReport, partition_match_report from microunit.registry import UnitKind, UnitScheme, get_scheme, list_schemes +from microunit.rule_helpers import ( + REFERENCE_PERSON_CODES, + REFERENCE_QUALIFYING_CHILD_CODES, + REFERENCE_QUALIFYING_RELATIVE_CODES, + REFERENCE_SPOUSE_CODES, + CPSRelationshipCode, + dependent_gross_income_limit, + qualifying_child_age_test, + reference_relationship_allows_qualifying_child, + reference_relationship_allows_qualifying_relative, + related_to_head_or_spouse, +) +from microunit.tax_unit_construction import ( + CENSUS_DOCUMENTED_MODE, + DEPENDENT, + HEAD, + POLICYENGINE_MODE, + SPOUSE, + SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES, + construct_tax_units, + estimate_dependent_gross_income, +) + +__version__ = "0.1.0" __all__ = [ + "__version__", + # Core containers "EgoUnitMembership", "PartitionMatchReport", "UnitKind", @@ -13,4 +39,23 @@ "get_scheme", "list_schemes", "partition_match_report", + # Rules-based tax-unit construction engine + "construct_tax_units", + "estimate_dependent_gross_income", + "HEAD", + "SPOUSE", + "DEPENDENT", + "POLICYENGINE_MODE", + "CENSUS_DOCUMENTED_MODE", + "SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES", + "CPSRelationshipCode", + "REFERENCE_PERSON_CODES", + "REFERENCE_SPOUSE_CODES", + "REFERENCE_QUALIFYING_CHILD_CODES", + "REFERENCE_QUALIFYING_RELATIVE_CODES", + "dependent_gross_income_limit", + "qualifying_child_age_test", + "reference_relationship_allows_qualifying_child", + "reference_relationship_allows_qualifying_relative", + "related_to_head_or_spouse", ] diff --git a/src/microunit/core.py b/src/microunit/core.py index dd85e46..8ea51a8 100644 --- a/src/microunit/core.py +++ b/src/microunit/core.py @@ -36,7 +36,9 @@ def __post_init__(self) -> None: raise ValueError("unit_id cannot contain missing values") if person_id.duplicated().any(): duplicates = person_id[person_id.duplicated()].unique().tolist() - raise ValueError(f"person_id must be unique, found duplicates: {duplicates}") + raise ValueError( + f"person_id must be unique, found duplicates: {duplicates}" + ) object.__setattr__(self, "person_id", person_id.reset_index(drop=True)) object.__setattr__(self, "unit_id", unit_id.reset_index(drop=True)) @@ -162,7 +164,9 @@ def from_mapping( for member in members: focal_ids.append(focal) member_ids.append(member) - return cls(unit_type, pd.Series(focal_ids), pd.Series(member_ids), source=source) + return cls( + unit_type, pd.Series(focal_ids), pd.Series(member_ids), source=source + ) def to_frame(self) -> pd.DataFrame: """Return membership rows keyed by focal person and member person.""" diff --git a/src/microunit/data/dependent_gross_income_limit.yaml b/src/microunit/data/dependent_gross_income_limit.yaml new file mode 100644 index 0000000..b63953e --- /dev/null +++ b/src/microunit/data/dependent_gross_income_limit.yaml @@ -0,0 +1,88 @@ +description: >- + Personal and dependent exemption amount under IRC 151(d). TCJA set the + deduction to $0 from 2018 (made permanent by OBBB), but the underlying + amount continues to be inflation-adjusted and published in annual Rev. Proc. + for other provisions that reference it, such as the qualifying relative + gross income test under IRC 152(d)(1)(B). The deduction suspension is + represented separately in gov.irs.income.exemption.suspended. +metadata: + unit: currency-USD + uprating: gov.irs.uprating + period: year + reference: + - title: 26 U.S. Code ยง 151(d)(1) - Exemption amount + href: https://www.law.cornell.edu/uscode/text/26/151#d_1 + - title: IRS Notice 2018-70 - Guidance on qualifying relative exemption amount + href: https://www.irs.gov/pub/irs-drop/n-18-70.pdf +values: + 2013-01-01: + value: 3_900 + reference: + - title: Rev. Proc. 2013-15 + href: https://www.irs.gov/pub/irs-drop/rp-13-15.pdf + 2014-01-01: + value: 3_950 + reference: + - title: Rev. Proc. 2013-35 + href: https://www.irs.gov/pub/irs-drop/rp-13-35.pdf + 2015-01-01: + value: 4_000 + reference: + - title: Rev. Proc. 2014-61 + href: https://www.irs.gov/pub/irs-drop/rp-14-61.pdf + 2016-01-01: + value: 4_050 + reference: + - title: Rev. Proc. 2015-53 + href: https://www.irs.gov/pub/irs-drop/rp-15-53.pdf + 2017-01-01: + value: 4_050 + reference: + - title: Rev. Proc. 2016-55 + href: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf + 2018-01-01: + value: 4_150 + reference: + - title: Rev. Proc. 2017-58 + href: https://www.irs.gov/pub/irs-drop/rp-17-58.pdf + 2019-01-01: + value: 4_200 + reference: + - title: Rev. Proc. 2018-57 + href: https://www.irs.gov/pub/irs-drop/rp-18-57.pdf + 2020-01-01: + value: 4_300 + reference: + - title: Rev. Proc. 2019-44 + href: https://www.irs.gov/pub/irs-drop/rp-19-44.pdf + 2021-01-01: + value: 4_300 + reference: + - title: Rev. Proc. 2020-45 + href: https://www.irs.gov/pub/irs-drop/rp-20-45.pdf + 2022-01-01: + value: 4_400 + reference: + - title: Rev. Proc. 2021-45 + href: https://www.irs.gov/pub/irs-drop/rp-21-45.pdf + 2023-01-01: + value: 4_700 + reference: + - title: Rev. Proc. 2022-38 + href: https://www.irs.gov/pub/irs-drop/rp-22-38.pdf + 2024-01-01: + value: 5_050 + reference: + - title: Rev. Proc. 2023-34 + href: https://www.irs.gov/pub/irs-drop/rp-23-34.pdf + 2025-01-01: + value: 5_200 + reference: + - title: Rev. Proc. 2024-40 + href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf + 2026-01-01: + value: 5_300 + reference: + - title: Rev. Proc. 2025-32 + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf + diff --git a/src/microunit/diagnostics.py b/src/microunit/diagnostics.py index a9bd08f..c3b78c2 100644 --- a/src/microunit/diagnostics.py +++ b/src/microunit/diagnostics.py @@ -32,7 +32,9 @@ def person_match_rate(self) -> float: return self.persons_in_matched_groups / self.person_count -def _signature(person_id: pd.Series, unit_id: pd.Series) -> frozenset[frozenset[Hashable]]: +def _signature( + person_id: pd.Series, unit_id: pd.Series +) -> frozenset[frozenset[Hashable]]: frame = pd.DataFrame({"person_id": person_id, "unit_id": unit_id}) return frozenset( frozenset(group["person_id"].tolist()) diff --git a/src/microunit/rule_helpers.py b/src/microunit/rule_helpers.py new file mode 100644 index 0000000..1714018 --- /dev/null +++ b/src/microunit/rule_helpers.py @@ -0,0 +1,155 @@ +"""Rules-based helpers for tax-unit construction. + +These helpers encode the federal dependency and filing rules used to assign +people into tax units: the qualifying-child age test, the +relationship-to-reference-person tests for qualifying children and qualifying +relatives, and the qualifying-relative gross income limit (the personal- and +dependent-exemption amount under IRC 151(d), used by the IRC 152(d)(1)(B) +gross income test). + +The CPS relationship codes mirror the Census ``A_EXPRRP`` recode used in the +ASEC. Consumers that start from a different relationship coding (for example +ACS ``RELSHIPP``) are expected to map onto these codes before calling +:func:`microunit.construct_tax_units`. + +The gross income limit is read from package data +(``data/dependent_gross_income_limit.yaml``) so the package is self-contained +and does not depend on ``policyengine-us`` being installed. +""" + +from __future__ import annotations + +from enum import IntEnum +from functools import cache +from importlib import resources + +import yaml + + +class CPSRelationshipCode(IntEnum): + """CPS ASEC relationship-to-reference-person recode (``A_EXPRRP``).""" + + REFERENCE_PERSON_WITH_RELATIVES = 1 + REFERENCE_PERSON_WITHOUT_RELATIVES = 2 + HUSBAND = 3 + WIFE = 4 + OWN_CHILD = 5 + GRANDCHILD = 7 + PARENT = 8 + SIBLING = 9 + OTHER_RELATIVE = 10 + FOSTER_CHILD = 11 + NONRELATIVE_WITH_RELATIVES = 12 + PARTNER_OR_ROOMMATE = 13 + NONRELATIVE_WITHOUT_RELATIVES = 14 + + +REFERENCE_PERSON_CODES = frozenset( + { + CPSRelationshipCode.REFERENCE_PERSON_WITH_RELATIVES, + CPSRelationshipCode.REFERENCE_PERSON_WITHOUT_RELATIVES, + } +) + +REFERENCE_SPOUSE_CODES = frozenset( + { + CPSRelationshipCode.HUSBAND, + CPSRelationshipCode.WIFE, + } +) + +REFERENCE_QUALIFYING_CHILD_CODES = frozenset( + { + CPSRelationshipCode.OWN_CHILD, + CPSRelationshipCode.GRANDCHILD, + CPSRelationshipCode.SIBLING, + CPSRelationshipCode.FOSTER_CHILD, + } +) + +REFERENCE_QUALIFYING_RELATIVE_CODES = frozenset( + { + CPSRelationshipCode.OWN_CHILD, + CPSRelationshipCode.GRANDCHILD, + CPSRelationshipCode.PARENT, + CPSRelationshipCode.SIBLING, + CPSRelationshipCode.OTHER_RELATIVE, + CPSRelationshipCode.FOSTER_CHILD, + } +) + + +def qualifying_child_age_test( + age: int | float, + is_full_time_student: bool = False, + is_permanently_disabled: bool = False, + non_student_age_limit: int = 19, + student_age_limit: int = 24, +) -> bool: + if is_permanently_disabled: + return True + age_limit = student_age_limit if is_full_time_student else non_student_age_limit + return float(age) < age_limit + + +def _relationship_from_code(relationship_code: int | None): + if relationship_code is None: + return None + try: + return CPSRelationshipCode(int(relationship_code)) + except ValueError: + return None + + +def reference_relationship_allows_qualifying_child( + relationship_code: int | None, +) -> bool: + relationship = _relationship_from_code(relationship_code) + return relationship in REFERENCE_QUALIFYING_CHILD_CODES + + +def reference_relationship_allows_qualifying_relative( + relationship_code: int | None, +) -> bool: + relationship = _relationship_from_code(relationship_code) + return relationship in REFERENCE_QUALIFYING_RELATIVE_CODES + + +def related_to_head_or_spouse(relationship_code: int | None) -> bool: + relationship = _relationship_from_code(relationship_code) + return relationship in ( + REFERENCE_PERSON_CODES + | REFERENCE_SPOUSE_CODES + | REFERENCE_QUALIFYING_RELATIVE_CODES + ) + + +@cache +def _gross_income_limit_values() -> dict: + parameter_path = ( + resources.files("microunit") / "data" / "dependent_gross_income_limit.yaml" + ) + with parameter_path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f)["values"] + + +@cache +def dependent_gross_income_limit(year: int) -> float: + values = _gross_income_limit_values() + + def _period_year(period) -> int: + if hasattr(period, "year"): + return int(period.year) + return int(str(period)[:4]) + + applicable_years = sorted( + _period_year(period) for period in values if _period_year(period) <= year + ) + if not applicable_years: + raise ValueError(f"No dependent gross income limit configured for {year}.") + + selected_year = applicable_years[-1] + for period, entry in values.items(): + if _period_year(period) == selected_year: + return float(entry["value"]) + raise ValueError(f"No dependent gross income limit configured for {year}.") diff --git a/src/microunit/tax_unit_construction.py b/src/microunit/tax_unit_construction.py new file mode 100644 index 0000000..ae323de --- /dev/null +++ b/src/microunit/tax_unit_construction.py @@ -0,0 +1,891 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np +import pandas as pd + +from microunit.rule_helpers import ( + REFERENCE_PERSON_CODES, + dependent_gross_income_limit, + qualifying_child_age_test, + reference_relationship_allows_qualifying_child, + reference_relationship_allows_qualifying_relative, +) +from microunit.rule_helpers import ( + related_to_head_or_spouse as reference_related_to_head_or_spouse, +) + +HEAD = "HEAD" +SPOUSE = "SPOUSE" +DEPENDENT = "DEPENDENT" + +POLICYENGINE_MODE = "policyengine" +CENSUS_DOCUMENTED_MODE = "census_documented" +SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES = frozenset( + { + POLICYENGINE_MODE, + CENSUS_DOCUMENTED_MODE, + } +) +DISABILITY_FLAGS = ( + "PEDISDRS", + "PEDISEAR", + "PEDISEYE", + "PEDISOUT", + "PEDISPHY", + "PEDISREM", +) +_GROSS_INCOME_COLUMN = "_tax_unit_gross_income" +_CLAIMANT_INCOME_COLUMN = "_tax_unit_claimant_income" +_TOTAL_MONEY_INCOME_COLUMN = "_tax_unit_total_money_income" +_HAS_DISABILITY_COLUMN = "_tax_unit_has_disability" +_IS_FULL_TIME_STUDENT_COLUMN = "_tax_unit_is_full_time_student" + + +@dataclass(frozen=True) +class _HouseholdPerson: + index: int + household_id: int + line_no: int + age: int + relationship_code: int | None + marital_status: int + spouse_line: int | None + parent_lines: tuple[int, ...] + gross_income: float + claimant_income: float + total_money_income: float + is_full_time_student: bool + is_permanently_disabled: bool + + @property + def starts_base_unit(self) -> bool: + return self.age >= 18 or self.marital_status in {1, 2, 3, 4, 5, 6} + + @property + def married_spouse_present(self) -> bool: + return self.marital_status in {1, 2} and self.spouse_line is not None + + +@dataclass +class _BaseTaxUnit: + key: tuple + household_id: int + head_index: int + spouse_index: int | None = None + claimant_lines: tuple[int, ...] = () + claimant_income: float = 0.0 + total_money_income: float = 0.0 + head_age: int = 0 + + +@dataclass(frozen=True) +class _ClaimCandidate: + unit_key: tuple + priority: int + score: tuple[Any, ...] + + +def _to_optional_positive_int(value) -> int | None: + if pd.isna(value): + return None + value = int(value) + return value if value > 0 else None + + +def _to_optional_parent_line(value) -> int | None: + if pd.isna(value): + return None + value = int(value) + return value if value > 0 else None + + +def _numeric_array( + person: pd.DataFrame, + column: str, + *, + default: float = 0, +) -> np.ndarray: + if column not in person: + return np.full(len(person), default, dtype=float) + series = person[column] + if pd.api.types.is_numeric_dtype(series): + values = series.to_numpy(dtype=float, copy=False) + else: + values = pd.to_numeric(series, errors="coerce").to_numpy( + dtype=float, + copy=False, + ) + return np.nan_to_num(values, nan=default) + + +def _positive_series(person: pd.DataFrame, column: str) -> np.ndarray: + values = _numeric_array(person, column) + return np.maximum(values, 0) + + +def estimate_dependent_gross_income(person: pd.DataFrame) -> np.ndarray: + return ( + _positive_series(person, "WSAL_VAL") + + _positive_series(person, "SEMP_VAL") + + _positive_series(person, "FRSE_VAL") + + _positive_series(person, "INT_VAL") + + _positive_series(person, "DIV_VAL") + + _positive_series(person, "RNT_VAL") + + _positive_series(person, "CAP_VAL") + + _positive_series(person, "UC_VAL") + + _positive_series(person, "OI_VAL") + + _positive_series(person, "ANN_VAL") + + _positive_series(person, "PNSN_VAL") + ) + + +def _estimate_claimant_income(person: pd.DataFrame) -> np.ndarray: + return estimate_dependent_gross_income(person) + _positive_series(person, "SS_VAL") + + +def _has_disability(person: pd.DataFrame) -> np.ndarray: + has_disability = np.zeros(len(person), dtype=bool) + for flag in DISABILITY_FLAGS: + if flag in person: + has_disability |= _numeric_array(person, flag) == 1 + return has_disability + + +def _is_full_time_student(person: pd.DataFrame) -> np.ndarray: + enrolled_values = _numeric_array(person, "A_ENRLW") + full_time_values = _numeric_array(person, "A_FTPT") + school_level_values = _numeric_array(person, "A_HSCOL") + # Limit this to tax-unit construction: CPS TAX_ID behavior treats current + # high-school or college enrollment as strong student evidence for young + # adults even when the full-time flag is absent or part-time. + return ((enrolled_values == 1) & (full_time_values == 1)) | ( + (enrolled_values == 1) & np.isin(school_level_values, [1, 2]) + ) + + +def _precompute_tax_unit_inputs(person: pd.DataFrame) -> pd.DataFrame: + gross_income = estimate_dependent_gross_income(person) + person[_GROSS_INCOME_COLUMN] = gross_income + person[_CLAIMANT_INCOME_COLUMN] = gross_income + _positive_series(person, "SS_VAL") + person[_TOTAL_MONEY_INCOME_COLUMN] = ( + _numeric_array(person, "PTOTVAL") + if "PTOTVAL" in person + else person[_CLAIMANT_INCOME_COLUMN].to_numpy(dtype=float, copy=False) + ) + person[_HAS_DISABILITY_COLUMN] = _has_disability(person) + person[_IS_FULL_TIME_STUDENT_COLUMN] = _is_full_time_student(person) + return person + + +def _prepare_household_people( + household: pd.DataFrame, + household_id: int, +) -> list[_HouseholdPerson]: + gross_income = ( + household[_GROSS_INCOME_COLUMN].to_numpy(dtype=float, copy=False) + if _GROSS_INCOME_COLUMN in household + else estimate_dependent_gross_income(household) + ) + claimant_income = ( + household[_CLAIMANT_INCOME_COLUMN].to_numpy(dtype=float, copy=False) + if _CLAIMANT_INCOME_COLUMN in household + else _estimate_claimant_income(household) + ) + total_money_income = ( + household[_TOTAL_MONEY_INCOME_COLUMN].to_numpy(dtype=float, copy=False) + if _TOTAL_MONEY_INCOME_COLUMN in household + else _numeric_array(household, "PTOTVAL") + if "PTOTVAL" in household + else claimant_income.copy() + ) + has_disability = ( + household[_HAS_DISABILITY_COLUMN].to_numpy(dtype=bool, copy=False) + if _HAS_DISABILITY_COLUMN in household + else _has_disability(household) + ) + is_full_time_student = ( + household[_IS_FULL_TIME_STUDENT_COLUMN].to_numpy(dtype=bool, copy=False) + if _IS_FULL_TIME_STUDENT_COLUMN in household + else _is_full_time_student(household) + ) + people = [] + for row_number, (index, row) in enumerate(household.iterrows()): + line_no = int(row["A_LINENO"]) + parent_lines = tuple( + parent + for parent in ( + _to_optional_parent_line(row.get("PEPAR1", 0)), + _to_optional_parent_line(row.get("PEPAR2", 0)), + ) + if parent is not None + ) + relationship_code = row.get("A_EXPRRP") + if pd.isna(relationship_code): + relationship_code = None + else: + relationship_code = int(relationship_code) + people.append( + _HouseholdPerson( + index=index, + household_id=household_id, + line_no=line_no, + age=int(row["A_AGE"]), + relationship_code=relationship_code, + marital_status=int(row.get("A_MARITL", 7)), + spouse_line=_to_optional_positive_int(row.get("A_SPOUSE", 0)), + parent_lines=parent_lines, + gross_income=float(gross_income[row_number]), + claimant_income=float(claimant_income[row_number]), + total_money_income=float(total_money_income[row_number]), + is_full_time_student=bool(is_full_time_student[row_number]), + is_permanently_disabled=bool(has_disability[row_number]), + ) + ) + return people + + +def _choose_pair_head( + person_a: _HouseholdPerson, + person_b: _HouseholdPerson, +) -> tuple[_HouseholdPerson, _HouseholdPerson]: + if person_a.relationship_code in {code.value for code in REFERENCE_PERSON_CODES}: + return person_a, person_b + if person_b.relationship_code in {code.value for code in REFERENCE_PERSON_CODES}: + return person_b, person_a + if person_a.age != person_b.age: + return ( + (person_a, person_b) + if person_a.age > person_b.age + else (person_b, person_a) + ) + return ( + (person_a, person_b) + if person_a.line_no < person_b.line_no + else (person_b, person_a) + ) + + +def _build_base_tax_units( + people: list[_HouseholdPerson], +) -> tuple[dict[tuple, _BaseTaxUnit], dict[int, tuple], tuple | None]: + by_line = {person.line_no: person for person in people} + paired_indices: set[int] = set() + units: dict[tuple, _BaseTaxUnit] = {} + base_unit_by_person: dict[int, tuple] = {} + reference_unit_key: tuple | None = None + + married_pairs: set[tuple[int, int]] = set() + for person in people: + if not person.married_spouse_present: + continue + spouse = by_line.get(person.spouse_line) + if ( + spouse is None + or spouse.index == person.index + or not spouse.married_spouse_present + ): + continue + married_pairs.add(tuple(sorted((person.line_no, spouse.line_no)))) + + for line_a, line_b in sorted(married_pairs): + person_a = by_line[line_a] + person_b = by_line[line_b] + head, spouse = _choose_pair_head(person_a, person_b) + key = ("pair", min(line_a, line_b), max(line_a, line_b)) + unit = _BaseTaxUnit( + key=key, + household_id=head.household_id, + head_index=head.index, + spouse_index=spouse.index, + claimant_lines=(head.line_no, spouse.line_no), + claimant_income=head.claimant_income + spouse.claimant_income, + total_money_income=head.total_money_income + spouse.total_money_income, + head_age=head.age, + ) + units[key] = unit + paired_indices.update({head.index, spouse.index}) + base_unit_by_person[head.index] = key + base_unit_by_person[spouse.index] = key + if head.relationship_code in { + code.value for code in REFERENCE_PERSON_CODES + } or spouse.relationship_code in { + code.value for code in REFERENCE_PERSON_CODES + }: + reference_unit_key = key + + for person in people: + if person.index in paired_indices or not person.starts_base_unit: + continue + key = ("single", person.line_no) + units[key] = _BaseTaxUnit( + key=key, + household_id=person.household_id, + head_index=person.index, + claimant_lines=(person.line_no,), + claimant_income=person.claimant_income, + total_money_income=person.total_money_income, + head_age=person.age, + ) + base_unit_by_person[person.index] = key + if person.relationship_code in {code.value for code in REFERENCE_PERSON_CODES}: + reference_unit_key = key + + return units, base_unit_by_person, reference_unit_key + + +def _parent_candidate_units( + person: _HouseholdPerson, + base_units: dict[tuple, _BaseTaxUnit], + eligible_units: set[tuple], +) -> list[tuple]: + candidates = [] + for unit_key in eligible_units: + unit = base_units[unit_key] + if any( + parent_line in unit.claimant_lines for parent_line in person.parent_lines + ): + candidates.append(unit_key) + return candidates + + +def _reference_candidate_unit( + person: _HouseholdPerson, + reference_unit_key: tuple | None, + base_unit_key: tuple | None, + eligible_units: set[tuple], +) -> tuple | None: + if ( + reference_unit_key is None + or reference_unit_key == base_unit_key + or reference_unit_key not in eligible_units + ): + return None + return reference_unit_key + + +def _unit_income_score( + unit_key: tuple, + base_units: dict[tuple, _BaseTaxUnit], +) -> tuple[float, int, int]: + unit = base_units[unit_key] + return ( + unit.claimant_income, + unit.head_age, + -unit.claimant_lines[0], + ) + + +def _choose_best_candidate(candidates: list[_ClaimCandidate]) -> tuple | None: + if not candidates: + return None + return max( + candidates, + key=lambda candidate: (candidate.priority, candidate.score), + ).unit_key + + +def _choose_best_parent_unit_by_total_money_income( + candidate_units: list[tuple], + base_units: dict[tuple, _BaseTaxUnit], +) -> tuple | None: + if not candidate_units: + return None + return max( + candidate_units, + key=lambda key: ( + base_units[key].total_money_income, + base_units[key].claimant_income, + base_units[key].head_age, + -base_units[key].claimant_lines[0], + ), + ) + + +def _choose_main_filing_unit( + base_units: dict[tuple, _BaseTaxUnit], + reference_unit_key: tuple | None, +) -> tuple | None: + if reference_unit_key in base_units: + return reference_unit_key + if not base_units: + return None + return max( + base_units, + key=lambda key: ( + base_units[key].total_money_income, + base_units[key].claimant_income, + base_units[key].head_age, + -base_units[key].claimant_lines[0], + ), + ) + + +def _select_claimant_unit( + person: _HouseholdPerson, + year: int, + base_units: dict[tuple, _BaseTaxUnit], + base_unit_key: tuple | None, + reference_unit_key: tuple | None, + eligible_units: set[tuple], +) -> tuple | None: + parent_units = _parent_candidate_units(person, base_units, eligible_units) + age_eligible = qualifying_child_age_test( + age=person.age, + is_full_time_student=person.is_full_time_student, + is_permanently_disabled=person.is_permanently_disabled, + ) + + reference_unit = _reference_candidate_unit( + person, + reference_unit_key, + base_unit_key, + eligible_units, + ) + candidates: list[_ClaimCandidate] = [] + + if age_eligible: + candidates.extend( + _ClaimCandidate( + unit_key=unit_key, + priority=100, + score=_unit_income_score(unit_key, base_units), + ) + for unit_key in parent_units + ) + if ( + reference_unit is not None + and not person.starts_base_unit + and not person.parent_lines + and person.age < 15 + ): + candidates.append( + _ClaimCandidate( + unit_key=reference_unit, + priority=80, + score=_unit_income_score(reference_unit, base_units), + ) + ) + selected = _choose_best_candidate(candidates) + if selected is not None: + return selected + + if person.gross_income >= dependent_gross_income_limit(year): + return None + + if person.starts_base_unit: + return None + + candidates.extend( + _ClaimCandidate( + unit_key=unit_key, + priority=60, + score=_unit_income_score(unit_key, base_units), + ) + for unit_key in parent_units + ) + + if ( + reference_unit is not None + and ( + reference_relationship_allows_qualifying_relative(person.relationship_code) + or (not person.parent_lines and person.age < 15) + ) + and person.age < 15 + ): + candidates.append( + _ClaimCandidate( + unit_key=reference_unit, + priority=50, + score=_unit_income_score(reference_unit, base_units), + ) + ) + + return _choose_best_candidate(candidates) + + +def _determine_final_assignments_for_household_policyengine( + people: list[_HouseholdPerson], + year: int, +) -> tuple[dict[int, tuple], dict[int, str], dict[tuple, str], dict[int, bool]]: + base_units, base_unit_by_person, reference_unit_key = _build_base_tax_units(people) + person_by_index = {person.index: person for person in people} + + adult_claims: dict[int, tuple] = {} + adult_candidates = [ + person + for person in people + if person.starts_base_unit + and base_unit_by_person.get(person.index) in base_units + and base_units[base_unit_by_person[person.index]].spouse_index is None + ] + eligible_units = set(base_units) + for person in sorted(adult_candidates, key=lambda item: (item.age, item.line_no)): + unit_key = _select_claimant_unit( + person=person, + year=year, + base_units=base_units, + base_unit_key=base_unit_by_person.get(person.index), + reference_unit_key=reference_unit_key, + eligible_units=eligible_units, + ) + if unit_key is not None: + adult_claims[person.index] = unit_key + claimed_person_unit_key = base_unit_by_person.get(person.index) + if claimed_person_unit_key is not None: + eligible_units.discard(claimed_person_unit_key) + + def _resolve_surviving_unit(unit_key: tuple) -> tuple: + seen: set[tuple] = set() + current_unit_key = unit_key + while current_unit_key not in seen: + seen.add(current_unit_key) + unit = base_units[current_unit_key] + if unit.spouse_index is not None: + return current_unit_key + next_unit_key = adult_claims.get(unit.head_index) + if next_unit_key is None: + return current_unit_key + current_unit_key = next_unit_key + return current_unit_key + + adult_claims = { + person_index: _resolve_surviving_unit(unit_key) + for person_index, unit_key in adult_claims.items() + } + + surviving_units = { + unit_key + for unit_key, unit in base_units.items() + if unit.spouse_index is not None or unit.head_index not in adult_claims + } + + child_claims: dict[int, tuple] = {} + child_candidates = [ + person + for person in people + if not person.starts_base_unit and person.index not in adult_claims + ] + for person in sorted(child_candidates, key=lambda item: (item.age, item.line_no)): + unit_key = _select_claimant_unit( + person=person, + year=year, + base_units=base_units, + base_unit_key=base_unit_by_person.get(person.index), + reference_unit_key=reference_unit_key, + eligible_units=surviving_units, + ) + if unit_key is not None: + child_claims[person.index] = unit_key + + final_unit_key_by_person: dict[int, tuple] = {} + roles_by_person: dict[int, str] = {} + for unit_key, unit in base_units.items(): + if unit.spouse_index is not None: + final_unit_key_by_person[unit.head_index] = unit_key + final_unit_key_by_person[unit.spouse_index] = unit_key + roles_by_person[unit.head_index] = HEAD + roles_by_person[unit.spouse_index] = SPOUSE + continue + if unit.head_index in adult_claims: + continue + final_unit_key_by_person[unit.head_index] = unit_key + roles_by_person[unit.head_index] = HEAD + + for person_index, unit_key in adult_claims.items(): + final_unit_key_by_person[person_index] = unit_key + roles_by_person[person_index] = DEPENDENT + + for person_index, unit_key in child_claims.items(): + final_unit_key_by_person[person_index] = unit_key + roles_by_person[person_index] = DEPENDENT + + for person in people: + if person.index in final_unit_key_by_person: + continue + unit_key = ("single", person.line_no) + final_unit_key_by_person[person.index] = unit_key + roles_by_person[person.index] = HEAD + + related_to_head_or_spouse: dict[int, bool] = {} + head_spouse_lines_by_unit: dict[tuple, set[int]] = {} + for person_index, unit_key in final_unit_key_by_person.items(): + role = roles_by_person[person_index] + if role in {HEAD, SPOUSE}: + head_spouse_lines_by_unit.setdefault(unit_key, set()).add( + person_by_index[person_index].line_no + ) + + filing_status_by_unit: dict[tuple, str] = {} + unit_members: dict[tuple, list[_HouseholdPerson]] = {} + for person_index, unit_key in final_unit_key_by_person.items(): + unit_members.setdefault(unit_key, []).append(person_by_index[person_index]) + + for unit_key, members in unit_members.items(): + roles = {person.index: roles_by_person[person.index] for person in members} + has_spouse = any(role == SPOUSE for role in roles.values()) + head = next(person for person in members if roles[person.index] == HEAD) + claimant_lines = head_spouse_lines_by_unit.get(unit_key, {head.line_no}) + + for person in members: + if roles[person.index] in {HEAD, SPOUSE}: + related_to_head_or_spouse[person.index] = True + continue + related_to_head_or_spouse[person.index] = any( + parent_line in claimant_lines for parent_line in person.parent_lines + ) or reference_related_to_head_or_spouse(person.relationship_code) + + if has_spouse: + filing_status_by_unit[unit_key] = "JOINT" + continue + + has_qualifying_child = any( + roles[person.index] == DEPENDENT + and ( + any( + parent_line in claimant_lines for parent_line in person.parent_lines + ) + or reference_relationship_allows_qualifying_child( + person.relationship_code + ) + ) + and qualifying_child_age_test( + age=person.age, + is_full_time_student=person.is_full_time_student, + is_permanently_disabled=person.is_permanently_disabled, + ) + for person in members + ) + has_qualifying_relative = any( + roles[person.index] == DEPENDENT + and related_to_head_or_spouse[person.index] + and person.gross_income < dependent_gross_income_limit(year) + for person in members + ) + has_head_of_household_person = has_qualifying_child or has_qualifying_relative + + if head.marital_status == 4 and has_qualifying_child: + filing_status_by_unit[unit_key] = "SURVIVING_SPOUSE" + elif has_head_of_household_person and head.marital_status != 6: + filing_status_by_unit[unit_key] = "HEAD_OF_HOUSEHOLD" + elif has_head_of_household_person and head.marital_status == 6: + filing_status_by_unit[unit_key] = "HEAD_OF_HOUSEHOLD" + elif head.marital_status == 6: + filing_status_by_unit[unit_key] = "SEPARATE" + else: + filing_status_by_unit[unit_key] = "SINGLE" + + return ( + final_unit_key_by_person, + roles_by_person, + filing_status_by_unit, + related_to_head_or_spouse, + ) + + +def _determine_final_assignments_for_household_census_documented( + people: list[_HouseholdPerson], + year: int, +) -> tuple[dict[int, tuple], dict[int, str], dict[tuple, str], dict[int, bool]]: + del year + # Follow the publicly documented Census tax-model flow: married + dependents + # + others, qualifying-child-only parent-pointer claims, and under-15 + # no-parent fallback to the household's main filing unit. + base_units, _, reference_unit_key = _build_base_tax_units(people) + person_by_index = {person.index: person for person in people} + main_unit_key = _choose_main_filing_unit(base_units, reference_unit_key) + + final_unit_key_by_person: dict[int, tuple] = {} + roles_by_person: dict[int, str] = {} + + for unit_key, unit in base_units.items(): + final_unit_key_by_person[unit.head_index] = unit_key + roles_by_person[unit.head_index] = HEAD + if unit.spouse_index is not None: + final_unit_key_by_person[unit.spouse_index] = unit_key + roles_by_person[unit.spouse_index] = SPOUSE + + dependent_claims: dict[int, tuple] = {} + for person in sorted(people, key=lambda item: (item.age, item.line_no)): + if person.index in final_unit_key_by_person or person.married_spouse_present: + continue + + age_eligible = qualifying_child_age_test( + age=person.age, + is_full_time_student=person.is_full_time_student, + is_permanently_disabled=person.is_permanently_disabled, + ) + if person.parent_lines and age_eligible: + parent_units = [ + unit_key + for unit_key, unit in base_units.items() + if any( + parent_line in unit.claimant_lines + for parent_line in person.parent_lines + ) + ] + unit_key = _choose_best_parent_unit_by_total_money_income( + parent_units, + base_units, + ) + if unit_key is not None: + dependent_claims[person.index] = unit_key + continue + + if not person.parent_lines and person.age < 15 and main_unit_key is not None: + dependent_claims[person.index] = main_unit_key + + for person_index, unit_key in dependent_claims.items(): + final_unit_key_by_person[person_index] = unit_key + roles_by_person[person_index] = DEPENDENT + + for person in people: + if person.index in final_unit_key_by_person: + continue + unit_key = ("single", person.line_no) + final_unit_key_by_person[person.index] = unit_key + roles_by_person[person.index] = HEAD + + related_to_head_or_spouse: dict[int, bool] = {} + unit_members: dict[tuple, list[_HouseholdPerson]] = {} + head_spouse_lines_by_unit: dict[tuple, set[int]] = {} + for person_index, unit_key in final_unit_key_by_person.items(): + unit_members.setdefault(unit_key, []).append(person_by_index[person_index]) + if roles_by_person[person_index] in {HEAD, SPOUSE}: + head_spouse_lines_by_unit.setdefault(unit_key, set()).add( + person_by_index[person_index].line_no + ) + + filing_status_by_unit: dict[tuple, str] = {} + for unit_key, members in unit_members.items(): + roles = {person.index: roles_by_person[person.index] for person in members} + has_spouse = any(role == SPOUSE for role in roles.values()) + has_dependents = any(role == DEPENDENT for role in roles.values()) + claimant_lines = head_spouse_lines_by_unit.get(unit_key, set()) + + for person in members: + if roles[person.index] in {HEAD, SPOUSE}: + related_to_head_or_spouse[person.index] = True + continue + related_to_head_or_spouse[person.index] = any( + parent_line in claimant_lines for parent_line in person.parent_lines + ) or reference_related_to_head_or_spouse(person.relationship_code) + + if has_spouse: + filing_status_by_unit[unit_key] = "JOINT" + elif has_dependents: + filing_status_by_unit[unit_key] = "HEAD_OF_HOUSEHOLD" + else: + filing_status_by_unit[unit_key] = "SINGLE" + + return ( + final_unit_key_by_person, + roles_by_person, + filing_status_by_unit, + related_to_head_or_spouse, + ) + + +def construct_tax_units( + person: pd.DataFrame, + year: int, + mode: str = POLICYENGINE_MODE, +) -> tuple[pd.DataFrame, pd.DataFrame]: + required_columns = { + "PH_SEQ", + "A_LINENO", + "A_AGE", + "A_MARITL", + "A_SPOUSE", + "PEPAR1", + "PEPAR2", + "A_EXPRRP", + } + missing = sorted( + column for column in required_columns if column not in person.columns + ) + if missing: + raise KeyError( + "Missing required CPS columns for tax-unit construction: " + + ", ".join(missing) + ) + if mode not in SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES: + raise ValueError( + "Unsupported tax-unit construction mode " + f"{mode!r}. Expected one of: " + + ", ".join(sorted(SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES)) + ) + + original_index = person.index + person = _precompute_tax_unit_inputs(person.reset_index(drop=True)) + person_assignments = pd.DataFrame(index=original_index) + unit_key_records: list[tuple] = [] + unit_filing_records: list[str] = [] + + household_unit_key_by_row: dict[Any, tuple] = {} + household_role_by_row: dict[Any, str] = {} + household_related_flag_by_row: dict[Any, bool] = {} + + assignment_fn = ( + _determine_final_assignments_for_household_policyengine + if mode == POLICYENGINE_MODE + else _determine_final_assignments_for_household_census_documented + ) + + for household_id, household in person.groupby("PH_SEQ", sort=False): + household_people = _prepare_household_people(household, int(household_id)) + ( + unit_key_by_person, + roles_by_person, + filing_status_by_unit, + related_to_head_or_spouse, + ) = assignment_fn(household_people, year) + + for row_index in household.index: + unit_key = (int(household_id),) + tuple(unit_key_by_person[row_index]) + household_unit_key_by_row[row_index] = unit_key + household_role_by_row[row_index] = roles_by_person[row_index] + household_related_flag_by_row[row_index] = related_to_head_or_spouse[ + row_index + ] + + for unit_key, filing_status in filing_status_by_unit.items(): + unit_key_records.append((int(household_id),) + tuple(unit_key)) + unit_filing_records.append(filing_status) + + ordered_household_unit_keys = [ + household_unit_key_by_row[row_index] for row_index in person.index + ] + dense_unit_ids = { + unit_key: unit_id + for unit_id, unit_key in enumerate( + dict.fromkeys(ordered_household_unit_keys), + start=1, + ) + } + person_assignments["TAX_ID"] = np.array( + [dense_unit_ids[unit_key] for unit_key in ordered_household_unit_keys], + dtype=np.int64, + ) + person_assignments["tax_unit_role_input"] = np.array( + [household_role_by_row[row_index] for row_index in person.index] + ).astype("S") + person_assignments["is_related_to_head_or_spouse"] = np.array( + [household_related_flag_by_row[row_index] for row_index in person.index], + dtype=bool, + ) + + tax_unit = pd.DataFrame( + { + "TAX_ID": np.array( + [dense_unit_ids[unit_key] for unit_key in unit_key_records], + dtype=np.int64, + ), + "filing_status_input": np.array(unit_filing_records).astype("S"), + } + ).drop_duplicates("TAX_ID") + tax_unit = tax_unit.sort_values("TAX_ID").reset_index(drop=True) + + return person_assignments, tax_unit diff --git a/src/microunit/units/__init__.py b/src/microunit/units/__init__.py index 43edb3b..6e83726 100644 --- a/src/microunit/units/__init__.py +++ b/src/microunit/units/__init__.py @@ -11,7 +11,7 @@ ) from microunit.units.snap import assign_snap_partition from microunit.units.spm import assign_spm_partition -from microunit.units.tax import assign_tax_partition +from microunit.units.tax import assign_tax_partition, construct_tax_partition __all__ = [ "assign_ego_units_from_spm", @@ -19,6 +19,7 @@ "assign_snap_partition", "assign_spm_partition", "assign_tax_partition", + "construct_tax_partition", "medicaid_magi_from_membership_frame", "medicaid_magi_from_spm", "partition_from_existing_id", diff --git a/src/microunit/units/tax.py b/src/microunit/units/tax.py index ebdcf29..7849d4d 100644 --- a/src/microunit/units/tax.py +++ b/src/microunit/units/tax.py @@ -7,6 +7,7 @@ import pandas as pd from microunit.core import UnitPartition +from microunit.tax_unit_construction import POLICYENGINE_MODE, construct_tax_units from microunit.units._helpers import first_present_column @@ -22,9 +23,11 @@ def assign_tax_partition( ) -> UnitPartition: """Assign tax units by preserving an existing source-data tax-unit ID. - Full rules-based tax-unit construction should be ported here from the - current PolicyEngine prototype. This adapter intentionally fails rather - than inventing filing units when no source assignment is available. + If no source tax-unit ID is present, callers should instead run the + rules-based constructor via :func:`construct_tax_partition`, which applies + federal filing/dependency rules to CPS-like person records. This adapter + intentionally fails rather than inventing filing units when no source + assignment is available. """ unit_col = first_present_column(persons, existing_unit_cols) @@ -32,6 +35,8 @@ def assign_tax_partition( raise KeyError( "No tax-unit ID column found. Expected one of: " + ", ".join(existing_unit_cols) + + ". To construct tax units from CPS-like records, use " + "construct_tax_partition()." ) role_col = first_present_column(persons, role_cols) @@ -43,3 +48,41 @@ def assign_tax_partition( role=role, source=unit_col, ) + + +def construct_tax_partition( + persons: pd.DataFrame, + year: int, + mode: str = POLICYENGINE_MODE, + person_col: str = "person_id", +) -> UnitPartition: + """Construct tax units from CPS-like person records using filing rules. + + This is a thin :class:`~microunit.core.UnitPartition` adapter over + :func:`microunit.construct_tax_units`. ``persons`` must use the CPS-like + column contract documented on ``construct_tax_units`` (``PH_SEQ``, + ``A_LINENO``, ``A_AGE``, ``A_MARITL``, ``A_SPOUSE``, ``PEPAR1``, + ``PEPAR2``, ``A_EXPRRP``, plus optional income/enrollment/disability + columns). Consumers reading non-CPS sources (e.g. ACS PUMS) must map their + columns onto this contract first. + + The returned partition's ``unit_id`` is the dense ``TAX_ID`` and its + ``role`` carries the decoded ``HEAD``/``SPOUSE``/``DEPENDENT`` role. + """ + + assignments, _ = construct_tax_units(persons, year=year, mode=mode) + roles = assignments["tax_unit_role_input"].map( + lambda value: value.decode() if isinstance(value, bytes) else value + ) + person_id = ( + persons[person_col] + if person_col in persons + else pd.Series(range(len(persons)), name=person_col) + ) + return UnitPartition( + unit_type="tax", + person_id=person_id.reset_index(drop=True), + unit_id=assignments["TAX_ID"].reset_index(drop=True), + role=roles.reset_index(drop=True), + source=f"construct_tax_units:{mode}", + ) diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..14351e3 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,38 @@ +import microunit + + +def test_version(): + assert microunit.__version__ == "0.1.0" + + +def test_public_api_is_exported(): + for name in ( + "construct_tax_units", + "HEAD", + "SPOUSE", + "DEPENDENT", + "POLICYENGINE_MODE", + "CENSUS_DOCUMENTED_MODE", + "dependent_gross_income_limit", + "qualifying_child_age_test", + ): + assert hasattr(microunit, name), name + + +def test_unit_role_constants(): + assert microunit.HEAD == "HEAD" + assert microunit.SPOUSE == "SPOUSE" + assert microunit.DEPENDENT == "DEPENDENT" + + +def test_modes(): + assert microunit.POLICYENGINE_MODE == "policyengine" + assert microunit.CENSUS_DOCUMENTED_MODE == "census_documented" + + +def test_packaged_yaml_resource_loads(): + # The qualifying-relative gross income limit must resolve from packaged + # data (importlib.resources), not from any source tree or external + # dependency. + assert microunit.dependent_gross_income_limit(2024) == 5_050 + assert microunit.dependent_gross_income_limit(2026) == 5_300 diff --git a/tests/test_tax_partition_adapter.py b/tests/test_tax_partition_adapter.py new file mode 100644 index 0000000..83ede28 --- /dev/null +++ b/tests/test_tax_partition_adapter.py @@ -0,0 +1,75 @@ +import numpy as np +import pandas as pd + +from microunit import UnitPartition +from microunit.units import construct_tax_partition + + +def _person_fixture(**overrides): + n = max((len(value) for value in overrides.values()), default=1) + defaults = { + "PH_SEQ": np.ones(n, dtype=int), + "A_LINENO": np.arange(1, n + 1, dtype=int), + "A_AGE": np.zeros(n, dtype=int), + "A_MARITL": np.full(n, 7, dtype=int), + "A_SPOUSE": np.zeros(n, dtype=int), + "PEPAR1": np.full(n, -1, dtype=int), + "PEPAR2": np.full(n, -1, dtype=int), + "A_EXPRRP": np.full(n, 14, dtype=int), + "WSAL_VAL": np.zeros(n, dtype=float), + } + defaults.update(overrides) + return pd.DataFrame(defaults) + + +def test_construct_tax_partition_returns_unit_partition_with_roles(): + person = _person_fixture( + person_id=[101, 102, 103], + A_AGE=[40, 38, 8], + A_MARITL=[1, 1, 7], + A_SPOUSE=[2, 1, 0], + A_EXPRRP=[1, 4, 5], + PEPAR1=[-1, -1, 1], + PEPAR2=[-1, -1, 2], + WSAL_VAL=[60_000, 20_000, 0], + ) + + partition = construct_tax_partition(person, year=2024) + + assert isinstance(partition, UnitPartition) + assert partition.unit_type == "tax" + assert partition.source == "construct_tax_units:policyengine" + assert partition.n_units == 1 + frame = partition.to_frame() + assert frame["person_id"].tolist() == [101, 102, 103] + assert frame["role"].tolist() == ["HEAD", "SPOUSE", "DEPENDENT"] + + +def test_construct_tax_partition_defaults_person_id_when_absent(): + person = _person_fixture( + A_AGE=[45, 22], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + WSAL_VAL=[70_000, 10_000], + ) + + partition = construct_tax_partition(person, year=2024) + + assert partition.n_units == 2 + assert partition.to_frame()["person_id"].tolist() == [0, 1] + + +def test_construct_tax_partition_supports_census_documented_mode(): + person = _person_fixture( + person_id=[1, 2], + A_AGE=[40, 12], + A_EXPRRP=[1, 14], + WSAL_VAL=[50_000, 0], + PTOTVAL=[50_000, 0], + ) + + partition = construct_tax_partition(person, year=2024, mode="census_documented") + + assert partition.source == "construct_tax_units:census_documented" + assert partition.n_units == 1 + assert partition.to_frame()["role"].tolist() == ["HEAD", "DEPENDENT"] diff --git a/tests/test_tax_unit_construction.py b/tests/test_tax_unit_construction.py new file mode 100644 index 0000000..0119150 --- /dev/null +++ b/tests/test_tax_unit_construction.py @@ -0,0 +1,441 @@ +import numpy as np +import pandas as pd + +from microunit import construct_tax_units + + +def _person_fixture(**overrides): + n = max((len(value) for value in overrides.values()), default=1) + defaults = { + "PH_SEQ": np.ones(n, dtype=int), + "A_LINENO": np.arange(1, n + 1, dtype=int), + "A_AGE": np.zeros(n, dtype=int), + "A_MARITL": np.full(n, 7, dtype=int), + "A_SPOUSE": np.zeros(n, dtype=int), + "PECOHAB": np.full(n, -1, dtype=int), + "PEPAR1": np.full(n, -1, dtype=int), + "PEPAR2": np.full(n, -1, dtype=int), + "A_EXPRRP": np.full(n, 14, dtype=int), + "A_ENRLW": np.zeros(n, dtype=int), + "A_FTPT": np.zeros(n, dtype=int), + "A_HSCOL": np.zeros(n, dtype=int), + "WSAL_VAL": np.zeros(n, dtype=float), + "SEMP_VAL": np.zeros(n, dtype=float), + "FRSE_VAL": np.zeros(n, dtype=float), + "INT_VAL": np.zeros(n, dtype=float), + "DIV_VAL": np.zeros(n, dtype=float), + "RNT_VAL": np.zeros(n, dtype=float), + "CAP_VAL": np.zeros(n, dtype=float), + "UC_VAL": np.zeros(n, dtype=float), + "OI_VAL": np.zeros(n, dtype=float), + "ANN_VAL": np.zeros(n, dtype=float), + "PNSN_VAL": np.zeros(n, dtype=float), + "PTOTVAL": np.zeros(n, dtype=float), + "SS_VAL": np.zeros(n, dtype=float), + "PEDISDRS": np.zeros(n, dtype=int), + "PEDISEAR": np.zeros(n, dtype=int), + "PEDISEYE": np.zeros(n, dtype=int), + "PEDISOUT": np.zeros(n, dtype=int), + "PEDISPHY": np.zeros(n, dtype=int), + "PEDISREM": np.zeros(n, dtype=int), + } + defaults.update(overrides) + return pd.DataFrame(defaults) + + +def _decoded_roles(assignments: pd.DataFrame) -> list[str]: + return [value.decode() for value in assignments["tax_unit_role_input"].tolist()] + + +def _decoded_statuses(tax_unit: pd.DataFrame) -> list[str]: + return [value.decode() for value in tax_unit["filing_status_input"].tolist()] + + +def test_construct_tax_units_keeps_married_couple_and_child_together(): + person = _person_fixture( + A_AGE=[40, 38, 8], + A_MARITL=[1, 1, 7], + A_SPOUSE=[2, 1, 0], + A_EXPRRP=[1, 4, 5], + PEPAR1=[-1, -1, 1], + PEPAR2=[-1, -1, 2], + WSAL_VAL=[60_000, 20_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 1 + assert _decoded_roles(assignments) == ["HEAD", "SPOUSE", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["JOINT"] + + +def test_construct_tax_units_claims_low_income_full_time_student(): + person = _person_fixture( + A_AGE=[45, 20], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + A_ENRLW=[0, 1], + A_FTPT=[0, 1], + WSAL_VAL=[70_000, 3_000], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 1 + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["HEAD_OF_HOUSEHOLD"] + + +def test_construct_tax_units_claims_enrolled_young_adult_student(): + person = _person_fixture( + A_AGE=[45, 21], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + A_ENRLW=[0, 1], + A_FTPT=[0, 2], + A_HSCOL=[0, 2], + WSAL_VAL=[70_000, 12_000], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 1 + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["HEAD_OF_HOUSEHOLD"] + + +def test_construct_tax_units_leaves_low_income_nonstudent_adult_child_independent(): + person = _person_fixture( + A_AGE=[45, 22], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + A_ENRLW=[0, 0], + A_FTPT=[0, 0], + WSAL_VAL=[70_000, 2_000], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 2 + assert _decoded_roles(assignments) == ["HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE"] + + +def test_construct_tax_units_leaves_zero_income_nonstudent_young_adult_child_independent(): + person = _person_fixture( + A_AGE=[45, 22], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + A_ENRLW=[0, 0], + A_FTPT=[0, 0], + WSAL_VAL=[70_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 2 + assert _decoded_roles(assignments) == ["HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE"] + + +def test_construct_tax_units_leaves_high_income_adult_child_independent(): + person = _person_fixture( + A_AGE=[45, 22], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + WSAL_VAL=[70_000, 10_000], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 2 + assert _decoded_roles(assignments) == ["HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE"] + + +def test_construct_tax_units_assigns_child_to_higher_income_separated_parent(): + person = _person_fixture( + A_AGE=[40, 38, 10], + A_MARITL=[6, 6, 7], + A_EXPRRP=[1, 13, 5], + PEPAR1=[-1, -1, 1], + PEPAR2=[-1, -1, 2], + WSAL_VAL=[50_000, 20_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 2 + assert _decoded_roles(assignments) == ["HEAD", "HEAD", "DEPENDENT"] + child_unit = assignments.loc[2, "TAX_ID"] + assert child_unit == assignments.loc[0, "TAX_ID"] + assert sorted(_decoded_statuses(tax_unit)) == ["HEAD_OF_HOUSEHOLD", "SEPARATE"] + + +def test_construct_tax_units_can_roll_child_of_claimed_adult_up_to_grandparent(): + person = _person_fixture( + A_AGE=[70, 22, 4], + A_EXPRRP=[1, 5, 7], + PEPAR1=[-1, 1, 2], + A_ENRLW=[0, 1, 0], + A_FTPT=[0, 1, 0], + WSAL_VAL=[40_000, 2_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].nunique() == 1 + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["HEAD_OF_HOUSEHOLD"] + + +def test_construct_tax_units_handles_nonconsecutive_person_index(): + person = _person_fixture( + A_AGE=[40, 10], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + WSAL_VAL=[50_000, 0], + ) + person.index = [10, 20] + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments.index.tolist() == [10, 20] + assert assignments["TAX_ID"].tolist() == [1, 1] + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["HEAD_OF_HOUSEHOLD"] + + +def test_construct_tax_units_handles_duplicate_person_index_labels(): + person = _person_fixture( + PH_SEQ=[1, 2], + A_LINENO=[1, 1], + A_AGE=[40, 30], + A_EXPRRP=[1, 1], + WSAL_VAL=[50_000, 45_000], + ) + person.index = [0, 0] + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments.index.tolist() == [0, 0] + assert assignments["TAX_ID"].tolist() == [1, 2] + assert _decoded_roles(assignments) == ["HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE"] + + +def test_construct_tax_units_preserves_original_order_for_interleaved_households(): + person = _person_fixture( + PH_SEQ=[1, 2, 1, 2], + A_LINENO=[1, 1, 2, 2], + A_AGE=[40, 32, 8, 29], + A_EXPRRP=[1, 1, 5, 13], + PEPAR1=[-1, -1, 1, -1], + WSAL_VAL=[50_000, 45_000, 0, 35_000], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 2, 1, 3] + assert _decoded_roles(assignments) == ["HEAD", "HEAD", "DEPENDENT", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == [ + "HEAD_OF_HOUSEHOLD", + "SINGLE", + "SINGLE", + ] + + +def test_construct_tax_units_allows_missing_optional_evidence_columns(): + person = _person_fixture( + A_AGE=[40, 10], + A_EXPRRP=[1, 5], + PEPAR1=[-1, 1], + ).drop( + columns=[ + "A_ENRLW", + "A_FTPT", + "A_HSCOL", + "PTOTVAL", + "PEDISDRS", + "PEDISEAR", + "PEDISEYE", + "PEDISOUT", + "PEDISPHY", + "PEDISREM", + ] + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 1] + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["HEAD_OF_HOUSEHOLD"] + + +def test_construct_tax_units_collapses_transitive_adult_claim_chains(): + person = _person_fixture( + A_AGE=[46, 69, 43], + A_MARITL=[5, 5, 7], + A_EXPRRP=[1, 10, 12], + PEPAR1=[-1, -1, 2], + WSAL_VAL=[0, 0, 0], + SEMP_VAL=[120_000, 0, 0], + A_ENRLW=[0, 0, 0], + A_FTPT=[0, 0, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 2, 3] + assert _decoded_roles(assignments) == ["HEAD", "HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE", "SINGLE"] + + +def test_construct_tax_units_prevents_mutual_adult_claim_cycles(): + person = _person_fixture( + A_AGE=[39, 75, 42], + A_MARITL=[7, 5, 7], + A_EXPRRP=[1, 8, 13], + PEPAR1=[2, -1, -1], + PECOHAB=[3, -1, 1], + WSAL_VAL=[0, 0, 40_000], + INT_VAL=[13, 3, 3], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 2, 3] + assert _decoded_roles(assignments) == ["HEAD", "HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE", "SINGLE"] + + +def test_construct_tax_units_does_not_claim_adult_child_with_children(): + person = _person_fixture( + A_AGE=[70, 42, 11], + A_EXPRRP=[1, 5, 7], + PEPAR1=[-1, 1, 2], + WSAL_VAL=[23_000, 0, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 2, 2] + assert _decoded_roles(assignments) == ["HEAD", "HEAD", "DEPENDENT"] + assert sorted(_decoded_statuses(tax_unit)) == ["HEAD_OF_HOUSEHOLD", "SINGLE"] + + +def test_construct_tax_units_keeps_older_grandchild_without_parent_pointer_separate(): + person = _person_fixture( + A_AGE=[64, 58, 16], + A_MARITL=[1, 1, 7], + A_SPOUSE=[2, 1, 0], + A_EXPRRP=[1, 4, 7], + WSAL_VAL=[0, 9_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 1, 2] + assert _decoded_roles(assignments) == ["HEAD", "SPOUSE", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["JOINT", "SINGLE"] + + +def test_construct_tax_units_claims_younger_grandchild_without_parent_pointer(): + person = _person_fixture( + A_AGE=[64, 58, 12], + A_MARITL=[1, 1, 7], + A_SPOUSE=[2, 1, 0], + A_EXPRRP=[1, 4, 7], + WSAL_VAL=[0, 9_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 1, 1] + assert _decoded_roles(assignments) == ["HEAD", "SPOUSE", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["JOINT"] + + +def test_construct_tax_units_claims_under15_nonrelative_without_parent_pointer(): + person = _person_fixture( + A_AGE=[40, 12], + A_EXPRRP=[1, 14], + WSAL_VAL=[50_000, 0], + ) + + assignments, tax_unit = construct_tax_units(person, year=2024) + + assert assignments["TAX_ID"].tolist() == [1, 1] + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["SINGLE"] + + +def test_census_documented_claims_under15_without_parent_pointer_to_main_unit(): + person = _person_fixture( + A_AGE=[40, 12], + A_EXPRRP=[1, 14], + WSAL_VAL=[50_000, 0], + PTOTVAL=[50_000, 0], + ) + + assignments, tax_unit = construct_tax_units( + person, + year=2024, + mode="census_documented", + ) + + assert assignments["TAX_ID"].tolist() == [1, 1] + assert _decoded_roles(assignments) == ["HEAD", "DEPENDENT"] + assert _decoded_statuses(tax_unit) == ["HEAD_OF_HOUSEHOLD"] + + +def test_census_documented_leaves_age15_without_parent_pointer_independent(): + person = _person_fixture( + A_AGE=[40, 15], + A_EXPRRP=[1, 14], + WSAL_VAL=[50_000, 0], + PTOTVAL=[50_000, 0], + ) + + assignments, tax_unit = construct_tax_units( + person, + year=2024, + mode="census_documented", + ) + + assert assignments["TAX_ID"].tolist() == [1, 2] + assert _decoded_roles(assignments) == ["HEAD", "HEAD"] + assert sorted(_decoded_statuses(tax_unit)) == ["SINGLE", "SINGLE"] + + +def test_census_documented_uses_total_money_income_for_split_parents(): + person = _person_fixture( + A_AGE=[40, 38, 10], + A_MARITL=[7, 7, 7], + A_EXPRRP=[1, 13, 5], + PEPAR1=[-1, -1, 1], + PEPAR2=[-1, -1, 2], + WSAL_VAL=[0, 50_000, 0], + PTOTVAL=[30_000, 20_000, 0], + ) + + assignments, tax_unit = construct_tax_units( + person, + year=2024, + mode="census_documented", + ) + + assert assignments["TAX_ID"].tolist() == [1, 2, 1] + assert _decoded_roles(assignments) == ["HEAD", "HEAD", "DEPENDENT"] + assert sorted(_decoded_statuses(tax_unit)) == ["HEAD_OF_HOUSEHOLD", "SINGLE"] + + +def test_construct_tax_units_rejects_unknown_mode(): + person = _person_fixture(A_AGE=[40], A_EXPRRP=[1]) + + try: + construct_tax_units(person, year=2024, mode="unknown") + except ValueError as error: + assert "Unsupported tax-unit construction mode" in str(error) + else: + raise AssertionError("Expected construct_tax_units to reject unknown modes") diff --git a/uv.lock b/uv.lock index dd73a37..dfa5e1b 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,32 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -19,6 +45,110 @@ 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 = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -28,25 +158,121 @@ 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/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { 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 = "microunit" -version = "0.1.0" +version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "numpy" }, { name = "pandas" }, + { name = "pyyaml" }, ] [package.optional-dependencies] dev = [ + { name = "build" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, + { name = "towncrier" }, ] [package.metadata] requires-dist = [ + { name = "build", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "numpy", specifier = ">=1.24" }, { name = "pandas", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1" }, + { name = "towncrier", marker = "extra == 'dev'", specifier = ">=24.8.0" }, ] provides-extras = ["dev"] @@ -216,6 +442,15 @@ 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 = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -232,6 +467,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -244,6 +493,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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 = "ruff" version = "0.15.12" @@ -278,6 +582,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "towncrier" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, +] + [[package]] name = "tzdata" version = "2026.2"