Initial build out
This commit is contained in:
parent
f037c50736
commit
29215e2bd2
40 changed files with 2622 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
34
pyproject.toml
Normal 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"]
|
||||
0
src/admin_analytics/__init__.py
Normal file
0
src/admin_analytics/__init__.py
Normal file
0
src/admin_analytics/bls/__init__.py
Normal file
0
src/admin_analytics/bls/__init__.py
Normal file
126
src/admin_analytics/cli.py
Normal file
126
src/admin_analytics/cli.py
Normal 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)
|
||||
50
src/admin_analytics/config.py
Normal file
50
src/admin_analytics/config.py
Normal 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}")
|
||||
0
src/admin_analytics/db/__init__.py
Normal file
0
src/admin_analytics/db/__init__.py
Normal file
12
src/admin_analytics/db/connection.py
Normal file
12
src/admin_analytics/db/connection.py
Normal 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))
|
||||
122
src/admin_analytics/db/schema.py
Normal file
122
src/admin_analytics/db/schema.py
Normal 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)
|
||||
0
src/admin_analytics/ipeds/__init__.py
Normal file
0
src/admin_analytics/ipeds/__init__.py
Normal file
108
src/admin_analytics/ipeds/dictionary.py
Normal file
108
src/admin_analytics/ipeds/dictionary.py
Normal 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)
|
||||
75
src/admin_analytics/ipeds/download.py
Normal file
75
src/admin_analytics/ipeds/download.py
Normal 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
|
||||
87
src/admin_analytics/ipeds/enrollment.py
Normal file
87
src/admin_analytics/ipeds/enrollment.py
Normal 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
|
||||
142
src/admin_analytics/ipeds/finance.py
Normal file
142
src/admin_analytics/ipeds/finance.py
Normal 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
|
||||
107
src/admin_analytics/ipeds/institution.py
Normal file
107
src/admin_analytics/ipeds/institution.py
Normal 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
|
||||
98
src/admin_analytics/ipeds/staff.py
Normal file
98
src/admin_analytics/ipeds/staff.py
Normal 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
|
||||
0
src/admin_analytics/irs990/__init__.py
Normal file
0
src/admin_analytics/irs990/__init__.py
Normal file
194
src/admin_analytics/irs990/download.py
Normal file
194
src/admin_analytics/irs990/download.py
Normal 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
|
||||
162
src/admin_analytics/irs990/loader.py
Normal file
162
src/admin_analytics/irs990/loader.py
Normal 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
|
||||
259
src/admin_analytics/irs990/parser.py
Normal file
259
src/admin_analytics/irs990/parser.py
Normal 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
|
||||
43
src/admin_analytics/irs990/titles.py
Normal file
43
src/admin_analytics/irs990/titles.py
Normal 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"
|
||||
0
src/admin_analytics/scraper/__init__.py
Normal file
0
src/admin_analytics/scraper/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal 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
73
tests/fixtures/990_sample.xml
vendored
Normal 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
3
tests/fixtures/ef2023.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
UNITID,EFYTOTLT
|
||||
130943,24120
|
||||
110635,45307
|
||||
|
3
tests/fixtures/f1a2023.csv
vendored
Normal file
3
tests/fixtures/f1a2023.csv
vendored
Normal 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
|
||||
|
3
tests/fixtures/hd2023.csv
vendored
Normal file
3
tests/fixtures/hd2023.csv
vendored
Normal 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
|
||||
|
7
tests/fixtures/s2023.csv
vendored
Normal file
7
tests/fixtures/s2023.csv
vendored
Normal 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
|
||||
|
22
tests/test_db_schema.py
Normal file
22
tests/test_db_schema.py
Normal 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
|
||||
65
tests/test_ipeds_download.py
Normal file
65
tests/test_ipeds_download.py
Normal 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
|
||||
25
tests/test_ipeds_enrollment.py
Normal file
25
tests/test_ipeds_enrollment.py
Normal 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
|
||||
25
tests/test_ipeds_finance.py
Normal file
25
tests/test_ipeds_finance.py
Normal 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
|
||||
41
tests/test_ipeds_institution.py
Normal file
41
tests/test_ipeds_institution.py
Normal 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
25
tests/test_ipeds_staff.py
Normal 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)
|
||||
25
tests/test_irs990_download.py
Normal file
25
tests/test_irs990_download.py
Normal 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())
|
||||
46
tests/test_irs990_loader.py
Normal file
46
tests/test_irs990_loader.py
Normal 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)
|
||||
47
tests/test_irs990_parser.py
Normal file
47
tests/test_irs990_parser.py
Normal 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
|
||||
45
tests/test_irs990_titles.py
Normal file
45
tests/test_irs990_titles.py
Normal 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
517
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue