Skip to content

Commit 501e0d4

Browse files
MaxGhenisclaude
andauthored
Add rules-based tax-unit construction engine + PyPI release machinery (#3)
* Add rules-based tax-unit construction engine from policyengine-us-data Extract the rules-based tax-unit / filing-status construction engine from policyengine-us-data into microunit (roadmap item 2). The engine is copied verbatim (no logic changes) and made source-agnostic and self-contained. This integrates additively with the existing unit-assignment scaffold. New modules: - src/microunit/tax_unit_construction.py: core engine. Public entry construct_tax_units(person, year, mode) with "policyengine" (default) and "census_documented" modes; HEAD/SPOUSE/DEPENDENT role constants. The only change vs. the source is the internal import (now microunit.rule_helpers); zero non-import edits to the logic. - src/microunit/rule_helpers.py: dependency/filing rule helpers (renamed from tax_unit_rule_helpers). The optional policyengine_us import shim is dropped; the qualifying-relative gross income limit now loads from packaged data, so the engine no longer depends on policyengine-us. - src/microunit/data/dependent_gross_income_limit.yaml: vendored IRC 151(d) exemption-amount values (through 2026), loaded via importlib.resources. Integration: - __init__.py: additively export construct_tax_units, the role constants, modes, CPSRelationshipCode, and the rule helpers (existing API unchanged). - units/tax.py: add construct_tax_partition(), a UnitPartition adapter over construct_tax_units, fulfilling the prior "port rules-based tax-unit construction here" TODO. assign_tax_partition still preserves native IDs. - units/__init__.py: export construct_tax_partition. - pyproject.toml: add numpy and pyyaml deps; ship the YAML as wheel/sdist data. - uv.lock: regenerated for the new direct dependencies. - README.md: document the engine, the two modes, the input contract, and the ACS-column-mapping boundary. Tests (60 passing total): test_tax_unit_construction.py ports the full CPS suite to the microunit namespace; test_tax_partition_adapter.py covers the new adapter; test_import.py checks the public API and packaged-data resolution. ACS boundary: acs_to_cps_columns.py (ACS-PUMS-specific RELSHIPP/RELP and spouse/parent inference) is intentionally NOT included. microunit takes already-normalized CPS-like person frames; ACS column mapping and the ACS-specific tests remain in policyengine-us-data. Extracted from PolicyEngine/policyengine-us-data@f7458313. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add release machinery: PyPI publishing, CI, changelog, LICENSE Productionize microunit so it publishes to PyPI via PolicyEngine's standard micro* release flow (matches microcalibrate/microimpute): - .github/workflows/pr.yaml: ruff (lint + format) + pytest (3.11/3.13/3.14) + build, on PRs - .github/workflows/versioning.yaml: on merge to main, bump the version from changelog.d fragments (towncrier) and publish to PyPI via secrets.PYPI - .github/workflows/changelog_entry.yaml: require a changelog fragment on PRs - .github/workflows/main.yml: tests on push to main - .github/{bump_version.py,fetch_version.py,publish-git-tag.sh} - Makefile (install/test/check-format/format/build/changelog) - towncrier config + CHANGELOG.md + changelog.d/ with the initial fragment - LICENSE (MIT) Base version set to 0.0.0 so the first auto-bump publishes 0.1.0. Existing core.py/diagnostics.py are ruff-format-normalized for the new format gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d08532a commit 501e0d4

27 files changed

Lines changed: 2626 additions & 10 deletions

.github/bump_version.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Infer semver bump from towncrier fragment types and update version."""
2+
3+
import re
4+
import sys
5+
from pathlib import Path
6+
7+
8+
def get_current_version(pyproject_path: Path) -> str:
9+
text = pyproject_path.read_text()
10+
match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE)
11+
if not match:
12+
print(
13+
"Could not find version in pyproject.toml",
14+
file=sys.stderr,
15+
)
16+
sys.exit(1)
17+
return match.group(1)
18+
19+
20+
def infer_bump(changelog_dir: Path) -> str:
21+
fragments = [
22+
f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep"
23+
]
24+
if not fragments:
25+
print("No changelog fragments found", file=sys.stderr)
26+
sys.exit(1)
27+
28+
categories = {f.suffix.lstrip(".") for f in fragments}
29+
for f in fragments:
30+
parts = f.stem.split(".")
31+
if len(parts) >= 2:
32+
categories.add(parts[-1])
33+
34+
if "breaking" in categories:
35+
return "major"
36+
if "added" in categories or "removed" in categories:
37+
return "minor"
38+
return "patch"
39+
40+
41+
def bump_version(version: str, bump: str) -> str:
42+
major, minor, patch = (int(x) for x in version.split("."))
43+
if bump == "major":
44+
return f"{major + 1}.0.0"
45+
elif bump == "minor":
46+
return f"{major}.{minor + 1}.0"
47+
else:
48+
return f"{major}.{minor}.{patch + 1}"
49+
50+
51+
def update_file(path: Path, old_version: str, new_version: str):
52+
text = path.read_text()
53+
updated = text.replace(
54+
f'version = "{old_version}"',
55+
f'version = "{new_version}"',
56+
)
57+
if updated != text:
58+
path.write_text(updated)
59+
print(f" Updated {path}")
60+
61+
62+
def main():
63+
root = Path(__file__).resolve().parent.parent
64+
pyproject = root / "pyproject.toml"
65+
changelog_dir = root / "changelog.d"
66+
67+
current = get_current_version(pyproject)
68+
bump = infer_bump(changelog_dir)
69+
new = bump_version(current, bump)
70+
71+
print(f"Version: {current} -> {new} ({bump})")
72+
73+
update_file(pyproject, current, new)
74+
75+
76+
if __name__ == "__main__":
77+
main()

.github/fetch_version.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Print the package version from pyproject.toml (used to tag releases)."""
2+
3+
import re
4+
from pathlib import Path
5+
6+
text = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text()
7+
match = re.search(r'^version\s*=\s*"(.+?)"', text, re.MULTILINE)
8+
if match is None:
9+
raise SystemExit("Could not find version in pyproject.toml")
10+
print(match.group(1))

.github/publish-git-tag.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#! /usr/bin/env bash
2+
3+
git tag `python .github/fetch_version.py` # create a new tag
4+
git push --tags || true # update the repository version
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Changelog
2+
3+
on:
4+
pull_request:
5+
branches: [ main ]
6+
7+
jobs:
8+
check-changelog:
9+
name: Check changelog fragment
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v6
13+
- name: Check for changelog fragment
14+
run: |
15+
FRAGMENTS=$(find changelog.d -type f ! -name '.gitkeep' | wc -l)
16+
if [ "$FRAGMENTS" -eq 0 ]; then
17+
echo "::error::No changelog fragment found in changelog.d/"
18+
echo "Add one with: echo 'Description.' > changelog.d/\$(git branch --show-current).<type>.md"
19+
echo "Types: added, changed, fixed, removed, breaking"
20+
exit 1
21+
fi

.github/workflows/main.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: CI
2+
on:
3+
push:
4+
branches: [ main ]
5+
6+
jobs:
7+
Test:
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
python-version: ["3.13"]
12+
steps:
13+
- name: Checkout repo
14+
uses: actions/checkout@v6
15+
- name: Install uv
16+
uses: astral-sh/setup-uv@v8.1.0
17+
- name: Set up Python ${{ matrix.python-version }}
18+
uses: actions/setup-python@v6
19+
with:
20+
python-version: ${{ matrix.python-version }}
21+
- name: Install dependencies
22+
run: uv pip install -e ".[dev]" --system
23+
- name: Run tests with coverage
24+
run: make test
25+
- name: Upload coverage to Codecov
26+
uses: codecov/codecov-action@v6
27+
with:
28+
files: ./coverage.xml
29+
fail_ci_if_error: false
30+
verbose: true

.github/workflows/pr.yaml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Pull request
2+
on:
3+
pull_request:
4+
branches: [ main ]
5+
6+
jobs:
7+
Lint:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v6
11+
- name: Set up Python
12+
uses: actions/setup-python@v6
13+
with:
14+
python-version: "3.13"
15+
- name: Install uv
16+
uses: astral-sh/setup-uv@v8.1.0
17+
- name: Install ruff
18+
run: uv pip install ruff --system
19+
- name: Check formatting and lint
20+
run: make check-format
21+
22+
Test:
23+
strategy:
24+
matrix:
25+
python-version: ["3.11", "3.13", "3.14"]
26+
fail-fast: false
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Checkout repo
30+
uses: actions/checkout@v6
31+
- name: Install uv
32+
uses: astral-sh/setup-uv@v8.1.0
33+
- name: Set up Python ${{ matrix.python-version }}
34+
uses: actions/setup-python@v6
35+
with:
36+
python-version: ${{ matrix.python-version }}
37+
- name: Install dependencies
38+
run: uv pip install -e ".[dev]" --system
39+
- name: Run tests with coverage
40+
run: make test
41+
- name: Upload coverage to Codecov
42+
uses: codecov/codecov-action@v6
43+
with:
44+
files: ./coverage.xml
45+
fail_ci_if_error: false
46+
verbose: true
47+
48+
Build:
49+
runs-on: ubuntu-latest
50+
steps:
51+
- name: Checkout repo
52+
uses: actions/checkout@v6
53+
- name: Install uv
54+
uses: astral-sh/setup-uv@v8.1.0
55+
- name: Set up Python
56+
uses: actions/setup-python@v6
57+
with:
58+
python-version: "3.13"
59+
- name: Install dependencies
60+
run: uv pip install -e ".[dev]" --system
61+
- name: Build package
62+
run: make build

.github/workflows/versioning.yaml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Workflow that runs on versioning metadata updates.
2+
3+
name: Versioning updates
4+
on:
5+
push:
6+
branches:
7+
- main
8+
9+
paths:
10+
- changelog.d/**
11+
- "!pyproject.toml"
12+
13+
jobs:
14+
Versioning:
15+
runs-on: ubuntu-latest
16+
if: |
17+
(!(github.event.head_commit.message == 'Update package version'))
18+
steps:
19+
- name: Generate GitHub App token
20+
id: app-token
21+
uses: actions/create-github-app-token@v3
22+
with:
23+
app-id: ${{ secrets.APP_ID }}
24+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
25+
- name: Checkout repo
26+
uses: actions/checkout@v6
27+
with:
28+
token: ${{ steps.app-token.outputs.token }}
29+
fetch-depth: 0
30+
- name: Setup Python
31+
uses: actions/setup-python@v6
32+
with:
33+
python-version: 3.13
34+
- name: Build changelog
35+
run: |
36+
pip install towncrier
37+
python .github/bump_version.py
38+
towncrier build --yes --version $(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))")
39+
- name: Update changelog
40+
uses: EndBug/add-and-commit@v10
41+
with:
42+
add: "."
43+
message: Update package version
44+
github_token: ${{ steps.app-token.outputs.token }}
45+
fetch: false
46+
publish-to-pypi:
47+
name: Publish to PyPI
48+
if: (github.event.head_commit.message == 'Update package version')
49+
runs-on: ubuntu-latest
50+
steps:
51+
- name: Checkout code
52+
uses: actions/checkout@v6
53+
with:
54+
fetch-depth: 0 # Fetch all history for all tags and branches
55+
- name: Set up Python
56+
uses: actions/setup-python@v6
57+
with:
58+
python-version: 3.13
59+
- name: Install package
60+
run: make install
61+
- name: Build package
62+
run: python -m build
63+
- name: Publish a git tag
64+
run: ".github/publish-git-tag.sh || true"
65+
- name: Publish to PyPI
66+
uses: pypa/gh-action-pypi-publish@release/v1
67+
with:
68+
user: __token__
69+
password: ${{ secrets.PYPI }}
70+
skip-existing: true

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ __pycache__/
66
dist/
77
build/
88
*.egg-info/
9+
.coverage
10+
coverage.xml
11+
htmlcov/

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
<!-- towncrier release notes start -->

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 PolicyEngine
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)