Initial build out

This commit is contained in:
emfurst 2026-03-30 07:15:14 -04:00
commit 29215e2bd2
40 changed files with 2622 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
data/raw/
data/*.duckdb
data/*.duckdb.wal
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.pytest_cache/

34
pyproject.toml Normal file
View file

@ -0,0 +1,34 @@
[project]
name = "admin-analytics"
version = "0.1.0"
description = "University of Delaware administrative cost benchmarking"
requires-python = ">=3.11"
dependencies = [
"duckdb>=1.0",
"typer>=0.12",
"polars>=1.0",
"httpx>=0.27",
"openpyxl>=3.1",
"xlrd>=2.0",
"pyarrow>=17.0",
"lxml>=5.0",
]
[project.scripts]
admin-analytics = "admin_analytics.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/admin_analytics"]
[dependency-groups]
dev = [
"pytest>=8.0",
"respx>=0.21",
]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

View file

126
src/admin_analytics/cli.py Normal file
View file

@ -0,0 +1,126 @@
from typing import Annotated
import typer
from admin_analytics.config import DEFAULT_YEAR_RANGE
from admin_analytics.db.connection import get_connection
from admin_analytics.db.schema import ensure_schema
app = typer.Typer(help="University of Delaware administrative analytics")
ingest_app = typer.Typer(help="Ingest data from external sources")
app.add_typer(ingest_app, name="ingest")
def _parse_year_range(year_range: str) -> range:
"""Parse '2005-2024' into a range object."""
parts = year_range.split("-")
if len(parts) != 2:
raise typer.BadParameter("Year range must be in format YYYY-YYYY")
start, end = int(parts[0]), int(parts[1])
return range(start, end + 1)
@ingest_app.command()
def ipeds(
year_range: Annotated[
str, typer.Option(help="Year range, e.g. 2005-2024")
] = f"{DEFAULT_YEAR_RANGE.start}-{DEFAULT_YEAR_RANGE.stop - 1}",
component: Annotated[
str, typer.Option(help="Component: all, hd, finance, staff, enrollment")
] = "all",
force: Annotated[
bool, typer.Option("--force", help="Re-download even if files exist")
] = False,
) -> None:
"""Ingest IPEDS data for the University of Delaware."""
years = _parse_year_range(year_range)
conn = get_connection()
ensure_schema(conn)
from admin_analytics.ipeds.download import download_all
from admin_analytics.ipeds.institution import load_institutions
from admin_analytics.ipeds.finance import load_finance
from admin_analytics.ipeds.staff import load_staff
from admin_analytics.ipeds.enrollment import load_enrollment
components = (
["hd", "finance", "staff", "enrollment"]
if component == "all"
else [component]
)
# Finance needs both F1A and F2 downloads (UD reports under FASB/F2)
download_components = list(components)
if "finance" in download_components:
download_components.append("finance_f2")
typer.echo(f"Downloading IPEDS data for {years.start}-{years.stop - 1}...")
download_all(years, download_components, force=force)
if "hd" in components:
typer.echo("Loading institutional directory (HD)...")
load_institutions(conn, years)
if "finance" in components:
typer.echo("Loading finance data (F1A)...")
load_finance(conn, years)
if "staff" in components:
typer.echo("Loading staff data (S)...")
load_staff(conn, years)
if "enrollment" in components:
typer.echo("Loading enrollment data (EF)...")
load_enrollment(conn, years)
typer.echo("IPEDS ingestion complete.")
conn.close()
@ingest_app.command()
def irs990(
year_range: Annotated[
str, typer.Option(help="Year range for index files, e.g. 2019-2024")
] = "2019-2024",
force: Annotated[
bool, typer.Option("--force", help="Re-download even if files exist")
] = False,
) -> None:
"""Ingest IRS 990 data for UD and UD Research Foundation."""
years = _parse_year_range(year_range)
conn = get_connection()
ensure_schema(conn)
from admin_analytics.irs990.download import download_all_filings
from admin_analytics.irs990.loader import load_all
typer.echo(f"Downloading 990 filings for {years.start}-{years.stop - 1}...")
download_all_filings(years, force=force)
typer.echo("Loading 990 data into database...")
totals = load_all(conn, years)
typer.echo(
f"IRS 990 ingestion complete: {totals['filings']} filings, "
f"{totals['part_vii']} Part VII rows, {totals['schedule_j']} Schedule J rows."
)
conn.close()
@ingest_app.command()
def cpi() -> None:
"""Ingest BLS CPI-U data. (Not yet implemented.)"""
typer.echo("CPI-U ingestion is not yet implemented (Sprint 3).")
raise typer.Exit(1)
@ingest_app.command(name="all")
def ingest_all(
year_range: Annotated[
str, typer.Option(help="Year range, e.g. 2005-2024")
] = f"{DEFAULT_YEAR_RANGE.start}-{DEFAULT_YEAR_RANGE.stop - 1}",
force: Annotated[
bool, typer.Option("--force", help="Re-download even if files exist")
] = False,
) -> None:
"""Ingest all data sources."""
ipeds(year_range=year_range, component="all", force=force)

View file

@ -0,0 +1,50 @@
from pathlib import Path
# University of Delaware IPEDS UNITID
UD_UNITID = 130943
# Paths
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = PROJECT_ROOT / "data" / "raw"
DB_PATH = PROJECT_ROOT / "data" / "admin_analytics.duckdb"
# IPEDS
IPEDS_BASE_URL = "https://nces.ed.gov/ipeds/datacenter/data"
IPEDS_DATA_DIR = DATA_DIR / "ipeds"
DEFAULT_YEAR_RANGE = range(2005, 2025) # 2005-2024 inclusive
# IRS 990
UD_EINS = [516000297, 516017306] # UD + UD Research Foundation
IRS990_BASE_URL = "https://apps.irs.gov/pub/epostcard/990/xml"
IRS990_DATA_DIR = DATA_DIR / "irs990"
def ipeds_filename(component: str, year: int) -> str:
"""Return the IPEDS filename (without .zip) for a component and year.
IPEDS uses different naming conventions per survey:
HD: HD{YYYY} e.g. HD2023
F1A: F{YY-1}{YY}_F1A e.g. F2223_F1A (fiscal year)
EF: EF{YYYY}A e.g. EF2023A
S: S{YYYY}_OC e.g. S2023_OC (occupational categories)
SAL: SAL{YYYY}_IS e.g. SAL2023_IS
"""
if component == "hd":
return f"HD{year}"
elif component == "finance":
yy_prev = (year - 1) % 100
yy = year % 100
return f"F{yy_prev:02d}{yy:02d}_F1A"
elif component == "finance_f2":
yy_prev = (year - 1) % 100
yy = year % 100
return f"F{yy_prev:02d}{yy:02d}_F2"
elif component == "enrollment":
return f"EF{year}A"
elif component == "staff":
return f"S{year}_OC"
elif component == "salaries":
return f"SAL{year}_IS"
else:
raise ValueError(f"Unknown IPEDS component: {component}")

View file

View file

@ -0,0 +1,12 @@
from pathlib import Path
import duckdb
from admin_analytics.config import DB_PATH
def get_connection(path: Path | None = None) -> duckdb.DuckDBPyConnection:
"""Return a DuckDB connection, creating the parent directory if needed."""
path = path or DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
return duckdb.connect(str(path))

View file

@ -0,0 +1,122 @@
import duckdb
TABLES = {
"raw_institution": """
CREATE TABLE IF NOT EXISTS raw_institution (
unitid INTEGER NOT NULL,
year INTEGER NOT NULL,
ein VARCHAR,
institution_name VARCHAR,
city VARCHAR,
state VARCHAR,
sector INTEGER,
control INTEGER,
carnegie_class INTEGER,
enrollment_total INTEGER,
PRIMARY KEY (unitid, year)
)
""",
"raw_ipeds_finance": """
CREATE TABLE IF NOT EXISTS raw_ipeds_finance (
unitid INTEGER NOT NULL,
year INTEGER NOT NULL,
reporting_standard VARCHAR,
total_expenses BIGINT,
instruction_expenses BIGINT,
research_expenses BIGINT,
public_service_expenses BIGINT,
academic_support_expenses BIGINT,
student_services_expenses BIGINT,
institutional_support_expenses BIGINT,
auxiliary_expenses BIGINT,
hospital_expenses BIGINT,
other_expenses BIGINT,
salaries_wages BIGINT,
benefits BIGINT,
PRIMARY KEY (unitid, year)
)
""",
"raw_ipeds_staff": """
CREATE TABLE IF NOT EXISTS raw_ipeds_staff (
unitid INTEGER NOT NULL,
year INTEGER NOT NULL,
total_staff INTEGER,
faculty_total INTEGER,
management_total INTEGER,
PRIMARY KEY (unitid, year)
)
""",
"raw_ipeds_enrollment": """
CREATE TABLE IF NOT EXISTS raw_ipeds_enrollment (
unitid INTEGER NOT NULL,
year INTEGER NOT NULL,
total_enrollment INTEGER,
PRIMARY KEY (unitid, year)
)
""",
"raw_990_filing": """
CREATE TABLE IF NOT EXISTS raw_990_filing (
object_id VARCHAR PRIMARY KEY,
ein VARCHAR,
tax_year INTEGER,
organization_name VARCHAR,
return_type VARCHAR,
filing_date DATE,
total_revenue BIGINT,
total_expenses BIGINT,
total_assets BIGINT
)
""",
"raw_990_schedule_j": """
CREATE SEQUENCE IF NOT EXISTS seq_990_schedule_j START 1;
CREATE TABLE IF NOT EXISTS raw_990_schedule_j (
id INTEGER PRIMARY KEY DEFAULT nextval('seq_990_schedule_j'),
object_id VARCHAR,
ein VARCHAR,
tax_year INTEGER,
person_name VARCHAR,
title VARCHAR,
base_compensation BIGINT,
bonus_compensation BIGINT,
other_compensation BIGINT,
deferred_compensation BIGINT,
nontaxable_benefits BIGINT,
total_compensation BIGINT,
compensation_from_related BIGINT
)
""",
"raw_990_part_vii": """
CREATE SEQUENCE IF NOT EXISTS seq_990_part_vii START 1;
CREATE TABLE IF NOT EXISTS raw_990_part_vii (
id INTEGER PRIMARY KEY DEFAULT nextval('seq_990_part_vii'),
object_id VARCHAR,
ein VARCHAR,
tax_year INTEGER,
person_name VARCHAR,
title VARCHAR,
avg_hours_per_week DOUBLE,
reportable_comp_from_org BIGINT,
reportable_comp_from_related BIGINT,
other_compensation BIGINT
)
""",
"raw_cpi_u": """
CREATE TABLE IF NOT EXISTS raw_cpi_u (
year INTEGER NOT NULL,
month INTEGER NOT NULL,
value DOUBLE,
series_id VARCHAR,
PRIMARY KEY (year, month)
)
""",
}
def ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
"""Create all raw tables if they don't exist."""
for ddl in TABLES.values():
# Some DDL blocks contain multiple statements (e.g. CREATE SEQUENCE + CREATE TABLE)
for stmt in ddl.split(";"):
stmt = stmt.strip()
if stmt:
conn.execute(stmt)

View file

View file

@ -0,0 +1,108 @@
from pathlib import Path
import openpyxl
import xlrd
def parse_dictionary(dict_path: Path) -> dict[str, str]:
"""Parse an IPEDS dictionary Excel file, returning {varname: vartitle}.
Handles both .xls (xlrd) and .xlsx (openpyxl) formats.
Looks for a sheet named 'varlist' (case-insensitive).
"""
suffix = dict_path.suffix.lower()
if suffix == ".xls":
return _parse_xls(dict_path)
elif suffix == ".xlsx":
return _parse_xlsx(dict_path)
else:
raise ValueError(f"Unsupported dictionary format: {suffix}")
def _parse_xls(path: Path) -> dict[str, str]:
wb = xlrd.open_workbook(str(path))
sheet = _find_varlist_sheet_xls(wb)
if sheet is None:
return {}
# Detect column positions from header row
header = [str(v).strip().lower() for v in sheet.row_values(0)]
varname_idx = header.index("varname") if "varname" in header else 1
vartitle_idx = header.index("vartitle") if "vartitle" in header else len(header) - 1
mapping = {}
for row_idx in range(1, sheet.nrows):
row = sheet.row_values(row_idx)
if len(row) > max(varname_idx, vartitle_idx):
varname = str(row[varname_idx]).strip().upper()
vartitle = str(row[vartitle_idx]).strip()
if varname:
mapping[varname] = vartitle
return mapping
def _parse_xlsx(path: Path) -> dict[str, str]:
wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
sheet = _find_varlist_sheet_xlsx(wb)
if sheet is None:
wb.close()
return {}
mapping = {}
varname_idx = 1 # default
vartitle_idx = -1 # default: last column
first = True
for row in sheet.iter_rows(values_only=True):
if first:
first = False
header = [str(v).strip().lower() if v else "" for v in row]
if "varname" in header:
varname_idx = header.index("varname")
if "vartitle" in header:
vartitle_idx = header.index("vartitle")
continue
if row and len(row) > varname_idx:
val = row[varname_idx]
if val:
varname = str(val).strip().upper()
vartitle = str(row[vartitle_idx]).strip() if row[vartitle_idx] else ""
if varname:
mapping[varname] = vartitle
wb.close()
return mapping
def _find_varlist_sheet_xls(wb: xlrd.Book):
for name in wb.sheet_names():
if name.lower() == "varlist":
return wb.sheet_by_name(name)
return None
def _find_varlist_sheet_xlsx(wb: openpyxl.Workbook):
for name in wb.sheetnames:
if name.lower() == "varlist":
return wb[name]
return None
def find_dict_file(dict_dir: Path) -> Path | None:
"""Find the dictionary Excel file in an extracted dictionary directory."""
for ext in (".xlsx", ".xls"):
files = list(dict_dir.glob(f"*{ext}"))
if files:
return files[0]
return None
def get_variable_mapping(component: str, year: int) -> dict[str, str]:
"""Load the variable mapping for a component/year pair.
Returns an empty dict if the dictionary file is not available.
"""
from admin_analytics.config import IPEDS_DATA_DIR
dict_dir = IPEDS_DATA_DIR / component / str(year) / "dict"
if not dict_dir.exists():
return {}
dict_file = find_dict_file(dict_dir)
if dict_file is None:
return {}
return parse_dictionary(dict_file)

View file

@ -0,0 +1,75 @@
import io
import zipfile
from pathlib import Path
import httpx
from admin_analytics import config
def _component_url(component: str, year: int) -> str:
"""Build the URL for an IPEDS data ZIP file."""
filename = config.ipeds_filename(component, year)
return f"{config.IPEDS_BASE_URL}/{filename}.zip"
def _dict_url(component: str, year: int) -> str:
"""Build the URL for an IPEDS dictionary ZIP file."""
filename = config.ipeds_filename(component, year)
return f"{config.IPEDS_BASE_URL}/{filename}_Dict.zip"
def _dest_dir(component: str, year: int) -> Path:
"""Return the local directory for a component/year pair."""
return config.IPEDS_DATA_DIR / component / str(year)
def _download_and_extract(url: str, dest: Path) -> Path:
"""Download a ZIP from url, extract to dest, return dest."""
dest.mkdir(parents=True, exist_ok=True)
with httpx.Client(follow_redirects=True, timeout=120.0) as client:
resp = client.get(url)
resp.raise_for_status()
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
zf.extractall(dest)
return dest
def download_component(
component: str, year: int, force: bool = False
) -> Path:
"""Download an IPEDS data file. Returns the extraction directory."""
dest = _dest_dir(component, year)
if not force and dest.exists() and any(dest.iterdir()):
return dest
url = _component_url(component, year)
return _download_and_extract(url, dest)
def download_dictionary(
component: str, year: int, force: bool = False
) -> Path:
"""Download an IPEDS dictionary file. Returns the extraction directory."""
dest = _dest_dir(component, year) / "dict"
if not force and dest.exists() and any(dest.iterdir()):
return dest
url = _dict_url(component, year)
return _download_and_extract(url, dest)
def download_all(
year_range: range,
components: list[str],
force: bool = False,
) -> None:
"""Download all requested components for all years."""
for component in components:
for year in year_range:
try:
download_component(component, year, force=force)
except httpx.HTTPStatusError as e:
print(f" Warning: {component}/{year} data not available ({e.response.status_code})")
try:
download_dictionary(component, year, force=force)
except httpx.HTTPStatusError:
pass # Dictionaries aren't always available

View file

@ -0,0 +1,87 @@
from pathlib import Path
import duckdb
import polars as pl
from admin_analytics import config
from admin_analytics.config import UD_UNITID
CANONICAL_COLUMNS = ["unitid", "year", "total_enrollment"]
def _find_csv(component_dir: Path) -> Path | None:
csvs = [f for f in component_dir.glob("*.csv") if "dict" not in f.stem.lower()]
return csvs[0] if csvs else None
def load_enrollment(
conn: duckdb.DuckDBPyConnection,
year_range: range,
unitid_filter: int | None = UD_UNITID,
) -> int:
"""Load IPEDS enrollment data into raw_ipeds_enrollment.
EF files have multiple rows per institution (breakdowns by level, race, etc.).
EFALEVEL=1 is the grand total row. If EFALEVEL is not present, we aggregate
by taking the max EFTOTLT/EFYTOTLT per UNITID.
"""
total = 0
for year in year_range:
data_dir = config.IPEDS_DATA_DIR / "enrollment" / str(year)
csv_path = _find_csv(data_dir)
if csv_path is None:
print(f" No EF CSV found for {year}, skipping")
continue
df = pl.read_csv(
csv_path,
infer_schema_length=0,
encoding="utf8-lossy",
)
upper_cols = {c.upper(): c for c in df.columns}
# Find the enrollment total column
enroll_col = None
for candidate in ["EFTOTLT", "EFYTOTLT"]:
if candidate in upper_cols:
enroll_col = upper_cols[candidate]
break
if enroll_col is None:
print(f" No enrollment total column found for {year}, skipping")
continue
unitid_col = upper_cols.get("UNITID", "UNITID")
# Filter to grand total row (EFALEVEL=1) if that column exists
efalevel_col = upper_cols.get("EFALEVEL")
if efalevel_col:
df = df.filter(pl.col(efalevel_col).str.strip_chars() == "1")
# Select, rename, cast
df = df.select(
pl.col(unitid_col).cast(pl.Int64).alias("unitid"),
pl.lit(year).cast(pl.Int64).alias("year"),
pl.col(enroll_col).cast(pl.Int64, strict=False).alias("total_enrollment"),
)
# If still multiple rows per UNITID (shouldn't be after EFALEVEL filter), take max
df = df.group_by("unitid").agg(
pl.col("year").first(),
pl.col("total_enrollment").max(),
)
if unitid_filter is not None:
df = df.filter(pl.col("unitid") == unitid_filter)
if df.height == 0:
continue
df = df.select(CANONICAL_COLUMNS)
conn.execute("DELETE FROM raw_ipeds_enrollment WHERE year = ?", [year])
conn.register("_tmp_enrollment", df.to_arrow())
conn.execute("INSERT INTO raw_ipeds_enrollment SELECT * FROM _tmp_enrollment")
conn.unregister("_tmp_enrollment")
total += df.height
return total

View file

@ -0,0 +1,142 @@
from pathlib import Path
import duckdb
import polars as pl
from admin_analytics import config
from admin_analytics.config import UD_UNITID
# F1A (GASB/public) column mappings — first match wins per canonical column.
F1A_COLUMN_VARIANTS = {
"unitid": ["UNITID"],
"total_expenses": ["F1C191", "F1D02"],
"instruction_expenses": ["F1C011"],
"research_expenses": ["F1C021"],
"public_service_expenses": ["F1C031"],
"academic_support_expenses": ["F1C051"],
"student_services_expenses": ["F1C061"],
"institutional_support_expenses": ["F1C071"],
"auxiliary_expenses": ["F1C111"],
"hospital_expenses": ["F1C121"],
"other_expenses": ["F1C141"],
"salaries_wages": ["F1C192"],
"benefits": ["F1C193"],
}
# F2 (FASB/private-style) column mappings — UD reports here despite being public.
F2_COLUMN_VARIANTS = {
"unitid": ["UNITID"],
"total_expenses": ["F2E131"],
"instruction_expenses": ["F2E011"],
"research_expenses": ["F2E021"],
"public_service_expenses": ["F2E031"],
"academic_support_expenses": ["F2E041"],
"student_services_expenses": ["F2E051"],
"institutional_support_expenses": ["F2E061"],
"auxiliary_expenses": ["F2E071"],
"hospital_expenses": ["F2E081"],
"other_expenses": ["F2E121"],
"salaries_wages": ["F2E132"],
"benefits": ["F2E133"],
}
CANONICAL_COLUMNS = [
"unitid", "year", "reporting_standard", "total_expenses",
"instruction_expenses", "research_expenses", "public_service_expenses",
"academic_support_expenses", "student_services_expenses",
"institutional_support_expenses", "auxiliary_expenses", "hospital_expenses",
"other_expenses", "salaries_wages", "benefits",
]
def _find_csv(component_dir: Path) -> Path | None:
csvs = [f for f in component_dir.glob("*.csv") if "dict" not in f.stem.lower()]
return csvs[0] if csvs else None
def _resolve_columns(df: pl.DataFrame, variants: dict) -> dict[str, str]:
"""For each canonical name, find the first matching column."""
upper_cols = {c.upper(): c for c in df.columns}
resolved = {}
for canonical, candidates in variants.items():
for var in candidates:
if var in upper_cols:
resolved[canonical] = upper_cols[var]
break
return resolved
def _load_file(
csv_path: Path,
year: int,
variants: dict,
reporting_standard: str,
conn: duckdb.DuckDBPyConnection,
unitid_filter: int | None,
) -> int:
"""Load a single finance CSV into raw_ipeds_finance."""
df = pl.read_csv(csv_path, infer_schema_length=0, encoding="utf8-lossy")
col_map = _resolve_columns(df, variants)
if "unitid" not in col_map:
return 0
# Build dataframe with canonical columns
result = pl.DataFrame({
canonical: df[actual] for canonical, actual in col_map.items()
})
result = result.with_columns(
pl.lit(year).alias("year"),
pl.lit(reporting_standard).alias("reporting_standard"),
)
# Cast numeric columns
for col in CANONICAL_COLUMNS:
if col not in result.columns:
result = result.with_columns(pl.lit(None).alias(col))
elif col not in ("reporting_standard",):
result = result.with_columns(pl.col(col).cast(pl.Int64, strict=False))
if unitid_filter is not None:
result = result.filter(pl.col("unitid") == unitid_filter)
if result.height == 0:
return 0
result = result.select(CANONICAL_COLUMNS)
conn.register("_tmp_finance", result.to_arrow())
conn.execute("INSERT INTO raw_ipeds_finance SELECT * FROM _tmp_finance")
conn.unregister("_tmp_finance")
return result.height
def load_finance(
conn: duckdb.DuckDBPyConnection,
year_range: range,
unitid_filter: int | None = UD_UNITID,
) -> int:
"""Load IPEDS finance data into raw_ipeds_finance.
Tries both F1A (GASB) and F2 (FASB) files since some public institutions
like UD report under FASB.
"""
total = 0
for year in year_range:
conn.execute("DELETE FROM raw_ipeds_finance WHERE year = ?", [year])
# Try F1A (GASB)
f1a_dir = config.IPEDS_DATA_DIR / "finance" / str(year)
csv_path = _find_csv(f1a_dir)
if csv_path:
total += _load_file(csv_path, year, F1A_COLUMN_VARIANTS, "GASB", conn, unitid_filter)
# Try F2 (FASB) — needed for institutions like UD
f2_dir = config.IPEDS_DATA_DIR / "finance_f2" / str(year)
csv_path = _find_csv(f2_dir)
if csv_path:
total += _load_file(csv_path, year, F2_COLUMN_VARIANTS, "FASB", conn, unitid_filter)
if total == 0 and not _find_csv(f1a_dir) and not _find_csv(f2_dir):
print(f" No finance CSV found for {year}, skipping")
return total

View file

@ -0,0 +1,107 @@
from pathlib import Path
import duckdb
import polars as pl
from admin_analytics import config
from admin_analytics.config import UD_UNITID
# HD column mappings: canonical name → list of IPEDS varnames (priority order).
# The first match found in the file's columns wins.
HD_COLUMN_VARIANTS = {
"unitid": ["UNITID"],
"institution_name": ["INSTNM"],
"city": ["CITY"],
"state": ["STABBR"],
"sector": ["SECTOR"],
"control": ["CONTROL"],
"ein": ["EIN"],
"carnegie_class": ["C21BASIC", "C18BASIC", "C15BASIC", "CCBASIC", "CARNEGIE"],
"enrollment_total": ["EFYTOTLT"],
}
CANONICAL_COLUMNS = [
"unitid", "year", "ein", "institution_name", "city", "state",
"sector", "control", "carnegie_class", "enrollment_total",
]
def _find_csv(component_dir: Path) -> Path | None:
"""Find the main data CSV in an extracted IPEDS directory."""
csvs = [f for f in component_dir.glob("*.csv") if "dict" not in f.stem.lower()]
return csvs[0] if csvs else None
def _resolve_columns(df: pl.DataFrame) -> dict[str, str]:
"""For each canonical name, find the first matching IPEDS column."""
upper_cols = {c.upper(): c for c in df.columns}
resolved = {}
for canonical, variants in HD_COLUMN_VARIANTS.items():
for var in variants:
if var in upper_cols:
resolved[canonical] = upper_cols[var]
break
return resolved
def _map_columns(df: pl.DataFrame, year: int) -> pl.DataFrame:
"""Rename IPEDS columns to canonical names and add year."""
col_map = _resolve_columns(df)
# Select and rename only the resolved columns
select_exprs = [pl.col(actual).alias(canonical) for canonical, actual in col_map.items()]
df = pl.DataFrame({canonical: df[actual] for canonical, actual in col_map.items()})
df = df.with_columns(pl.lit(year).alias("year"))
# Ensure all canonical columns exist
for col in CANONICAL_COLUMNS:
if col not in df.columns:
df = df.with_columns(pl.lit(None).alias(col))
return df.select(CANONICAL_COLUMNS)
def load_institutions(
conn: duckdb.DuckDBPyConnection,
year_range: range,
unitid_filter: int | None = UD_UNITID,
) -> int:
"""Load IPEDS HD data into raw_institution. Returns rows loaded."""
total = 0
for year in year_range:
data_dir = config.IPEDS_DATA_DIR / "hd" / str(year)
csv_path = _find_csv(data_dir)
if csv_path is None:
print(f" No HD CSV found for {year}, skipping")
continue
df = pl.read_csv(
csv_path,
infer_schema_length=0, # read everything as string first
encoding="utf8-lossy",
)
df = _map_columns(df, year)
# Cast types
df = df.with_columns(
pl.col("unitid").cast(pl.Int64),
pl.col("year").cast(pl.Int64),
pl.col("sector").cast(pl.Int64, strict=False),
pl.col("control").cast(pl.Int64, strict=False),
pl.col("carnegie_class").cast(pl.Int64, strict=False),
pl.col("enrollment_total").cast(pl.Int64, strict=False),
)
if unitid_filter is not None:
df = df.filter(pl.col("unitid") == unitid_filter)
if df.height == 0:
continue
conn.execute("DELETE FROM raw_institution WHERE year = ?", [year])
conn.register("_tmp_institution", df.to_arrow())
conn.execute("INSERT INTO raw_institution SELECT * FROM _tmp_institution")
conn.unregister("_tmp_institution")
total += df.height
return total

View file

@ -0,0 +1,98 @@
from pathlib import Path
import duckdb
import polars as pl
from admin_analytics import config
from admin_analytics.config import UD_UNITID
CANONICAL_COLUMNS = ["unitid", "year", "total_staff", "faculty_total", "management_total"]
def _find_csv(component_dir: Path) -> Path | None:
csvs = [f for f in component_dir.glob("*.csv") if "dict" not in f.stem.lower()]
return csvs[0] if csvs else None
def load_staff(
conn: duckdb.DuckDBPyConnection,
year_range: range,
unitid_filter: int | None = UD_UNITID,
) -> int:
"""Load IPEDS Fall Staff data into raw_ipeds_staff.
The S_OC file has multiple rows per institution (by occupational category).
We extract:
- total_staff: FTPT=2, OCCUPCAT=100 (full-time total, all occupations)
- management_total: FTPT=2, OCCUPCAT=200 (full-time management)
- faculty_total: FTPT=2, OCCUPCAT=250 (full-time instruction/research)
"""
total = 0
for year in year_range:
data_dir = config.IPEDS_DATA_DIR / "staff" / str(year)
csv_path = _find_csv(data_dir)
if csv_path is None:
print(f" No Staff CSV found for {year}, skipping")
continue
df = pl.read_csv(
csv_path,
infer_schema_length=0,
encoding="utf8-lossy",
)
upper_cols = {c.upper(): c for c in df.columns}
unitid_col = upper_cols.get("UNITID", "UNITID")
hrtotlt_col = upper_cols.get("HRTOTLT")
occupcat_col = upper_cols.get("OCCUPCAT")
ftpt_col = upper_cols.get("FTPT")
stafftot_col = upper_cols.get("STAFFTOT") # older format: single row
if hrtotlt_col and occupcat_col and ftpt_col:
# Modern multi-row format: filter by FTPT and OCCUPCAT
df = df.with_columns(
pl.col(ftpt_col).str.strip_chars().alias("_ftpt"),
pl.col(occupcat_col).str.strip_chars().alias("_occupcat"),
pl.col(unitid_col).cast(pl.Int64).alias("unitid"),
pl.col(hrtotlt_col).cast(pl.Int64, strict=False).alias("_count"),
)
# Full-time totals only (FTPT=2)
ft = df.filter(pl.col("_ftpt") == "2")
# Pivot occupational categories into columns per UNITID
total_staff = ft.filter(pl.col("_occupcat") == "100").select("unitid", pl.col("_count").alias("total_staff"))
management = ft.filter(pl.col("_occupcat") == "200").select("unitid", pl.col("_count").alias("management_total"))
faculty = ft.filter(pl.col("_occupcat") == "250").select("unitid", pl.col("_count").alias("faculty_total"))
result = total_staff.join(management, on="unitid", how="left").join(faculty, on="unitid", how="left")
elif stafftot_col:
# Older single-row format
result = df.select(
pl.col(unitid_col).cast(pl.Int64).alias("unitid"),
pl.col(stafftot_col).cast(pl.Int64, strict=False).alias("total_staff"),
)
result = result.with_columns(
pl.lit(None).cast(pl.Int64).alias("faculty_total"),
pl.lit(None).cast(pl.Int64).alias("management_total"),
)
else:
print(f" Unrecognized staff file format for {year}, skipping")
continue
result = result.with_columns(pl.lit(year).cast(pl.Int64).alias("year"))
if unitid_filter is not None:
result = result.filter(pl.col("unitid") == unitid_filter)
if result.height == 0:
continue
result = result.select(CANONICAL_COLUMNS)
conn.execute("DELETE FROM raw_ipeds_staff WHERE year = ?", [year])
conn.register("_tmp_staff", result.to_arrow())
conn.execute("INSERT INTO raw_ipeds_staff SELECT * FROM _tmp_staff")
conn.unregister("_tmp_staff")
total += result.height
return total

View file

View file

@ -0,0 +1,194 @@
"""IRS 990 e-file download: index filtering and XML extraction from bulk ZIPs."""
import io
import re
import tempfile
import zipfile
from pathlib import Path
import httpx
import polars as pl
from admin_analytics import config
def download_index(year: int, force: bool = False) -> Path:
"""Download the IRS 990 e-file index CSV for a given year."""
dest_dir = config.IRS990_DATA_DIR / str(year)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"index_{year}.csv"
if not force and dest.exists():
return dest
url = f"{config.IRS990_BASE_URL}/{year}/index_{year}.csv"
with httpx.Client(follow_redirects=True, timeout=120.0) as client:
resp = client.get(url)
resp.raise_for_status()
dest.write_bytes(resp.content)
return dest
def filter_index(
index_path: Path,
eins: list[int],
return_types: list[str] | None = None,
) -> pl.DataFrame:
"""Filter the index CSV to specific EINs and return types.
Args:
index_path: Path to the index CSV.
eins: EINs to filter for.
return_types: e.g. ["990", "990PF"]. Default: ["990"] (skip 990T, 990EZ).
"""
if return_types is None:
return_types = ["990"]
df = pl.read_csv(index_path, infer_schema_length=0, encoding="utf8-lossy")
# Normalize column names to uppercase
df = df.rename({c: c.strip().upper() for c in df.columns})
ein_strs = [str(e) for e in eins]
df = df.filter(
pl.col("EIN").is_in(ein_strs)
& pl.col("RETURN_TYPE").is_in(return_types)
)
return df
def get_batch_list(year: int) -> list[str]:
"""Get the list of available TEOS XML batch ZIP IDs for a year.
Tries common naming patterns: {YEAR}_TEOS_XML_{MM}{L} where
MM is 01-12 and L is A-D.
"""
batches = []
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
for month in range(1, 13):
for letter in "ABCD":
batch_id = f"{year}_TEOS_XML_{month:02d}{letter}"
url = f"{config.IRS990_BASE_URL}/{year}/{batch_id}.zip"
try:
resp = client.head(url)
if resp.status_code == 200:
batches.append(batch_id)
except httpx.HTTPError:
continue
return batches
def _extract_xml_from_zip(
zip_url: str, object_id: str, dest: Path
) -> bool:
"""Download a batch ZIP and extract a specific XML file.
Streams the ZIP to a temp file, extracts the target, deletes the temp.
Returns True if the file was found and extracted.
"""
target_name = f"{object_id}_public.xml"
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
with httpx.Client(follow_redirects=True, timeout=600.0) as client:
with client.stream("GET", zip_url) as resp:
resp.raise_for_status()
with open(tmp_path, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=1024 * 1024):
f.write(chunk)
with zipfile.ZipFile(tmp_path) as zf:
names = zf.namelist()
# Look for our target file (case-insensitive)
match = None
for name in names:
if name.lower() == target_name.lower():
match = name
break
if match is None:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
with zf.open(match) as src, open(dest, "wb") as dst:
dst.write(src.read())
return True
finally:
tmp_path.unlink(missing_ok=True)
def download_filing_xml(
year: int,
object_id: str,
batch_id: str | None = None,
force: bool = False,
) -> Path | None:
"""Download a specific 990 XML filing from the IRS bulk data.
Args:
year: The index year (not necessarily the tax year).
object_id: The OBJECT_ID from the index CSV.
batch_id: The XML_BATCH_ID if known (2024+ indexes have this).
force: Re-download even if already exists.
Returns:
Path to the extracted XML file, or None if not found.
"""
dest = config.IRS990_DATA_DIR / str(year) / f"{object_id}.xml"
if not force and dest.exists():
return dest
if batch_id:
# We know which ZIP to look in
url = f"{config.IRS990_BASE_URL}/{year}/{batch_id}.zip"
if _extract_xml_from_zip(url, object_id, dest):
return dest
return None
# No batch ID — try each batch until we find our file
batches = get_batch_list(year)
for bid in batches:
url = f"{config.IRS990_BASE_URL}/{year}/{bid}.zip"
print(f" Searching batch {bid}...")
if _extract_xml_from_zip(url, object_id, dest):
print(f" Found in {bid}")
return dest
print(f" Warning: filing {object_id} not found in any batch for {year}")
return None
def download_all_filings(
year_range: range,
eins: list[int] | None = None,
force: bool = False,
) -> list[Path]:
"""Download all 990 XML filings for the target EINs across years.
Returns list of paths to downloaded XML files.
"""
eins = eins or config.UD_EINS
xml_paths = []
for year in year_range:
print(f" Downloading index for {year}...")
try:
index_path = download_index(year, force=force)
except httpx.HTTPStatusError as e:
print(f" Index not available for {year} ({e.response.status_code})")
continue
filings = filter_index(index_path, eins)
if filings.height == 0:
print(f" No filings found for target EINs in {year}")
continue
print(f" Found {filings.height} filing(s) in {year}")
for row in filings.iter_rows(named=True):
object_id = row["OBJECT_ID"]
batch_id = row.get("XML_BATCH_ID")
taxpayer = row.get("TAXPAYER_NAME", "")
print(f" Downloading {taxpayer} ({object_id})...")
path = download_filing_xml(year, object_id, batch_id, force=force)
if path:
xml_paths.append(path)
return xml_paths

View file

@ -0,0 +1,162 @@
"""Load parsed IRS 990 data into DuckDB."""
from pathlib import Path
import duckdb
from admin_analytics import config
from admin_analytics.irs990.parser import parse_xml, parse_filing, parse_part_vii, parse_schedule_j
def load_filing(conn: duckdb.DuckDBPyConnection, xml_path: Path, object_id: str) -> bool:
"""Parse a 990 XML and insert the filing header into raw_990_filing.
Returns True if a row was inserted.
"""
root = parse_xml(xml_path)
filing = parse_filing(root)
if not filing["ein"] or not filing["tax_year"]:
return False
conn.execute(
"DELETE FROM raw_990_filing WHERE object_id = ?", [object_id]
)
conn.execute(
"""INSERT INTO raw_990_filing
(object_id, ein, tax_year, organization_name, return_type,
total_revenue, total_expenses, total_assets)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
[
object_id,
filing["ein"],
filing["tax_year"],
filing["organization_name"],
filing["return_type"],
filing["total_revenue"],
filing["total_expenses"],
filing["total_assets"],
],
)
return True
def load_part_vii(conn: duckdb.DuckDBPyConnection, xml_path: Path, object_id: str) -> int:
"""Parse Part VII compensation and insert into raw_990_part_vii.
Returns number of rows inserted.
"""
root = parse_xml(xml_path)
filing = parse_filing(root)
people = parse_part_vii(root)
if not people:
return 0
ein = filing["ein"]
tax_year = filing["tax_year"]
conn.execute(
"DELETE FROM raw_990_part_vii WHERE object_id = ?", [object_id]
)
count = 0
for person in people:
if not person["person_name"]:
continue
conn.execute(
"""INSERT INTO raw_990_part_vii
(object_id, ein, tax_year, person_name, title,
avg_hours_per_week, reportable_comp_from_org,
reportable_comp_from_related, other_compensation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
object_id,
ein,
tax_year,
person["person_name"],
person["title"],
person["avg_hours_per_week"],
person["reportable_comp_from_org"],
person["reportable_comp_from_related"],
person["other_compensation"],
],
)
count += 1
return count
def load_schedule_j(conn: duckdb.DuckDBPyConnection, xml_path: Path, object_id: str) -> int:
"""Parse Schedule J compensation and insert into raw_990_schedule_j.
Returns number of rows inserted.
"""
root = parse_xml(xml_path)
filing = parse_filing(root)
people = parse_schedule_j(root)
if not people:
return 0
ein = filing["ein"]
tax_year = filing["tax_year"]
conn.execute(
"DELETE FROM raw_990_schedule_j WHERE object_id = ?", [object_id]
)
count = 0
for person in people:
if not person["person_name"]:
continue
conn.execute(
"""INSERT INTO raw_990_schedule_j
(object_id, ein, tax_year, person_name, title,
base_compensation, bonus_compensation, other_compensation,
deferred_compensation, nontaxable_benefits,
total_compensation, compensation_from_related)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
object_id,
ein,
tax_year,
person["person_name"],
person["title"],
person["base_compensation"],
person["bonus_compensation"],
person["other_compensation"],
person["deferred_compensation"],
person["nontaxable_benefits"],
person["total_compensation"],
person["compensation_from_related"],
],
)
count += 1
return count
def load_all(
conn: duckdb.DuckDBPyConnection,
year_range: range,
) -> dict[str, int]:
"""Load all downloaded 990 XML filings into DuckDB.
Returns counts of rows loaded per table.
"""
totals = {"filings": 0, "part_vii": 0, "schedule_j": 0}
for year in year_range:
year_dir = config.IRS990_DATA_DIR / str(year)
if not year_dir.exists():
continue
for xml_path in sorted(year_dir.glob("*.xml")):
object_id = xml_path.stem
if load_filing(conn, xml_path, object_id):
totals["filings"] += 1
totals["part_vii"] += load_part_vii(conn, xml_path, object_id)
totals["schedule_j"] += load_schedule_j(conn, xml_path, object_id)
return totals

View file

@ -0,0 +1,259 @@
"""Parse IRS 990 XML filings: header, Part VII, and Schedule J."""
from pathlib import Path
from lxml import etree
def _strip_ns(tree: etree._Element) -> etree._Element:
"""Strip all namespaces from an XML tree for simpler XPath queries."""
for el in tree.iter():
if isinstance(el.tag, str) and "{" in el.tag:
el.tag = el.tag.split("}", 1)[1]
for attr_name in list(el.attrib):
if "{" in attr_name:
new_name = attr_name.split("}", 1)[1]
el.attrib[new_name] = el.attrib.pop(attr_name)
return tree
def _text(el: etree._Element | None, path: str) -> str | None:
"""Get text of a child element by relative XPath, or None."""
if el is None:
return None
child = el.find(path)
return child.text.strip() if child is not None and child.text else None
def _int(el: etree._Element | None, path: str) -> int | None:
"""Get integer value of a child element."""
text = _text(el, path)
if text is None:
return None
try:
return int(text)
except ValueError:
return None
def _float(el: etree._Element | None, path: str) -> float | None:
"""Get float value of a child element."""
text = _text(el, path)
if text is None:
return None
try:
return float(text)
except ValueError:
return None
def _find_first(root: etree._Element, xpaths: list[str]) -> list:
"""Try multiple XPaths, return the first one that produces results."""
for xpath in xpaths:
results = root.findall(xpath)
if results:
return results
return []
def _find_first_text(el: etree._Element, paths: list[str]) -> str | None:
"""Try multiple child element names, return the first one with text."""
for path in paths:
val = _text(el, path)
if val is not None:
return val
return None
def _find_first_int(el: etree._Element, paths: list[str]) -> int | None:
"""Try multiple child element names, return the first integer found."""
for path in paths:
val = _int(el, path)
if val is not None:
return val
return None
def parse_xml(xml_path: Path) -> etree._Element:
"""Parse an XML file and strip namespaces."""
tree = etree.parse(str(xml_path))
return _strip_ns(tree.getroot())
def parse_filing(root: etree._Element) -> dict:
"""Extract filing header fields from a parsed 990 XML."""
header = root.find(".//ReturnHeader")
ein = _find_first_text(header, [
"Filer/EIN",
"Filer/EIN",
]) if header is not None else None
tax_year = _find_first_text(header, [
"TaxYr",
"TaxYear",
]) if header is not None else None
# If no TaxYr element, extract from TaxPeriodEndDt (YYYY-MM-DD -> YYYY)
if tax_year is None and header is not None:
tax_period = _find_first_text(header, [
"TaxPeriodEndDt",
"TaxPrdEndDt",
])
if tax_period:
tax_year = tax_period[:4]
org_name = None
if header is not None:
org_name = _find_first_text(header, [
"Filer/BusinessName/BusinessNameLine1Txt",
"Filer/BusinessName/BusinessNameLine1",
"Filer/Name/BusinessNameLine1",
])
return_type = _find_first_text(header, [
"ReturnTypeCd",
"ReturnType",
]) if header is not None else None
# Get financial summary from the main form
irs990 = root.find(".//IRS990")
total_revenue = _find_first_int(irs990, [
"CYTotalRevenueAmt",
"TotalRevenueCurrentYear",
"TotalRevenueAmt",
]) if irs990 is not None else None
total_expenses = _find_first_int(irs990, [
"CYTotalExpensesAmt",
"TotalExpensesCurrentYear",
"TotalFunctionalExpensesAmt",
]) if irs990 is not None else None
total_assets = _find_first_int(root, [
".//IRS990/TotalAssetsEOYAmt",
".//IRS990/TotalAssetsEndOfYear",
".//TotalAssetsEOYAmt",
])
return {
"ein": ein,
"tax_year": int(tax_year) if tax_year else None,
"organization_name": org_name,
"return_type": return_type,
"total_revenue": total_revenue,
"total_expenses": total_expenses,
"total_assets": total_assets,
}
def parse_part_vii(root: etree._Element) -> list[dict]:
"""Extract Part VII (compensation summary) from a parsed 990 XML.
Returns one dict per listed person with compensation details.
"""
groups = _find_first(root, [
".//IRS990/Form990PartVIISectionAGrp",
".//IRS990/Form990PartVIISectionA",
])
results = []
for grp in groups:
person_name = _find_first_text(grp, [
"PersonNm",
"NamePerson",
"PersonName",
"NameBusiness/BusinessNameLine1Txt",
])
title = _find_first_text(grp, [
"TitleTxt",
"Title",
])
hours = _find_first(grp, ["AverageHoursPerWeekRt", "AverageHoursPerWeek"])
avg_hours = None
if hours:
try:
avg_hours = float(hours[0].text)
except (ValueError, TypeError):
pass
results.append({
"person_name": person_name,
"title": title,
"avg_hours_per_week": avg_hours,
"reportable_comp_from_org": _find_first_int(grp, [
"ReportableCompFromOrgAmt",
"ReportableCompFromOrganization",
]),
"reportable_comp_from_related": _find_first_int(grp, [
"ReportableCompFromRltdOrgAmt",
"ReportableCompFromRelatedOrgs",
]),
"other_compensation": _find_first_int(grp, [
"OtherCompensationAmt",
"OtherCompensation",
]),
})
return results
def parse_schedule_j(root: etree._Element) -> list[dict]:
"""Extract Schedule J (detailed compensation) from a parsed 990 XML.
Returns one dict per person with itemized compensation breakdown.
"""
groups = _find_first(root, [
".//IRS990ScheduleJ/RltdOrgOfficerTrstKeyEmplGrp",
".//IRS990ScheduleJ/Form990ScheduleJPartII",
".//IRS990ScheduleJ/SupplementalInformationDetail",
])
results = []
for grp in groups:
person_name = _find_first_text(grp, [
"PersonNm",
"NamePerson",
"PersonName",
"NameBusiness/BusinessNameLine1Txt",
])
title = _find_first_text(grp, [
"TitleTxt",
"Title",
])
results.append({
"person_name": person_name,
"title": title,
"base_compensation": _find_first_int(grp, [
"BaseCompensationFilingOrgAmt",
"BaseCompensation",
]),
"bonus_compensation": _find_first_int(grp, [
"BonusFilingOrgAmt",
"Bonus",
]),
"other_compensation": _find_first_int(grp, [
"OtherCompensationFilingOrgAmt",
"OtherCompensation",
]),
"deferred_compensation": _find_first_int(grp, [
"DeferredCompensationFlngOrgAmt",
"DeferredCompensation",
]),
"nontaxable_benefits": _find_first_int(grp, [
"NontaxableBenefitsFilingOrgAmt",
"NontaxableBenefits",
]),
"total_compensation": _find_first_int(grp, [
"TotalCompensationFilingOrgAmt",
"TotalCompensation",
]),
"compensation_from_related": _find_first_int(grp, [
"CompensationFromOtherSrcsAmt",
"CompensationFromOtherSources",
"CompReportedInPriorFormFilingOrgAmt",
]),
})
return results

View file

@ -0,0 +1,43 @@
"""Normalize 990 titles to canonical roles."""
import re
# Canonical roles and their matching patterns.
# Order matters — first match wins. More specific patterns before general ones.
TITLE_PATTERNS: list[tuple[str, re.Pattern]] = [
# VP patterns must come before PRESIDENT to avoid false matches
("PROVOST", re.compile(r"\bprovost\b", re.I)),
("VP_FINANCE", re.compile(r"(?:\bv\.?p\.?\b|\bvice\s+president\b).*\b(?:financ|budget|business|admin)|\b(?:financ|budget|business|admin).*(?:\bv\.?p\.?\b|\bvice\s+president\b)", re.I)),
("VP_RESEARCH", re.compile(r"(?:\bv\.?p\.?\b|\bvice\s+president\b).*\bresearch|\bresearch.*(?:\bv\.?p\.?\b|\bvice\s+president\b)", re.I)),
("VP_STUDENT_AFFAIRS", re.compile(r"(?:\bv\.?p\.?\b|\bvice\s+president\b).*\bstudent|\bstudent.*(?:\bv\.?p\.?\b|\bvice\s+president\b)", re.I)),
("VP_ADVANCEMENT", re.compile(r"(?:\bv\.?p\.?\b|\bvice\s+president\b).*\b(?:advancement|development|giving|fundrais)|\b(?:advancement|development|giving|fundrais).*(?:\bv\.?p\.?\b|\bvice\s+president\b)", re.I)),
("VP_OTHER", re.compile(r"\bv\.?p\.?\b|\bvice\s+president\b", re.I)),
("CFO", re.compile(r"\b(cfo|chief\s+financial)\b", re.I)),
("CIO", re.compile(r"\b(cio|chief\s+information)\b", re.I)),
("COO", re.compile(r"\b(coo|chief\s+operating)\b", re.I)),
("GENERAL_COUNSEL", re.compile(r"\b(general\s+counsel|chief\s+legal)\b", re.I)),
("PRESIDENT", re.compile(r"\bpresident\b", re.I)),
("DEAN", re.compile(r"\bdean\b", re.I)),
("TREASURER", re.compile(r"\btreasurer\b", re.I)),
("SECRETARY", re.compile(r"\bsecretary\b", re.I)),
("TRUSTEE", re.compile(r"\btrustee\b", re.I)),
("DIRECTOR", re.compile(r"\bdirector\b", re.I)),
]
def normalize_title(raw_title: str | None) -> str | None:
"""Map a raw 990 title string to a canonical role.
Returns the canonical role string, or "OTHER" for unmatched titles,
or None if the input is None/empty.
"""
if not raw_title or not raw_title.strip():
return None
title = raw_title.strip()
for role, pattern in TITLE_PATTERNS:
if pattern.search(title):
return role
return "OTHER"

View file

0
tests/__init__.py Normal file
View file

21
tests/conftest.py Normal file
View file

@ -0,0 +1,21 @@
from pathlib import Path
import duckdb
import pytest
from admin_analytics.db.schema import ensure_schema
@pytest.fixture
def db_conn(tmp_path):
"""Provide a fresh DuckDB connection with schema initialized."""
conn = duckdb.connect(str(tmp_path / "test.duckdb"))
ensure_schema(conn)
yield conn
conn.close()
@pytest.fixture
def fixtures_dir():
"""Return the path to the test fixtures directory."""
return Path(__file__).parent / "fixtures"

73
tests/fixtures/990_sample.xml vendored Normal file
View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<Return xmlns="http://www.irs.gov/efile" returnVersion="2022v5.0">
<ReturnHeader>
<ReturnTypeCd>990</ReturnTypeCd>
<TaxYr>2022</TaxYr>
<Filer>
<EIN>516000297</EIN>
<BusinessName>
<BusinessNameLine1Txt>UNIVERSITY OF DELAWARE</BusinessNameLine1Txt>
</BusinessName>
</Filer>
</ReturnHeader>
<ReturnData>
<IRS990>
<CYTotalRevenueAmt>1800000000</CYTotalRevenueAmt>
<CYTotalExpensesAmt>1700000000</CYTotalExpensesAmt>
<TotalAssetsEOYAmt>5000000000</TotalAssetsEOYAmt>
<Form990PartVIISectionAGrp>
<PersonNm>JOHN DOE</PersonNm>
<TitleTxt>PRESIDENT</TitleTxt>
<AverageHoursPerWeekRt>40.00</AverageHoursPerWeekRt>
<ReportableCompFromOrgAmt>850000</ReportableCompFromOrgAmt>
<ReportableCompFromRltdOrgAmt>0</ReportableCompFromRltdOrgAmt>
<OtherCompensationAmt>150000</OtherCompensationAmt>
</Form990PartVIISectionAGrp>
<Form990PartVIISectionAGrp>
<PersonNm>JANE SMITH</PersonNm>
<TitleTxt>VICE PRESIDENT FOR FINANCE</TitleTxt>
<AverageHoursPerWeekRt>40.00</AverageHoursPerWeekRt>
<ReportableCompFromOrgAmt>450000</ReportableCompFromOrgAmt>
<ReportableCompFromRltdOrgAmt>0</ReportableCompFromRltdOrgAmt>
<OtherCompensationAmt>80000</OtherCompensationAmt>
</Form990PartVIISectionAGrp>
<Form990PartVIISectionAGrp>
<PersonNm>BOB JONES</PersonNm>
<TitleTxt>TRUSTEE</TitleTxt>
<AverageHoursPerWeekRt>2.00</AverageHoursPerWeekRt>
<ReportableCompFromOrgAmt>0</ReportableCompFromOrgAmt>
<ReportableCompFromRltdOrgAmt>0</ReportableCompFromRltdOrgAmt>
<OtherCompensationAmt>0</OtherCompensationAmt>
</Form990PartVIISectionAGrp>
</IRS990>
<IRS990ScheduleJ>
<RltdOrgOfficerTrstKeyEmplGrp>
<PersonNm>JOHN DOE</PersonNm>
<TitleTxt>PRESIDENT</TitleTxt>
<BaseCompensationFilingOrgAmt>700000</BaseCompensationFilingOrgAmt>
<BonusFilingOrgAmt>100000</BonusFilingOrgAmt>
<OtherCompensationFilingOrgAmt>50000</OtherCompensationFilingOrgAmt>
<DeferredCompensationFlngOrgAmt>75000</DeferredCompensationFlngOrgAmt>
<NontaxableBenefitsFilingOrgAmt>25000</NontaxableBenefitsFilingOrgAmt>
<TotalCompensationFilingOrgAmt>950000</TotalCompensationFilingOrgAmt>
<CompensationFromOtherSrcsAmt>0</CompensationFromOtherSrcsAmt>
</RltdOrgOfficerTrstKeyEmplGrp>
<RltdOrgOfficerTrstKeyEmplGrp>
<PersonNm>JANE SMITH</PersonNm>
<TitleTxt>VICE PRESIDENT FOR FINANCE</TitleTxt>
<BaseCompensationFilingOrgAmt>380000</BaseCompensationFilingOrgAmt>
<BonusFilingOrgAmt>40000</BonusFilingOrgAmt>
<OtherCompensationFilingOrgAmt>30000</OtherCompensationFilingOrgAmt>
<DeferredCompensationFlngOrgAmt>50000</DeferredCompensationFlngOrgAmt>
<NontaxableBenefitsFilingOrgAmt>20000</NontaxableBenefitsFilingOrgAmt>
<TotalCompensationFilingOrgAmt>520000</TotalCompensationFilingOrgAmt>
<CompensationFromOtherSrcsAmt>0</CompensationFromOtherSrcsAmt>
</RltdOrgOfficerTrstKeyEmplGrp>
</IRS990ScheduleJ>
</ReturnData>
</Return>

3
tests/fixtures/ef2023.csv vendored Normal file
View file

@ -0,0 +1,3 @@
UNITID,EFYTOTLT
130943,24120
110635,45307
1 UNITID EFYTOTLT
2 130943 24120
3 110635 45307

3
tests/fixtures/f1a2023.csv vendored Normal file
View file

@ -0,0 +1,3 @@
UNITID,F1C191,F1C011,F1C021,F1C031,F1C051,F1C061,F1C071,F1C111,F1C121,F1C141,F1C192,F1C193
130943,1200000000,400000000,200000000,50000000,100000000,80000000,150000000,60000000,0,30000000,500000000,200000000
110635,3500000000,1200000000,800000000,100000000,300000000,200000000,400000000,150000000,500000000,100000000,1500000000,600000000
1 UNITID F1C191 F1C011 F1C021 F1C031 F1C051 F1C061 F1C071 F1C111 F1C121 F1C141 F1C192 F1C193
2 130943 1200000000 400000000 200000000 50000000 100000000 80000000 150000000 60000000 0 30000000 500000000 200000000
3 110635 3500000000 1200000000 800000000 100000000 300000000 200000000 400000000 150000000 500000000 100000000 1500000000 600000000

3
tests/fixtures/hd2023.csv vendored Normal file
View file

@ -0,0 +1,3 @@
UNITID,INSTNM,CITY,STABBR,SECTOR,CONTROL,C18BASIC,EIN,EFYTOTLT
130943,University of Delaware,Newark,DE,1,1,15,510049975,24120
110635,University of California-Berkeley,Berkeley,CA,1,1,15,946036494,45307
1 UNITID INSTNM CITY STABBR SECTOR CONTROL C18BASIC EIN EFYTOTLT
2 130943 University of Delaware Newark DE 1 1 15 510049975 24120
3 110635 University of California-Berkeley Berkeley CA 1 1 15 946036494 45307

7
tests/fixtures/s2023.csv vendored Normal file
View file

@ -0,0 +1,7 @@
UNITID,STAFFCAT,FTPT,OCCUPCAT,HRTOTLT
130943,2100,2,100,5081
130943,2200,2,200,1271
130943,2250,2,250,124
110635,2100,2,100,15000
110635,2200,2,200,800
110635,2250,2,250,3500
1 UNITID STAFFCAT FTPT OCCUPCAT HRTOTLT
2 130943 2100 2 100 5081
3 130943 2200 2 200 1271
4 130943 2250 2 250 124
5 110635 2100 2 100 15000
6 110635 2200 2 200 800
7 110635 2250 2 250 3500

22
tests/test_db_schema.py Normal file
View file

@ -0,0 +1,22 @@
def test_tables_created(db_conn):
tables = db_conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'"
).fetchall()
table_names = {t[0] for t in tables}
expected = {
"raw_institution",
"raw_ipeds_finance",
"raw_ipeds_staff",
"raw_ipeds_enrollment",
"raw_990_filing",
"raw_990_schedule_j",
"raw_990_part_vii",
"raw_cpi_u",
}
assert expected.issubset(table_names)
def test_schema_idempotent(db_conn):
"""Calling ensure_schema twice should not raise."""
from admin_analytics.db.schema import ensure_schema
ensure_schema(db_conn) # already called in fixture; second call should be fine

View file

@ -0,0 +1,65 @@
import io
import zipfile
import httpx
import respx
import admin_analytics.config as config
from admin_analytics.config import ipeds_filename
from admin_analytics.ipeds.download import download_component
def test_ipeds_filename_patterns():
assert ipeds_filename("hd", 2023) == "HD2023"
assert ipeds_filename("finance", 2023) == "F2223_F1A"
assert ipeds_filename("finance", 2005) == "F0405_F1A"
assert ipeds_filename("enrollment", 2023) == "EF2023A"
assert ipeds_filename("staff", 2023) == "S2023_OC"
assert ipeds_filename("salaries", 2023) == "SAL2023_IS"
def _make_zip_bytes(filename: str, content: str) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(filename, content)
return buf.getvalue()
@respx.mock
def test_download_component(tmp_path):
zip_bytes = _make_zip_bytes("HD2023.csv", "UNITID,INSTNM\n130943,University of Delaware\n")
respx.get("https://nces.ed.gov/ipeds/datacenter/data/HD2023.zip").mock(
return_value=httpx.Response(200, content=zip_bytes)
)
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
try:
dest = download_component("hd", 2023)
finally:
config.IPEDS_DATA_DIR = original
assert dest.exists()
csvs = list(dest.glob("*.csv"))
assert len(csvs) == 1
assert "University of Delaware" in csvs[0].read_text()
@respx.mock
def test_download_component_skips_if_exists(tmp_path):
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
dest = tmp_path / "ipeds" / "hd" / "2023"
dest.mkdir(parents=True)
(dest / "HD2023.csv").write_text("UNITID\n130943\n")
route = respx.get("https://nces.ed.gov/ipeds/datacenter/data/HD2023.zip")
try:
result = download_component("hd", 2023, force=False)
finally:
config.IPEDS_DATA_DIR = original
assert result == dest
assert not route.called

View file

@ -0,0 +1,25 @@
import shutil
import admin_analytics.config as config
from admin_analytics.config import UD_UNITID
from admin_analytics.ipeds.enrollment import load_enrollment
def test_load_enrollment_filters_to_ud(db_conn, fixtures_dir, tmp_path):
ipeds_dir = tmp_path / "ipeds" / "enrollment" / "2023"
ipeds_dir.mkdir(parents=True)
shutil.copy(fixtures_dir / "ef2023.csv", ipeds_dir / "ef2023.csv")
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
try:
count = load_enrollment(db_conn, range(2023, 2024), unitid_filter=UD_UNITID)
finally:
config.IPEDS_DATA_DIR = original
assert count == 1
row = db_conn.execute(
"SELECT total_enrollment FROM raw_ipeds_enrollment WHERE unitid = ?",
[UD_UNITID],
).fetchone()
assert row[0] == 24120

View file

@ -0,0 +1,25 @@
import shutil
import admin_analytics.config as config
from admin_analytics.config import UD_UNITID
from admin_analytics.ipeds.finance import load_finance
def test_load_finance_filters_to_ud(db_conn, fixtures_dir, tmp_path):
ipeds_dir = tmp_path / "ipeds" / "finance" / "2023"
ipeds_dir.mkdir(parents=True)
shutil.copy(fixtures_dir / "f1a2023.csv", ipeds_dir / "f1a2023.csv")
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
try:
count = load_finance(db_conn, range(2023, 2024), unitid_filter=UD_UNITID)
finally:
config.IPEDS_DATA_DIR = original
assert count == 1
row = db_conn.execute(
"SELECT institutional_support_expenses FROM raw_ipeds_finance WHERE unitid = ?",
[UD_UNITID],
).fetchone()
assert row[0] == 150000000

View file

@ -0,0 +1,41 @@
import shutil
import admin_analytics.config as config
from admin_analytics.config import UD_UNITID
from admin_analytics.ipeds.institution import load_institutions
def test_load_institutions_filters_to_ud(db_conn, fixtures_dir, tmp_path):
"""HD loader should only load UD's row when unitid_filter is set."""
ipeds_dir = tmp_path / "ipeds" / "hd" / "2023"
ipeds_dir.mkdir(parents=True)
shutil.copy(fixtures_dir / "hd2023.csv", ipeds_dir / "hd2023.csv")
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
try:
count = load_institutions(db_conn, range(2023, 2024), unitid_filter=UD_UNITID)
finally:
config.IPEDS_DATA_DIR = original
assert count == 1
rows = db_conn.execute("SELECT * FROM raw_institution").fetchall()
assert len(rows) == 1
assert rows[0][0] == UD_UNITID
assert rows[0][3] == "University of Delaware"
def test_load_institutions_no_filter(db_conn, fixtures_dir, tmp_path):
"""Without filter, all institutions should load."""
ipeds_dir = tmp_path / "ipeds" / "hd" / "2023"
ipeds_dir.mkdir(parents=True)
shutil.copy(fixtures_dir / "hd2023.csv", ipeds_dir / "hd2023.csv")
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
try:
count = load_institutions(db_conn, range(2023, 2024), unitid_filter=None)
finally:
config.IPEDS_DATA_DIR = original
assert count == 2

25
tests/test_ipeds_staff.py Normal file
View file

@ -0,0 +1,25 @@
import shutil
import admin_analytics.config as config
from admin_analytics.config import UD_UNITID
from admin_analytics.ipeds.staff import load_staff
def test_load_staff_filters_to_ud(db_conn, fixtures_dir, tmp_path):
ipeds_dir = tmp_path / "ipeds" / "staff" / "2023"
ipeds_dir.mkdir(parents=True)
shutil.copy(fixtures_dir / "s2023.csv", ipeds_dir / "s2023.csv")
original = config.IPEDS_DATA_DIR
config.IPEDS_DATA_DIR = tmp_path / "ipeds"
try:
count = load_staff(db_conn, range(2023, 2024), unitid_filter=UD_UNITID)
finally:
config.IPEDS_DATA_DIR = original
assert count == 1
row = db_conn.execute(
"SELECT total_staff, faculty_total, management_total FROM raw_ipeds_staff WHERE unitid = ?",
[UD_UNITID],
).fetchone()
assert row == (5081, 124, 1271)

View file

@ -0,0 +1,25 @@
import admin_analytics.config as config
from admin_analytics.irs990.download import filter_index
def test_filter_index(fixtures_dir, tmp_path):
"""Test that index filtering finds UD filings and skips 990T."""
# Create a mock index CSV
index = tmp_path / "index_2023.csv"
index.write_text(
"RETURN_ID,FILING_TYPE,EIN,TAX_PERIOD,SUB_DATE,TAXPAYER_NAME,RETURN_TYPE,DLN,OBJECT_ID\n"
"1,EFILE,516000297,202206,2023,UNIVERSITY OF DELAWARE,990,123,OBJ001\n"
"2,EFILE,516000297,202206,2023,UNIVERSITY OF DELAWARE,990T,456,OBJ002\n"
"3,EFILE,516017306,202212,2023,UNIVERSITY OF DELAWARE RESEARCH FOUNDATION,990,789,OBJ003\n"
"4,EFILE,999999999,202212,2023,SOME OTHER ORG,990,000,OBJ004\n"
)
result = filter_index(index, config.UD_EINS)
assert result.height == 2 # UD 990 + Research Foundation 990, not 990T
eins = result["EIN"].to_list()
assert "516000297" in eins
assert "516017306" in eins
# All should be type 990 (not 990T)
assert all(rt == "990" for rt in result["RETURN_TYPE"].to_list())

View file

@ -0,0 +1,46 @@
from admin_analytics.irs990.loader import load_filing, load_part_vii, load_schedule_j
def test_load_filing(db_conn, fixtures_dir):
xml_path = fixtures_dir / "990_sample.xml"
assert load_filing(db_conn, xml_path, "TEST001")
row = db_conn.execute(
"SELECT ein, tax_year, organization_name, total_expenses FROM raw_990_filing WHERE object_id = 'TEST001'"
).fetchone()
assert row[0] == "516000297"
assert row[1] == 2022
assert row[2] == "UNIVERSITY OF DELAWARE"
assert row[3] == 1700000000
def test_load_filing_idempotent(db_conn, fixtures_dir):
xml_path = fixtures_dir / "990_sample.xml"
load_filing(db_conn, xml_path, "TEST001")
load_filing(db_conn, xml_path, "TEST001") # second load should overwrite
count = db_conn.execute(
"SELECT COUNT(*) FROM raw_990_filing WHERE object_id = 'TEST001'"
).fetchone()[0]
assert count == 1
def test_load_part_vii(db_conn, fixtures_dir):
xml_path = fixtures_dir / "990_sample.xml"
count = load_part_vii(db_conn, xml_path, "TEST001")
assert count == 3
rows = db_conn.execute(
"SELECT person_name, reportable_comp_from_org FROM raw_990_part_vii WHERE object_id = 'TEST001' ORDER BY reportable_comp_from_org DESC"
).fetchall()
assert rows[0] == ("JOHN DOE", 850000)
def test_load_schedule_j(db_conn, fixtures_dir):
xml_path = fixtures_dir / "990_sample.xml"
count = load_schedule_j(db_conn, xml_path, "TEST001")
assert count == 2
row = db_conn.execute(
"SELECT person_name, base_compensation, total_compensation FROM raw_990_schedule_j WHERE object_id = 'TEST001' AND person_name = 'JOHN DOE'"
).fetchone()
assert row == ("JOHN DOE", 700000, 950000)

View file

@ -0,0 +1,47 @@
from admin_analytics.irs990.parser import parse_xml, parse_filing, parse_part_vii, parse_schedule_j
def test_parse_filing(fixtures_dir):
root = parse_xml(fixtures_dir / "990_sample.xml")
filing = parse_filing(root)
assert filing["ein"] == "516000297"
assert filing["tax_year"] == 2022
assert filing["organization_name"] == "UNIVERSITY OF DELAWARE"
assert filing["return_type"] == "990"
assert filing["total_revenue"] == 1800000000
assert filing["total_expenses"] == 1700000000
assert filing["total_assets"] == 5000000000
def test_parse_part_vii(fixtures_dir):
root = parse_xml(fixtures_dir / "990_sample.xml")
people = parse_part_vii(root)
assert len(people) == 3
president = people[0]
assert president["person_name"] == "JOHN DOE"
assert president["title"] == "PRESIDENT"
assert president["avg_hours_per_week"] == 40.0
assert president["reportable_comp_from_org"] == 850000
trustee = people[2]
assert trustee["person_name"] == "BOB JONES"
assert trustee["title"] == "TRUSTEE"
assert trustee["reportable_comp_from_org"] == 0
def test_parse_schedule_j(fixtures_dir):
root = parse_xml(fixtures_dir / "990_sample.xml")
people = parse_schedule_j(root)
assert len(people) == 2
president = people[0]
assert president["person_name"] == "JOHN DOE"
assert president["base_compensation"] == 700000
assert president["bonus_compensation"] == 100000
assert president["deferred_compensation"] == 75000
assert president["total_compensation"] == 950000
vp = people[1]
assert vp["person_name"] == "JANE SMITH"
assert vp["total_compensation"] == 520000

View file

@ -0,0 +1,45 @@
from admin_analytics.irs990.titles import normalize_title
def test_president():
assert normalize_title("PRESIDENT") == "PRESIDENT"
assert normalize_title("President & CEO") == "PRESIDENT"
def test_vice_president_finance():
assert normalize_title("VICE PRESIDENT FOR FINANCE") == "VP_FINANCE"
assert normalize_title("VP Finance and Administration") == "VP_FINANCE"
assert normalize_title("EVP & VP for Business") == "VP_FINANCE"
def test_vice_president_other():
assert normalize_title("VICE PRESIDENT") == "VP_OTHER"
def test_provost():
assert normalize_title("PROVOST") == "PROVOST"
assert normalize_title("Executive Vice President and Provost") == "PROVOST"
def test_trustee():
assert normalize_title("TRUSTEE") == "TRUSTEE"
def test_dean():
assert normalize_title("DEAN OF ENGINEERING") == "DEAN"
def test_cfo():
assert normalize_title("CHIEF FINANCIAL OFFICER") == "CFO"
assert normalize_title("CFO") == "CFO"
def test_other():
assert normalize_title("ATHLETIC DIRECTOR") == "DIRECTOR"
assert normalize_title("FOOTBALL COACH") == "OTHER"
def test_none_and_empty():
assert normalize_title(None) is None
assert normalize_title("") is None
assert normalize_title(" ") is None

517
uv.lock generated Normal file
View file

@ -0,0 +1,517 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "admin-analytics"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "duckdb" },
{ name = "httpx" },
{ name = "lxml" },
{ name = "openpyxl" },
{ name = "polars" },
{ name = "pyarrow" },
{ name = "typer" },
{ name = "xlrd" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "respx" },
]
[package.metadata]
requires-dist = [
{ name = "duckdb", specifier = ">=1.0" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "lxml", specifier = ">=5.0" },
{ name = "openpyxl", specifier = ">=3.1" },
{ name = "polars", specifier = ">=1.0" },
{ name = "pyarrow", specifier = ">=17.0" },
{ name = "typer", specifier = ">=0.12" },
{ name = "xlrd", specifier = ">=2.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "respx", specifier = ">=0.21" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "duckdb"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/62/590caabec6c41003f46a244b6fd707d35ca2e552e0c70cbf454e08bf6685/duckdb-1.5.1.tar.gz", hash = "sha256:b370d1620a34a4538ef66524fcee9de8171fa263c701036a92bc0b4c1f2f9c6d", size = 17995082, upload-time = "2026-03-23T12:12:15.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/3e/827ffcf58f0abc6ad6dcf826c5d24ebfc65e03ad1a20d74cad9806f91c99/duckdb-1.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bc7ca6a1a40e7e4c933017e6c09ef18032add793df4e42624c6c0c87e0bebdad", size = 30067835, upload-time = "2026-03-23T12:10:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/04/b5/e921ecf8a7e0cc7da2100c98bef64b3da386df9444f467d6389364851302/duckdb-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:446d500a2977c6ae2077f340c510a25956da5c77597175c316edfa87248ceda3", size = 15970464, upload-time = "2026-03-23T12:10:42.063Z" },
{ url = "https://files.pythonhosted.org/packages/dd/da/ed804006cd09ba303389d573c8b15d74220667cbd1fd990c26e98d0e0a5b/duckdb-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8b0808dba0c63b7633bdaefb34e08fe0612622224f9feb0e7518904b1615101", size = 14222994, upload-time = "2026-03-23T12:10:45.162Z" },
{ url = "https://files.pythonhosted.org/packages/b3/43/c904d81a61306edab81a9d74bb37bbe65679639abb7030d4c4fec9ed84f7/duckdb-1.5.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:553c273a6a8f140adaa6da6a6135c7f95bdc8c2e5f95252fcdf9832d758e2141", size = 19244880, upload-time = "2026-03-23T12:10:48.529Z" },
{ url = "https://files.pythonhosted.org/packages/50/db/358715d677bfe5e117d9e1f2d6cc2fc2b0bd621144d1f15335b8b59f95d7/duckdb-1.5.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40c5220ec93790b18ec6278da9c6ac2608d997ee6d6f7cd44c5c3992764e8e71", size = 21350874, upload-time = "2026-03-23T12:10:52.095Z" },
{ url = "https://files.pythonhosted.org/packages/3f/db/fd647ce46315347976f5576a279bacb8134d23b1f004bd0bcda7ce9cf429/duckdb-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:36e8e32621a9e2a9abe75dc15a4b54a3997f2d8b1e53ad754bae48a083c91130", size = 13068140, upload-time = "2026-03-23T12:10:55.622Z" },
{ url = "https://files.pythonhosted.org/packages/27/95/e29d42792707619da5867ffab338d7e7b086242c7296aa9cfc6dcf52d568/duckdb-1.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:5ae7c0d744d64e2753149634787cc4ab60f05ef1e542b060eeab719f3cdb7723", size = 13908823, upload-time = "2026-03-23T12:10:58.572Z" },
{ url = "https://files.pythonhosted.org/packages/3f/06/be4c62f812c6e23898733073ace0482eeb18dffabe0585d63a3bf38bca1e/duckdb-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6f7361d66cc801d9eb4df734b139cd7b0e3c257a16f3573ebd550ddb255549e6", size = 30113703, upload-time = "2026-03-23T12:11:02.536Z" },
{ url = "https://files.pythonhosted.org/packages/44/03/1794dcdda75ff203ab0982ff7eb5232549b58b9af66f243f1b7212d6d6be/duckdb-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a6acc2040bec1f05de62a2f3f68f4c12f3ec7d6012b4317d0ab1a195af26225", size = 15991802, upload-time = "2026-03-23T12:11:06.321Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/293bccd838a293d42ea26dec7f4eb4f58b57b6c9ffcfabc6518a5f20a24a/duckdb-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed6d23a3f806898e69c77430ebd8da0c79c219f97b9acbc9a29a653e09740c59", size = 14246803, upload-time = "2026-03-23T12:11:09.624Z" },
{ url = "https://files.pythonhosted.org/packages/15/2c/7b4f11879aa2924838168b4640da999dccda1b4a033d43cb998fd6dc33ea/duckdb-1.5.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6af347debc8b721aa72e48671166282da979d5e5ae52dbc660ab417282b48e23", size = 19271654, upload-time = "2026-03-23T12:11:13.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d6/8f9a6b1fbcc669108ec6a4d625a70be9e480b437ed9b70cd56b78cd577a6/duckdb-1.5.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8150c569b2aa4573b51ba8475e814aa41fd53a3d510c1ffb96f1139f46faf611", size = 21386100, upload-time = "2026-03-23T12:11:16.758Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/8d02c6473273468cf8d43fd5d73c677f8cdfcd036c1e884df0613f124c2b/duckdb-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:054ad424b051b334052afac58cb216f3b1ebb8579fc8c641e60f0182e8725ea9", size = 13083506, upload-time = "2026-03-23T12:11:19.785Z" },
{ url = "https://files.pythonhosted.org/packages/96/0b/2be786b9c153eb263bf5d3d5f7ab621b14a715d7e70f92b24ecf8536369e/duckdb-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:6ba302115f63f6482c000ccfd62efdb6c41d9d182a5bcd4a90e7ab8cd13856eb", size = 13888862, upload-time = "2026-03-23T12:11:22.84Z" },
{ url = "https://files.pythonhosted.org/packages/a5/f2/af476945e3b97417945b0f660b5efa661863547c0ea104251bb6387342b1/duckdb-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:26e56b5f0c96189e3288d83cf7b476e23615987902f801e5788dee15ee9f24a9", size = 30113759, upload-time = "2026-03-23T12:11:26.5Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9d/5a542b3933647369e601175190093597ce0ac54909aea0dd876ec51ffad4/duckdb-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:972d0dbf283508f9bc446ee09c3838cb7c7f114b5bdceee41753288c97fe2f7c", size = 15991463, upload-time = "2026-03-23T12:11:30.025Z" },
{ url = "https://files.pythonhosted.org/packages/53/a5/b59cff67f5e0420b8f337ad86406801cffacae219deed83961dcceefda67/duckdb-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:482f8a13f2600f527e427f73c42b5aa75536f9892868068f0aaf573055a0135f", size = 14246482, upload-time = "2026-03-23T12:11:33.33Z" },
{ url = "https://files.pythonhosted.org/packages/e9/12/d72a82fe502aae82b97b481bf909be8e22db5a403290799ad054b4f90eb4/duckdb-1.5.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da137802688190835b4c863cafa77fd7e29dff662ee6d905a9ffc14f00299c91", size = 19270816, upload-time = "2026-03-23T12:11:36.79Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c3/ee49319b15f139e04c067378f0e763f78336fbab38ba54b0852467dd9da4/duckdb-1.5.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d4147422d91ccdc2d2abf6ed24196025e020259d1d267970ae20c13c2ce84b1", size = 21385695, upload-time = "2026-03-23T12:11:40.465Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f5/a15498e75a27a136c791ca1889beade96d388dadf9811375db155fc96d1a/duckdb-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:05fc91767d0cfc4cf2fa68966ab5b479ac07561752e42dd0ae30327bd160f64a", size = 13084065, upload-time = "2026-03-23T12:11:43.763Z" },
{ url = "https://files.pythonhosted.org/packages/93/81/b3612d2bbe237f75791095e16767c61067ea5d31c76e8591c212dac13bd0/duckdb-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:a28531cee2a5a42d89f9ba4da53bfeb15681f12acc0263476c8705380dadce07", size = 13892892, upload-time = "2026-03-23T12:11:47.222Z" },
{ url = "https://files.pythonhosted.org/packages/ad/75/e9e7893542ca738bcde2d41d459e3438950219c71c57ad28b049dc2ae616/duckdb-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:eba81e0b3011c1f23df7ea47ef4ffaa8239817959ae291515b6efd068bde2161", size = 30123677, upload-time = "2026-03-23T12:11:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/df/db/f7420ee7109a922124c02f377ae1c56156e9e4aa434f4726848adaef0219/duckdb-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:afab8b4b1f4469c3879bb049dd039f8fce402712050324e9524a43d7324c5e87", size = 15996808, upload-time = "2026-03-23T12:11:54.964Z" },
{ url = "https://files.pythonhosted.org/packages/df/57/2c4c3de1f1110417592741863ba58b4eca2f7690a421712762ddbdcd72e6/duckdb-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:71dddcebbc5a70e946a06c30b59b5dd7999c9833d307168f90fb4e4b672ab63e", size = 14248990, upload-time = "2026-03-23T12:11:58.576Z" },
{ url = "https://files.pythonhosted.org/packages/2b/81/e173b33ffac53124a3e39e97fb60a538f26651a0df6e393eb9bf7540126c/duckdb-1.5.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac2804043bd1bc10b5da18f8f4c706877197263a510c41be9b4c0062f5783dcc", size = 19276013, upload-time = "2026-03-23T12:12:02.034Z" },
{ url = "https://files.pythonhosted.org/packages/d4/4c/47e838393aa90d3d78549c8c04cb09452efeb14aaae0ee24dc0bd61c3a41/duckdb-1.5.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8843bd9594e1387f1e601439e19ad73abdf57356104fd1e53a708255bb95a13d", size = 21387569, upload-time = "2026-03-23T12:12:05.693Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9b/ce65743e0e85f5c984d2f7e8a81bc908d0bac345d6d8b6316436b29430e7/duckdb-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:d68c5a01a283cb13b79eafe016fe5869aa11bff8c46e7141c70aa0aac808010f", size = 13603876, upload-time = "2026-03-23T12:12:09.344Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ac/f9e4e731635192571f86f52d86234f537c7f8ca4f6917c56b29051c077ef/duckdb-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3be2072315982e232bfe49c9d3db0a59ba67b2240a537ef42656cc772a887c7", size = 14370790, upload-time = "2026-03-23T12:12:12.497Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
{ url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
{ url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
{ url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
{ url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
{ url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
{ url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
{ url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
{ url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
{ url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "polars"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" },
{ url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" },
{ url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" },
{ url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" },
{ url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" },
]
[[package]]
name = "pyarrow"
version = "23.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" },
{ url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" },
{ url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" },
{ url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" },
{ url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" },
{ url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" },
{ url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" },
{ url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" },
{ url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" },
{ url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" },
{ url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" },
{ url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" },
{ url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" },
{ url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" },
{ url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" },
{ url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" },
{ url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" },
{ url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" },
{ url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" },
{ url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" },
{ url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" },
{ url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" },
{ url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" },
{ url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" },
{ url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "respx"
version = "0.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "xlrd"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" },
]