diff --git a/.gitignore b/.gitignore index f0ccc32..3df010a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ -.venv/ +*.egg-info/ __pycache__/ +.pytest_cache/ +.mypy_cache/ +.venv/ +.vscode/ +dist/ *.py[cod] +.DS_Store +*.egg +.coverage +*.bak diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f860ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,240 @@ +# AGENTS.md — EpiCON Cost Calculator + +This file describes the conventions, roles, and constraints for contributors working in this +repository. All agents — whether running in GitHub Actions or invoked interactively — should +read and follow this document before making changes. + +--- + +## Project overview + +**EpiCON** is a browser-based epidemiological cost calculator built with **Streamlit** and +distributed as a static **stlite** build for browser execution. + +The current app supports two model flows: + +1. **Python + YAML models** + - A Python module in `models/` implements model logic. + - A paired YAML file provides default parameters. + - `app.py` loads the Python module, loads YAML defaults, renders parameter inputs, + runs the model, and renders sections. + +2. **Excel-driven models** + - An uploaded `.xlsx` file is parsed by `utils/excel_model_runner.py`. + - Parameters and computed outputs are rendered from workbook contents. + +Current high-level flow: + +`discover_models() → load_model_from_file() / load_model_params() → render_parameters_with_indent() → run_model() → build_sections() → render_sections()` + +Persistence helpers: +- `store_model_state()` +- `save_current_model()` + +--- + +## Architecture notes + +Use this section to reason about where changes belong and what contracts must remain stable. + +### Runtime layers + +1. **UI composition layer** (`src/epicc/__main__.py`) + - Owns Streamlit controls, session-state synchronization, run triggers, and wiring for model selection. + - Delegates rendering and IO behavior to helpers in `src/epicc/utils/`. +2. **Model execution layer** (`src/epicc/model/`, `src/epicc/models/`, `models/`) + - `BaseSimulationModel` defines the contract for Python-coded models. + - Built-in model classes in `src/epicc/models/` implement `run()`, defaults, scenario labels, and section construction. +3. **Format/validation layer** (`src/epicc/formats/`, `src/epicc/model/schema.py`) + - Parses YAML/XLSX input into a shared dictionary shape. + - Applies typed validation via Pydantic where a strict schema is required. + +### Pydantic model system + +- `src/epicc/model/schema.py` defines the canonical typed schema for structured model documents: + - `Model` (root object), + - `Metadata`, `Parameter`, `Equation`, `Table`, `Scenario`, and `Figure` submodels. +- This schema is the primary contract for validating model-like YAML payloads and should be updated in lockstep with any document-structure changes. +- `src/epicc/formats/__init__.py` exposes `opaque_to_typed()` and `read_from_format()` to bridge: + - untyped dictionaries from parsers, and + - typed Pydantic objects used by callers. +- `src/epicc/utils/parameter_loader.py` uses a lightweight `RootModel[dict[str, Any]]` envelope (`OpaqueParameters`) when only shape-preserving parse/validation is needed, without imposing the full simulation-document schema. + +### `epicc.formats` package design + +- `src/epicc/formats/base.py` defines `BaseFormat[T]` with three responsibilities: + - `read()` for parse to opaque dict + template, + - `write()` for serialize from opaque dict (optionally preserving template trivia), + - `write_template()` for schema-driven starter files. +- `src/epicc/formats/__init__.py` performs suffix-based dispatch (`.yaml`, `.yml`, `.xlsx`) through `get_format()`. +- `src/epicc/formats/yaml.py` uses ruamel round-trip nodes (`CommentedMap`) so edits can preserve comments/formatting when writing back. +- `src/epicc/formats/xlsx.py` maps worksheet rows to dot-notation keys for nested dictionaries and reuses workbook templates when possible. +- `src/epicc/formats/template.py` builds model templates from Pydantic defaults/placeholders and delegates rendering to the target format backend. + +### Architectural guardrails for contributors + +- Prefer adding format support through a new `BaseFormat` implementation and `_FORMATS` registration, rather than branching logic in UI code. +- Keep Streamlit concerns in `__main__.py`/`utils` and keep schema/serialization concerns in `model` + `formats`. +- If a change affects model document structure, update all of: + - Pydantic schema (`src/epicc/model/schema.py`), + - relevant format reader/writer behavior, + - tests under `tests/epicc/`. +- Preserve backward compatibility for existing model YAML and Excel templates unless breaking changes are explicitly approved. + +--- + +## Repository layout + +Top-level files and directories you will use most often: + +- `app.py`: + - Root Streamlit shim that adds `src/` to `PYTHONPATH` and imports `epicc.__main__`. +- `src/epicc/__main__.py`: + - Main app composition and UI flow (model selector, parameter widgets, run triggers). +- `src/epicc/model/`: + - Core model abstractions and schema definitions (`base.py`, `schema.py`). +- `src/epicc/formats/`: + - Parameter format readers/writers (`yaml.py`, `xlsx.py`, templates). +- `src/epicc/utils/`: + - App support modules (`model_loader.py`, `parameter_loader.py`, `parameter_ui.py`, `section_renderer.py`, `excel_model_runner.py`). +- `models/`: + - Built-in model implementations and matching parameter defaults. +- `config/`: + - App configuration (`app.yaml`, `global_defaults.yaml`, `paths.yaml`). +- `styles/` and `src/epicc/web/`: + - UI styling resources. +- `tests/epicc/`: + - Unit tests for formats and model loading. +- `.devcontainer/`: + - Development container setup. `post-create.sh` is the source of truth for extra setup steps. +- `.github/workflows/`: + - CI and agent workflow definitions. + +--- + +## Local development commands + +Use `uv` for dependency and command execution. + +- Install dependencies: + - `uv sync` +- Run Streamlit app: + - `uv run -m streamlit run app.py` +- Run complete quality gate (recommended before PR): + - `make check` +- Individual checks: + - `make lint` + - `make typecheck` + - `make test` +- Build static stlite bundle: + - `make build` +- Serve static bundle locally: + - `make serve` + +If you are in a devcontainer or CI environment intended to mirror local contributor setup, +ensure development dependencies are present: + +- `uv sync --frozen --group dev --no-install-project` + +--- + +## Coding conventions + +- Keep changes minimal and targeted to the requested behavior. +- Preserve existing module boundaries under `src/epicc/`: + - model logic in model modules, + - format parsing/serialization in `formats`, + - Streamlit rendering concerns in `utils`/`__main__.py`. +- Prefer explicit typing for new public functions and non-trivial internal helpers. +- Follow current style used in the repository: + - straightforward function names, + - small helpers for Streamlit state/UI behavior, + - concise docstrings where useful. +- Do not introduce broad refactors unless explicitly requested. + +--- + +## Model and parameter rules + +When adding or editing Python+YAML models: + +- Keep model pairs aligned: + - `models/.py` with `models/.yaml`. +- Ensure YAML default keys map to parameters expected by model code. +- Preserve scenario label behavior: + - Python models can provide scenario labels, + - Excel flow supports header overrides from uploaded workbook columns. +- If changing parameter structures, validate both: + - default-loading behavior, + - reset-to-default behavior in sidebar controls. + +When editing Excel-driven behavior: + +- Maintain support for uploaded `.xlsx` files in the sidebar. +- Keep computed outputs and editable defaults behavior intact. +- Avoid breaking scenario-header override support. + +--- + +## Testing expectations + +- Add or update tests when behavior changes. +- Run `make check` locally before finalizing changes. +- At minimum for focused fixes, run the most relevant subset: + - `uv run -m pytest tests/epicc/.py` +- Keep tests deterministic and file-system safe (use temp files/fixtures). + +--- + +## CI and workflow guidance + +- Workflows that pull container images from GHCR must declare explicit permissions: + - `contents: read` + - `packages: read` +- Keep GitHub Actions environment setup aligned with `.devcontainer/post-create.sh` + when the workflow's intent is to mirror local contributor/agent environments. +- Use frozen dependency sync in CI where practical for reproducibility. + +--- + +## Agent operating checklist + +Before editing: + +1. Read the relevant modules and tests for the target behavior. +2. Identify whether the change affects Python-model flow, Excel flow, or both. +3. Confirm config and schema assumptions. + +During editing: + +1. Keep patches narrowly scoped. +2. Preserve user-visible text and layout unless change requires otherwise. +3. Avoid introducing new dependencies without justification. + +Before handoff: + +1. Run targeted tests (or `make check` for broader changes). +2. Summarize exactly what changed and why. +3. Call out anything not validated. + +--- + +## Common pitfalls + +- Breaking session-state reset behavior when switching model/input files. +- Updating parameter loaders without updating UI/reset paths. +- Changing schema/format behavior without corresponding tests. +- Introducing workflow changes that work in permissive repos but fail in restricted + `GITHUB_TOKEN` permission settings. + +--- + +## Definition of done + +A change is complete when: + +1. Requested behavior is implemented. +2. Existing model flows still run (Python+YAML and Excel-driven, if affected). +3. Relevant tests pass locally. +4. Documentation/config/workflow updates needed for the change are included. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bab5841 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +UV ?= uv + +STLITE_VER ?= 0.86.0 +PORT ?= 8000 + +APP_PY := app.py +SOURCE := src/epicc +DIST_DIR := dist + +.DEFAULT_GOAL := help + +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: setup +setup: install build ## Install dependencies and build the stlite app + +.PHONY: install +install: ## Install Python dependencies with uv + $(UV) sync + +.PHONY: build +build: $(DIST_DIR) ## Generate the bundle. + mkdir -p $(DIST_DIR) + $(UV) run scripts/build.py --app $(APP_PY) --out $(DIST_DIR) + +.PHONY: serve +serve: build ## Serve the stlite static build + $(UV) run python -m http.server $(PORT) --directory $(DIST_DIR) + +.PHONY: dev +dev: ## Run normal Streamlit locally + $(UV) run streamlit run $(APP_PY) + +.PHONY: stlite +stlite: setup serve ## Install, build, and serve the stlite app + +.PHONY: lint +lint: ## Run ruff linter + $(UV) run -m ruff check $(SOURCE) + +.PHONY: typecheck +typecheck: ## Run mypy type checker + $(UV) run -m mypy --check-untyped-defs $(SOURCE) + +.PHONY: test +test: ## Run pytest + $(UV) run -m pytest + +.PHONY: check +check: lint typecheck test ## Run all quality checks + +.PHONY: clean +clean: ## Remove build artifacts + rm -rf $(DIST_DIR) .mypy_cache .ruff_cache .pytest_cache __pycache__ diff --git a/app.py b/app.py index 650c4f4..c44996b 100644 --- a/app.py +++ b/app.py @@ -1,291 +1,15 @@ -import os -import yaml -import streamlit as st -import inspect +"""Top-level Streamlit entrypoint. -from utils.model_loader import discover_models, load_model_from_file -from utils.parameter_loader import load_model_params, flatten_dict -from utils.section_renderer import render_sections -from utils.parameter_ui import render_parameters_with_indent, reset_parameters_to_defaults -from utils.excel_model_runner import ( - load_excel_params_defaults_with_computed, - run_excel_driven_model, - get_scenario_headers -) +This shim makes the app runnable from the repository root without requiring +manual PYTHONPATH tweaks. +""" -# DIRECTORY & CONFIG -base_dir = os.path.dirname(os.path.abspath(__file__)) +import sys +from pathlib import Path -with open(os.path.join(base_dir, "config/app.yaml")) as f: - app_config = yaml.safe_load(f) +SRC_DIR = Path(__file__).resolve().parent / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) -with open(os.path.join(base_dir, "config/paths.yaml")) as f: - path_config = yaml.safe_load(f) - - -# UI STYLES -def load_css(file_path: str): - if os.path.exists(file_path): - with open(file_path) as f: - st.markdown(f"", unsafe_allow_html=True) - - -load_css(os.path.join(base_dir, "styles/sidebar.css")) - -# MODEL SELECTION -st.sidebar.header("Simulation Controls") - -selected_path = os.path.join(base_dir, path_config["model_paths"]["selected_path"]) -default_custom_path = os.path.join(base_dir, path_config["model_paths"]["custom_path"]) - -user_custom_path = st.sidebar.text_input( - "Custom Models Folder", - value=default_custom_path -) - -selected_models = discover_models(selected_path) -custom_models = discover_models(user_custom_path) - -model_options = {} -model_options["Excel Driven Model"] = "__EXCEL_DRIVEN__" -model_options.update({name: file for name, file in selected_models.items()}) -model_options.update({f"Custom: {name}": file for name, file in custom_models.items()}) - -if not model_options: - st.sidebar.warning("No valid model files found.") - st.stop() - -selected_label = st.sidebar.selectbox( - "Select Model", - list(model_options.keys()) -) - -selected_model_file = model_options[selected_label] - -# MODEL CHANGE RESET -model_key = ( - "__EXCEL_DRIVEN__" - if selected_model_file == "__EXCEL_DRIVEN__" - else os.path.basename(selected_model_file) -) - -if "active_model_key" not in st.session_state: - st.session_state.active_model_key = model_key - st.session_state.params = {} - -elif st.session_state.active_model_key != model_key: - st.session_state.active_model_key = model_key - st.session_state.params = {} - -params = st.session_state.params -label_overrides = {} - -# PARAMETER INPUTS -st.sidebar.subheader("Input Parameters") - -# EXCEL-DRIVEN MODEL -if selected_model_file == "__EXCEL_DRIVEN__": - - uploaded_excel_model = st.sidebar.file_uploader( - "Upload Excel model file (.xlsx)", - type=["xlsx"], - key="excel_model_uploader" - ) - - if uploaded_excel_model: - - # reset params if Excel file changes - if ( - "excel_active_name" not in st.session_state - or st.session_state.excel_active_name != uploaded_excel_model.name - ): - st.session_state.excel_active_name = uploaded_excel_model.name - st.session_state.params = {} - - params = st.session_state.params - - # Load the defaults - editable_defaults, _ = load_excel_params_defaults_with_computed( - uploaded_excel_model, - sheet_name=None, - start_row=3 - ) - - # Load Headers - current_headers = get_scenario_headers(uploaded_excel_model) - - - # RESET CALLBACK - def handle_reset_excel(): - # 1. Reset Parameters - reset_parameters_to_defaults(editable_defaults, params, uploaded_excel_model.name) - # 2. Reset Header Labels - if current_headers: - for col_letter, default_text in current_headers.items(): - st.session_state[f"label_override_{col_letter}"] = default_text - - - # Display Button with Callback - st.sidebar.button("Reset Parameters", on_click=handle_reset_excel) - - # Outcome Headers (Column B-E) - if current_headers: - with st.sidebar.expander("Output Scenario Headers", expanded=False): - st.caption("Rename the output headers (B, C, D, E)") - - for col_letter in sorted(current_headers.keys()): - default_text = current_headers[col_letter] - widget_key = f"label_override_{col_letter}" - - # Robust Widget Logic - if widget_key in st.session_state: - new_text = st.text_input( - f"Column {col_letter} Label", - key=widget_key - ) - else: - new_text = st.text_input( - f"Column {col_letter} Label", - value=default_text, - key=widget_key - ) - label_overrides[col_letter] = new_text - - # Render Main Parameters - render_parameters_with_indent( - editable_defaults, - params, - model_id=uploaded_excel_model.name - ) - - else: - st.sidebar.info("Upload an Excel model file to edit parameters.") - -# PYTHON MODEL -else: - param_source = st.sidebar.radio( - "Parameter Source", - ["Model Default (YAML)", "Excel (.xlsx)", "YAML (.yaml)"], - horizontal=True - ) - - uploaded_excel = None - uploaded_yaml = None - - if param_source == "Excel (.xlsx)": - uploaded_excel = st.sidebar.file_uploader("Upload Excel parameter file", type=["xlsx"]) - elif param_source == "YAML (.yaml)": - uploaded_yaml = st.sidebar.file_uploader("Upload YAML parameter file", type=["yaml", "yml"]) - - # PARAMETER SOURCE RESET LOGIC - param_identity = ( - param_source, - uploaded_excel.name if uploaded_excel else None, - uploaded_yaml.name if uploaded_yaml else None, - ) - - if "active_param_identity" not in st.session_state: - st.session_state.active_param_identity = param_identity - st.session_state.params = {} - elif st.session_state.active_param_identity != param_identity: - st.session_state.active_param_identity = param_identity - st.session_state.params = {} - - params = st.session_state.params - - if param_source == "YAML (.yaml)" and uploaded_yaml: - raw = yaml.safe_load(uploaded_yaml) or {} - model_defaults = flatten_dict(raw) - else: - model_defaults = load_model_params( - selected_model_file, - uploaded_excel=uploaded_excel - ) - - # Load Python Model Module to check for Labels - model_module = load_model_from_file(selected_model_file) - # Check for SCENARIO_LABELS constant in the python file - current_headers = getattr(model_module, "SCENARIO_LABELS", None) - - if model_defaults: - # Define Python Model Reset Callback - def handle_reset_python(): - # 1. Reset Parameters - reset_parameters_to_defaults(model_defaults, params, model_key) - # 2. Reset Header Labels - if current_headers: - for key, default_text in current_headers.items(): - # We use a unique key format for python models to avoid conflicts - st.session_state[f"py_label_{model_key}_{key}"] = default_text - - - st.sidebar.button("Reset Parameters", on_click=handle_reset_python) - - # SCENARIO LABELS (PYTHON) - if current_headers: - with st.sidebar.expander("Output Scenario Headers", expanded=False): - st.caption("Rename the output headers") - - for key, default_text in current_headers.items(): - widget_key = f"py_label_{model_key}_{key}" - - if widget_key in st.session_state: - new_val = st.text_input(f"Label for '{default_text}'", key=widget_key) - else: - new_val = st.text_input(f"Label for '{default_text}'", value=default_text, key=widget_key) - - label_overrides[key] = new_val - - render_parameters_with_indent( - model_defaults, - params, - model_id=model_key - ) - else: - st.sidebar.info("No default parameters defined for this model.") - -# RUN SIMULATION -if st.sidebar.button("Run Simulation"): - - # EXCEL-DRIVEN MODEL RUN - if selected_model_file == "__EXCEL_DRIVEN__": - - uploaded_excel_model = st.session_state.get("excel_model_uploader") - - if not uploaded_excel_model: - st.error("Please upload an Excel model file first.") - st.stop() - - with st.spinner(f"Running Excel-driven model: {uploaded_excel_model.name}..."): - - results = run_excel_driven_model( - excel_file=uploaded_excel_model, - filename=uploaded_excel_model.name, - params=params, - sheet_name=None, - label_overrides=label_overrides - ) - - st.title(results.get("model_title", "Excel Driven Model")) - st.write(results.get("model_description", "")) - - render_sections(results["sections"]) - - # PYTHON MODEL RUN - else: - with st.spinner(f"Running {selected_label}..."): - # Load module again (or reuse) - model_module = load_model_from_file(selected_model_file) - - st.title(getattr(model_module, "model_title", app_config["title"])) - st.write(getattr(model_module, "model_description", app_config["description"])) - - # Check if run_model accepts label_overrides - sig = inspect.signature(model_module.run_model) - if "label_overrides" in sig.parameters: - results = model_module.run_model(params, label_overrides=label_overrides) - else: - results = model_module.run_model(params) - - sections = model_module.build_sections(results) - render_sections(sections) +# Importing this module executes the Streamlit app definition. +import epicc.__main__ # noqa: E402, F401 diff --git a/config/app.yaml b/config/app.yaml deleted file mode 100644 index 79c87bd..0000000 --- a/config/app.yaml +++ /dev/null @@ -1,2 +0,0 @@ -title: "TB Isolation Cost Calculator" -description: "Streamlit-based simulation tool for tuberculosis (TB) isolation scenarios." diff --git a/config/global_defaults.yaml b/config/global_defaults.yaml deleted file mode 100644 index 3050cab..0000000 --- a/config/global_defaults.yaml +++ /dev/null @@ -1,3 +0,0 @@ -Global Settings: - Decimal precision: 28 - UI theme: "dark" diff --git a/config/paths.yaml b/config/paths.yaml deleted file mode 100644 index 341f437..0000000 --- a/config/paths.yaml +++ /dev/null @@ -1,3 +0,0 @@ -model_paths: - selected_path: "models" - custom_path: "custom_models" diff --git a/models/measles_outbreak.py b/models/measles_outbreak.py deleted file mode 100644 index 4992c0d..0000000 --- a/models/measles_outbreak.py +++ /dev/null @@ -1,132 +0,0 @@ -from decimal import Decimal, ROUND_HALF_EVEN, getcontext -import pandas as pd - -""" -Measles outbreak simulation -""" - -model_title = "Measles Outbreak Cost Estimation" -model_description = "Estimates hospitalization, tracing, and productivity costs for measles outbreaks." - -SCENARIO_LABELS = { - "22_cases": "22 Cases", - "100_cases": "100 Cases", - "803_cases": "803 Cases" -} - - -def run_model(params, label_overrides: dict = None): - getcontext().prec = 28 - ONE = Decimal("1") - CENT = Decimal("0.01") - - if label_overrides is None: - label_overrides = {} - - lbl_22 = label_overrides.get("22_cases", SCENARIO_LABELS["22_cases"]) - lbl_100 = label_overrides.get("100_cases", SCENARIO_LABELS["100_cases"]) - lbl_803 = label_overrides.get("803_cases", SCENARIO_LABELS["803_cases"]) - - def q2(x: Decimal) -> Decimal: - """ - Conditional rounding: - - If absolute value > 10, round to whole number (0 decimal places). - - Otherwise, round to 2 decimal places. - """ - if abs(x) > 10: - return x.quantize(ONE, rounding=ROUND_HALF_EVEN) - return x.quantize(CENT, rounding=ROUND_HALF_EVEN) - - def getp(default, *names): - """ - Helper that searches for alternative parameter labels. - Returns Decimal. - """ - for n in names: - if n in params and params[n] != "": - try: - return Decimal(str(params[n])) - except: - pass - return Decimal(str(default)) - - # extract parameters - cost_hosp = getp(0, "Cost of measles hospitalization") - prop_hosp = getp(0, "Proportion of cases hospitalized") - - missed_ratio = getp(1.0, "Proportion of quarantine days that would be a missed day of work") - wage_worker = getp(0, "Hourly wage of worker (hourly_wage_worker)", "Hourly wage for worker") - - wage_tracer = getp(0, "Hourly wage for contract tracer") - hrs_tracing = getp(0, "Hours of contact tracing per contact") - - contacts = getp(0, "Number of contacts per case") - vacc_rate = getp(0, "Vaccination rate in community") - quarantine = int(getp(21, "Length of quarantine (days)")) - - # core calculations - - # hospitalizations - hosp_22 = q2(22 * prop_hosp * cost_hosp) - hosp_100 = q2(100 * prop_hosp * cost_hosp) - hosp_803 = q2(803 * prop_hosp * cost_hosp) - - # lost productivity - lost_22 = q2( - 22 * contacts * (1 - vacc_rate) * quarantine * - missed_ratio * wage_worker - ) - lost_100 = q2( - 100 * contacts * (1 - vacc_rate) * quarantine * - missed_ratio * wage_worker - ) - lost_803 = q2( - 803 * contacts * (1 - vacc_rate) * quarantine * - missed_ratio * wage_worker - ) - - # contact tracing cost - trace_22 = q2(22 * contacts * hrs_tracing * wage_tracer) - trace_100 = q2(100 * contacts * hrs_tracing * wage_tracer) - trace_803 = q2(803 * contacts * hrs_tracing * wage_tracer) - - # totals - total_22 = q2(hosp_22 + lost_22 + trace_22) - total_100 = q2(hosp_100 + lost_100 + trace_100) - total_803 = q2(hosp_803 + lost_803 + trace_803) - - # dataframe - df_costs = pd.DataFrame({ - "Cost Type": [ - "Hospitalization", - "Lost productivity", - "Contact tracing", - "TOTAL" - ], - lbl_22: [ - hosp_22, lost_22, trace_22, total_22 - ], - lbl_100: [ - hosp_100, lost_100, trace_100, total_100 - ], - lbl_803: [ - hosp_803, lost_803, trace_803, total_803 - ] - }) - - return { - "df_costs": df_costs - } - - -# ui -def build_sections(results): - df_costs = results["df_costs"] - - sections = [ - { - "title": "Measles Outbreak Costs", - "content": [df_costs] - } - ] - return sections diff --git a/models/tb_isolation.py b/models/tb_isolation.py deleted file mode 100644 index 85d581a..0000000 --- a/models/tb_isolation.py +++ /dev/null @@ -1,160 +0,0 @@ -from decimal import Decimal, ROUND_HALF_EVEN, getcontext -import pandas as pd - -""" -TB Isolation simulation. -""" - -model_title = "TB Isolation Cost Calculator" -model_description = "Streamlit-based simulation tool comparing 14-day and 5-day isolation scenarios." - -SCENARIO_LABELS = { - "14_day": "14-day Isolation", - "5_day": "5-day Isolation" -} - - -def run_model(params: dict, label_overrides: dict = None): - getcontext().prec = 28 - ONE = Decimal("1") - CENT = Decimal("0.01") - - if label_overrides is None: - label_overrides = {} - - lbl_14 = label_overrides.get("14_day", SCENARIO_LABELS["14_day"]) - lbl_5 = label_overrides.get("5_day", SCENARIO_LABELS["5_day"]) - - def q2(x: Decimal) -> Decimal: - """ - Conditional rounding: - - If absolute value > 10, round to whole number (0 decimal places). - - Otherwise, round to 2 decimal places. - """ - if abs(x) > 10: - return x.quantize(ONE, rounding=ROUND_HALF_EVEN) - return x.quantize(CENT, rounding=ROUND_HALF_EVEN) - - def q2n(x: Decimal) -> Decimal: - """Quantize numbers (counts/probabilities) to 2 dp.""" - return x.quantize(CENT, rounding=ROUND_HALF_EVEN) - - def getp(default, *names) -> Decimal: - normalized_params = {k.lower(): v for k, v in params.items()} - - for n in names: - n_lower = n.lower() - - if n_lower in normalized_params and normalized_params[n_lower] != "": - return Decimal(str(normalized_params[n_lower])) - - for key, val in normalized_params.items(): - if f"({n_lower})" in key and val != "": - return Decimal(str(val)) - return Decimal(str(default)) - - # parameter extraction - contacts_per_case = getp(0, "Number of contacts for each released TB case") - prob_latent_if_14day = getp(0, "Probability that contact develops latent TB if 14-day isolation") - infectiousness_multiplier = getp(1.5, "Multiplier for infectiousness with 5-day vs. 14-day isolation") - workday_ratio = getp(0.714, "Ratio of workdays to total days") - - # probabilities of progression - prob_latent_to_active_2yr = getp(0, "prob_latent_to_active_2yr", "First 2 years") - prob_latent_to_active_lifetime = getp(0, "prob_latent_to_active_lifetime", "Rest of lifetime") - - # secondary infection costs - cost_latent = getp(0, "cost_latent", "Cost of latent TB infection") - cost_active = getp(0, "cost_active", "Cost of active TB infection") - - # isolation scenario parameters - isolation_type = int(getp(3, "isolation_type", "Isolation type (1=hospital,2=motel,3=home)")) - daily_hosp_cost = getp(0, "isolation_cost", "Daily isolation cost") - direct_med_cost_day = getp(0, "Direct medical cost of a day of isolation") # Often used for hospital stay - - cost_motel_room = getp(0, "Cost of motel room per day") - hourly_wage_nurse = getp(0, "Hourly wage for nurse") - time_nurse_checkin = getp(0, "Time for nurse to check in w/ pt in motel or home (hrs)") - hourly_wage_worker = getp(0, "Hourly wage for worker") - - discount_rate = getp(0, "discount_rate", "Discount rate") - remaining_years = int(getp(40, "remaining_years", "Remaining years of life")) - - # determine daily isolation cost based on isolation type - if isolation_type == 1: - daily_cost = direct_med_cost_day if direct_med_cost_day > 0 else daily_hosp_cost - elif isolation_type == 2: - daily_cost = cost_motel_room + (hourly_wage_nurse * time_nurse_checkin) - else: - daily_cost = (hourly_wage_nurse * time_nurse_checkin) - - # core calculations - latent_14_day = q2n(contacts_per_case * prob_latent_if_14day) - latent_5_day = q2n(latent_14_day * infectiousness_multiplier) - - # progression math - active_14_day = q2n( - latent_14_day * prob_latent_to_active_2yr - + latent_14_day * (ONE - prob_latent_to_active_2yr) * prob_latent_to_active_lifetime - ) - active_5_day = q2n( - latent_5_day * prob_latent_to_active_2yr - + latent_5_day * (ONE - prob_latent_to_active_2yr) * prob_latent_to_active_lifetime - ) - - # outcomes dataframe - df_infections = pd.DataFrame({ - "Outcome": ["Latent TB infections", "Active TB disease"], - lbl_14: [latent_14_day, active_14_day], - lbl_5: [latent_5_day, active_5_day], - }) - - # costs - direct_cost_14_day = q2(daily_cost * Decimal(14)) - direct_cost_5_day = q2(daily_cost * Decimal(5)) - - # productivity loss - productivity_loss_14_day = q2(Decimal(14) * workday_ratio * hourly_wage_worker * Decimal(8)) - productivity_loss_5_day = q2(Decimal(5) * workday_ratio * hourly_wage_worker * Decimal(8)) - - # discounted secondary tb costs - base = ONE + discount_rate - discounted_2yr = (prob_latent_to_active_2yr / Decimal(2)) / (base ** 1) + ( - prob_latent_to_active_2yr / Decimal(2)) / (base ** 2) - - discounted_lifetime = sum( - (prob_latent_to_active_lifetime / Decimal(remaining_years)) / (base ** y) - for y in range(3, remaining_years + 1) - ) - - sec_cost_per_latent = q2(cost_latent + cost_active * (discounted_2yr + discounted_lifetime)) - - secondary_cost_14_day = q2(latent_14_day * sec_cost_per_latent) - secondary_cost_5_day = q2(latent_5_day * sec_cost_per_latent) - - total_14_day = q2(direct_cost_14_day + productivity_loss_14_day + secondary_cost_14_day) - total_5_day = q2(direct_cost_5_day + productivity_loss_5_day + secondary_cost_5_day) - - # cost dataframe - df_costs = pd.DataFrame({ - "Cost Type": [ - "Direct cost of isolation", - "Lost productivity for index case", - "Cost of secondary infections", - "Total cost", - ], - lbl_14: [direct_cost_14_day, productivity_loss_14_day, secondary_cost_14_day, total_14_day], - lbl_5: [direct_cost_5_day, productivity_loss_5_day, secondary_cost_5_day, total_5_day], - }) - - return { - "df_infections": df_infections, - "df_costs": df_costs, - } - - -def build_sections(results): - return [ - {"title": "Number of Secondary Infections", "content": [results["df_infections"]]}, - {"title": "Costs", "content": [results["df_costs"]]}, - ] diff --git a/pyproject.toml b/pyproject.toml index 95140ff..e13a489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,31 @@ dependencies = [ "streamlit>=1.55.0", "pandas>=2.3.0", "numpy>=2.4.0", - "pyyaml>=6.0.0", "openpyxl>=3.1.0", + "pydantic>=2.12.5", + "ruamel-yaml>=0.19.1", ] [dependency-groups] dev = [ "ruff>=0.15.6", + "pytest>=8.0.0", + "mypy>=1.20.0", + "types-pyyaml>=6.0.12.20250915", + "pandas-stubs>=3.0.0.260204", + "types-openpyxl>=3.1.0.20240106", ] + +[tool.pytest.ini_options] +pythonpath = ["src"] + +[tool.mypy] +mypy_path = "src" +explicit_package_bases = true + +[tool.stlite] +title = "EpiCON Cost Calculator" +mount_dirs = ["src"] +text_suffixes = [".py", ".yaml", ".yml", ".css", ".js", ".html"] +css_url = "https://cdn.jsdelivr.net/npm/@stlite/browser@0.85.1/build/stlite.css" +js_url = "https://cdn.jsdelivr.net/npm/@stlite/browser@0.85.1/build/stlite.js" \ No newline at end of file diff --git a/Measles Outbreak.xlsx b/sample/Measles Outbreak.xlsx similarity index 100% rename from Measles Outbreak.xlsx rename to sample/Measles Outbreak.xlsx diff --git a/TB Isolation.xlsx b/sample/TB Isolation.xlsx similarity index 100% rename from TB Isolation.xlsx rename to sample/TB Isolation.xlsx diff --git a/sample/~$TB Isolation.xlsx b/sample/~$TB Isolation.xlsx new file mode 100644 index 0000000..5e40a09 Binary files /dev/null and b/sample/~$TB Isolation.xlsx differ diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..db396fa --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import re +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def cli(): + parser = argparse.ArgumentParser( + description="Build stlite bundle from pyproject.toml" + ) + + parser.add_argument( + "--app", + default="app.py", + help="Path to Streamlit entrypoint (default: app.py)", + ) + parser.add_argument( + "--out", + default="dist", + help="Output path for generated bundle (default: dist)", + ) + + return parser + + +def strip_dependency_name(dep: str) -> str: + name = dep.split("[")[0] + for op in (">=", "==", "<=", ">", "<", "~=", "!="): + name = name.split(op)[0] + + return name.strip() + + +def load_config(pyproject_path: Path) -> dict: + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + + deps = pyproject.get("project", {}).get("dependencies", []) + packages = [strip_dependency_name(dep) for dep in deps] + stlite_config = pyproject.get("tool", {}).get("stlite", {}) + required_fields = ["mount_dirs", "text_suffixes", "title", "css_url", "js_url"] + missing = [field for field in required_fields if field not in stlite_config] + + if missing: + print( + f"Error: Missing required fields in [tool.stlite]: {', '.join(missing)}", + file=sys.stderr, + ) + sys.exit(1) + + return { + "packages": packages, + "mount_dirs": tuple(stlite_config["mount_dirs"]), + "text_suffixes": tuple(stlite_config["text_suffixes"]), + "title": stlite_config["title"], + "css_url": stlite_config["css_url"], + "js_url": stlite_config["js_url"], + } + + +def should_mount_file(path: Path, text_suffixes: tuple[str, ...]) -> bool: + if not path.is_file(): + return False + + if path.name.startswith("."): + return False + + if "__pycache__" in path.parts: + return False + + if path.suffix in (".pyc", ".pyo"): + return False + + return path.suffix.lower() in text_suffixes + + +def hash_content(content: str) -> str: + """Bust those caches! Generate a short hash of the content (Python files) to bust browser caches.""" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def get_hashed_filename(path: str, content: str) -> str: + """ + Examples: + "app.py" + content -> "app.abc12345.py" + "src/epicc/__init__.py" + content -> "src/epicc/__init__.def67890.py" + """ + + file_hash = hash_content(content) + + if "." in Path(path).name: + stem = Path(path).stem + suffix = Path(path).suffix + parent = Path(path).parent + + hashed_name = f"{stem}.{file_hash}{suffix}" + return str(parent / hashed_name) if parent != Path(".") else hashed_name + else: + return f"{path}.{file_hash}" + + +def collect_files( + project_root: Path, + app_path: Path, + mount_dirs: tuple[str, ...], + text_suffixes: tuple[str, ...], +) -> dict[str, str]: + """Collect source files to mount in the stlite virtual filesystem. + + Returns: + dict mapping relative paths to file contents + """ + mounted_files: dict[str, str] = {} + files_to_mount: list[Path] = [app_path] + + # Scan configured directories, read, and then mount eligible files + for dirname in mount_dirs: + directory = project_root / dirname + if directory.exists(): + files_to_mount.extend(sorted(directory.rglob("*"))) + + for path in files_to_mount: + if not should_mount_file(path, text_suffixes): + continue + + try: + relative_path = path.relative_to(project_root).as_posix() + mounted_files[relative_path] = path.read_text(encoding="utf-8") + except Exception as e: + print(f"warning: I could not read {path}: {e}", file=sys.stderr) + + return mounted_files + + +def write_source_files( + mounted_files: dict[str, str], + output_dir: Path, +) -> dict[str, str]: + """Write source files to output directory with content hashes.""" + path_mapping = {} + + for relative_path, content in mounted_files.items(): + # Generate hashed filename + hashed_path = get_hashed_filename(relative_path, content) + + # Full output path + output_path = output_dir / "files" / hashed_path + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Write file + output_path.write_text(content, encoding="utf-8") + + # Store mapping for config (relative to output_dir) + url_path = f"./files/{hashed_path}" + path_mapping[relative_path] = url_path + + return path_mapping + + +def get_stlite_config_file( + *, + entrypoint: str, + packages: list[str], + file_urls: dict[str, str], + output_dir: Path, +) -> dict[str, Any]: + return { + "entrypoint": entrypoint, + "requirements": packages, + "files": { + original_path: {"url": url} for original_path, url in file_urls.items() + }, + } + + +def build_loader_html( + *, + title: str, + css_url: str, + js_url: str, +) -> str: + title_html = html.escape(title, quote=True) + css_html = html.escape(css_url, quote=True) + js_json = json.dumps(js_url) + + return f""" + + + + + + + + + + + + + + + + + + + + + + + + + + {title_html} + + + +
+ + + +""" + + +def main(): + args = cli().parse_args() + + # Resolve paths + script_path = Path(__file__).resolve() + maybe_project_root = script_path.parent + + while True: + if (maybe_project_root / "pyproject.toml").exists(): + project_root = maybe_project_root + break + + if maybe_project_root.parent == maybe_project_root: + print( + "Error: Could not find pyproject.toml in any parent directory", + file=sys.stderr, + ) + return 1 + + maybe_project_root = maybe_project_root.parent + + project_root = maybe_project_root + pyproject_path = project_root / "pyproject.toml" + assert pyproject_path.exists() + + # Load configuration + config = load_config(pyproject_path) + + # Validate app file exists + app_path = (project_root / args.app).resolve() + if not app_path.exists(): + print(f"Error: App file not found at {app_path}", file=sys.stderr) + return 1 + + # Collect files to mount + print("Collecting files...", file=sys.stderr) + mounted_files = collect_files( + project_root, + app_path, + config["mount_dirs"], + config["text_suffixes"], + ) + + # Prepare output directory + output_dir = project_root / args.out + html_index_path = (output_dir / "index.html").resolve() + stlite_config_path = (output_dir / "stlite-config.json").resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + # Move source files to output w/ hashes. + print("Writing source files...", file=sys.stderr) + file_urls = write_source_files(mounted_files, output_dir) + + # Config file so stlite loader can find the mounted files and entrypoint. + print("Generating config...", file=sys.stderr) + stlite_config_obj = get_stlite_config_file( + entrypoint=app_path.relative_to(project_root).as_posix(), + packages=config["packages"], + file_urls=file_urls, + output_dir=output_dir, + ) + + stlite_config = json.dumps( + stlite_config_obj, ensure_ascii=False, separators=(",", ":") + ) + stlite_config_path.write_text(stlite_config, encoding="utf-8") + + # Minimal HTML loader. + print("Generating HTML loader...", file=sys.stderr) + html_text = build_loader_html( + title=config["title"], + css_url=config["css_url"], + js_url=config["js_url"], + ) + + html_index_path.write_text(html_text, encoding="utf-8") + + # Success message + size_kb = len(html_text.encode("utf-8")) / 1024 + print("\nCompleted build, yo!") + print(f" Output: {html_index_path.relative_to(project_root)}") + print(f" Size: {size_kb:.1f} KB") + print(f" Files mounted: {len(mounted_files)}") + print(f" Directories scanned: {', '.join(config['mount_dirs'])}") + print(f" Pyodide packages: {', '.join(config['packages'])}") + + # Extract and display stlite version + match = re.search(r"@stlite/mountable@([\d.]+)", config["js_url"]) + if match: + print(f" stlite version: {match.group(1)}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/epicc/__main__.py b/src/epicc/__main__.py new file mode 100644 index 0000000..94d82a2 --- /dev/null +++ b/src/epicc/__main__.py @@ -0,0 +1,239 @@ +import importlib.resources +from typing import Any + +import streamlit as st + +from epicc.config import CONFIG +from epicc.formats import VALID_PARAMETER_SUFFIXES +from epicc.model.base import BaseSimulationModel +from epicc.utils.excel_model_runner import ( + get_scenario_headers, + load_excel_params_defaults_with_computed, + run_excel_driven_model, +) +from epicc.utils.model_loader import get_built_in_models +from epicc.utils.parameter_loader import load_model_params +from epicc.utils.parameter_ui import ( + render_parameters_with_indent, + reset_parameters_to_defaults, +) +from epicc.utils.section_renderer import render_sections + + +def _load_styles() -> None: + with importlib.resources.files("epicc").joinpath("web/sidebar.css").open("rb") as f: + css_content = f.read().decode("utf-8") + st.markdown(f"", unsafe_allow_html=True) + + +def _sync_active_model(model_key: str) -> dict[str, Any]: + active_model_key = st.session_state.get("active_model_key") + if active_model_key != model_key: + st.session_state.active_model_key = model_key + st.session_state.params = {} + + if "params" not in st.session_state: + st.session_state.params = {} + + return st.session_state.params + + +def _render_excel_parameter_inputs( + params: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, str]]: + label_overrides: dict[str, str] = {} + + uploaded_excel_model = st.sidebar.file_uploader( + "Upload Excel model file (.xlsx)", type=["xlsx"], key="excel_model_uploader" + ) + if not uploaded_excel_model: + st.sidebar.info("Upload an Excel model file to edit parameters.") + return params, label_overrides + + uploaded_excel_name = uploaded_excel_model.name + if st.session_state.get("excel_active_name") != uploaded_excel_name: + st.session_state.excel_active_name = uploaded_excel_name + st.session_state.params = {} + params = st.session_state.params + + editable_defaults, _ = load_excel_params_defaults_with_computed( + uploaded_excel_model, sheet_name=None, start_row=3 + ) + current_headers = get_scenario_headers(uploaded_excel_model) + + def handle_reset_excel() -> None: + reset_parameters_to_defaults(editable_defaults, params, uploaded_excel_name) + for col_letter, default_text in current_headers.items(): + st.session_state[f"label_override_{col_letter}"] = default_text + + st.sidebar.button("Reset Parameters", on_click=handle_reset_excel) + + if current_headers: + with st.sidebar.expander("Output Scenario Headers", expanded=False): + st.caption("Rename the output headers (B, C, D, E)") + for col_letter in sorted(current_headers.keys()): + default_text = current_headers[col_letter] + widget_key = f"label_override_{col_letter}" + if widget_key in st.session_state: + label_overrides[col_letter] = st.text_input( + f"Column {col_letter} Label", key=widget_key + ) + continue + + label_overrides[col_letter] = st.text_input( + f"Column {col_letter} Label", + value=default_text, + key=widget_key, + ) + + render_parameters_with_indent( + editable_defaults, params, model_id=uploaded_excel_name + ) + return params, label_overrides + + +def _render_python_parameter_inputs( + model: BaseSimulationModel, + model_key: str, + params: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, str]]: + label_overrides: dict[str, str] = {} + + sorted_suffixes = sorted(VALID_PARAMETER_SUFFIXES) + uploaded_params = st.sidebar.file_uploader( + "Optional parameter override file", + type=sorted_suffixes, + help="If omitted, model defaults are used.", + ) + + param_identity = ( + "upload" if uploaded_params else "default", + uploaded_params.name if uploaded_params else None, + ) + if st.session_state.get("active_param_identity") != param_identity: + st.session_state.active_param_identity = param_identity + st.session_state.params = {} + params = st.session_state.params + + model_defaults = load_model_params( + model, + uploaded_params=uploaded_params or None, + uploaded_name=uploaded_params.name if uploaded_params else None, + ) + + if not model_defaults: + st.sidebar.info("No default parameters defined for this model.") + return params, label_overrides + + current_headers = model.scenario_labels + + def handle_reset_python() -> None: + reset_parameters_to_defaults(model_defaults, params, model_key) + if not current_headers: + return + + for key, default_text in current_headers.items(): + st.session_state[f"py_label_{model_key}_{key}"] = default_text + + st.sidebar.button("Reset Parameters", on_click=handle_reset_python) + + if current_headers: + with st.sidebar.expander("Output Scenario Headers", expanded=False): + st.caption("Rename the output headers") + for key, default_text in current_headers.items(): + widget_key = f"py_label_{model_key}_{key}" + default_label = str(default_text) + if widget_key in st.session_state: + label_overrides[key] = st.text_input( + f"Label for '{default_label}'", key=widget_key + ) + continue + + label_overrides[key] = st.text_input( + f"Label for '{default_label}'", + value=default_label, + key=widget_key, + ) + + render_parameters_with_indent(model_defaults, params, model_id=model_key) + return params, label_overrides + + +def _run_excel_simulation( + params: dict[str, Any], label_overrides: dict[str, str] +) -> None: + uploaded_excel_model = st.session_state.get("excel_model_uploader") + if not uploaded_excel_model: + st.error("Please upload an Excel model file first.") + st.stop() + + with st.spinner(f"Running Excel-driven model: {uploaded_excel_model.name}..."): + results = run_excel_driven_model( + excel_file=uploaded_excel_model, + filename=uploaded_excel_model.name, + params=params, + sheet_name=None, + label_overrides=label_overrides, + ) + st.title(results.get("model_title", "Excel Driven Model")) + st.write(results.get("model_description", "")) + render_sections(results["sections"]) + + +def _run_python_simulation( + selected_label: str, + model: BaseSimulationModel, + params: dict[str, Any], + label_overrides: dict[str, str], +) -> None: + with st.spinner(f"Running {selected_label}..."): + st.title(model.model_title or CONFIG.app.title) + st.write(model.model_description or CONFIG.app.description) + results = model.run(params, label_overrides=label_overrides) + render_sections(model.build_sections(results)) + + +st.set_page_config( + page_title="EpiCON Cost Calculator", + layout="wide", + initial_sidebar_state="expanded", +) + +_load_styles() + +st.sidebar.title("EpiCON Cost Calculator") +st.sidebar.header("Simulation Controls") + +built_in_models = get_built_in_models() +model_registry: dict[str, BaseSimulationModel] = { + m.human_name(): m for m in built_in_models +} +model_labels = [*model_registry.keys(), "Excel Driven Model"] + +selected_label = st.sidebar.selectbox("Select Model", model_labels, index=0) +is_excel_model = selected_label == "Excel Driven Model" +model_key = selected_label + +params = _sync_active_model(model_key) + +st.sidebar.subheader("Input Parameters") + +if is_excel_model: + params, label_overrides = _render_excel_parameter_inputs(params) +else: + params, label_overrides = _render_python_parameter_inputs( + model_registry[selected_label], + model_key, + params, + ) + +if not st.sidebar.button("Run Simulation"): + st.stop() + +if is_excel_model: + _run_excel_simulation(params, label_overrides) + st.stop() + +_run_python_simulation( + selected_label, model_registry[selected_label], params, label_overrides +) diff --git a/src/epicc/config/__init__.py b/src/epicc/config/__init__.py new file mode 100644 index 0000000..44dddff --- /dev/null +++ b/src/epicc/config/__init__.py @@ -0,0 +1,22 @@ +""" +TODO: A lot of of this stuff is very restrictive, since the calculator was initially built to be + deployed for handling one single model without being retargetable. In the future, we may want to + make this more flexible, but for now, it's good enough. +""" + +import importlib.resources +from typing import Any + +from epicc.config.schema import Config +from epicc.formats import read_from_format + + +def load_config(name: str) -> tuple[Config, Any]: + resource = importlib.resources.files("epicc").joinpath(f"config/{name}.yaml") + filename = f"config/{name}.yaml" + return read_from_format(filename, resource.open("rb"), Config) + + +CONFIG, _ = load_config("default") + +__all__ = ["CONFIG"] diff --git a/src/epicc/config/default.yaml b/src/epicc/config/default.yaml new file mode 100644 index 0000000..e8b9b17 --- /dev/null +++ b/src/epicc/config/default.yaml @@ -0,0 +1,7 @@ +app: + title: TB Isolation Cost Calculator + description: Streamlit-based simulation tool for tuberculosis (TB) isolation scenarios. + +defaults: + decimal_precision: 4 + ui_theme: light diff --git a/src/epicc/config/schema.py b/src/epicc/config/schema.py new file mode 100644 index 0000000..daa1833 --- /dev/null +++ b/src/epicc/config/schema.py @@ -0,0 +1,33 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class AppConfig(BaseModel): + title: str = Field(description="Display title of the application.") + + description: str = Field( + description="Brief description of the application and its purpose." + ) + + +class DefaultsConfig(BaseModel): + decimal_precision: int = Field( + default=4, # This used to be 28, not sure why, since that's very large. + ge=1, + le=16, + description="Number of decimal places used in cost and probability calculations.", + ) + + ui_theme: Literal["light", "dark"] = Field( + default="light", + description="UI theme. One of 'light' or 'dark'.", + ) + + +class Config(BaseModel): + app: AppConfig + defaults: DefaultsConfig + + +__all__ = ["Config"] diff --git a/src/epicc/formats/__init__.py b/src/epicc/formats/__init__.py new file mode 100644 index 0000000..b703e9f --- /dev/null +++ b/src/epicc/formats/__init__.py @@ -0,0 +1,99 @@ +""" +Tools for working with parameter files in heterogeneous formats. This module provides a common +interface for reading parameter files into a standardized dictionary format that can be used +by the rest of the application, as well as writing. + +Supported formats are denoted in _LOADERS. + +The rationale behind this abstraction is that, in order to support both effective serialization, +non-technical epidemiologists, and more technical users, we want to allow for multiple file +formats. For example, YAML files are more human-friendly and support nested structures, while +XLSX files are more familiar to epis who already work in spreadsheets and may find it easier to +organize parameters in that format. +""" + +from pathlib import Path +from typing import IO, Any, TypeVar + +from pydantic import BaseModel + +from epicc.formats.base import BaseFormat +from epicc.formats.template import generate_template +from epicc.formats.xlsx import XLSXFormat +from epicc.formats.yaml import YAMLFormat + +M = TypeVar("M", bound=BaseModel) +"""Type variable for Pydantic models used in validation.""" + +_FORMATS: dict[str, type[BaseFormat]] = { + ".yaml": YAMLFormat, + ".yml": YAMLFormat, + ".xlsx": XLSXFormat, +} + +VALID_PARAMETER_SUFFIXES = set(k[1:] for k in _FORMATS.keys()) +"""Set of valid file suffixes for parameter files. These do not begin with a dot.""" + + +def get_format(path: Path | str) -> BaseFormat: + """Return the appropriate reader for the given file path. + + Args: + path: Path to the file to read. If a string, it will be converted to a Path object. + + Returns: + A reader instance appropriate for the file format. + + Raises: + ValueError: If the file format is not supported. + """ + + suffix = Path(path).suffix.lower() # calling constructor to handle `str` case. + reader_class = _FORMATS.get(suffix) + if reader_class is None: + supported = ", ".join(_FORMATS.keys()) + raise ValueError( + f"Unsupported file format '{suffix}'. Supported formats: {supported}" + ) + + return reader_class(path) + + +def opaque_to_typed(data: dict, model: type[M]) -> M: + """ + Validate the given data against a given Pydantic model. + """ + + try: + return model.model_validate(data) + except Exception as e: + raise ValueError(f"Data validation failed: {e}") from e + + +def read_from_format(path: Path | str, data: IO, model: type[M]) -> tuple[M, Any]: + """ + Read parameters from the given file path, and validate. See get_format() and + opaque_to_typed() for details. + + The reason for needing both a path and a data stream is that, in some cases, we + may want to read from a file-like object (e.g. an uploaded file in Streamlit) that + doesn't have a path, but we still need to determine the file format based on the + original file name (which is what the path argument is for). + """ + + reader = get_format(path) + opaque, template = reader.read(data) + + return opaque_to_typed(opaque, model), template + + +__all__ = [ + "VALID_PARAMETER_SUFFIXES", + "BaseFormat", + "YAMLFormat", + "XLSXFormat", + "generate_template", + "get_format", + "opaque_to_typed", + "read_from_format", +] diff --git a/src/epicc/formats/base.py b/src/epicc/formats/base.py new file mode 100644 index 0000000..a0cfb50 --- /dev/null +++ b/src/epicc/formats/base.py @@ -0,0 +1,87 @@ +from typing import Any, IO, Generic, TypeVar +from pathlib import Path +from abc import ABC, abstractmethod + +from pydantic import BaseModel + +T = TypeVar("T") + +class BaseFormat(ABC, Generic[T]): + """ + Abstract base class for parameter file formats. + """ + + def __init__(self, path: Path | str) -> None: + """ + Initialize the reader. + + Parameters: + path: Path to the file to read. If a string, it will be converted to a Path object. + """ + + self.path = Path(path) if isinstance(path, str) else path + + @abstractmethod + def read(self, data: IO) -> tuple[dict[str, Any], T]: + """ + Read a stream and return its contents as a format-agnostic dictionary. + + Args: + data: some data input stream. + + Returns: + A tuple containing: + - Dictionary representation of the file contents. + - Template object of type T. This can be used to preserve formatting or other metadata + when writing back to the file. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file cannot be parsed. + """ + + ... + + @abstractmethod + def write(self, data: dict[str, Any], template: T | None = None) -> bytes: + """ + Write a agnostic-dictionary dictionary to the appropriate format. + + Args: + data: Dictionary to write. + template: Optional template object to use when writing. This can be used to preserve + formatting or other metadata. + + Returns: + Byte array containing the data in the appropriate format. If the format is text-based, + this is UTF-8 encoded. Otherwise, for binary formats like XLSX, the encoding is format + specified. + + Raises: + ValueError: If the data cannot be serialized in the appropriate format. + """ + + ... + + @abstractmethod + def write_template(self, model: BaseModel) -> bytes: + """ + Generate a fill-in template from a populated model instance. + + The model will have been instantiated with defaults and placeholder + values. Each backend serialises it in a format-appropriate way, + including field descriptions where the format supports them. + + Args: + model: A ``BaseModel`` instance whose values serve as the template + defaults. Field metadata (descriptions, etc.) is available via + ``type(model).model_fields``. + + Returns: + Byte array of the template in the appropriate format. + """ + + ... + + +__all__ = ["BaseFormat"] diff --git a/src/epicc/formats/template.py b/src/epicc/formats/template.py new file mode 100644 index 0000000..e45b310 --- /dev/null +++ b/src/epicc/formats/template.py @@ -0,0 +1,94 @@ +"""Utilities for generating fill-in templates from Pydantic models.""" + +from __future__ import annotations + +import types +import typing +from typing import Any, Literal, get_args, get_origin + +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + +from epicc.formats.base import BaseFormat + + +def generate_template(model_cls: type[BaseModel], fmt: BaseFormat) -> bytes: + """Generate a fill-in template for the given Pydantic model. + + Instantiates ``model_cls`` with defaults and type-appropriate placeholders + for any required fields, then delegates serialisation to ``fmt``. + + Args: + model_cls: The Pydantic ``BaseModel`` subclass to template. + fmt: Any ``BaseFormat`` instance; determines the output format and + how field metadata (descriptions, etc.) is rendered. + + Returns: + Serialised bytes ready to write to a file. + """ + return fmt.write_template(_instantiate(model_cls)) + + +def _instantiate(model_cls: type[BaseModel]) -> BaseModel: + """Recursively build a model instance using defaults and placeholders.""" + kwargs: dict[str, Any] = {} + for name, field_info in model_cls.model_fields.items(): + kwargs[name] = _resolve(field_info) + + return model_cls.model_construct(**kwargs) + + +def _resolve(field_info: FieldInfo) -> Any: + if field_info.default is not PydanticUndefined: + return field_info.default + + if field_info.default_factory is not None: + # `default_factory` may be called without arguments, but mypy doesn't know that. + return field_info.default_factory() # type: ignore + + return _placeholder(field_info.annotation) + + +def _placeholder(annotation: Any) -> Any: + inner = _unwrap_optional(annotation) + + if _is_model(inner): + return _instantiate(inner) + + origin = get_origin(inner) + if origin is Literal: + args = get_args(inner) + return args[0] if args else None + if origin is list: + return [] + if origin is dict: + return {} + if inner is str: + return "" + if inner is int: + return 0 + if inner is float: + return 0.0 + if inner is bool: + return False + return None + + +def _unwrap_optional(annotation: Any) -> Any: + origin = get_origin(annotation) + if origin is types.UnionType or origin is typing.Union: + non_none = [a for a in get_args(annotation) if a is not type(None)] + if len(non_none) == 1: + return non_none[0] + return annotation + + +def _is_model(annotation: Any) -> bool: + try: + return isinstance(annotation, type) and issubclass(annotation, BaseModel) + except TypeError: + return False + + +__all__ = ["generate_template"] diff --git a/src/epicc/formats/xlsx.py b/src/epicc/formats/xlsx.py new file mode 100644 index 0000000..fd5be4f --- /dev/null +++ b/src/epicc/formats/xlsx.py @@ -0,0 +1,182 @@ +""" +Generic reader for XLSX parameter files. Expects a spreadsheet with at least two columns: + + - Column A: parameter name + - Column B: parameter value + - Column C (optional): description or notes, ignored during loading +""" + +from io import BytesIO +from typing import IO, Any + +import openpyxl +from openpyxl import Workbook +from pydantic import BaseModel + +from epicc.formats.base import BaseFormat + +# Expected column indices (0-based) +_COL_PARAMETER = 0 +_COL_VALUE = 1 + + +class XLSXFormat(BaseFormat[Workbook]): + """ + Reader for XLSX parameter files. + + Expects a spreadsheet with at least two columns: + - Column A: parameter name + - Column B: parameter value + - Column C (optional): description or notes, ignored during loading + + The first row is treated as a header and skipped. Empty rows are skipped. + Parameter names may use dot notation to represent nested structure, + e.g. "costs.latent" will be parsed into {"costs": {"latent": }}. + """ + + def read(self, data: IO) -> tuple[dict[str, Any], Workbook]: + """ + Read an XLSX file and return its contents as a dictionary. + + Args: + data: Input stream containing the XLSX data. + + Returns: + A tuple containing: + - Dictionary representation of the XLSX contents. + - Workbook object representing the XLSX file, which can be used as a template for writing. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file cannot be parsed or is missing required columns. + """ + + try: + wb = openpyxl.load_workbook(data, data_only=False) + except Exception as e: + raise ValueError(f"Failed to open XLSX file {self.path}") from e + + ws = wb.active + if not ws or ws.max_column < 2: + raise ValueError( + f"XLSX file {self.path} must have at least 2 columns for parameters and values." + ) + + rows = list(ws.iter_rows(values_only=True)) + + if len(rows) < 2: + raise ValueError( + f"XLSX file {self.path} must have a header row and at least one data row." + ) + + opaque: dict[str, Any] = {} + + for row in rows[1:]: # skip header + if not row or row[_COL_PARAMETER] is None: + continue + + key = str(row[_COL_PARAMETER]).strip() + value = row[_COL_VALUE] if len(row) > _COL_VALUE else None + + if not key: + continue + + _set_nested(opaque, key, value) + + return opaque, wb + + def write(self, data: dict[str, Any], template: Workbook | None = None) -> bytes: + """ + Write a dictionary to an XLSX file. + + Args: + data: Dictionary to write. + template: Optional Workbook object to use as a template for writing. If provided, the + structure and formatting of the template will be preserved as much as possible. + + Returns: + Byte array containing the XLSX data. + """ + + wb = template or Workbook() + ws = wb.active + assert ws is not None, "Workbook must have an active worksheet (bug)." + flat_data = _flatten_dict(data) + + # Populate with the provided data piecewise. + for row in ws.iter_rows(): + key_cell = row[_COL_PARAMETER] + val_cell = row[_COL_VALUE] + if key_cell.value in flat_data: + val_cell.value = flat_data[key_cell.value] # type: ignore[index] + + # Awful, but openpyxl is only capable of doing it this way. + output = BytesIO() + wb.save(output) + return output.getvalue() + + def write_template(self, model: BaseModel) -> bytes: + """Write an XLSX template from a model instance. + + Produces a three-column spreadsheet (Parameter, Value, Description). + Nested models are flattened to dot-notation keys. Descriptions come + from ``Field(description=...)`` on each field. + """ + wb = Workbook() + ws = wb.active + assert ws is not None + ws.append(["Parameter", "Value", "Description"]) + for key, value, description in _flatten(model): + ws.append([key, value, description]) + output = BytesIO() + wb.save(output) + return output.getvalue() + + +def _flatten(model: BaseModel, prefix: str = "") -> list[tuple[str, Any, str]]: + """Recursively flatten a model instance to ``[(dot_key, value, description)]``.""" + rows: list[tuple[str, Any, str]] = [] + for name, field_info in type(model).model_fields.items(): + key = f"{prefix}.{name}" if prefix else name + value = getattr(model, name) + description = field_info.description or "" + if isinstance(value, BaseModel): + rows.extend(_flatten(value, prefix=key)) + else: + rows.append((key, value, description)) + return rows + + +def _flatten_dict(data: dict[str, Any], prefix: str = "") -> dict[str, Any]: + """Flatten nested dict values to dot-notation keys for worksheet lookups.""" + flattened: dict[str, Any] = {} + for key, value in data.items(): + dot_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + flattened.update(_flatten_dict(value, prefix=dot_key)) + else: + flattened[dot_key] = value + return flattened + + +def _set_nested(d: dict, key: str, value: Any) -> None: + """ + Set a value in a nested dictionary using dot-notation keys. + + For example, "costs.latent" with value 300.0 produces: + {"costs": {"latent": 300.0}} + + Args: + d: Dictionary to mutate. + key: Dot-separated key string. + value: Value to set. + """ + + parts = key.split(".") + for part in parts[:-1]: + d = d.setdefault(part, {}) + + d[parts[-1]] = value + + +__all__ = ["XLSXFormat"] diff --git a/src/epicc/formats/yaml.py b/src/epicc/formats/yaml.py new file mode 100644 index 0000000..061dc9f --- /dev/null +++ b/src/epicc/formats/yaml.py @@ -0,0 +1,93 @@ +""" +Generic reader for YAML parameter files. Expects a YAML file with a mapping at the top level, which +is parsed into a dictionary. +""" + +from io import StringIO +from typing import IO, Any + +from pydantic import BaseModel +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from epicc.formats.base import BaseFormat + + +class YAMLFormat(BaseFormat[CommentedMap]): + """Reader for YAML parameter files.""" + + def read(self, data: IO) -> tuple[dict[str, Any], CommentedMap]: + """Read a YAML file and return its contents as a dictionary. + + Args: + data: Input stream containing the YAML data. + + Returns: + A tuple containing: + - Dictionary representation of the YAML contents. + - Parsed YAML mapping (CommentedMap), which can be used as a template for writing. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file cannot be parsed as valid YAML, + or if the top-level structure is not a mapping. + """ + + yaml = YAML(typ="rt") + + try: + data = yaml.load(data) + except Exception as e: + raise ValueError(f"Failed to parse YAML data at {self.path}") from e + + if not isinstance(data, CommentedMap): + raise ValueError( + f"Expected a YAML mapping at the top level in {self.path}, got {type(data).__name__}" + ) + + return data, data + + def write( + self, data: dict[str, Any], template: CommentedMap | None = None + ) -> bytes: + """Write a dictionary to a YAML file. + + Args: + data: Dictionary to write. + template: Optional parsed YAML mapping to use as a write template. When provided, + comments and formatting trivia from the template are preserved. + + Returns: + Byte array containing the YAML data, UTF-8 encoded. + """ + + yaml = YAML(typ="rt") + if template is not None: + _merge_mapping(template, data) + payload: dict[str, Any] | CommentedMap = template + else: + payload = data + output = StringIO() + yaml.dump(payload, output) + return output.getvalue().encode("utf-8") + + def write_template(self, model: BaseModel) -> bytes: + """Write a YAML template from a model instance. + + The model is dumped to a nested mapping; the natural YAML structure + is preserved without flattening. Descriptions are not embedded as + comments (YAML comment support is left to a future enhancement). + """ + return self.write(model.model_dump()) + + +__all__ = ["YAMLFormat"] + + +def _merge_mapping(target: CommentedMap, updates: dict[str, Any]) -> None: + """Recursively merge plain updates into a CommentedMap template.""" + for key, value in updates.items(): + if isinstance(value, dict) and isinstance(target.get(key), CommentedMap): + _merge_mapping(target[key], value) + else: + target[key] = value diff --git a/src/epicc/model/__init__.py b/src/epicc/model/__init__.py new file mode 100644 index 0000000..fc7ab38 --- /dev/null +++ b/src/epicc/model/__init__.py @@ -0,0 +1,17 @@ +import importlib.resources +from pathlib import Path +from typing import Any + +from epicc.formats import read_from_format +from epicc.model.schema import Model + + +def load_model(name: str) -> tuple[Model, Any]: + # Use a Traversable for opening the resource, and a real Path/str for suffix detection. + config_resource = importlib.resources.files("epicc").joinpath(f"models/{name}.yaml") + config_name = Path(f"{name}.yaml") + + return read_from_format(config_name, config_resource.open("rb"), Model) + + +__all__ = ["load_model"] diff --git a/src/epicc/model/base.py b/src/epicc/model/base.py new file mode 100644 index 0000000..b1db798 --- /dev/null +++ b/src/epicc/model/base.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class BaseSimulationModel(ABC): + """Abstract contract for Python-defined simulation models.""" + + @abstractmethod + def human_name(self) -> str: + """Human-readable name shown in model selection UI.""" + + @property + @abstractmethod + def model_title(self) -> str: + """Page title displayed above simulation output.""" + + @property + @abstractmethod + def model_description(self) -> str: + """Short text describing the simulation.""" + + @property + @abstractmethod + def scenario_labels(self) -> dict[str, str]: + """Default scenario labels exposed for sidebar overrides.""" + + @abstractmethod + def run( + self, + params: dict[str, Any], + label_overrides: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Run the model and return result payload for rendering.""" + + @abstractmethod + def default_params(self) -> dict[str, Any]: + """Return the model's default parameters as a raw (unflattened) dict.""" + + @abstractmethod + def build_sections(self, results: dict[str, Any]) -> list[dict[str, Any]]: + """Transform run results into section payloads for UI rendering.""" diff --git a/src/epicc/model/schema.py b/src/epicc/model/schema.py new file mode 100644 index 0000000..ebc90fc --- /dev/null +++ b/src/epicc/model/schema.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class Author(BaseModel): + name: str + email: str | None = None + + +class Metadata(BaseModel): + title: str + description: str + authors: list[Author] = Field(default_factory=list) + introduction: str | None = None + + +class Parameter(BaseModel): + type: Literal["integer", "number", "string", "boolean"] + label: str + description: str | None = None + default: int | float | str | bool + min: int | float | None = None + max: int | float | None = None + unit: str | None = None + references: list[str] = Field(default_factory=list) + + +class Equation(BaseModel): + label: str + unit: str | None = None + output: Literal["integer", "number"] | None = None + compute: str = Field( + ..., + description="Python-evaluable expression referencing parameter/scenario variable names.", + ) + + +class ScenarioVars(BaseModel): + model_config = {"extra": "allow"} # arbitrary vars like n_cases + + +class Scenario(BaseModel): + id: str + label: str + vars: ScenarioVars + + +class TableRow(BaseModel): + label: str + value: str = Field(..., description="Key into the equations dict.") + emphasis: Literal["strong", "em"] | None = None + + +class Table(BaseModel): + scenarios: list[Scenario] = Field(default_factory=list) + rows: list[TableRow] = Field(default_factory=list) + + +class Figure(BaseModel): + title: str + alt_text: str | None = Field(None, alias="alt-text") + py_code: str | None = Field(None, alias="py-code") + + model_config = {"populate_by_name": True} + + +class Model(BaseModel): + metadata: Metadata + parameters: dict[str, Parameter] + equations: dict[str, Equation] + table: Table + figures: list[Figure] = Field(default_factory=list) + + +__all__ = ["Model"] diff --git a/src/epicc/models/measles_outbreak.py b/src/epicc/models/measles_outbreak.py new file mode 100644 index 0000000..e71ac01 --- /dev/null +++ b/src/epicc/models/measles_outbreak.py @@ -0,0 +1,125 @@ +import importlib.resources +from decimal import ROUND_HALF_EVEN, Decimal, getcontext +from typing import Any + +import pandas as pd +from ruamel.yaml import YAML + +from epicc.model.base import BaseSimulationModel + + +class MeaslesOutbreakModel(BaseSimulationModel): + def human_name(self) -> str: + return "Measles Outbreak" + + @property + def model_title(self) -> str: + return "Measles Outbreak Cost Estimation" + + @property + def model_description(self) -> str: + return "Estimates hospitalization, tracing, and productivity costs for measles outbreaks." + + @property + def scenario_labels(self) -> dict[str, str]: + return { + "22_cases": "22 Cases", + "100_cases": "100 Cases", + "803_cases": "803 Cases", + } + + def default_params(self) -> dict[str, Any]: + with ( + importlib.resources.files("epicc.models") + .joinpath("measles_outbreak.yaml") + .open("rb") as f + ): + return dict(YAML().load(f)) + + def run( + self, + params: dict[str, Any], + label_overrides: dict[str, str] | None = None, + ) -> dict[str, Any]: + getcontext().prec = 28 + one = Decimal("1") + cent = Decimal("0.01") + + if label_overrides is None: + label_overrides = {} + + lbl_22 = label_overrides.get("22_cases", self.scenario_labels["22_cases"]) + lbl_100 = label_overrides.get("100_cases", self.scenario_labels["100_cases"]) + lbl_803 = label_overrides.get("803_cases", self.scenario_labels["803_cases"]) + + def q2(x: Decimal) -> Decimal: + if abs(x) > 10: + return x.quantize(one, rounding=ROUND_HALF_EVEN) + return x.quantize(cent, rounding=ROUND_HALF_EVEN) + + def getp(default: float | int, *names: str) -> Decimal: + for n in names: + if n in params and params[n] != "": + try: + return Decimal(str(params[n])) + except Exception: + pass + return Decimal(str(default)) + + cost_hosp = getp(0, "Cost of measles hospitalization") + prop_hosp = getp(0, "Proportion of cases hospitalized") + missed_ratio = getp( + 1.0, + "Proportion of quarantine days that would be a missed day of work", + ) + wage_worker = getp( + 0, + "Hourly wage of worker (hourly_wage_worker)", + "Hourly wage for worker", + ) + wage_tracer = getp(0, "Hourly wage for contract tracer") + hrs_tracing = getp(0, "Hours of contact tracing per contact") + contacts = getp(0, "Number of contacts per case") + vacc_rate = getp(0, "Vaccination rate in community") + quarantine = int(getp(21, "Length of quarantine (days)")) + + hosp_22 = q2(22 * prop_hosp * cost_hosp) + hosp_100 = q2(100 * prop_hosp * cost_hosp) + hosp_803 = q2(803 * prop_hosp * cost_hosp) + + lost_22 = q2( + 22 * contacts * (1 - vacc_rate) * quarantine * missed_ratio * wage_worker + ) + lost_100 = q2( + 100 * contacts * (1 - vacc_rate) * quarantine * missed_ratio * wage_worker + ) + lost_803 = q2( + 803 * contacts * (1 - vacc_rate) * quarantine * missed_ratio * wage_worker + ) + + trace_22 = q2(22 * contacts * hrs_tracing * wage_tracer) + trace_100 = q2(100 * contacts * hrs_tracing * wage_tracer) + trace_803 = q2(803 * contacts * hrs_tracing * wage_tracer) + + total_22 = q2(hosp_22 + lost_22 + trace_22) + total_100 = q2(hosp_100 + lost_100 + trace_100) + total_803 = q2(hosp_803 + lost_803 + trace_803) + + df_costs = pd.DataFrame( + { + "Cost Type": [ + "Hospitalization", + "Lost productivity", + "Contact tracing", + "TOTAL", + ], + lbl_22: [hosp_22, lost_22, trace_22, total_22], + lbl_100: [hosp_100, lost_100, trace_100, total_100], + lbl_803: [hosp_803, lost_803, trace_803, total_803], + } + ) + + return {"df_costs": df_costs} + + def build_sections(self, results: dict[str, Any]) -> list[dict[str, Any]]: + return [{"title": "Measles Outbreak Costs", "content": [results["df_costs"]]}] diff --git a/models/measles_outbreak.yaml b/src/epicc/models/measles_outbreak.yaml similarity index 100% rename from models/measles_outbreak.yaml rename to src/epicc/models/measles_outbreak.yaml diff --git a/src/epicc/models/tb_isolation.py b/src/epicc/models/tb_isolation.py new file mode 100644 index 0000000..3a8f86b --- /dev/null +++ b/src/epicc/models/tb_isolation.py @@ -0,0 +1,215 @@ +import importlib.resources +from decimal import ROUND_HALF_EVEN, Decimal, getcontext +from typing import Any + +import pandas as pd +from ruamel.yaml import YAML + +from epicc.model.base import BaseSimulationModel + + +class TBIsolationModel(BaseSimulationModel): + def human_name(self) -> str: + return "TB Isolation" + + @property + def model_title(self) -> str: + return "TB Isolation Cost Calculator" + + @property + def model_description(self) -> str: + return "Estimates hospitalization, tracing, and productivity costs for TB isolation scenarios." + + @property + def scenario_labels(self) -> dict[str, str]: + return { + "14_day": "14-day Isolation", + "5_day": "5-day Isolation", + } + + def default_params(self) -> dict[str, Any]: + with ( + importlib.resources.files("epicc.models") + .joinpath("tb_isolation.yaml") + .open("rb") as f + ): + return dict(YAML().load(f)) + + def run( + self, + params: dict[str, Any], + label_overrides: dict[str, str] | None = None, + ) -> dict[str, Any]: + getcontext().prec = 28 + one = Decimal("1") + cent = Decimal("0.01") + + if label_overrides is None: + label_overrides = {} + + lbl_14 = label_overrides.get("14_day", self.scenario_labels["14_day"]) + lbl_5 = label_overrides.get("5_day", self.scenario_labels["5_day"]) + + def q2(x: Decimal) -> Decimal: + if abs(x) > 10: + return x.quantize(one, rounding=ROUND_HALF_EVEN) + return x.quantize(cent, rounding=ROUND_HALF_EVEN) + + def q2n(x: Decimal) -> Decimal: + return x.quantize(cent, rounding=ROUND_HALF_EVEN) + + def getp(default: float | int, *names: str) -> Decimal: + normalized_params = {k.lower(): v for k, v in params.items()} + + for n in names: + n_lower = n.lower() + + if n_lower in normalized_params and normalized_params[n_lower] != "": + return Decimal(str(normalized_params[n_lower])) + + for key, val in normalized_params.items(): + if f"({n_lower})" in key and val != "": + return Decimal(str(val)) + return Decimal(str(default)) + + contacts_per_case = getp(0, "Number of contacts for each released TB case") + prob_latent_if_14day = getp( + 0, + "Probability that contact develops latent TB if 14-day isolation", + ) + infectiousness_multiplier = getp( + 1.5, + "Multiplier for infectiousness with 5-day vs. 14-day isolation", + ) + workday_ratio = getp(0.714, "Ratio of workdays to total days") + + prob_latent_to_active_2yr = getp( + 0, "prob_latent_to_active_2yr", "First 2 years" + ) + prob_latent_to_active_lifetime = getp( + 0, + "prob_latent_to_active_lifetime", + "Rest of lifetime", + ) + + cost_latent = getp(0, "cost_latent", "Cost of latent TB infection") + cost_active = getp(0, "cost_active", "Cost of active TB infection") + + isolation_type = int( + getp(3, "isolation_type", "Isolation type (1=hospital,2=motel,3=home)") + ) + daily_hosp_cost = getp(0, "isolation_cost", "Daily isolation cost") + direct_med_cost_day = getp(0, "Direct medical cost of a day of isolation") + + cost_motel_room = getp(0, "Cost of motel room per day") + hourly_wage_nurse = getp(0, "Hourly wage for nurse") + time_nurse_checkin = getp( + 0, "Time for nurse to check in w/ pt in motel or home (hrs)" + ) + hourly_wage_worker = getp(0, "Hourly wage for worker") + + discount_rate = getp(0, "discount_rate", "Discount rate") + remaining_years = int(getp(40, "remaining_years", "Remaining years of life")) + + if isolation_type == 1: + daily_cost = ( + direct_med_cost_day if direct_med_cost_day > 0 else daily_hosp_cost + ) + elif isolation_type == 2: + daily_cost = cost_motel_room + (hourly_wage_nurse * time_nurse_checkin) + else: + daily_cost = hourly_wage_nurse * time_nurse_checkin + + latent_14_day = q2n(contacts_per_case * prob_latent_if_14day) + latent_5_day = q2n(latent_14_day * infectiousness_multiplier) + + active_14_day = q2n( + latent_14_day * prob_latent_to_active_2yr + + latent_14_day + * (one - prob_latent_to_active_2yr) + * prob_latent_to_active_lifetime + ) + active_5_day = q2n( + latent_5_day * prob_latent_to_active_2yr + + latent_5_day + * (one - prob_latent_to_active_2yr) + * prob_latent_to_active_lifetime + ) + + df_infections = pd.DataFrame( + { + "Outcome": ["Latent TB infections", "Active TB disease"], + lbl_14: [latent_14_day, active_14_day], + lbl_5: [latent_5_day, active_5_day], + } + ) + + direct_cost_14_day = q2(daily_cost * Decimal(14)) + direct_cost_5_day = q2(daily_cost * Decimal(5)) + + productivity_loss_14_day = q2( + Decimal(14) * workday_ratio * hourly_wage_worker * Decimal(8) + ) + productivity_loss_5_day = q2( + Decimal(5) * workday_ratio * hourly_wage_worker * Decimal(8) + ) + + base = one + discount_rate + discounted_2yr = (prob_latent_to_active_2yr / Decimal(2)) / (base**1) + ( + (prob_latent_to_active_2yr / Decimal(2)) / (base**2) + ) + discounted_lifetime = sum( + (prob_latent_to_active_lifetime / Decimal(remaining_years)) / (base**y) + for y in range(3, remaining_years + 1) + ) + + sec_cost_per_latent = q2( + cost_latent + cost_active * (discounted_2yr + discounted_lifetime) + ) + + secondary_cost_14_day = q2(latent_14_day * sec_cost_per_latent) + secondary_cost_5_day = q2(latent_5_day * sec_cost_per_latent) + + total_14_day = q2( + direct_cost_14_day + productivity_loss_14_day + secondary_cost_14_day + ) + total_5_day = q2( + direct_cost_5_day + productivity_loss_5_day + secondary_cost_5_day + ) + + df_costs = pd.DataFrame( + { + "Cost Type": [ + "Direct cost of isolation", + "Lost productivity for index case", + "Cost of secondary infections", + "Total cost", + ], + lbl_14: [ + direct_cost_14_day, + productivity_loss_14_day, + secondary_cost_14_day, + total_14_day, + ], + lbl_5: [ + direct_cost_5_day, + productivity_loss_5_day, + secondary_cost_5_day, + total_5_day, + ], + } + ) + + return { + "df_infections": df_infections, + "df_costs": df_costs, + } + + def build_sections(self, results: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "title": "Number of Secondary Infections", + "content": [results["df_infections"]], + }, + {"title": "Costs", "content": [results["df_costs"]]}, + ] diff --git a/models/tb_isolation.yaml b/src/epicc/models/tb_isolation.yaml similarity index 100% rename from models/tb_isolation.yaml rename to src/epicc/models/tb_isolation.yaml diff --git a/utils/excel_model_runner.py b/src/epicc/utils/excel_model_runner.py similarity index 84% rename from utils/excel_model_runner.py rename to src/epicc/utils/excel_model_runner.py index 15d7aaf..060e4b3 100644 --- a/utils/excel_model_runner.py +++ b/src/epicc/utils/excel_model_runner.py @@ -1,15 +1,23 @@ +""" +DEPRECATED, remove as per #26, "Remove XLSX Model Specification Code" + +https://github.com/EpiForeSITE/epiworldPythonStreamlit/issues/26 +""" + from __future__ import annotations +import ast import os import re -import ast from dataclasses import dataclass -from typing import Any, Dict, Optional, List, Tuple +from typing import Any import pandas as pd from openpyxl import load_workbook from openpyxl.worksheet.worksheet import Worksheet +from epicc.utils.parameter_loader import flatten_dict + _CELL_REF_RE = re.compile(r"(\$?[A-Z]{1,3}\$?\d+)") _RANGE_REF_RE = re.compile(r"(\$?[A-Z]{1,3}\$?\d+)\s*:\s*(\$?[A-Z]{1,3}\$?\d+)") @@ -88,7 +96,7 @@ def _index_to_col(idx: int) -> str: return result -def _scenario_columns_before_F(ws: Worksheet) -> List[str]: +def _scenario_columns_before_F(ws: Worksheet) -> list[str]: f_idx = _col_to_index("F") return [_index_to_col(i) for i in range(_col_to_index("B"), f_idx)] @@ -173,10 +181,10 @@ def __rtruediv__(self, other): return self._binary_op(other, lambda a, b: b / a if a != 0 else 0.0) def __pow__(self, other): - return self._binary_op(other, lambda a, b: a ** b) + return self._binary_op(other, lambda a, b: a**b) def __rpow__(self, other): - return self._binary_op(other, lambda a, b: b ** a) + return self._binary_op(other, lambda a, b: b**a) def _binary_op(self, other, op): other_val = other.value if isinstance(other, ExcelValue) else other @@ -185,11 +193,17 @@ def _binary_op(self, other, op): other_is_list = isinstance(other_val, list) if self_is_list and other_is_list: - return ExcelValue([op(_to_float(a), _to_float(b)) for a, b in zip(self.value, other_val)]) + return ExcelValue( + [op(_to_float(a), _to_float(b)) for a, b in zip(self.value, other_val)] + ) elif self_is_list: - return ExcelValue([op(_to_float(a), _to_float(other_val)) for a in self.value]) + return ExcelValue( + [op(_to_float(a), _to_float(other_val)) for a in self.value] + ) elif other_is_list: - return ExcelValue([op(_to_float(self.value), _to_float(b)) for b in other_val]) + return ExcelValue( + [op(_to_float(self.value), _to_float(b)) for b in other_val] + ) else: return ExcelValue(op(_to_float(self.value), _to_float(other_val))) @@ -197,7 +211,7 @@ def _binary_op(self, other, op): @dataclass class FormulaEngine: ws: Worksheet - cache: Dict[str, Any] + cache: dict[str, Any] def __init__(self, ws: Worksheet): self.ws = ws @@ -226,9 +240,10 @@ def cell_value(self, ref: str) -> float: print(f"ERROR evaluating formula in {ref}: {v}") print(f"ERROR message: {e}") val = 0.0 - elif hasattr(v, "text") and isinstance(v.text, str): + + elif hasattr(v, "text") and isinstance(v.text, str): # type: ignore # ArrayFormula support - formula_text = v.text + formula_text = v.text # type: ignore if formula_text and formula_text.startswith("="): try: out = self.eval_formula(formula_text) @@ -279,12 +294,7 @@ def cell_repl(m: re.Match) -> str: expr = expr.replace("<>", "!=") expr = re.sub(r"(?=!])=(?![<>=])", "==", expr) - - expr = re.sub( - r'(".*?")\s*\+\s*(VAL\(\"[A-Z]+\d+\"\))', - r'\1 + STR(\2)', - expr - ) + expr = re.sub(r'(".*?")\s*\+\s*(VAL\(\"[A-Z]+\d+\"\))', r"\1 + STR(\2)", expr) expr = expr.replace("^", "**") @@ -410,7 +420,7 @@ def SUMPRODUCT(*args): if len(unwrapped) == 1 and isinstance(unwrapped[0], list): return float(sum([_to_float(v) for v in unwrapped[0]])) - lists: List[List[float]] = [] + lists: list[list[float]] = [] for a in unwrapped: if isinstance(a, list): lists.append([_to_float(v) for v in a]) @@ -419,7 +429,7 @@ def SUMPRODUCT(*args): max_len = max(len(L) for L in lists) - norm: List[List[float]] = [] + norm: list[list[float]] = [] for L in lists: if len(L) == 1 and max_len > 1: norm.append(L * max_len) @@ -462,22 +472,10 @@ def VAL(r): return [float(_to_float(v)) for v in out] return float(_to_float(out)) -def flatten_dict(d: dict, level: int = 0) -> Dict[str, Any]: - flat: Dict[str, Any] = {} - for key, value in d.items(): - indented_key = ("\t" * level) + str(key) - - if isinstance(value, dict): - flat[indented_key] = None - flat.update(flatten_dict(value, level + 1)) - else: - flat[indented_key] = value - return flat - -def excel_rows_to_nested_dict(rows: List[Tuple[int, str, Any]]) -> dict: - root: Dict[str, Any] = {} - stack: List[Tuple[int, Dict[str, Any]]] = [(0, root)] +def excel_rows_to_nested_dict(rows: list[tuple[int, str, Any]]) -> dict: + root: dict[str, Any] = {} + stack: list[tuple[int, dict[str, Any]]] = [(0, root)] for level, name, value in rows: while len(stack) > 1 and stack[-1][0] >= level + 1: @@ -490,15 +488,16 @@ def excel_rows_to_nested_dict(rows: List[Tuple[int, str, Any]]) -> dict: parent[name] = value return root + def apply_params_to_workbook( - ws: Worksheet, - params: Dict[str, Any], - name_col: str = "F", - value_col: str = "G", - start_row: int = 3, - overwrite_formulas: bool = True, + ws: Worksheet, + params: dict[str, Any], + name_col: str = "F", + value_col: str = "G", + start_row: int = 3, + overwrite_formulas: bool = True, ): - lookup: Dict[str, Any] = {} + lookup: dict[str, Any] = {} for k, v in params.items(): norm = str(k).replace("\t", "").strip() lookup[norm] = v @@ -514,27 +513,34 @@ def apply_params_to_workbook( if not name: continue - if (isinstance(val_cell.value, str) and str(val_cell.value).startswith("=")) and not overwrite_formulas: + if ( + isinstance(val_cell.value, str) and str(val_cell.value).startswith("=") + ) and not overwrite_formulas: continue - if name in lookup and lookup[name] is not None and str(lookup[name]).strip() != "": + if ( + name in lookup + and lookup[name] is not None + and str(lookup[name]).strip() != "" + ): ws[f"{value_col}{r}"].value = lookup[name] def load_excel_params_defaults_with_computed( - excel_file, - sheet_name: Optional[str] = None, - name_col: str = "F", - value_col: str = "G", - start_row: int = 3 -) -> Tuple[Dict[str, Any], Dict[str, Any]]: + excel_file, + sheet_name: str | None = None, + name_col: str = "F", + value_col: str = "G", + start_row: int = 3, +) -> tuple[dict[str, Any], dict[str, Any]]: wb = load_workbook(excel_file, data_only=False) ws = wb[sheet_name] if sheet_name else wb.active + assert ws engine = FormulaEngine(ws) - editable_rows: List[Tuple[int, str, Any]] = [] - computed_defaults: Dict[str, Any] = {} + editable_rows: list[tuple[int, str, Any]] = [] + computed_defaults: dict[str, Any] = {} for r in range(start_row, ws.max_row + 1): name_cell = ws[f"{name_col}{r}"] @@ -566,7 +572,8 @@ def load_excel_params_defaults_with_computed( return editable_defaults, computed_defaults -def _find_outcome_header_row(ws: Worksheet) -> Optional[int]: + +def _find_outcome_header_row(ws: Worksheet) -> int | None: """ Looks for the start of the result table by finding the first non-empty cell in Column A, starting from Row 2. @@ -578,8 +585,10 @@ def _find_outcome_header_row(ws: Worksheet) -> Optional[int]: return None -def _iter_outcome_rows(ws: Worksheet, header_row: int, scenario_cols: List[str]) -> List[int]: - rows: List[int] = [] +def _iter_outcome_rows( + ws: Worksheet, header_row: int, scenario_cols: list[str] +) -> list[int]: + rows: list[int] = [] blank_streak = 0 r = header_row + 1 @@ -609,9 +618,10 @@ def _iter_outcome_rows(ws: Worksheet, header_row: int, scenario_cols: List[str]) return rows -def _detect_active_scenario_columns(ws: Worksheet, header_row: int, scenario_cols: List[str], rows: List[int]) -> List[ - str]: - active: List[str] = [] +def _detect_active_scenario_columns( + ws: Worksheet, header_row: int, scenario_cols: list[str], rows: list[int] +) -> list[str]: + active: list[str] = [] for col in scenario_cols: header = ws[f"{col}{header_row}"].value has_header = header is not None and str(header).strip() != "" @@ -626,13 +636,14 @@ def _detect_active_scenario_columns(ws: Worksheet, header_row: int, scenario_col return active -def get_scenario_headers(excel_file, sheet_name: Optional[str] = None) -> Dict[str, str]: +def get_scenario_headers(excel_file, sheet_name: str | None = None) -> dict[str, str]: """ Reads the header row (found by _find_outcome_header_row) and extracts current values for columns B, C, D, E. """ wb = load_workbook(excel_file, data_only=True) ws = wb[sheet_name] if sheet_name else wb.active + assert ws header_row = _find_outcome_header_row(ws) @@ -651,14 +662,16 @@ def get_scenario_headers(excel_file, sheet_name: Optional[str] = None) -> Dict[s def build_sections_from_excel_outcomes( - ws: Worksheet, - engine: FormulaEngine, - header_row: int, - label_overrides: Dict[str, str] = None -) -> List[dict]: + ws: Worksheet, + engine: FormulaEngine, + header_row: int, + label_overrides: dict[str, str] | None = None, +) -> list[dict]: scenario_cols_all = _scenario_columns_before_F(ws) outcome_rows = _iter_outcome_rows(ws, header_row, scenario_cols_all) - scenario_cols = _detect_active_scenario_columns(ws, header_row, scenario_cols_all, outcome_rows) + scenario_cols = _detect_active_scenario_columns( + ws, header_row, scenario_cols_all, outcome_rows + ) if label_overrides is None: label_overrides = {} @@ -672,11 +685,13 @@ def build_sections_from_excel_outcomes( col_titles[col] = label_overrides[col].strip() else: val = ws[f"{col}{header_row}"].value - col_titles[col] = str(val).strip() if val is not None and str(val).strip() != "" else col + col_titles[col] = ( + str(val).strip() if val is not None and str(val).strip() != "" else col + ) - sections: List[dict] = [] - current_title: Optional[str] = None - current_records: List[dict] = [] + sections: list[dict] = [] + current_title: str | None = None + current_records: list[dict] = [] for r in outcome_rows: a_val = ws[f"A{r}"].value @@ -693,7 +708,9 @@ def build_sections_from_excel_outcomes( if all_blank: if current_title and current_records: - sections.append({"title": current_title, "content": [pd.DataFrame(current_records)]}) + sections.append( + {"title": current_title, "content": [pd.DataFrame(current_records)]} + ) current_records = [] current_title = a_text continue @@ -705,13 +722,16 @@ def build_sections_from_excel_outcomes( current_records.append(rec) if current_title and current_records: - sections.append({"title": current_title, "content": [pd.DataFrame(current_records)]}) + sections.append( + {"title": current_title, "content": [pd.DataFrame(current_records)]} + ) if not sections and current_records: sections = [{"title": "Results", "content": [pd.DataFrame(current_records)]}] return sections + def _is_numberish(v: Any) -> bool: if v is None or v == "": return False @@ -736,14 +756,16 @@ def _cell_display(ws: Worksheet, engine: FormulaEngine, cell_ref: str) -> Any: return str(v).strip() -def build_sections_from_generic_tables(ws: Worksheet, engine: FormulaEngine) -> List[dict]: +def build_sections_from_generic_tables( + ws: Worksheet, engine: FormulaEngine +) -> list[dict]: max_scan_rows = min(ws.max_row, 250) max_scan_cols = _col_to_index("E") def cell_is_blank(v: Any) -> bool: return v is None or str(v).strip() == "" - tables: List[Tuple[int, int, int, int]] = [] + tables: list[tuple[int, int, int, int]] = [] for top in range(1, max_scan_rows + 1): for left_idx in range(1, max_scan_cols - 1): @@ -789,10 +811,20 @@ def cell_is_blank(v: Any) -> bool: tables.append((top, left_idx, bottom, right)) if not tables: - return [{ - "title": "Outputs", - "content": [pd.DataFrame([{"Error": "No Outcome found and no output table detected in A–E."}])] - }] + return [ + { + "title": "Outputs", + "content": [ + pd.DataFrame( + [ + { + "Error": "No Outcome found and no output table detected in A–E." + } + ] + ) + ], + } + ] tables.sort(key=lambda t: (t[2] - t[0] + 1) * (t[3] - t[1] + 1), reverse=True) top, left_idx, bottom, right_idx = tables[0] @@ -801,7 +833,11 @@ def cell_is_blank(v: Any) -> bool: headers = [] for c in range(left_idx, right_idx + 1): v = ws[f"{_index_to_col(c)}{header_row}"].value - headers.append(str(v).strip() if v is not None and str(v).strip() != "" else _index_to_col(c)) + headers.append( + str(v).strip() + if v is not None and str(v).strip() != "" + else _index_to_col(c) + ) records = [] for r in range(header_row + 1, bottom + 1): @@ -833,15 +869,17 @@ def col_is_effectively_empty(series: pd.Series) -> bool: # Main + def run_excel_driven_model( - excel_file, - filename: str, - params: Dict[str, Any], - sheet_name: Optional[str] = None, - label_overrides: Dict[str, str] = None -) -> Dict[str, Any]: + excel_file, + filename: str, + params: dict[str, Any], + sheet_name: str | None = None, + label_overrides: dict[str, str] | None = None, +) -> dict[str, Any]: wb = load_workbook(excel_file, data_only=False) ws = wb[sheet_name] if sheet_name else wb.active + assert ws apply_params_to_workbook(ws, params, start_row=3, overwrite_formulas=True) @@ -849,7 +887,9 @@ def run_excel_driven_model( header_row = _find_outcome_header_row(ws) if header_row is not None: - sections = build_sections_from_excel_outcomes(ws, engine, header_row, label_overrides) + sections = build_sections_from_excel_outcomes( + ws, engine, header_row, label_overrides + ) else: sections = build_sections_from_generic_tables(ws, engine) diff --git a/src/epicc/utils/model_loader.py b/src/epicc/utils/model_loader.py new file mode 100644 index 0000000..1bb09f8 --- /dev/null +++ b/src/epicc/utils/model_loader.py @@ -0,0 +1,7 @@ +from epicc.model.base import BaseSimulationModel +from epicc.models.measles_outbreak import MeaslesOutbreakModel +from epicc.models.tb_isolation import TBIsolationModel + + +def get_built_in_models() -> list[BaseSimulationModel]: + return [TBIsolationModel(), MeaslesOutbreakModel()] diff --git a/src/epicc/utils/parameter_loader.py b/src/epicc/utils/parameter_loader.py new file mode 100644 index 0000000..d1f64c3 --- /dev/null +++ b/src/epicc/utils/parameter_loader.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path +from typing import IO, Any + +from pydantic import RootModel + +from epicc.formats import read_from_format +from epicc.model.base import BaseSimulationModel + + +class OpaqueParameters(RootModel[dict[str, Any]]): + """Minimal typed envelope for opaque parameter payloads.""" + + +def flatten_dict(data: dict[str, Any], level: int = 0) -> dict[str, Any]: + """Flatten nested dictionaries for the sidebar renderer using tab-indented labels.""" + + flat: dict[str, Any] = {} + for key, value in data.items(): + indented_key = ("\t" * level) + str(key) + if isinstance(value, dict): + flat[indented_key] = None + flat.update(flatten_dict(value, level + 1)) + continue + + flat[indented_key] = value + + return flat + + +def _load_typed_params(path: Path, data: IO[bytes]) -> dict[str, Any]: + typed, _ = read_from_format(path, data, OpaqueParameters) + return typed.root + + +def load_model_params( + model: BaseSimulationModel, + uploaded_params: IO[bytes] | None = None, + uploaded_name: str | None = None, +) -> dict[str, Any]: + """Load model parameters from upload or model defaults.""" + + if uploaded_params is not None: + if not uploaded_name: + raise ValueError("Uploaded parameter files must include a filename.") + uploaded_params.seek(0) + return flatten_dict(_load_typed_params(Path(uploaded_name), uploaded_params)) + + return flatten_dict(model.default_params()) diff --git a/src/epicc/utils/parameter_ui.py b/src/epicc/utils/parameter_ui.py new file mode 100644 index 0000000..c815084 --- /dev/null +++ b/src/epicc/utils/parameter_ui.py @@ -0,0 +1,109 @@ +from typing import Any + +import streamlit as st + + +def _item_level(key: str) -> int: + return len(key) - len(key.lstrip("\t")) + + +def _set_param_and_widget( + widget_key: str, params_key: str, value: Any, params: dict[str, str] +) -> None: + value_as_str = str(value) + st.session_state[widget_key] = value_as_str + params[params_key] = value_as_str + + +def reset_parameters_to_defaults( + param_dict: dict[str, Any], params: dict[str, str], model_id: str +) -> None: + """Reset session-state widgets and params to defaults from flattened parameter data.""" + + items = list(param_dict.items()) + i = 0 + n = len(items) + + while i < n: + key, value = items[i] + level = _item_level(key) + label = key.strip() + + if value is not None: + _set_param_and_widget(f"{model_id}:{label}", label, value, params) + i += 1 + continue + + j = i + 1 + while j < n: + subkey, subval = items[j] + sublevel = _item_level(subkey) + if sublevel <= level: + break + if sublevel == level + 1 and subval is not None: + sublabel = subkey.strip() + _set_param_and_widget( + f"{model_id}:{label}:{sublabel}", + sublabel, + subval, + params, + ) + j += 1 + + i = j + + +def render_parameters_with_indent( + param_dict: dict[str, Any], params: dict[str, str], model_id: str +) -> None: + """Render flattened parameter data as top-level controls and one-level nested expanders.""" + + items = list(param_dict.items()) + i = 0 + n = len(items) + + while i < n: + key, value = items[i] + level = _item_level(key) + label = key.strip() + + if value is not None: + widget_key = f"{model_id}:{label}" + if widget_key in st.session_state: + params[label] = st.sidebar.text_input(label, key=widget_key) + i += 1 + continue + + params[label] = st.sidebar.text_input( + label, + value=str(value), + key=widget_key, + ) + i += 1 + continue + + children: list[tuple[str, Any]] = [] + j = i + 1 + while j < n: + subkey, subval = items[j] + sublevel = _item_level(subkey) + if sublevel <= level: + break + if sublevel == level + 1 and subval is not None: + children.append((subkey.strip(), subval)) + j += 1 + + with st.sidebar.expander(label, expanded=False): + for sublabel, subval in children: + widget_key = f"{model_id}:{label}:{sublabel}" + if widget_key in st.session_state: + params[sublabel] = st.text_input(sublabel, key=widget_key) + continue + + params[sublabel] = st.text_input( + sublabel, + value=str(subval), + key=widget_key, + ) + + i = j diff --git a/src/epicc/utils/section_renderer.py b/src/epicc/utils/section_renderer.py new file mode 100644 index 0000000..94922ea --- /dev/null +++ b/src/epicc/utils/section_renderer.py @@ -0,0 +1,28 @@ +from typing import Any + +import streamlit as st + + +def _render_block(block: Any) -> None: + if hasattr(block, "columns"): + st.table(block) + return + + if isinstance(block, str): + st.markdown(block, unsafe_allow_html=True) + return + + st.write(block) + + +def render_sections(sections: list[dict[str, Any]]) -> None: + for i, section in enumerate(sections): + title = section.get("title", "") + content = section.get("content", []) + + st.markdown(f"## {title}") + for block in content: + _render_block(block) + + if i < len(sections) - 1: + st.markdown('
', unsafe_allow_html=True) diff --git a/styles/sidebar.css b/src/epicc/web/sidebar.css similarity index 66% rename from styles/sidebar.css rename to src/epicc/web/sidebar.css index 5cabe0c..f4e3944 100644 --- a/styles/sidebar.css +++ b/src/epicc/web/sidebar.css @@ -1,3 +1,13 @@ +/* Remove weird sidebar padding at top. */ +[data-testid="stSidebarHeader"] { + display: none; +} + +/* Hide the "Deploy" button. */ +.stAppDeployButton { + display: none; +} + /* Sidebar section headers */ .sidebar-subtitle { background-color: #2a2a2a; @@ -15,3 +25,4 @@ .sidebar-indent { margin-left: 15px; } + diff --git a/tests/epicc/test_formats.py b/tests/epicc/test_formats.py new file mode 100644 index 0000000..5cb1830 --- /dev/null +++ b/tests/epicc/test_formats.py @@ -0,0 +1,37 @@ +"""Tests for epicc.formats (get_format, read_from_format).""" + +from io import BytesIO + +import pytest +from pydantic import BaseModel + +from epicc.formats import ( + XLSXFormat, + YAMLFormat, + get_format, + read_from_format, +) + + +class _Simple(BaseModel): + x: int + y: str + + +def test_get_format_yaml_returns_yaml_format(): + assert isinstance(get_format("params.yaml"), YAMLFormat) + + +def test_get_format_xlsx_returns_xlsx_format(): + assert isinstance(get_format("params.xlsx"), XLSXFormat) + + +def test_get_format_unsupported_raises(): + with pytest.raises(ValueError, match="Unsupported file format"): + get_format("params.csv") + + +def test_read_from_format_yaml(): + model, _ = read_from_format("params.yaml", BytesIO(b"x: 10\ny: hello\n"), _Simple) + assert model.x == 10 + assert model.y == "hello" diff --git a/tests/epicc/test_formats_template.py b/tests/epicc/test_formats_template.py new file mode 100644 index 0000000..07fe6d3 --- /dev/null +++ b/tests/epicc/test_formats_template.py @@ -0,0 +1,97 @@ +"""Integration tests for epicc.formats.template (generate_template).""" + +from io import BytesIO +from typing import Literal + +import openpyxl +from pydantic import BaseModel, Field + +from epicc.formats.template import generate_template +from epicc.formats.xlsx import XLSXFormat +from epicc.formats.yaml import YAMLFormat + +# --------------------------------------------------------------------------- +# Test models +# --------------------------------------------------------------------------- + + +class _Inner(BaseModel): + rate: float + label: str = Field(default="default label", description="A label.") + + +class _Outer(BaseModel): + name: str = Field(description="The name.") + count: int = 5 + inner: _Inner + theme: Literal["light", "dark"] = "light" + note: str | None = None + + +# --------------------------------------------------------------------------- +# YAML +# --------------------------------------------------------------------------- + + +def test_yaml_template_contains_defaults(): + result = generate_template(_Outer, YAMLFormat("template.yaml")) + data, _ = YAMLFormat("template.yaml").read(BytesIO(result)) + + assert data["count"] == 5 + assert data["theme"] == "light" + assert data["note"] is None + + +def test_yaml_template_nested_model(): + result = generate_template(_Outer, YAMLFormat("template.yaml")) + data, _ = YAMLFormat("template.yaml").read(BytesIO(result)) + + assert isinstance(data["inner"], dict) + assert data["inner"]["label"] == "default label" + + +def test_yaml_template_placeholder_for_required_fields(): + result = generate_template(_Outer, YAMLFormat("template.yaml")) + data, _ = YAMLFormat("template.yaml").read(BytesIO(result)) + + assert data["name"] == "" + assert data["inner"]["rate"] == 0.0 + + +# --------------------------------------------------------------------------- +# XLSX +# --------------------------------------------------------------------------- + + +def _read_xlsx_rows(data: bytes) -> dict[str, tuple]: + """Parse an XLSX template into {key: (value, description)}.""" + wb = openpyxl.load_workbook(BytesIO(data)) + assert wb.active + + rows = list(wb.active.iter_rows(values_only=True)) + return {str(r[0]): (r[1], r[2]) for r in rows[1:] if r[0] is not None} + + +def test_xlsx_template_flattens_nested(): + result = generate_template(_Outer, XLSXFormat("template.xlsx")) + rows = _read_xlsx_rows(result) + + assert "inner.rate" in rows + assert "inner.label" in rows + + +def test_xlsx_template_includes_descriptions(): + result = generate_template(_Outer, XLSXFormat("template.xlsx")) + rows = _read_xlsx_rows(result) + + assert rows["name"][1] == "The name." + assert rows["inner.label"][1] == "A label." + + +def test_xlsx_template_values_match_defaults(): + result = generate_template(_Outer, XLSXFormat("template.xlsx")) + rows = _read_xlsx_rows(result) + + assert rows["count"][0] == 5 + assert rows["theme"][0] == "light" + assert rows["inner.label"][0] == "default label" diff --git a/tests/epicc/test_formats_xlsx.py b/tests/epicc/test_formats_xlsx.py new file mode 100644 index 0000000..feda09e --- /dev/null +++ b/tests/epicc/test_formats_xlsx.py @@ -0,0 +1,95 @@ +"""Tests for epicc.formats.xlsx (XLSXFormat).""" + +from io import BytesIO + +import openpyxl +import pytest + +from epicc.formats.xlsx import XLSXFormat + + +def _make_xlsx(rows: list[list]) -> BytesIO: + wb = openpyxl.Workbook() + ws = wb.active + assert ws + + for row in rows: + ws.append(row) + + buf = BytesIO() + wb.save(buf) + buf.seek(0) + + return buf + + +def _fmt() -> XLSXFormat: + return XLSXFormat("test.xlsx") + + +def test_read_dot_notation_creates_nested(): + buf = _make_xlsx( + [ + ["param", "value"], + ["costs.latent", 300], + ["costs.active", 500], + ] + ) + data, _ = _fmt().read(buf) + assert data == {"costs": {"latent": 300, "active": 500}} + + +def test_read_skips_empty_rows(): + buf = _make_xlsx([["param", "value"], ["a", 1], [None, None], ["b", 2]]) + data, _ = _fmt().read(buf) + assert data == {"a": 1, "b": 2} + + +def test_read_too_few_columns_raises(): + wb = openpyxl.Workbook() + ws = wb.active + assert ws + + ws.append(["only_one_col"]) + ws.append(["value"]) + buf = BytesIO() + wb.save(buf) + buf.seek(0) + + with pytest.raises(ValueError, match="at least 2 columns"): + _fmt().read(buf) + + +def test_write_with_template_round_trips(): + buf = _make_xlsx([["param", "value"], ["x", 1], ["y", 2]]) + _, wb_template = _fmt().read(buf) + + result_bytes = _fmt().write({"x": 99}, wb_template) + + wb_out = openpyxl.load_workbook(BytesIO(result_bytes)) + ws_out = wb_out.active + assert ws_out + rows = list(ws_out.iter_rows(values_only=True)) + row_dict = {r[0]: r[1] for r in rows[1:] if r[0] is not None} + assert row_dict["x"] == 99 + + +def test_write_with_template_supports_nested_data(): + buf = _make_xlsx( + [ + ["param", "value"], + ["costs.latent", 300], + ["costs.active", 500], + ] + ) + _, wb_template = _fmt().read(buf) + + result_bytes = _fmt().write({"costs": {"latent": 1}}, wb_template) + + wb_out = openpyxl.load_workbook(BytesIO(result_bytes)) + ws_out = wb_out.active + assert ws_out + rows = list(ws_out.iter_rows(values_only=True)) + row_dict = {r[0]: r[1] for r in rows[1:] if r[0] is not None} + assert row_dict["costs.latent"] == 1 + assert row_dict["costs.active"] == 500 diff --git a/tests/epicc/test_formats_yaml.py b/tests/epicc/test_formats_yaml.py new file mode 100644 index 0000000..36c5a03 --- /dev/null +++ b/tests/epicc/test_formats_yaml.py @@ -0,0 +1,60 @@ +"""Tests for epicc.formats.yaml (YAMLFormat).""" + +from io import BytesIO + +import pytest + +from epicc.formats.yaml import YAMLFormat + + +def _fmt() -> YAMLFormat: + return YAMLFormat("test.yaml") + + +def _stream(text: str) -> BytesIO: + return BytesIO(text.encode()) + + +def test_read_simple_mapping(): + data, _ = _fmt().read(_stream("a: 1\nb: hello\n")) + assert data == {"a": 1, "b": "hello"} + + +def test_read_nested_mapping(): + data, _ = _fmt().read(_stream("costs:\n latent: 300\n active: 500\n")) + assert data == {"costs": {"latent": 300, "active": 500}} + + +def test_read_non_mapping_raises(): + with pytest.raises(ValueError, match="Expected a YAML mapping"): + _fmt().read(_stream("- a\n- b\n")) + + +def test_read_invalid_yaml_raises(): + with pytest.raises(ValueError, match="Failed to parse YAML"): + _fmt().read(_stream("key: [unclosed")) + + +def test_write_round_trips(): + original = {"a": 1, "b": {"c": 2}} + result = _fmt().write(original) + data, _ = _fmt().read(BytesIO(result)) + assert data == original + + +def test_write_with_template_preserves_comments(): + source = """# top comment +costs: + # latent comment + latent: 300 + active: 500 +""" + data, template = _fmt().read(_stream(source)) + data["costs"]["latent"] = 123 + + result = _fmt().write(data, template) + text = result.decode("utf-8") + + assert "# top comment" in text + assert "# latent comment" in text + assert "latent: 123" in text diff --git a/tests/epicc/test_model_loader.py b/tests/epicc/test_model_loader.py new file mode 100644 index 0000000..f551a41 --- /dev/null +++ b/tests/epicc/test_model_loader.py @@ -0,0 +1,101 @@ +"""Tests for epicc.model (load_model).""" + +import tempfile +import textwrap +from pathlib import Path +from unittest.mock import patch + +import pytest + +from epicc.model import load_model +from epicc.model.schema import Model + +_VALID_MODEL_YAML = textwrap.dedent("""\ + metadata: + title: Test Model + description: A minimal test model. + parameters: + rate: + type: number + label: Rate + default: 0.5 + equations: + total: + label: Total + compute: "rate * 100" + table: + scenarios: [] + rows: [] +""") + + +@pytest.fixture +def valid_model_path(): + with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as f: + f.write(_VALID_MODEL_YAML) + path = Path(f.name) + try: + yield path + finally: + path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# load_model: patched to avoid relying on built-in model file format +# --------------------------------------------------------------------------- + + +def test_load_model_returns_model_and_template(valid_model_path: Path): + with patch("epicc.model.importlib.resources") as mock_res: + mock_res.files.return_value.joinpath.return_value = valid_model_path + model, template = load_model("my_model") + + assert isinstance(model, Model) + assert model.metadata.title == "Test Model" + assert "rate" in model.parameters + assert "total" in model.equations + + +def test_load_model_has_parameters(valid_model_path: Path): + with patch("epicc.model.importlib.resources") as mock_res: + mock_res.files.return_value.joinpath.return_value = valid_model_path + model, _ = load_model("my_model") + + assert len(model.parameters) > 0 + + +def test_load_model_parameter_fields(valid_model_path: Path): + with patch("epicc.model.importlib.resources") as mock_res: + mock_res.files.return_value.joinpath.return_value = valid_model_path + model, _ = load_model("my_model") + + rate = model.parameters["rate"] + assert rate.type == "number" + assert rate.default == 0.5 + + +def test_load_model_equations(valid_model_path: Path): + with patch("epicc.model.importlib.resources") as mock_res: + mock_res.files.return_value.joinpath.return_value = valid_model_path + model, _ = load_model("my_model") + + assert model.equations["total"].compute == "rate * 100" + + +def test_load_model_nonexistent_file_raises(): + with tempfile.NamedTemporaryFile(suffix=".yaml") as f: + missing = Path(f.name + ".gone") + with patch("epicc.model.importlib.resources") as mock_res: + mock_res.files.return_value.joinpath.return_value = missing + with pytest.raises(Exception): + load_model("does_not_exist") + + +def test_load_model_invalid_schema_raises(): + with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as f: + f.write("just: a flat file\nwith: no structure\n") + bad_path = Path(f.name) + with patch("epicc.model.importlib.resources") as mock_res: + mock_res.files.return_value.joinpath.return_value = bad_path + with pytest.raises(ValueError, match="Data validation failed"): + load_model("bad_model") diff --git a/utils/model_loader.py b/utils/model_loader.py deleted file mode 100644 index 1ed89ea..0000000 --- a/utils/model_loader.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -import importlib.util -import sys - -def discover_models(path): - """Return {model_name: file_path} for Python model files.""" - if not os.path.isdir(path): - return {} - models = {} - for f in os.listdir(path): - if f.endswith(".py") and not f.startswith("__"): - name = f[:-3] - models[name] = os.path.join(path, f) - return models - - -def load_model_from_file(filepath): - """ - Loads a Python model file dynamically and returns the module object. - The module must contain `run_model()` and `build_sections()`. - """ - module_name = os.path.splitext(os.path.basename(filepath))[0] - - spec = importlib.util.spec_from_file_location(module_name, filepath) - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - - return module diff --git a/utils/parameter_loader.py b/utils/parameter_loader.py deleted file mode 100644 index 902d0a6..0000000 --- a/utils/parameter_loader.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import yaml -import pandas as pd -from decimal import Decimal -import re - - -def load_params_from_excel(excel_file): - df = pd.read_excel( - excel_file, - usecols="F:H", - header=1 - ) - - df.columns = [str(c).strip().lower() for c in df.columns] - - if not {"parameter", "value"}.issubset(df.columns): - raise ValueError("Expected columns Parameter and Value in F:G") - - params = {} - for _, row in df.iterrows(): - if pd.isna(row["parameter"]) or pd.isna(row["value"]): - continue - - cleaned = re.sub(r"[^0-9.\-]", "", str(row["value"])) - if cleaned == "": - continue - - params[str(row["parameter"]).strip()] = Decimal(cleaned) - - return params - - -def flatten_dict(d, level=0): - flat = {} - for key, value in d.items(): - indented_key = ("\t" * level) + str(key) - - if isinstance(value, dict): - flat[indented_key] = None - flat.update(flatten_dict(value, level + 1)) - else: - flat[indented_key] = value - return flat - - -def load_model_params(model_file_path, uploaded_excel=None): - if uploaded_excel: - return load_params_from_excel(uploaded_excel) - - base = os.path.dirname(model_file_path) - name = os.path.basename(model_file_path).replace(".py", "") - yaml_path = os.path.join(base, f"{name}.yaml") - - if not os.path.exists(yaml_path): - return {} - - with open(yaml_path, "r") as f: - raw = yaml.safe_load(f) or {} - - return flatten_dict(raw) diff --git a/utils/parameter_ui.py b/utils/parameter_ui.py deleted file mode 100644 index 445796f..0000000 --- a/utils/parameter_ui.py +++ /dev/null @@ -1,111 +0,0 @@ -import streamlit as st - - -def reset_parameters_to_defaults(param_dict, params, model_id): - """ - Resets Streamlit session state widgets and params dict to values found in param_dict. - """ - items = list(param_dict.items()) - i = 0 - n = len(items) - - while i < n: - key, value = items[i] - level = len(key) - len(key.lstrip("\t")) - label = key.strip() - - if value is None: - # Handle Children - j = i + 1 - while j < n: - subkey, subval = items[j] - sublevel = len(subkey) - len(subkey.lstrip("\t")) - - if sublevel <= level: - break - - # Reset logic for child - if sublevel == level + 1 and subval is not None: - sublabel = subkey.strip() - widget_key = f"{model_id}:{label}:{sublabel}" - - st.session_state[widget_key] = str(subval) - params[sublabel] = str(subval) - j += 1 - i = j - continue - - # Reset logic for Top-level - widget_key = f"{model_id}:{label}" - st.session_state[widget_key] = str(value) - params[label] = str(value) - i += 1 - - -def render_parameters_with_indent(param_dict, params, model_id): - """ - Render hierarchical parameters with indentation-based expanders. - Checks session_state before setting 'value' to avoid DuplicateWidgetID/API warnings. - """ - items = list(param_dict.items()) - i = 0 - n = len(items) - - while i < n: - key, value = items[i] - - level = len(key) - len(key.lstrip("\t")) - label = key.strip() - - if value is None: - # Collect all children - children = [] - j = i + 1 - while j < n: - subkey, subval = items[j] - sublevel = len(subkey) - len(subkey.lstrip("\t")) - - if sublevel <= level: - break - - if sublevel == level + 1 and subval is not None: - sublabel = subkey.strip() - children.append((sublabel, subval)) - - j += 1 - - # Render the expander with all children inside - with st.sidebar.expander(label, expanded=False): - for sublabel, subval in children: - widget_key = f"{model_id}:{label}:{sublabel}" - if widget_key in st.session_state: - params[sublabel] = st.text_input( - sublabel, - key=widget_key - ) - else: - # First load: Pass the default value - params[sublabel] = st.text_input( - sublabel, - value=str(subval), - key=widget_key - ) - - i = j - continue - - # TOP-LEVEL PARAMS - widget_key = f"{model_id}:{label}" - - if widget_key in st.session_state: - params[label] = st.sidebar.text_input( - label, - key=widget_key - ) - else: - params[label] = st.sidebar.text_input( - label, - value=str(value), - key=widget_key - ) - i += 1 diff --git a/utils/section_renderer.py b/utils/section_renderer.py deleted file mode 100644 index 8e4be6b..0000000 --- a/utils/section_renderer.py +++ /dev/null @@ -1,20 +0,0 @@ -import streamlit as st - -def render_sections(sections): - for i, section in enumerate(sections): - - # Section title - st.markdown(f"## {section['title']}") - - # Render all content blocks inside the section - for block in section["content"]: - if hasattr(block, "columns"): # DataFrame check - st.table(block) - elif isinstance(block, str): # Markdown string - st.markdown(block, unsafe_allow_html=True) - else: - st.write(block) # fallback - - # Divider between sections - if i < len(sections) - 1: - st.markdown('
', unsafe_allow_html=True) diff --git a/uv.lock b/uv.lock index ab4236d..9e2d01c 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410 }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -163,13 +172,19 @@ dependencies = [ { name = "numpy" }, { name = "openpyxl" }, { name = "pandas" }, - { name = "pyyaml" }, + { name = "pydantic" }, + { name = "ruamel-yaml" }, { name = "streamlit" }, ] [package.dev-dependencies] dev = [ + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "pytest" }, { name = "ruff" }, + { name = "types-openpyxl" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -177,12 +192,20 @@ requires-dist = [ { name = "numpy", specifier = ">=2.4.0" }, { name = "openpyxl", specifier = ">=3.1.0" }, { name = "pandas", specifier = ">=2.3.0" }, - { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "ruamel-yaml", specifier = ">=0.19.1" }, { name = "streamlit", specifier = ">=1.55.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.6" }] +dev = [ + { name = "mypy", specifier = ">=1.20.0" }, + { name = "pandas-stubs", specifier = ">=3.0.0.260204" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "ruff", specifier = ">=0.15.6" }, + { name = "types-openpyxl", specifier = ">=3.1.0.20240106" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, +] [[package]] name = "et-xmlfile" @@ -226,6 +249,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -265,6 +297,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529 }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669 }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279 }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288 }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809 }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075 }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486 }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219 }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750 }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624 }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969 }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000 }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495 }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081 }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309 }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804 }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907 }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217 }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622 }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987 }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132 }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195 }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946 }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689 }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875 }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058 }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313 }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994 }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770 }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409 }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473 }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866 }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248 }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629 }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615 }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328 }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722 }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755 }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -328,6 +420,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, ] +[[package]] +name = "mypy" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525 }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469 }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953 }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363 }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005 }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616 }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091 }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344 }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400 }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384 }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378 }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170 }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526 }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456 }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331 }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047 }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585 }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075 }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141 }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925 }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089 }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710 }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013 }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240 }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565 }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874 }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380 }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174 }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + [[package]] name = "narwhals" version = "2.18.0" @@ -466,6 +610,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 }, ] +[[package]] +name = "pandas-stubs" +version = "3.0.0.260204" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/1d/297ff2c7ea50a768a2247621d6451abb2a07c0e9be7ca6d36ebe371658e5/pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3", size = 109383 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540 }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, +] + [[package]] name = "pillow" version = "12.1.1" @@ -535,6 +700,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + [[package]] name = "protobuf" version = "6.33.5" @@ -593,6 +767,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807 }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, +] + [[package]] name = "pydeck" version = "0.9.1" @@ -606,6 +870,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403 }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -627,52 +916,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489 }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -783,6 +1026,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102 }, +] + [[package]] name = "ruff" version = "0.15.6" @@ -890,6 +1142,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016 }, ] +[[package]] +name = "types-openpyxl" +version = "3.1.5.20260322" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/bf/15240de4d68192d2a1f385ef2f6f1ecb29b85d2f3791dd2e2d5b980be30f/types_openpyxl-3.1.5.20260322.tar.gz", hash = "sha256:a61d66ebe1e49697853c6db8e0929e1cda2c96755e71fb676ed7fc48dfdcf697", size = 101325 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/b4/c14191b30bcb266365b124b2bb4e67ecd68425a78ba77ee026f33667daa9/types_openpyxl-3.1.5.20260322-py3-none-any.whl", hash = "sha256:2f515f0b0bbfb04bfb587de34f7522d90b5151a8da7bbbd11ecec4ca40f64238", size = 166102 }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338 }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -899,6 +1169,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "tzdata" version = "2025.3"