diff --git a/.gitignore b/.gitignore index 6bf31c0..bf0c1fa 100644 --- a/.gitignore +++ b/.gitignore @@ -259,4 +259,16 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -embedded-assets/tmpo_c6_8gw.html +.gitignore +rissk_kedro/stats.json +.vscode/mcp.json +configuration/main.yaml +rissk/prompt.md +FEATURES_SCORES.md +rissk_kedro/conf/base/catalog.yml +FEATURES_SCORES_updated.md +prompt.md +markdown_docs/ +requirements_legacy.txt +rissk_kedro/notebooks/ +rissk/utils/testing_utils.py diff --git a/Kedro_vs_Legacy_Changelog.md b/Kedro_vs_Legacy_Changelog.md new file mode 100644 index 0000000..63a44cf --- /dev/null +++ b/Kedro_vs_Legacy_Changelog.md @@ -0,0 +1,717 @@ +# RISSK: Kedro Pipeline vs Legacy — Full Changelog + +This document details every meaningful change between the legacy Ploomber/Python 3.9 pipeline (`rissk/`) and the new Kedro pipeline (`rissk_kedro/`). Changes are separated into **architectural changes**, **intentional behavioural changes**, and **bug fixes**. + +--- + +## Table of Contents + +1. [Environment & Infrastructure](#1-environment--infrastructure) +2. [Architecture Overview](#2-architecture-overview) +3. [Data Ingestion](#3-data-ingestion) +4. [Feature Creation](#4-feature-creation) +5. [Item Scoring](#5-item-scoring) +6. [Unit Scoring](#6-unit-scoring) +7. [Unscored / Missing Features](#7-unscored--missing-features) +8. [Bug Fixes](#8-bug-fixes) + +--- + +## 1. Environment & Infrastructure + +| Aspect | Legacy | Kedro | +|---|---|---| +| Orchestrator | Ploomber (`pipeline.yaml`) | Kedro 1.2.0 | +| Python | 3.9 | 3.13 | +| NumPy | 1.26 | 2.3 | +| Pandas | 2.2 | 2.3 | +| SciPy | 1.13 | 1.17 | +| scikit-learn | 1.6 | 1.8 | +| Architecture | OOP class hierarchy with stateful `self.*` DataFrames | Functional pure-function nodes; data flows via Kedro Data Catalog | +| File I/O | `df.to_csv()`, direct `open()` calls, manual pickle | Kedro Dataset abstractions only | +| Configuration | Hardcoded defaults inside classes | `conf/base/parameters.yml` | +| Type hints | `List`, `Dict` from `typing` | Native `list`, `dict` (Python 3.10+ style) via `from __future__ import annotations` | +| Legacy protection | — | All Kedro rewrites are in new `*_kedro.py` files; legacy files are never modified | + +--- + +## 2. Architecture Overview + +### Legacy — Three-Level Class Hierarchy + +``` +FeatureProcessing (feature_processing.py) — data loading, base dataframes + └── ItemFeatureProcessing (item_processing.py) — item-level feature & score calculations + └── UnitDataProcessing (unit_proccessing.py) — unit/responsible scores + global risk +``` + +State was maintained on the class instance (`self._df_item`, `self._df_unit`, `self._df_resp`). Methods were discovered dynamically via `dir()` and `getattr()` naming conventions (`make_feature_item__*`, `make_score_unit__*`). + +### Kedro — Functional Pipeline Graph + +``` +data_ingestion → feature_creation → rissk_scoring +``` + +Each pipeline is a DAG of pure functions. DataFrames and parameters flow explicitly as node inputs/outputs. No shared mutable state. Intermediate datasets are persisted to the Data Catalog. + +--- + +## 3. Data Ingestion + +### 3.1 Structural Changes + +#### Microdata split into two pipeline steps +- **Legacy:** Microdata is loaded and merged with questionnaire metadata in a single step → `microdata.parquet`. +- **Kedro:** Split into two nodes: + 1. `load_raw_microdata_node` — reads raw files, applies `transform_multi` (requires questionnaire data), melts to long format, sets `qnr`/`qnr_version`, applies `normalize_column_name`. Output: `raw_microdata` (`20_INTERIM/microdata_raw.parquet`). + 2. `merge_microdata_questionnaire_node` — merges `raw_microdata` with questionnaire metadata. Output: `microdata` (`30_PROCESSED/microdata.parquet`). +- **Downstream impact:** All downstream pipelines consume `microdata` from step 2, which is structurally identical to the legacy output. `raw_microdata` is a new intermediate dataset not present in legacy. + +#### Unzip as a separate node +- **Legacy:** Unzip logic was inlined inside data processing functions. +- **Kedro:** `extract_zip` is its own dedicated node, called optionally before any data processing. + +#### Each output dataframe is independently constructed +- **Legacy:** Outputs could share mutable state or be derived from one another. +- **Kedro:** `questionnaire`, `paradata_processed`, and `microdata` are each produced by a dedicated node with no hidden dependencies. + +#### Skip/ignore empty files +- **Legacy:** A missing paradata or questionnaire export for a specific version would raise an exception and abort the run. +- **Kedro:** Each load node checks for the presence of `Paradata` or `Tabular` exports per version (both questionnaire and microdata loading use the `Tabular` key — there is no separate Questionnaire or Microdata export type). Four failure modes are handled: + - **Missing export folder** — logs `WARNING` and skips that version. + - **File exists but has no data rows** (header-only, valid file) — `pd.read_csv` / `read_stata` succeeds and returns a 0-row DataFrame; the node detects the empty result and logs a `WARNING` (`"file may be empty or corrupt"`). For paradata, the 0-row DataFrame preserves column schema and contributes no rows to the combined output. For microdata, `get_microdata_raw` skips header-only files entirely (returning a column-less empty DataFrame), so the node always warns for empty microdata files. + - **File exists but is corrupt or unreadable** — the utility function catches the parse exception and returns an empty DataFrame; `get_paradata_raw` and `read_microdata_file` log `ERROR`, while `read_json_questionnaire` (called by `get_questionnaire`) logs `WARNING`. The node then appends the empty DataFrame and logs a `WARNING`. The empty DataFrame contributes no rows to the final output. + - **Unrecoverable exception in the node itself** — caught by the node's outer `try/except`, logs `ERROR`, and continues to the next version. +- If **all** versions are empty or missing, the combined output for that stage is an empty DataFrame. Downstream nodes detect this and log `ERROR` or `WARNING` before returning early: + - `process_paradata_node` — logs `ERROR` if `paradata_raw` is entirely empty and returns `pd.DataFrame()`. + - `build_removed_answers_node` (`feat_answer_removed`) — logs `WARNING` if `paradata_processed` is empty or missing the `'event'` column and returns `pd.DataFrame()`. + - `create_base_item_table` — logs `ERROR` if `microdata` is entirely empty and returns `pd.DataFrame()`. + - `create_base_unit_table` — logs `ERROR` if `paradata_full` is entirely empty and returns `pd.DataFrame()`. + - `calculate_item_scores` and `calculate_unit_scores` — log `WARNING` if their input feature tables are empty and return empty DataFrames without attempting model fitting. +- The pipeline therefore signals all-empty data clearly through `ERROR`/`WARNING` log messages and completes without a cryptic crash. + +#### VERSION `[]` includes all available versions +- **Legacy:** `VERSION` in `env.yaml` required explicit version numbers. +- **Kedro:** An empty list `VERSION: []` now means "include all versions found in the survey folder". Explicit version lists still work as before. + ```yaml + questionnaires: + - name: "slchbs_saintlucia_2025" + VERSION: [] # all available versions + ``` + +#### Questionnaire no longer loaded inside paradata ingestion +- **Legacy:** Paradata loading was coupled with questionnaire metadata (`answer_sequence`, `n_answers`). +- **Kedro:** Questionnaire is loaded independently. `answer_sequence` and `n_answers` are now correctly populated via the linked category data from the questionnaire JSON — in legacy these columns were always empty because categories in Excel files were never matched to the questionnaire (extension mismatch + dashes in JSON names). + +#### Processed paradata has no `index` column +- **Legacy:** `paradata_processed` carried an `index` column (redundant row numbering). +- **Kedro:** The `index` column is dropped; `reset_index(drop=True)` is used after concatenation. + +#### Unzip limitation +- The `extract_zip` node only works on **direct Survey Solutions downloads**. On macOS, re-zipping a folder introduces an extra directory layer that breaks the hardcoded path resolution. Re-zipped files must be extracted manually. + +### 3.2 Intentional Behavioural Changes + +#### `active_mask` question_scope — filter moved into feature creation +- **Legacy (OOP):** Filled all NaN `question_scope` values with `''` globally via `fillna('')`, then filtered `question_scope in [0, '']`. This correctly let pause events (`Resumed`, `Restarted`) and interview-level events (`InterviewCreated`) through because they have no question scope (NaN → `''` after fill). +- **Legacy (Ploomber):** The `fillna('')` step was missing. The `question_scope in [0, '']` filter dropped all pause and interview-level events. No pause or interview creation events reached `paradata_active` — a silent data loss. +- **Kedro:** The `paradata_active` dataset was removed entirely. Feature creation functions consume `paradata_processed` directly and apply per-event-type filters inline: + - `AnswerSet`, `AnswerRemoved`, `CommentSet` → filter `question_scope == 0` + - `Resumed`, `Restarted`, `InterviewCreated` → no `question_scope` filter (these events have `NaN` scope and are included as-is) + This correctly includes pause events in unit-level time features while excluding supervisor-scoped question events. + +#### `limit_unit` / `filter_by_consent` — now active before scoring +- **Legacy (Ploomber):** The `filter_by_consent` function existed in `feature_processing.py` but was commented out in the Ploomber pipeline (`10_process_paradata`). The `limit_unit` config option had no effect. +- **Kedro:** `filter_by_consent` is an explicit node in the `rissk_scoring` pipeline, running before `calculate_item_scores`. It is configured via `filter_var` in `globals.yml` (per questionnaire). The value must be a single-key dict `{variable_name: answer_value}` — interviews where that variable in paradata does **not** match the answer value are dropped from `item_features`, `unit_features`, and `removed_answers` before scoring. Set to `null` (default) to skip filtering. If `filter_var` is set but matches 0 interviews, the pipeline raises a `ValueError` rather than silently producing empty results. + +#### Microdata `value` column normalization (integer floats) +- **Legacy:** `value` column contained mixed formats — e.g., `"1"` and `"1.0"` for the same logical integer value (artefact of Pandas NaN-forced float conversion). +- **Kedro:** Explicitly normalizes: if a float is integer-equivalent (`x.is_integer() == True`), it is converted to `int` before stringification. `"1.0"` → `"1"`. Also applied inside list-strings: `"[1.0, 2.0]"` → `"[1, 2]"`. +- **Downstream impact:** No current code in the pipeline performs exact string matching against numeric values in the `value` column — all comparisons use non-numeric sentinels (`'##N/A##'`, `-999999999`) or type-based checks (`pd.isnull`, `isinstance`). This note is a caution for future extensions: any new code performing exact string matching against numeric values (e.g., `val == "1.0"`) should use type-safe comparisons instead. + +### 3.3 Utility Function Changes (`import_utils_kedro.py`) + +#### `extract_zip` — complete rewrite +- **Legacy:** Read the entire zip into memory (`BytesIO`) before extracting — inefficient and risky for large datasets. +- **Kedro:** Streams extraction directly from disk. Adds **directory traversal security checks** (prevents zip-slip attacks). + +#### `get_survey_info` +- **Legacy:** Relied on implicit list appends and looser dictionary construction. +- **Kedro:** Uses `setdefault` for cleaner dictionary building. Added explicit `try-except` around filename parsing so one malformed file does not crash the whole pipeline. + +#### `get_questionnaire` / `read_json_questionnaire` +- **Legacy:** Used `os.path.join` and bare `open()`. +- **Kedro:** Fully converted to `pathlib.Path`. Added explicit UTF-8 encoding on all JSON reads (required for cross-platform Python 3.13 compatibility). + +#### `get_paradata` / `read_paradata` +- **Legacy:** `read_paradata` uses `os.path.join` and bare `open()` with no encoding hint; `pd.read_csv` has no `low_memory` flag. Parameter splitting uses an unguarded two-step `str.split` / `str.rsplit` on the `parameters` column — if the column is absent or a row has no `||` delimiter the assignment raises. +- **Kedro:** `read_paradata` converts to `pathlib.Path` and adds `encoding='utf-8'` (matching the questionnaire change) and `low_memory=False` to suppress `DtypeWarning` on mixed-type data. The parameter splitting in `get_paradata_raw` still uses `str.split` / `str.rsplit` but wraps each step with a `.shape[1] == 2` guard and a `.notna().any()` check before the answer split, so malformed or missing parameter values no longer raise. + +#### `get_microdata` +- **Legacy:** `drop_list` and `is_valid` are both local nested definitions inside `get_microdata`. `is_valid` accepts **any non-empty list unconditionally** (`isinstance(value, list): return True`) and rejects only empty strings and NaN scalars. Filtering uses `.apply(is_valid)`. +- **Kedro:** `get_microdata` is split into `get_microdata_raw` (transform + filter + stringify) and `merge_microdata_questionnaire` (questionnaire metadata join). Filtering still uses `.apply(is_valid_fast)` — not vectorized. `is_valid_fast` is more defensive: handles `None`, empty lists/tuples/arrays, and uses a try/except around `pd.isna` to avoid `TypeError: boolean value of NA is ambiguous` on Pandas 2.x nullable types. Key behavioural difference: `is_valid_fast` rejects lists where every element is NaN or empty (rather than accepting all non-empty lists), which is what drives the TextListQuestion all-NaN filtering in §3.3 Case A. + +#### `transform_multi` — MultyOptionsQuestion (linked) bug fix +- **Legacy:** For linked `MultyOptionsQuestion`, the comparison `sub = [ele if ele != [] else '##N/A##' for ele in sub]` evaluated to `True` for integers and `False` for objects in NumPy < 2.x. The result was that a list like `[[3, -999..., -999, ...], [1, -999..., ...]]` collapsed to `[##N/A##, ##N/A##, ...]` — discarding the one valid answer per row. +- **Kedro:** Fixed to produce `[[3], [1], ...]` — preserving the valid answer and correctly filtering out missing-coded values. + +#### `TextListQuestion` disabled-question filtering + +Two distinct cases arise from how Survey Solutions exports `TextListQuestion` data. Both are handled differently between legacy and Kedro. + +**Case A — Disabled question (questionnaire logic prevented it from being shown):** +- Export: all columns are system missing (NaN). NaN passes the `!= '##N/A##'` mask in `transform_multi`, so NaN entries are collected into the list and `remove_unset_value` preserves them → `[nan, nan, nan, ...]`. +- **Legacy:** `is_valid` accepts any list unconditionally → row kept in `df_item` with `value = [nan, nan, nan, ...]`. `~pd.isnull(value)` is `True` (a list object is not null) → incorrectly counted as **answered** in `f__number_answered`. +- **Kedro:** `is_valid_fast` rejects all-NaN lists → row absent from `df_item` → not counted. Correct behaviour. + +**Case B — Unanswered but enabled question (shown to the interviewer, left blank):** +- Export: all columns are `'##N/A##'`. The `!= '##N/A##'` mask blocks all columns → nothing is appended → `x` stays `[]`. The outer dispatch `if x else ...` short-circuits on the falsy `[]` before `remove_unset_value` is ever called. +- **Legacy:** Produces `float('nan')` → dropped by `is_valid` → row absent from `df_item` → **not counted in `f__number_unanswered`**. This is a bug: the question was shown and left blank, so it should be counted as unanswered. +- **Kedro (fixed):** The dispatch now returns `'##N/A##'` for `transformation_type == 'list'` when the list is empty: `'##N/A##' if transformation_type == 'list' else float('nan')`. The scalar `'##N/A##'` survives `is_valid_fast`, the row lands in `df_item`, and is counted in `f__number_unanswered`. See also Bug 18. +- **Practical impact:** No interviews with unanswered-but-enabled `TextListQuestion` items were found in the test data of over 2000 interviews. The fix is correct in principle but is expected to have no real-world effect in the surveys tested. + +**Net effect on features (both cases combined):** +- `f__number_answered` is lower in Kedro (Case A: disabled questions no longer counted as answered). No disabled `TextListQuestion` rows were found in the test data of over 2000 interviews, so the effect may be minor in practice. +- `f__number_unanswered` is higher in Kedro (Case B: unanswered-but-enabled questions now correctly counted). No such cases were found in the test data of over 2000 interviews — this fix is expected to have no real-world effect in practice. +- `s__pause_count = f__pause_count / f__number_answered` — smaller denominator in Kedro → higher value for affected interviews (Case A only). No such interviews were found in the test data of over 2000 interviews. +- `s__number_answered` is directly lower in Kedro (Case A, same source as `f__number_answered`). No such cases were found in the test data of over 2000 interviews, so the effect is expected to be minor in practice. +- All other features are unaffected: numeric/position/selection features filter by question type and exclude `TextListQuestion`; time and `f__answer_changed` features operate on paradata events, not `df_item` rows. + +#### `MultyOptionsQuestion` ComboBox category matching fix +- **Legacy (`file_process_utils.py`):** `get_categories` indexes the categories dict by `file.name` (full filename, e.g. `"mycat.xlsx"`). `update_df_categories` then looks up `row['CategoriesId']` directly — but the questionnaire JSON stores `CategoriesId` as the stem only (e.g. `"mycat"`). Because `"mycat" != "mycat.xlsx"`, the lookup **always fails** — no ComboBox question ever gets `n_answers` or `answer_sequence` populated. +- **Kedro (`file_process_utils_kedro.py`):** Two fixes: + 1. `get_categories` now keys by `file.stem` (no extension), matching the format of `CategoriesId` in the JSON. + 2. `update_df_categories` strips unicode dash characters (`unicodedata.category(c) == 'Pd'`) from both the `CategoriesId` value and the dictionary keys before comparing, handling cases where the JSON uses a different unicode dash character than the filename. + +--- + +## 4. Feature Creation + +### 4.1 Structural Changes + +#### OOP to functional architecture +- **Legacy:** Class methods (`make_feature_item__*`, `make_feature_unit__*`) mutated `self._df_item` / `self._df_unit` in-place. Discovery via dynamic `getattr`. +- **Kedro:** Stateless functions in `feature_processing_kedro.py`. Feature dispatch uses an explicit dictionary (`ITEM_FEATURE_MAP`, `UNIT_FEATURE_MAP`) instead of `getattr`. Easier to debug; no hidden side effects. + +#### Explicit pipeline nodes +Four dedicated nodes replace the monolithic class initialization: +1. `create_base_item_table_node` — microdata + paradata_processed → base item table +2. `create_base_unit_table_node` — paradata_processed → base unit table +3. `enrich_item_features_node` — base item table + paradata_processed → item features +4. `enrich_unit_features_node` — unit base + item features + paradata_processed → unit features +5. `build_removed_answers_node` — paradata_processed → removed_answers (for `answer_removed` scoring) + +#### Active paradata node removed +- **Legacy (Ploomber):** A separate `filter_active_paradata` step produced `paradata_active` as a distinct dataset consumed by all feature nodes. +- **Kedro:** The `paradata_active` intermediate dataset no longer exists. All feature creation nodes receive `paradata_processed` directly and apply the correct per-event-type filters inline (see §3.2). + +#### `df_item` carries questionnaire name and version columns +- **Legacy:** `df_item` had no questionnaire identifier. +- **Kedro:** Two extra columns (`qnr`, `qnr_version`) are propagated from microdata into `df_item` for informational purposes (logging, output labeling, joining results across questionnaires). Scoring is always run independently per questionnaire. + +### 4.2 Intentional Behavioural Changes + +#### `f__first_decimal` renamed to `f__first_decimals` +- The feature always extracted the **first two decimal digits**, but was misnamed `f__first_decimal` (singular) in legacy. +- **Kedro:** Renamed to `f__first_decimals` to accurately reflect what is computed. + +#### Numeric sentinel filter for `f__numeric_response`, `f__first_digit`, `f__last_digit`, `f__first_decimals` +- **Legacy:** Questions of type `NumericQuestion` can have predefined special-meaning answers (e.g. `NONE=0`, `NO PHONE NUMBER=9999999`, `Don't know=-99`). These were treated as real numeric responses in all numeric features. +- **Kedro:** A sentinel detection flag is introduced. When enabled (default: `True` for all numeric features), values that appear in the question's `answers` list (predefined option set) are excluded before computing the feature. This prevents sentinel codes from polluting first-digit distributions, decimal patterns, and numeric response statistics. +- **Important note:** The filter flag for `f__numeric_response` and `f__first_digit` must be set to the same value, because `f__first_digit` score calculation uses `f__numeric_response` for the Benford frequency/magnitude filters. `f__first_digit` itself is only used to count unique leading digits. + +#### `f__first_digit` — interval `[-1, 1]` mapped to first digit `0` +- Values where `|val| < 1` (e.g. `0.12`) cannot have a meaningful first significant digit under Benford's Law. Both legacy and Kedro assign `first_digit = 0` for this range. This behaviour is preserved explicitly in Kedro. + +#### `f__answer_selected` — list parsing via `ast.literal_eval` +- **Legacy:** Uses a bare `isinstance(val, list)` check — returns NaN if the value is not already a Python list object (see §4.3 for the bug this causes). +- **Kedro:** Applies `ast.literal_eval(str(val))` before checking length. This handles both native list objects and string-serialized lists (e.g. `"[1, 2]"`), and also wraps in try/except to return NaN on unparseable values. + +#### `f__gps` — minor robustness improvement only +- **Legacy:** Splits value string by `,`, assigns named columns, sets `f__gps = True/False` boolean flag plus `f__gps_latitude`, `f__gps_longitude`, `f__gps_accuracy`. +- **Kedro:** Identical behaviour — `f__gps = True/False` boolean flag is still set. The only change is a defensive `if gps_data.shape[1] >= 3` guard before reading the coordinate columns, preventing an `IndexError` if the CSV split produces fewer columns than expected. + +### 4.3 Legacy Bugs Fixed in Kedro — Feature Creation + +The following items were **broken in the legacy pipeline** and are **corrected in Kedro**. They are not "remaining differences" — the Kedro values are the correct ones. + +#### `f__answer_position` — always returned NaN in legacy +- **Legacy (both OOP and Ploomber):** `get_microdata` unconditionally converts all values to strings (`astype(str)`) before returning. `make_feature_item__answer_position` then checks `row['value'] in row['answer_sequence']` where `value` is a string (e.g. `"3"`) and `answer_sequence` is a list of integers (e.g. `[1, 2, 3, 4]`). The membership check always fails — `f__answer_position` is always NaN in both legacy pipelines. +- **Kedro:** Three changes fix the lookup: (1) `answer_sequence` is parsed back to a Python list via `ast.literal_eval(str(...))`, (2) `value` is converted to numeric via `pd.to_numeric(..., errors='coerce')`, and (3) if all sequence values are integers and the numeric value is integer-equivalent, it is cast to `int` to ensure type-safe membership testing. Results verified manually against a subset of answers across four surveys. + +#### `f__answer_selected` — always returned NaN in legacy +- **Legacy (both OOP and Ploomber):** `get_microdata` unconditionally applies `astype(str)` to the `value` column before returning, converting Python list objects (e.g. `[1, 2, 3]` from `transform_multi`) into their string representations (e.g. `"[1, 2, 3]"`). By the time `make_feature_item__answer_selected` runs, `isinstance(value, list)` is always `False` — no parquet round-trip is needed for this to fail. The legacy function never extracted a selection count. +- **Kedro:** `value` is converted back to a list via `ast.literal_eval(str(val))` before checking length. Also adds an explicit `n_answers > 0` guard (replaces the missing `is_linked` flag check) before dividing by `n_answers`. + +#### `f__answer_changed` — `yes_list` changes ignored for `MultyOptionsQuestion` with yes/no view +- **Legacy:** For `MultyOptionsQuestion` with `yes_no_view == True`, the yes_list change check was immediately overwritten by the no_list check — two consecutive `.loc` assignments on the same `yesno_mask`. Only removal of `no` answers was ever counted; changes to `yes` answers were silently discarded. +- **Kedro fix:** Both checks are combined with bitwise OR before a single assignment: `yes_changed | no_changed`. A change in either list is now correctly counted. `multi_mask` is also narrowed to `qtype == 'MultyOptionsQuestion'` explicitly (no practical effect in legacy since `TextListQuestion` rows have `NaN` for `yes_no_view` which does not match `== False`, but makes intent clear). +- **Impact:** `f__answer_changed` will be higher in Kedro for interviews with `MultyOptionsQuestion` where `yes` answers were changed independently of `no` answers. + +#### `f__number_answered` / `f__number_unanswered` — sentinel comparison against string values +- **Legacy (both OOP and Ploomber):** `get_microdata` stringifies the entire `value` column via `astype(str)` before returning. The sentinel comparisons `value == -999999999` and `value != -999999999` then compare strings to an integer, which is always `False`/`True` respectively. As a result: (1) `f__number_answered` **includes** sentinel-coded unanswered items (they pass the `!= -999999999` guard), (2) `f__number_unanswered` **never** counts them (the `== -999999999` check never matches a string). +- **Kedro:** Uses `_is_missing_numeric_sentinel`: `pd.to_numeric(values, errors='coerce').eq(-999999999)`. This coerces the string `"-999999999"` to the float `-999999999.0` before comparing, which correctly identifies the sentinel regardless of string/numeric type. +- **Downstream impact:** `f__number_unanswered` is higher in Kedro (now correctly counts sentinel-coded unanswered items); `f__number_answered` is lower. + +### 4.4 Missing Features (present in legacy, absent in Kedro) + +The following features exist in the legacy `ITEM_FEATURE_MAP` equivalent but are **entirely absent** from the Kedro feature map. If enabled in `parameters.yml`, they silently produce no output (the map lookup returns `None` and is skipped without error): + +| Feature | Legacy Location | +|---|---| +| `f__comment_length` | `feature_processing.py` | +| `f__comment_set` | `feature_processing.py` | +| `f__answer_removed` (item-level) | `item_processing.py` | + +Missing from `UNIT_FEATURE_MAP`: + +| Feature | Legacy Location | +|---|---| +| `f__translation_positions` | `feature_processing.py` | + +--- + +## 5. Item Scoring + +### 5.1 Structural Changes + +#### Scoring is enforced per questionnaire at the pipeline level +- **Legacy:** Scoring ran over whatever data was loaded in a single run. In practice this was typically a single questionnaire, but the code did not enforce this. +- **Kedro:** One questionnaire is configured at a time via `questionnaire.name` in `conf/base/globals.yml`. All catalog paths resolve to that questionnaire's data folder. To run a different questionnaire, the `globals.yml` entry is changed and the pipeline re-run. Per-questionnaire isolation is enforced through configuration, not through automatic multi-questionnaire dispatch. + +#### OOP `make_score__*` → pure functions +Each legacy `make_score__*` method is now a standalone function in `item_processing_kedro.py`, accepting `df_item` and parameters, returning a modified `df_item`. No `self` attributes. + +#### Score initialization: `0` → `np.nan` +- **Legacy:** Pre-filters rows with non-null feature values; initializes score column to `0` (explicit "no anomaly") for all returned rows. +- **Kedro:** Works on the full `df_item`. Initializes score column to `np.nan`. Only rows belonging to variables that pass the frequency/uniqueness threshold receive `0`/`1` from the model. All other rows remain `NaN`. +- **Semantic change:** `NaN` means "this item was not evaluated" rather than "no anomaly detected". This distinction is preserved at item level but resolved to `0` at unit level via `.fillna(0)` after `groupby().mean()`. + +#### `s__answer_removed` — moved from item-level to unit-level +- **Legacy:** `make_score__answer_removed` calls `get_feature_item__answer_removed`, which reads directly from `self.df_paradata` (not `df_item`). It counts `AnswerRemoved` events per `(interview__id, responsible, variable_name, qnr_seq)` — including events for items no longer present in microdata. The result is an item-level DataFrame scored by ECOD per variable. +- **Kedro:** `calculate_answer_removed_unit_score` also reads directly from `paradata_full` (equivalent coverage). The difference is architectural: instead of producing item-level scores that are later aggregated, Kedro computes the unit-level score directly — returning a Series indexed by `interview__id` that is mapped into `df_unit`, bypassing the item table entirely. + +### 5.2 Intentional Behavioural Changes + +#### `filter_variable_name_by_frequency` — stricter NaN filtering in Kedro +- **Legacy:** Counts unique values as `len(group[feature_name].unique())` and frequency as `len(group) > frequency`. Neither excludes NaN from the count, so NaN counts as a unique value and inflates the frequency. +- **Kedro:** Filters NaN values before computing both `nunique()` and `count()`. Variables with exactly 100 non-NaN entries pass in Kedro (the boundary is inclusive) but fail in legacy (legacy uses `>`, so 100 entries fail and 101 pass). For example, a variable with exactly 100 entries is evaluated in Kedro but not in legacy. + +#### `s__first_digit` — frequency and magnitude filter changed +- **Legacy:** Frequency filter uses all non-NaN `f__numeric_response` values (including zeros). A variable with ~100 entries of `1`, `10`, and `1000` would pass. +- **Kedro:** Frequency filter uses `f__first_digit` excluding zeros (values in `[-1, 1]` map to first digit `0` and are excluded from the frequency count). Magnitude filter also excludes `|val| < 1` from the order-of-magnitude evaluation. +- **Impact:** Variables that are mostly zeros or have a narrow magnitude range are excluded in Kedro but may have been included in legacy. The effect of this is that fewer variables get scored. Overall it is likely minimal as the there are further filters in the Benford scoring functions. + +#### `s__first_digit` — score mapped to entire `df_item` +- **Legacy:** Scores were mapped back only to the non-NaN entries in `df_item` (filtered subset). +- **Kedro:** Scores are mapped back to the **entire `df_item`**. NaN entries for a variable can receive a score if they belong to a `(responsible, variable_name)` pair that was evaluated. This has no effect on unit/responsible aggregation (NaN items don't contribute to means) but produces different item-level score tables. + +#### `f__first_decimals` COF warning suppression +- **Legacy:** No warning suppression. +- **Kedro:** COF on `f__first_decimals` produces expected `RuntimeWarning`s from `pyod.models.cof` (divide-by-zero in chaining distance) and `numpy._core._methods` (overflow in squared-distance arithmetic) when the data contains many identical values (e.g. `x.00`). These are handled gracefully by COF internally. `warnings.filterwarnings('ignore', ...)` is applied to both modules for the duration of `fit`/`predict` to keep the log clean. `INNE` and `IForest` were tested as alternatives but COF produces better results, so COF is retained despite the warnings. Adding jitter to COF was also tested but produced incorrect results — common values such as `0` were incorrectly flagged as outliers because jitter breaks the tied-distance structure that COF relies on to score high-frequency decimal patterns. + +#### Responsible score guard — minimum 2 columns with variance +- **Legacy:** Constant columns are removed before fitting PCA (`df_resp.loc[:, df_resp.nunique() != 1]`), but there is no guard against 0 or 1 varying columns remaining. With 0 columns, `StandardScaler.fit_transform` raises. With 1 column, PCA produces scores but they are based on a single component — reconstruction error has no minor eigenvectors to measure against, so all scores are effectively identical. +- **Kedro:** After dropping constant columns, an explicit `if df_pca_input.shape[1] < 2:` guard sets `responsible_score = NaN` and returns early (also applies when no columns remain at all). A second guard in `combine_unit_scores` (`if resp_score_series.notna().any() and resp_score_series.nunique() > 1:`) skips the responsible score multiplication when scores are all-NaN or constant, preserving the IForest-derived unit scores as-is. A warning is logged. +- **Impact:** Surveys where all score columns are constant, or only one varies (e.g. listing surveys with very little interviewer variation), no longer raise or produce misleading responsible scores. + +#### `answer_hour_set` high-frequency correction — guarded +- **Legacy:** `df.loc[df[score_name] == 0]['frequency'].min()` — if all rows are flagged as anomalies, this returns `NaN`, so the correction silently never fires. +- **Kedro:** Explicit guard `if inlier_mask.any():` — when all rows are anomalies, the `if` branch is skipped. ECOD predictions are preserved as-is. Silent failure is eliminated. + +#### GPS aggregation — per variable → per interview +- **Legacy:** `make_score__gps` calls `get_clean_pivot_table`, which pivots `df_item` on `variable_name`, producing multi-level columns like `f__gps_latitude_gps_q1`, `f__gps_latitude_gps_q2`. `replace_with_feature_name` then renames all of them to `f__gps_latitude` — creating duplicate column names. Any column access (`data['f__gps_latitude']`) returns a DataFrame instead of a Series, and the subsequent `lat_lon_to_cartesian` call crashes. Legacy **only works correctly if there is exactly one GPS variable**. +- **Kedro:** Keeps each `(interview__id, variable_name)` row as a distinct GPS point — no grouping, no mean. All GPS points across all GPS variables are pooled together into the KDTree/COF/LOF model, and scores are written back to their original rows. Multiple GPS variables are handled correctly; each variable's coordinates contribute independently to the outlier model. + +#### GPS — `s__gps_extreme_outlier` latitude check overwritten by longitude check +- **Legacy intent:** Flag a point as an extreme outlier only when **both** `latitude == 0` and `longitude == 0` (the comment reads "0,0 as coordinates" — a failed GPS fix). +- **Legacy bug:** Three consecutive assignments: first sets the column to `0`, second sets it to `1` where `latitude == 0`, third sets it to `1` where `longitude == 0` — **overwriting** the latitude result entirely. Only `longitude == 0` is ever actually flagged; a point at `(lat=0, lon=5)` is incorrectly flagged as an outlier, while a point at `(lat=5, lon=0)` is also incorrectly flagged. A point at `(lat=0, lon=0)` is flagged correctly but only by coincidence. +- **Kedro (fixed):** Single vectorised expression: `(latitude == 0) & (longitude == 0)`. A point is flagged only when **both** coordinates are zero — matching the original intent. +- **Impact:** Legacy flags any point with `longitude == 0` (regardless of latitude) and misses points where only `latitude == 0`. Kedro correctly flags only true `(0, 0)` fixes. + +#### GPS — accuracy and search radius divided by `1e6` instead of `1e3` +- **Legacy:** `data['accuracy'] = data['f__gps_accuracy'] / 1e6` and `radius = 10 / 1e6`. `lat_lon_to_cartesian` returns coordinates in kilometres (Earth radius = 6371 km). Accuracy (in metres) should be converted to km by dividing by `1e3`. Using `1e6` instead makes both the per-point accuracy term and the base search radius ~1000× too small — effectively zero. `s__gps_proximity_counts` is almost always `0` in legacy regardless of how close the GPS points are. +- **Kedro:** `data['accuracy'] = data['f__gps_accuracy'].fillna(0) / 1e3` and `radius = 10 / 1e3`. Correct metres → km conversion. Neighbours within 10 m + device accuracy are counted properly. +- **Impact:** `s__gps_proximity_counts` will be substantially higher in Kedro for surveys where interviewers collected GPS points at the same or nearby locations. + +#### GPS — extreme outlier edge case +- **Legacy:** When all GPS points are extreme outliers, the COF/LOF model may throw an exception or produce unpredictable results. +- **Kedro:** Sets score to `NaN` (evaluation not possible) — handled cleanly. + +#### Entropy normalization fix +- **Legacy:** `calculate_entropy` divides by `np.log2(unique_values)`. +- **Kedro:** Divides by `np.log(unique_values)` (natural log). Raw entropy values in Kedro are correctly normalized to [0, 1]; legacy values are in [0, ln(2)] ≈ [0, 0.693]. Since entropy is only used in a relative median comparison (`x < median − 0.5 × median`), the ln(2) factor cancels and no responsibles are flagged differently. Raw entropy values differ. + +#### `s__last_digit` — not implemented +- **Legacy:** `make_score__last_digit` was commented out. +- **Kedro:** Not implemented (consistent with legacy). + +#### `s__answer_removed` — fallback path undercounts (Kedro) +- **Primary path:** `calculate_answer_removed_unit_score` reads from `paradata_full` — complete coverage including deleted items. +- **Fallback path (when `removed_answers` is `None`):** Falls back to `df_item`-based aggregation using a `how='left'` join on the microdata item table. Items deleted from microdata post-collection are absent from `df_item`, so their `AnswerRemoved` events are silently dropped — systematic undercount. +- **Impact:** Only relevant if `removed_answers` dataset is unavailable. The primary path is equivalent to legacy. In practice this should never trigger. + +### 5.3 Scoring Coverage + +| Score | Kedro Implementation | Notes | +|---|---|---| +| `s__answer_hour_set` | ✅ Implemented | Guarded high-freq correction | +| `s__sequence_jump` | ✅ Faithful port | — | +| `s__first_decimal` | ✅ Faithful port | — | +| `s__answer_changed` | ✅ Faithful port | Feature bug fixed (see §4.3); scoring logic identical | +| `s__answer_removed` | ✅ Implemented (unit-level only) | Direct from `paradata_full`; no item-level score | +| `s__answer_position` | ✅ Faithful port | — | +| `s__answer_selected_lower/upper` | ✅ Faithful port | Intermediate `s__answer_selected` dropped | +| `s__answer_duration_lower/upper` | ✅ Faithful port | — | +| `s__single_question` | ✅ Faithful port | — | +| `s__multi_option_question` | ✅ Faithful port (bug fixed) | See Bug Fix §8.2 | +| `s__first_digit` | ✅ Faithful port | — | +| `s__gps_proximity_counts`, `s__gps_outlier`, `s__gps_extreme_outlier` | ✅ Implemented (bugs fixed) | See §5.2: accuracy `/1e6` bug, extreme-outlier overwrite bug, aggregation change | +| `s__last_digit` | ❌ Not implemented | Commented out in legacy too | + +--- + +## 6. Unit Scoring + +### 6.1 Structural Changes + +#### OOP `make_score_unit__*` → pure function nodes +All unit-level score methods have been ported to pure functions in `unit_processing_kedro.py`. Aggregation and global modeling logic (IForest, PCA, windsorization) are now explicit node functions. + +#### `windsorize_95_percentile` — non-mutating +- **Legacy:** No `.copy()` call; mutates the input DataFrame. +- **Kedro:** Uses `df_out = df.copy()` — input is never mutated. Adds `is_numeric_dtype` guard before operating. + +### 6.2 Intentional Behavioural Changes + +#### `s__pause_count` — division by zero +- **Legacy:** No zero-division guard; produces `NaN` or `inf` when `f__number_answered == 0`. +- **Kedro:** `np.where(f__number_answered != 0, ..., 0)` — returns `0` when `f__number_answered == 0`. + +#### `s__pause_duration` — division by zero +- **Legacy:** No zero-division guard; produces `NaN`/`inf` when `f__total_elapse == 0`. +- **Kedro:** Returns `0` when `f__total_elapse == 0`. + +#### `s__answer_hour_set` — missing `fillna(0)` in legacy +- **Legacy:** Interviews not appearing in the grouped data (no hour-set events) get `NaN`. +- **Kedro:** `fillna(0)` applied uniformly. Interviews with no hour-set events get `0`. + +#### Responsible score variance guard +- **Legacy:** Always multiplies by `responsible_score` even when it is constant (all-zero), which causes MinMaxScaler to produce `NaN` for all rows. +- **Kedro:** Guards with `if resp_score_series.nunique() > 1:`. If `responsible_score` is constant, the multiplication is skipped and a warning is logged. IForest-derived scores are preserved. + +#### ECOD on `f__total_elapse` — NaN rows +- **Legacy:** Fits ECOD on all rows, including any `NaN` in `f__total_elapse` — may error or produce incorrect scores. +- **Kedro:** Filters to `valid_mask` before fitting ECOD; NaN rows are handled cleanly. + +#### `s__time_changed`, `s__total_duration`, `s__days_from_start` — missing feature column guard +- **Legacy:** No existence check — raises `KeyError` if the required feature column is absent from `df_unit` (e.g. a survey type that produces no paradata events for that feature). The exception is caught by the `df_unit_score` property loop with a generic `print` warning; the actual error is discarded. +- **Kedro:** Explicit `if 'f__...' in df.columns:` guard — skips cleanly and emits a warning. + +#### `windsorize_95_percentile` assignment (pandas ≥ 2.0 compatibility) +- **Legacy:** `self._df_unit['unit_risk_score'] = windsorize_95_percentile(self.df_unit[['unit_risk_score']].copy())` — assigns a DataFrame to a Series column, which raises `ValueError` in pandas ≥ 2.0. +- **Kedro:** Correctly extracts the Series: `df_unit['unit_risk_score'] = windsorize_95_percentile(df_unit[['unit_risk_score']])['unit_risk_score']`. + +#### Responsible score — `_df_resp` granularity preserved +- **Legacy:** `make_responsible_score` replaces the entire `_df_resp` with the grouped result (`self._df_resp = self._df_resp.groupby(...).mean()`), permanently losing per-interview rows. +- **Kedro:** Operates on a copy and merges back, preserving original granularity. + +### 6.3 NaN vs 0 Divergence — Frequency-Filtered Scores + +The `0` (legacy) vs `NaN` (Kedro) item-score initialization only materially affects scores that use `filter_variable_name_by_frequency`. Items belonging to low-frequency variables receive `0` in legacy and `NaN` in Kedro. At unit level, `groupby().mean()` skips NaN by default, so the unit mean denominator differs: legacy includes those zero-scored items in the mean; Kedro excludes them. + +**Scores with frequency filter — unit/responsible means can diverge:** + +| Score | Aggregation | Filter threshold | Divergence risk | +|---|---|---|---| +| `s__sequence_jump` | unit mean | 100 records, 3 unique | Medium — jumps are sparse, many variables fail | +| `s__first_decimal` | unit mean | 100 records, 3 unique | Medium — only numeric questions | +| `s__answer_changed` | unit mean | 100 records, 1 unique | Lower — low bar, most variables pass | +| `s__answer_selected_lower/upper` | unit mean | 100 records, 3 unique | Medium | +| `s__answer_duration_lower/upper` | unit mean | 100 records, 3 unique | Medium | +| `s__answer_position` | responsible mean | 100 records, 3 unique | Medium | +| `s__single_question` | responsible mean | 100 records, 3 unique | Medium | +| `s__multi_option_question` | responsible mean | Legacy: 100 records only (no unique filter); Kedro: 100 records + 3 unique | Medium — Kedro additionally excludes variables with < 3 unique answer combos | +| `s__first_digit` | responsible mean | 100 records, 3 unique + 3-magnitude filter | **High** — strictest filter; most numerics excluded | + +`s__first_digit` carries the highest risk for **responsible scoring / PCA** because its filter is strictest. `s__sequence_jump` and `s__answer_selected/duration` carry the highest risk for **unit scoring / IForest** because sequence jumps are naturally sparse. + +`s__answer_removed` is excluded from this table: in Kedro it is computed directly at unit level from `paradata_full` without going through item-level scoring at all (see §5.1). The NaN/0 item-initialization divergence mechanism does not apply; any difference in values is due to the architectural change, not score initialization. + +**Scores without frequency filter — unit means are equivalent:** +`s__answer_hour_set`, `s__time_changed`, `s__total_duration`, `s__days_from_start`, `s__total_elapse_lower/upper`, `s__pause_duration`, `s__pause_count`, `s__number_answered`, `s__number_unanswered`, `s__gps_*`. + +--- + +### 6.4 Summary — Scenarios Producing Different Outputs + +| Scenario | Legacy output | Kedro output | +|---|---|---| +| Interview with no `answer_hour_set` events | `s__answer_hour_set = NaN` | `= 0` | +| `f__total_elapse == 0` | `s__pause_duration = NaN/inf` | `= 0` | +| `f__number_answered == 0` | `s__pause_count = NaN/inf` | `= 0` | +| All `responsible_score` constant | `unit_risk_score = NaN` (all rows) | Preserved from IForest | +| NaN rows in `f__total_elapse` | ECOD fit may include NaN | ECOD fit excludes NaN | +| pandas ≥ 2.0 windsorize assignment | `ValueError` | Correct | +| Survey missing feature column (e.g. no GPS) | `KeyError` caught with generic print; error discarded | Clean skip with warning | +| First digit Benford with zeros in `f__numeric_response` | Included in sample | Excluded from sample | +| `s__answer_removed` with `removed_answers = None` | Full coverage from paradata | Systematic undercount (fallback to `df_item`) | +--- + +## 7. Unscored / Missing Features + +The following features are enabled (`use: true`) in `parameters.yml` but are **never consumed by any Kedro scoring function**. They compute silently and their values do not appear in `item_scores` or `unit_risk_score`: + +| Feature | Calculation status | Scoring status | Notes | +|---|---|---|---| +| `last_digit` | ✅ Calculated | ❌ Not scored | Legacy scoring functions exist but were never ported to Kedro | +| `answer_share_selected` | ❌ Not calculated | ❌ Not scored | Removed from Kedro `parameters.yml` entirely — no feature function ever existed; orphaned entry superseded by `answer_selected` | +| `comment_length` | ✅ Calculated | ❌ Not scored | Legacy scoring exists but was commented out | +| `comment_set` | ✅ Calculated | ❌ Not scored | Same as above | +| `comment_duration` | ✅ Calculated | ❌ Not scored | No `s__comment_duration` is produced anywhere | +| `pause_list` | ✅ Calculated | ❌ Not scored | Computes a list of pause durations; nothing consumes it for scoring | +| `string_length` | ✅ Calculated | ❌ Not scored | No scoring function; GUI also marks it false by default | + +`answer_share_selected` has already been removed from the Kedro `parameters.yml`. The remaining entries (`last_digit`, `comment_length`, `comment_set`, `comment_duration`, `pause_list`, `string_length`) are still present and could be candidates for removal to reduce confusion. They remain for now in case some of the scoring functions will be re-instated in the future. + +--- + +## 8. Bug Fixes + +The following bugs exist in the legacy code and are corrected in the Kedro pipeline. + +--- + +### Bug 1 — `f__pause_count` inflated by `'size'` aggregation + +**File:** `rissk/feature_processing.py` +**Severity:** High — massively inflates `f__pause_count` + +**Legacy:** Uses `('f__pause_duration', 'size')` in the aggregation, which counts **all rows in the group** regardless of NaN. Since `f__pause_duration` is NaN for non-pause events, this counts every paradata row per interview as a "pause". + +**Kedro fix:** Uses `('f__pause_duration', 'count')` which counts only **non-NaN rows** — i.e., actual pause events. + +--- + +### Bug 2 — `make_score__multi_option_question`: silent no-op initialization + +**File:** `rissk/item_processing.py` +**Severity:** Medium — score column silently stays uninitialized + +**Legacy:** +```python +df.loc[score_name] = 0 # Bug: row assignment, not column assignment +``` +`df.loc[score_name]` on an integer-indexed DataFrame adds a **spurious row** with the score name string as the index label, setting all existing columns to `0`. The score column is never created. The column is then implicitly created by the later `df.loc[mask, score_name] = ...` inside the loop — but only for rows that match a variable with enough records. Rows outside any valid variable get `NaN` instead of the intended `0`. + +**Kedro fix:** +```python +df[score_name] = np.nan # Correct column assignment; consistent with NaN initialization elsewhere +``` + +--- + +### Bug 3 — `feat_answer_changed`: yes_list change overwritten by no_list check + +**File:** `rissk/feature_processing.py` +**Severity:** High — `yes_list`-only changes are never flagged + +**Legacy:** Applies the `yes_list` change check and then immediately overwrites the same column with the `no_list` check via two separate `.loc` assignments on the same mask. Any interview where only the yes answers changed is never flagged. + +**Kedro fix:** Both `yes_list` and `no_list` checks are combined with a bitwise OR before assignment. + +--- + +### Bug 4 — `make_score_unit__pause_count`: dead `pause_mask` variable + +**File:** `rissk/unit_proccessing.py` +**Severity:** Medium — no zero-division guard + +**Legacy:** +```python +pause_mask = ~pd.isnull(self._df_unit[feature_name]) # computed but never used +self._df_unit[score_name] = self._df_unit[feature_name] / self._df_unit['f__number_answered'] +# → NaN or inf when f__number_answered == 0 +``` +`pause_mask` is defined but never applied. Division proceeds over all rows including null `f__pause_count`, with no guard against `f__number_answered == 0`. + +**Kedro fix:** +```python +np.where(df['f__number_answered'] != 0, df['f__pause_count'] / df['f__number_answered'], 0) +``` + +--- + +### Bug 5 — `make_score_unit__total_elapse`: destructive in-place mutation of feature column + +**File:** `rissk/unit_proccessing.py` +**Severity:** High — corrupts `f__total_elapse` for all downstream consumers + +**Legacy:** +```python +self._df_unit[feature_name] = round(self._df_unit[feature_name] / 300) # permanently overwrites +``` +This permanently replaces `f__total_elapse` with the scaled value (`f__total_elapse / 300`) before ECOD is fitted. Any downstream consumer that runs after this method — including `make_score_unit__pause_duration`, which computes `f__pause_duration / f__total_elapse` — receives the scaled value. This causes `s__pause_duration` to be ~300× larger than intended. + +**Kedro fix:** Uses a temporary `f__total_elapse_scaled` column for the ECOD fitting step and drops it afterwards. `f__total_elapse` is never modified. + +--- + +### Bug 6 — `windsorize_95_percentile`: DataFrame assigned to Series column (pandas ≥ 2.0) + +**File:** `rissk/unit_proccessing.py` +**Severity:** High — crashes on pandas ≥ 2.0 + +**Legacy:** +```python +self._df_unit['unit_risk_score'] = windsorize_95_percentile(self.df_unit[['unit_risk_score']].copy()) +# windsorize returns a DataFrame; assigning a DataFrame to a column raises ValueError in pandas ≥ 2.0 +``` + +**Kedro fix:** +```python +df_unit['unit_risk_score'] = windsorize_95_percentile(df_unit[['unit_risk_score']])['unit_risk_score'] +``` + +--- + +### Bug 7 — `make_responsible_score`: responsible score multiplication with no variance guard + +**File:** `rissk/unit_proccessing.py` +**Severity:** Medium — can zero out all `unit_risk_score` values + +**Legacy:** Calls `make_responsible_score()` and multiplies without checking if `responsible_score` has any variance. When there are too few interviewers, PCA produces a constant responsible score of 0, and multiplying all unit risk scores by 0 → all zeros → MinMaxScaler produces all-NaN. + +**Kedro fix:** Guards with `if resp_score_series.nunique() > 1:`. Logs a warning and skips the multiplication when responsible score is constant. + +--- + +### Bug 8 — `transform_multi`: MultyOptionsQuestion (linked) collapses valid answers to `##N/A##` + +**File:** `rissk/utils/import_utils.py` +**Severity:** High — valid responses replaced with missing-value sentinel + +**Legacy:** The list comprehension `[ele if ele != [] else '##N/A##' for ele in sub]` compared `ele != []` against integer values. In NumPy < 2.x this comparison returned `True` for integers and `False` for objects, causing all elements to be treated as empty lists. A linked multi-option list `[[3, -999, -999, ...], [1, -999, ...]]` collapsed to `[##N/A##, ##N/A##, ...]`. + +**Kedro fix (`import_utils_kedro.py`):** Replaced the comparison with an explicit type check and value filter. Valid integer answers are preserved; missing-coded values (`-999999999`) are stripped. Result: `[[3], [1], ...]`. + +--- + +### Bug 9 — `MultyOptionsQuestion` ComboBox: category files never matched + +**File:** `rissk/utils/import_utils.py` +**Severity:** Medium — ComboBox answer options always unlinked + +**Legacy:** Category files for ComboBox questions are Excel files (e.g. `my-categories.xlsx`). `get_categories` keys the categories dict by `file.name` (full filename, e.g. `"my-categories.xlsx"`), but the questionnaire JSON stores `CategoriesId` as the stem only (e.g. `"my-categories"`). Because `"my-categories" != "my-categories.xlsx"`, the lookup in `update_df_categories` **always fails** — no ComboBox question ever gets `n_answers` or `answer_sequence` populated. + +**Kedro fix (`file_process_utils_kedro.py`):** Two fixes: + 1. `get_categories` keys by `file.stem` (no extension), matching the format of `CategoriesId` in the JSON. + 2. `update_df_categories` strips unicode dash characters (`unicodedata.category(c) == 'Pd'`) from both the `CategoriesId` value and the dictionary keys before comparing, handling cases where the JSON uses a different unicode dash variant than the filename. + +--- + +### Bug 10 — `make_score__gps`: `s__gps_extreme_outlier` latitude check overwritten by longitude check + +**File:** `rissk/unit_proccessing.py` +**Severity:** Medium — only longitude == 0 is flagged as extreme outlier + +**Legacy:** `s__gps_extreme_outlier` is set twice sequentially — first for zero latitude, then for zero longitude. The second assignment overwrites the first. Only points with `longitude == 0` end up flagged; `latitude == 0` points are silently cleared. + +**Kedro fix:** Both latitude and longitude zero-checks are combined with AND before assigning `s__gps_extreme_outlier`. + +--- + +### Bug 11 — `make_score__gps`: distance units divide by `1e6` instead of `1e3` + +**File:** `rissk/unit_proccessing.py` +**Severity:** Medium — GPS accuracy and proximity radius are 1000× too small + +**Legacy:** +```python +data['accuracy'] = data['f__gps_accuracy'] / 1e6 # Should be / 1e3 (metres → km) +radius = 10 / 1e6 # Should be 10 / 1e3 = 0.01 km +``` +The comments state the values are in kilometres, but dividing by `1e6` produces microkm. The 10-metre proximity radius becomes 0.00001 km instead of 0.01 km, making the proximity count almost always zero. + +**Kedro fix:** Both `accuracy` and `radius` divide by `1e3` (correct km conversion). + +--- + +### Bug 12 — `feat_answer_position`: always NaN in legacy Ploomber pipeline + +**File:** `rissk/feature_processing.py` (Ploomber execution) +**Severity:** High — feature is always NaN in production pipeline + +**Legacy:** `get_microdata` calls `.astype(str)` on all values unconditionally. `f__answer_position` checks membership with `value in answer_sequence` where `answer_sequence` is a list of integers. Because `value` is now a string, the check always fails. + +**Kedro fix:** `answer_sequence` is parsed back from its string representation to a Python list of integers before the membership check. Results spot-checked manually against four surveys and confirmed correct. + +--- + +### Bug 13 — `feat_answer_selected`: always NaN in legacy Ploomber pipeline + +**File:** `rissk/feature_processing.py` (Ploomber execution) +**Severity:** High — feature is always NaN in production pipeline + +**Legacy:** `isinstance(value, list)` is always `False` when values have been serialized to parquet and read back as strings (e.g. `"[1, 2]"`). The function never enters the selection-count branch. + +**Kedro fix:** `value` is converted back to a Python list before the `isinstance` check. Also adds `n_answers > 0` guard to handle edge cases where `n_answers` is zero or NaN (replaces the missing `is_linked` flag check). + +--- + +### Bug 14 — `get_clean_pivot_table`: float `0.2` passed as integer threshold + +**File:** `rissk/unit_proccessing.py` +**Severity:** Low — GPS `filter_columns` filter disabled by incorrect type + +**Legacy:** `get_clean_pivot_table` calls `filter_columns(threshold=0.2)`, but `filter_columns` expects an integer count threshold (default 100). A threshold of `0.2` means "keep columns with more than 0.2 non-NaN values" which is effectively always true — the filter is a no-op. Whether `0.2` was intended as a proportion or was simply a typo is unclear. + +**Kedro fix:** Uses the integer default of `100` (consistent with all other `filter_columns` calls in the codebase). The GPS filter using this threshold is currently disabled in Kedro anyway. + +### Bug 15 — `transform_multi`: unanswered `TextListQuestion` silently dropped instead of counted as unanswered + +**File:** `rissk/utils/file_process_utils_kedro.py` (Kedro fix only; legacy bug retained) +**Severity:** Very Low — `f__number_unanswered` understated for interviews with unanswered `TextListQuestion` items. No such cases were found in the test data of over 2000 interviews; this fix is correct in principle but is expected to have no real-world effect in practice. + +**Root cause:** When all columns for a `TextListQuestion` row are `'##N/A##'` (the interviewer was shown the question but left it blank), the `transform_multi` column loop appends nothing and `x` remains `[]`. The outer dispatch line: +```python +[remove_unset_value(x) if x else float('nan') for x in transformation] +``` +short-circuits on the falsy `[]` and returns `float('nan')` — bypassing `remove_unset_value` entirely. `float('nan')` is then dropped by `is_valid` / `is_valid_fast`, so the row never reaches `df_item` and is never counted by `f__number_unanswered`. + +Note: `remove_unset_value` in legacy does contain logic that would return `'##N/A##'` for an empty input list, but it is unreachable because of the `if x` short-circuit. + +**Kedro fix:** Changed the dispatch to: +```python +[remove_unset_value(x) if x else ('##N/A##' if transformation_type == 'list' else float('nan')) for x in transformation] +``` +For `transformation_type == 'list'`, an empty list now produces the scalar `'##N/A##'` instead of `float('nan')`. This value survives `is_valid_fast`, the row lands in `df_item` with `value = '##N/A##'`, and is correctly counted in `f__number_unanswered`. All other transformation types (`unlinked`, `linked`) continue to produce `float('nan')` for empty lists, as an empty list there means no selection was made — correct behaviour. + +--- + +## 9. GUI / App Changes + +The NiceGUI-based application was updated to reflect the scoring changes: + +### Features removed entirely +- `answer_share_selected`: No implementation exists at any level (feature or scoring). Removed from `parameters.yml` and GUI. + +### Features set to `false` and removed from GUI controls +These features are calculated but not scored. They remain in `parameters.yml` (set to `false`) as placeholders for potential future use, but are hidden from the GUI to avoid confusion: + +| Feature | Reason hidden | +|---|---| +| `last_digit` | Legacy scoring was commented out; not ported | +| `comment_length` | Legacy scoring was commented out; not ported | +| `comment_duration` | No scoring function exists | +| `comment_set` | Legacy scoring was commented out; not ported | +| `pause_list` | Computed but not consumed by any score | +| `string_length` | No scoring function; already defaulted to `false` | + +### `numeric_response` linked to `first_digit` in GUI +- `f__numeric_response` is required by the `s__first_digit` score calculation (Benford law uses `f__numeric_response` for frequency/magnitude filtering). Their `use` flags must be set to the same value. +- In the GUI, enabling/disabling `numeric_response` also enables/disables `first_digit`. A comment in `parameters.yml` documents this dependency. + +--- + diff --git a/Makefile b/Makefile deleted file mode 100644 index f884dc4..0000000 --- a/Makefile +++ /dev/null @@ -1,100 +0,0 @@ -################################################################################# -# GLOBALS # -################################################################################# - -PROJECT_NAME = rissk -PYTHON_VERSION = 3.10 -PYTHON_INTERPRETER = python - -# Extract SURVEY value from env.yaml -SURVEY := $(shell $(PYTHON_INTERPRETER) -c "import yaml; print(yaml.safe_load(open('env.yaml'))['SURVEY'])") - - -################################################################################# -# COMMANDS # -################################################################################# - - -## Install Python Dependencies -.PHONY: requirements -requirements: - conda env update --name $(PROJECT_NAME) --file environment.yml --prune - - - R -e "IRkernel::installspec(user = TRUE)" - - -## Delete all compiled Python files -.PHONY: clean -clean: - find . -type f -name "*.py[co]" -delete - find . -type d -name "__pycache__" -delete - -## Lint using flake8 and black (use `make format` to do formatting) -.PHONY: lint -lint: - flake8 rissk - isort --check --diff --profile black rissk - black --check --config pyproject.toml rissk - -## Format source code with black -.PHONY: format -format: - black --config pyproject.toml rissk - - -## Download Data from storage system -.PHONY: sync_data_down -sync_data_down: - aws s3 sync s3://surveytool/$(SURVEY)/latest/ \ - data/$(SURVEY)/latest \ - --exclude "*" \ - --include "*.zip" - -## Upload Data to storage system -.PHONY: sync_data_up -sync_data_up: - aws s3 sync data/$(SURVEY)/latest \ - s3://surveytool/$(SURVEY)/latest \ - --exclude "*.m4a" \ - --exclude "10_RAW/*" \ - --exclude "20_INTERIM/*" \ - --include "20_INTERIM/**/paradata.parquet" \ - --include "10_RAW/**/Questionnaire/*" - -## Set up python (R) interpreter environment -.PHONY: create_environment -create_environment: - conda env create --name $(PROJECT_NAME) -f environment.yml - - @echo ">>> conda env created. Activate with:\nconda activate $(PROJECT_NAME)" - - -################################################################################# -# PROJECT RULES # -################################################################################# - - -## Make Dataset -.PHONY: data -data: requirements - $(PYTHON_INTERPRETER) rissk/dataset.py - - -################################################################################# -# Self Documenting Commands # -################################################################################# - -.DEFAULT_GOAL := help - -define PRINT_HELP_PYSCRIPT -import re, sys; \ -lines = '\n'.join([line for line in sys.stdin]); \ -matches = re.findall(r'\n## (.*)\n[\s\S]+?\n([a-zA-Z_-]+):', lines); \ -print('Available rules:\n'); \ -print('\n'.join(['{:25}{}'.format(*reversed(match)) for match in matches])) -endef -export PRINT_HELP_PYSCRIPT - -help: - @$(PYTHON_INTERPRETER) -c "${PRINT_HELP_PYSCRIPT}" < $(MAKEFILE_LIST) diff --git a/README.md b/README.md index d1df331..36a7f35 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,348 @@ -### Download Data from storage system -Please Note that you need [aws cli](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html#getting-started-install-instructions) installed and set up with credentials to download and uplaod the data. If you do not need the sync from S#, you can simply comment with `#` the next line. +![RISSK Logo](images/rissk.png) +# What is RISSK? -# How to Install RISSK -1. go to terminal inside the rissk folder and run +RISSK utilizes machine learning algorithms to generate a **Unit Risk Score (URS)** from your **[Survey Solutions](https://mysurvey.solutions/en/)** export files. This score indicates the likelihood of unwanted interviewer behaviour in individual interviews. It is a valuable tool to prioritize suspicious interviews for verification exercises such as back-checking or audio audits. Designed to be generic, RISSK can be easily integrated into the monitoring systems of most CAPI or CATI surveys conducted using Survey Solutions. Setting up and running RISSK on your local machine is straightforward and platform-independent. Running locally, it ensures the privacy and security of your survey data. Explore further details in the chapters below. + +- [Getting started](#getting-started) +- [Advanced use](#advanced-use) +- [Interpretation](#interpretation) +- [Survey integration](#survey-integration) +- [Limitations](#limitations) +- [Confirmation of results](#confirmation-of-results) +- [Process description](#process-description) +- [Roadmap](#roadmap) + +# Getting started + +These instructions will guide you on how to install and run RISSK on your local machine. For full installation details see [SETUP.md](rissk_kedro/SETUP.md). + +## Prerequisites + +- **Python 3.13** installed on your machine +- An internet connection for the initial install +- Survey Solutions export files (Main Survey Data + Paradata ZIPs) + +Verify your Python version: + +```bash +python --version +``` + +## Setup + +### Option A — uv (recommended) + +[uv](https://docs.astral.sh/uv/) is a fast, self-contained Python package manager. You do **not** need to manage virtual environments manually. + +1. **Install uv** — [instructions](https://docs.astral.sh/uv/getting-started/installation/) +2. **Get the code**: +```bash +git clone https://github.com/rowsquared/rissk.git +cd rissk +``` +3. **Install dependencies**: +```bash +uv sync --extra gui +``` +4. **Launch the GUI**: +```bash +bash rissk_kedro/run_gui.sh # macOS / Linux +rissk_kedro\run_gui.bat # Windows +``` + +### Option B — conda + +```bash +git clone https://github.com/rowsquared/rissk.git +cd rissk +conda env create -f environment.yml +conda activate rissk_kedro +bash rissk_kedro/run_gui.sh # macOS / Linux +rissk_kedro\run_gui.bat # Windows +``` + +Your browser will open automatically at **http://localhost:8080**. + +## Setting up export folder + +In the GUI **Setup** tab, choose a data folder and note the subfolder shown (e.g. `pmpmd_household/latest/10_RAW/`). Place your unmodified Survey Solutions ZIP files there. + +**Export data from Survey Solutions:** +- **Main Survey Data** — choose *Tab separated* or *Stata 14*, tick *Include meta information about questionnaire*.
Click to see a screenshot of the selected export options for Main Survey Data.![Export options Main Survey Data](images/export_main.png)
+- **Paradata** — under *Data Type* select *Paradata*.
Click to see a screenshot of the selected export options for Paradata.![Export options Paradata](images/export_para.png)
+ +Export both files from the **same questionnaire version** consecutively. For multiple compatible versions, export each separately and place all ZIPs in the same folder. **Do not rename, modify, or unzip the files.** + +## Running RISSK + +1. In the GUI **Setup** tab, enter your questionnaire name and version numbers, then click **Save configuration**. +2. Switch to the **Run** tab and click **Run RISSK**. +3. Monitor progress in the live log. Results are written to: +``` +//latest/40_SCORED/unit_rissk_scores.csv +``` + +**Command-line alternative** (from the `rissk_kedro/` directory): ```bash -$ make create_environment +kedro run +``` + +# Advanced use + +## Exporting feature scores +By default, RISSK exports only `unit_rissk_scores.csv` containing the `unit_risk_score` for each interview. To also export individual feature scores, enable **Feature score export** in the GUI **Advanced** tab before running. + +The additional output file `item_scores.parquet` contains the detailed feature scores for each interview. For guidance on how to interpret each feature score, refer to [FEATURES_SCORES.md](FEATURES_SCORES.md). + +## Excluding features + +By default, RISSK includes all available features when calculating the Unit Risk Score (URS). To exclude a specific feature, open the GUI **Advanced** tab and toggle the feature off before running. + +Alternatively, edit `rissk_kedro/conf/local/parameters.yml` directly: + +```yaml +features: + answer_changed: + use: false +``` + +## Adjusting contamination level + +Default contamination values have been set based on our testing data. To override them, edit `rissk_kedro/conf/local/parameters.yml`: + +```yaml +features: + answer_changed: + use: true + parameters: + contamination: 0.12 ``` -2. run -```bash -$ conda activate rissk + +Or adjust per-feature thresholds in the GUI **Advanced** tab. + +## Automatically determining contamination level + +The `medfilt` thresholding method can automatically determine contamination levels for each algorithm. This increases memory use and runtime but improved RISSK's effectiveness in our [experiment](#confirmation-of-results). + +Enable this in the GUI **Advanced** tab (**Automatic contamination**), or set it in `rissk_kedro/conf/local/parameters.yml`: + +```yaml +processing: + automatic_contamination: true ``` + +# Interpretation + +RISSK generates `unit_rissk_scores.csv`, which contains the following variables for each interview: `interview__id`, `responsible` and `unit_risk_score`. + +The `unit_risk_score` ranges from 0 to 100. A higher score suggests that the interview exhibits more anomalies and is therefore at a greater risk of containing problematic interviewer behavior. Interviews with elevated URS should be prioritized for verification and review. + +To identify these anomalies, RISSK analyzes the following features: +- Interview timing (hour of the day) +- Duration of the interview and individual questions +- Geographical location (if GPS questions are set) +- Sequence of questions asked +- Modifications to question answers +- Pauses during the interview +- Statistical properties of answers (variance, entropy, etc.) + +For more information on how URS is calculated, refer to chapter [Process description](#process-description). For a detailed breakdown of all features and scores, consult [FEATURES_SCORES.md](FEATURES_SCORES.md). + +> [!WARNING] +> The URS is **not** definitive proof of interviewer misconduct. It may include **false positives**, where legitimate interviews receive high scores due to unusual circumstances, and **false negatives**, where problematic interviews receive low scores because they contain few or no detectable anomalies. To conclusively identify interviewer misconduct, further verification and review is required. See [Survey integration](#survey-integration) for more details. + +The URS is a _relative_ measure, influenced by the data patterns in the set of interviews within the Survey Solutions export files. Therefore, scores will change when RISSK is run again with different data. When comparing URS between interviews, ensure that the scores were generated using the same set of export files. Direct comparison of URS values between different surveys is not advised. + +By design, RISSK considers only the interview data up to the first interaction by a Supervisor or HQ role to eliminate the influence of confounding post-interview actions. This means that if substantial parts of an interview are completed after this point, the URS may not accurately reflect the interview's risk level. Consequently, modifying an interview after rejection will not improve its URS. + +Note that RISSK does not currently take into account outstanding error messages or interviewer comments. These elements are easily accessible in the Survey Solutions interface and should ideally be reviewed systematically. + +# Survey integration + +RISSK serves as a useful tool to prioritize at-risk interviews in your quality assurance processes, such as in-depth reviews, back-checks, or audio auditing. Additionally, the Unit Risk Score (URS) can be monitored by interviewer and over time to identify trends. This chapter outlines general guidance on how to integrate RISSK into your survey. For advice specific to your context, please [list an issue](https://github.com/rowsquared/rissk/issues/new/choose) (use label 'questions') or reach out to the authors. + +> [!WARNING] +> While RISSK enhances the data quality assurance system of a survey, it should not replace other essential components such as back-checks or audio audits. + +**Frequency** + +Ideally, RISSK should be executed—and its results reviewed and acted upon—regularly during fieldwork to detect and address issues promptly. For most surveys, a frequency ranging from **daily** to **weekly** is advisable. This usually means that RISSK's output will need to be processed and reviewed in batches. + +**System integration** + +If possible, integrate RISSK into your survey's data management and monitoring system. This allows for automated execution and the consolidation of various monitoring indicators. For example: +- Run RISSK as part of the scripts that export from Survey Solutions. +- Use the output to identify interviews for review/verification and add them to the backlog for supervisors or data monitors. +- Incorporate URS values into your monitoring dashboard alongside other indicators. + +## Interview prioritization + +The URS is specifically designed to guide the **initial** review or verification of an interview. It only takes into account data collected before any interactions by a Supervisor or HQ role. Any actions taken by the interviewer after the first review or rejection should be monitored through other means. + +For each batch of interviews, it's most efficient to prioritize those with the **highest URS values** for review or verification, as they are most likely to contain issues. In our [experiment](#confirmation-of-results), 82% of interviews that fell within the top 5% of URS values were fabricated. However, because the URS can also include false negatives it may be beneficial to include some interviews with lower URS values in your review process. For instance, one approach could be to review or verify 10% of interviews with the highest URS in each batch, along with an additional random 5% drawn from the remaining pool. This could also be tailored to focus on specific interviewers or other criteria. + +## Review/Verification + +The process of reviewing or verifying interviews can involve various activities: + +- External verification through back-check interviews or audio audits. +- In-depth examination of the interview and its paradata. +- Direct queries or confrontations with interviewers. +- Direct observation of future interviews. + +It is advisable to keep a structured record of the outcome of the review/verification. Specifically, document for each interview whether it was found to contain problematic behaviour and, if so, describe the nature of the identified issues. This information can help you to finetune the composition of the URS (see chapter [Advanced use](#advanced-use) for details). The authors would also appreciate receiving these outcomes, together with the output file, to continue improving RISSK. + +If problematic interviewer misbehaviour is confirmed, timely and appropriate consequences should ensue. These can range from a stern warning (akin to a "yellow card") to the loss of a bonus or even dismissal in cases of intentional misconduct such as data fabrication. For unintentional mistakes or if an interviewer is struggling, tailored feedback, explanatory phone calls, or in-person retraining may be necessary. Failure to address issues promptly can lead to persistent problems during fieldwork, negatively impacting data quality. + +## Feedback to interviewers + +Informing interviewers that their activities are closely monitored and that an algorithm is used to flag suspicious cases typically offers two benefits: + +1. It encourages better performance, as people generally perform better when they understand the significance of their work. +2. It acts as a deterrent against misconduct, as there is a real risk of detection and subsequent consequences. + +However, it's crucial to exercise caution in your communication with interviewers. Specifically, **do NOT** reveal details about how RISSK operates, such as the specific features analyzed or the scores influencing the Unit Risk Score (URS). Doing so could allow interviewers to adjust their behavior to evade detection. + +For instance, providing feedback like, _"Your interview was flagged because it took place at night"_, could lead interviewers to falsify data during regular working hours or manipulate device time settings. Instead, opt for a generic initial inquiry, asking for details about the flagged interview and then cross-referencing this information with paradata and additional input from respondents or supervisors. For example, you might say, _"Your interview has been flagged and is currently under investigation. Could you please provide all the details about when it was conducted, with whom, how many visits were needed, any challenging aspects, pauses, and so forth?"_ + +Additionally, aim to provide feedback that is both **useful and actionable**. Generalized statements like, _"Your interview scored high; stop doing whatever you're doing wrong,"_ are not helpful. Instead, try to identify the underlying issues through verification or review and tailor your feedback accordingly. For instance, you could say, _"We've noticed that your interviews are unusually short and involve fewer households engaged in agriculture. If the respondent says 'No' in Q1, make sure to probe for XYZ. If the respondent mentions ABC, it should also be considered as a 'Yes' in Q1."_ + +## Monitoring + +To use the URS as a monitoring indicator, average `unit_risk_score` by interviewer (and/or team) and over time (week/month), and visualize it e.g. as part of a survey monitoring dashboard. While individual interviews by one interviewer may not score high enough to be reviewed/verified, a repeated high average score over time for one interviewer may signal potential issues and the need to take action. Monitoring the average URS by interviewer and time also helps to check if interviewers have adjusted to feedback or warnings (lower URS post-intervention) or continue to produce problematic interviews (equal or higher URS). + +> [!IMPORTANT] +> If your survey deploys multiple questionnaire templates, run RISSK separately for each one. + +# Limitations + +- **Majority behavior assumption**: RISSK assumes that the majority of interviews are conducted as desired, using this as a baseline for normal behavior. If a survey has an extreme level of problematic interviewer behavior, the scores may become unreliable. + +- **Low number of interviews**: Some scores require a minimum number of observations to be calculated. Anomaly detection functions better with more observations. While there are only few interviews, e.g., during the first few days of fieldwork, the scores are less effective and reliable. + +- **Interviewing events only**: RISSK considers only the interview data up to the first interaction by a Supervisor or HQ role. This means that if substantial parts of an interview are completed after this point, the URS may not accurately reflect the interview's risk level. If you're using [partial synchronization](https://docs.mysurvey.solutions/headquarters/config/admin-settings/) it's recommended that Supervisor and HQ roles refrain from opening interview files before they are completed to maintain reliability. + +- **System Requirements**: RISSK has been tested on a laptop with 16GB of RAM, processing paradata files up to 1GB in size. Larger datasets may require more advanced hardware. + +- **Data Format**: As of now, RISSK doesn't support SPSS format for microdata exports from Survey Solutions. Use STATA or TAB formats instead. + +- **Version Compatibility**: RISSK is designed to support export formats from Survey Solutions version 23.06 and later. While it may be possible to use RISSK with earlier versions, such compatibility has not been officially tested. + +- **Non-Contact and non-response**: Interviews containing non-contact or non-response cases can distort the URS, as these often follow an atypical path through the questionnaire. + +- **Interviews in Progress**: When using the online Interviewer, incomplete interviews that are still in progress can be included in the analysis, potentially distorting the URS. To minimize this issue, it's advisable to run RISSK during periods when there is minimal interviewing activity, such as during nighttime hours. + +- **Question Types**: No microdata based features have been developed for barcode, picture, audio, and geography questions. These question types are only considered through their related events in the paradata. + +- **Survey Modes**: RISSK is designed for CAPI or CATI modes. It has not been tested for CAWI mode in Survey Solutions. + +# Confirmation of results + +To rigorously test the RISSK package's effectiveness in identifying high-risk interviews, we conducted an experiment using both real and artificially created "fake" interviews. These fake interviews were designed to mimic various types of problematic interviewer behavior. + +## Methodology + +We utilized a real CATI survey dataset (specific details are confidential). To this, we added 77 artificial fake interviews created by 11 interviewers just after completion of fieldwork, each following one of seven scenarios designed to induce different types of interviewer behavior. Here are the scenarios: + +1. **Non-incentivized, uninformed**. Pretend you are interviewing and fill in the questionnaire. +2. **Incentivized, uninformed**. Fake as good as you can, try not to get caught. +3. Same as Scenario 2 (to generate more cases). +4. **Incentivized, real timing**. Fake as good as you can, try to be realistic in timings. +5. **Incentivized, real answers**. Fake as good as you can, try to set as real answers as possible. +6. **Non-incentivized, low effort**. Fake without putting effort. +7. **Incentivized, speed**. Fake as fast as possible. + +These artificial fake interviews were then mixed with 268 real interviews, creating a test set of 345 interviews. Real interviews for this survey are believed to be of general low-risk, as they were conducted by a small team of interviewers with a trusted, long-term relationship, incentives to perform well and deterrents to do badly, as well as a good data monitoring structure in place. Furthermore, interviewers were aware that the data they collected would be used to validate secondary data and that discrepancies would be investigated. Nevertheless, it could not be ruled out that some real interviews contained problematic interviewer behaviour. + +## Metrics + +To measure RISSK's utility in a practical survey setting, we sorted all interviews by `unit_risk_score`, select the top _N%_ - as would be done if using the URS to prioritize interviews for review/verification - and calculate the share of artificial fake interviews `share_urs`. We compare this to `share_rand`, the share of artificial fakes one would obtain if selecting _N%_ at random, which is equal to the prevalence of artificial fakes in the data. We also calculate the ratio of `share_urs/share_rand` measuring how many more artificial fake interviews are contained in the selection guided by URS vs a random selection. + +## Results + +The table below summarizes the results for the top 5, 10, 15 and 20 percent: + +| N | Share selecting top URS
(share_urs) | Share selecting at random
(share_rand) | Ratio
(share_urs/share_rand) | +|-----:|----------------------------------------:|-----------------------------------------------:|---------------------------------:| +| 5% | 82% | 22.4% | 3.7 | +| 10% | 56% | 22.4% | 2.5 | +| 15% | 43% | 22.4% | 1.9 | +| 20% | 41% | 22.4% | 1.9 | + +In our test, selecting the top 5% of interviews based on their URS yielded 3.7 times more artificial fakes than if selected randomly. This ratio decreases as we select a larger percentage of interviews, but at 1.9 for 20% continues to be significantly higher within the range of review/verification ratios common in surveys. + +In the chart below, the blue line (with the y-axis on the left) illustrates how `share_urs` varies as we increase the number of interviews selected, ranging from 1 to 100 of all interviews. The orange horizontal line, set at 22.4%, represents `share_rand`. The green line (with the y-axis on the right) indicates the percentage of all artificially created fake interviews contained within the top N% of interviews, sorted by their URS. As the chart shows, over two-thirds of all artificial interviews are found within the top 40% of interviews when sorted by URS. + +![experiment](images/final_output_no_automatic_contamintation.png) + +The results presented above were obtained by running RISSK with its default settings. The chart below shows results obtained using the automatic contamination option. This option enables the system to automatically determine the contamination levels employed by the relevant algorithms during score calculations. In our tests, using the automatic contamination level showed slightly weaker performance in the 0-10% range but surpassed the default settings in the 10-20% range. + +![experiment](images/final_output_automatic_contamination.png) + +Please note that our results are based on the classification of interviews as either real or artificially created, according to the experiment's design. While none of the artificially created interviews can be devoid of issues, some of the real interviews with relatively high `unit_risk_score` may also contain problematic behavior. This could potentially increase the `share_urs` value, further demonstrating the utility of the tool in identifying at-risk interviews. + +> [!NOTE] +> The effectiveness is likely to differ between surveys as it depends on the nature of problematic interviews. + +# Process description + +This chapter outlines the key steps that the RISSK pipeline follows to generate the Unit Risk Score (URS). + +## Data preparation + +1. **Unzipping files**. The pipeline scans the configured data folder for Survey Solutions export ZIP files (Main Survey Data in Tab or Stata format, and Paradata). Each version's files are extracted to a subfolder, and `Questionnaire/content.zip` is further unzipped. + +2. **Constructing questionnaire data**. For each version, a dataframe `df_questionnaire` is constructed from `Questionnaire/content/document.json` and the Excel files in `Questionnaire/content/Categories`. This dataframe has one row per questionnaire item (questions, variables, subsections) with columns for each item's properties (question type, variable name, etc.). + +3. **Constructing microdata**. All microdata export files are identified (excluding `interview__*` and `assignment__*` files). For each file: + - The data is loaded into a dataframe. + - If loaded from Stata, non-response values are adjusted to match the tabular export format. + - Columns related to multi-variable questions are consolidated into single columns. + - System-generated variables are removed. + - The dataframe is reshaped to long format and all version-specific dataframes are appended together. + - All rows relating to disabled questions or Survey Solutions variables are dropped. + - Question properties from `df_questionnaire` are merged. + +4. **Constructing paradata**. Each version's paradata file is loaded into a dataframe. The `parameters` column is split and question properties are merged in from `df_questionnaire`. + +5. **Appending versions**. The questionnaire, microdata, and paradata dataframes from all versions are appended to create comprehensive dataframes for each. + +## Indicator generation + +6. **Isolating interviewing events**. The paradata and microdata are filtered to focus solely on what we term _interviewing events_ — the initial interview process, prior to any corrections or updates after the first intervention by Supervisor or HQ roles. + - In the paradata of every interview, the first event of type `['RejectedBySupervisor', 'OpenedBySupervisor', 'RejectedByHQ', 'OpenedByHQ']` is identified and all subsequent events are removed. + - The remaining paradata is merged into the microdata, retaining only data points that correspond to interviewing events. + +7. **Constructing features**. Various features are derived from the refined paradata and microdata. These features can either be _unit-level_ (referring to the entire interview) or _item-level_ (pertaining to the answer of an individual question on a roster instance/row). Features are absolute values, such as the time spent on a particular question measured in seconds. For a detailed explanation of how each feature is calculated, refer to [FEATURES_SCORES.md](FEATURES_SCORES.md). + +## Generating scores + +8. **Evaluation and score calculation**. Individual features are evaluated and corresponding scores are calculated. For an in-depth understanding of all scores, please consult [FEATURES_SCORES.md](FEATURES_SCORES.md). Generally, scores are categorized into three types: + - **Type 1 Score — Item-level features aggregated to unit**: Anomalies are initially detected at the item level, such as identifying atypical hours of the day for a question to have been answered. Subsequently, the proportion of anomalies within each interview is calculated. + - **Type 2 Score — Unit-level features to unit-level**: Features are directly transformed at the unit level without any aggregation. The specific transformation depends on the nature of the feature. + - **Type 3 Score — Item-level features aggregated to interviewer**: Anomalies are first identified at the item level, grouped by both `variable_name` and `interviewer`. Then, the proportion of anomalies is calculated at the interviewer level. All interviews conducted by the same interviewer share the same Type 3 scores. + +9. **Score aggregation and normalization**. Individual scores are synthesized through the following steps: + - Type 3 Scores are aggregated using [Principal Component Analysis](https://en.wikipedia.org/wiki/Principal_component_analysis) (PCA), which is well-suited for the distribution of Type 3 Scores. + - Type 1 and 2 Scores are aggregated using [Isolation Forest](https://en.wikipedia.org/wiki/Isolation_forest). Due to the multiple distinct types of distributions present in Type 1 and 2 Scores, Isolation Forest was preferred over PCA. + - Results from the PCA and Isolation Forest are combined by a normalized product. + - This product is then [winsorized](https://en.wikipedia.org/wiki/Winsorizing) to mitigate the impact of extreme outliers. + - Finally, the winsorized product is [rescaled](https://en.wikipedia.org/wiki/Feature_scaling#Rescaling_(min-max_normalization)) to a range of 0–100, rendering the resulting `unit_risk_score` easy to interpret. + +# Roadmap +We have planned a series of enhancements and additions to RISSK, which are maintained as [issues](https://github.com/rowsquared/rissk/issues). They can be categorized into the following broad areas: + +- **Deploy RISSK:** + - Expand its application across diverse survey types and contexts. + - Actively seek feedback to refine the tool, address bugs, and gather more evidence on its efficacy. +- **Improve usability**: + - Provide additional outputs, including standardized summary reports and dashboards. +- **Refine methodology**: + - Minimize both false positives and negatives to bolster the reliability of the tool. + - Experiment with feature engineering, score design, and aggregation methods with more testing data. +- **Make RISSK learn**: + - Develop standardized framework for users to record review/verification outcomes. + - Produce an anonymized output format, allowing users to share data that can help refine RISSK's algorithms. + - For individual surveys: Establish a feedback loop enabling RISSK to adapt based on previous verification outcomes. + - Across different surveys: With standard verification outcome and access to more testing data, alternative methodologies can be explored, such as training neural networks, to enhance RISSK's prediction accuracy. diff --git a/configuration/environment/notebook_environment.yaml b/configuration/environment/notebook_environment.yaml deleted file mode 100644 index 350301d..0000000 --- a/configuration/environment/notebook_environment.yaml +++ /dev/null @@ -1,11 +0,0 @@ -data: - externals: data/externals - raw: data/raw - processed: data/processed - final: data/final - results: results - - -reload: true -extract: true -save_to_disk: false \ No newline at end of file diff --git a/configuration/main.yaml b/configuration/main.yaml deleted file mode 100644 index c8851ac..0000000 --- a/configuration/main.yaml +++ /dev/null @@ -1,101 +0,0 @@ -# @package _global_ -version_base: 0.01 -defaults: - - environment/notebook_environment - -export_path: . - -output_file: results/unit_risk_score.csv -feature_score: false - -surveys: [ifad_tunesia] -survey_version: 'EndlineFINALV106_1' -password: null -limit_unit: null - -automatic_contamination: false -features: - answer_hour_set: - use: true - parameters: - contamination: 0.11 - answer_changed: - use: true - parameters: - contamination: 0.1 - answer_removed: - use: true - parameters: - contamination: 0.1 - answer_selected: - use: true - parameters: - contamination: 0.1 - answer_share_selected: - use: false - answer_duration: - use: true - parameters: - contamination: 0.1 - first_decimal: - use: true - parameters: - contamination: 0.11 - frequency: 100 - first_digit: - use: true - last_digit: - use: false - numeric_response: - use: true - sequence_jump: - use: true - parameters: - contamination: 0.1 - time_changed: - use: true - gps: - use: true - sub_features: [gps_latitude, gps_longitude, gps_accuracy] - parameters: - contamination: 0.11 - pause_count: - use: true - parameters: - contamination: 0.11 - pause_duration: - use: true - parameters: - contamination: 0.11 - pause_list: - use: false - number_unanswered: - use: false - number_answered: - use: true - parameters: - contamination: 0.11 - total_duration: - use: true - parameters: - contamination: 0.11 - total_elapse: - use: true - parameters: - contamination: 0.11 - single_question: - use: true - multi_option_question: - use: true - days_from_start: - use: false - answer_position: - use: false - comment_length: - use: false - comment_set: - use: false - comment_duration: - use: false - string_length: - use: false diff --git a/env.yaml b/env.yaml deleted file mode 100644 index 52d08c2..0000000 --- a/env.yaml +++ /dev/null @@ -1,7 +0,0 @@ -SURVEY: "hies2024" -QUESTIONAIRE: - - name: "snb_hies_hh" - VERSION: [9, 10, 11] - - name: "slbhies_listing" - VERSION: [6, 7] -limit_unit: None \ No newline at end of file diff --git a/environment.yml b/environment.yml index cb2c699..1294b05 100644 --- a/environment.yml +++ b/environment.yml @@ -1,42 +1,39 @@ -name: rissk +name: rissk_kedro channels: - conda-forge - - defaults dependencies: - - python=3.9 - # R and its dependencies - - r-base + - python=3.13 + # R Core - 3.13 compatible binaries (not used in pipeline code) + - r-base>=4.4 - r-ggplot2 - r-dplyr - r-tidyr - - r-shiny - r-readr - - r-irkernel # For running R in Jupyter Notebooks - r-stringr - # Interoperability - - rpy2 # For using R within Python - # Graphing libs + # Graphviz System Deps - graphviz - - pygraphviz - # Other tools + - python-graphviz + - pydot - pip - pip: - - jupyter_contrib_nbextensions - - awscli - - botocore - - loguru==0.7.3 - - tqdm==4.67.1 - - pandas==2.2.2 - - seaborn==0.13.2 - - docutils==0.16 - - openpyxl==3.1.2 - - pyarrow==15.0.2 - - pyod==1.1.3 - - python-dotenv==1.0.1 - - pythresh==0.3.6 - - ploomber==0.23.3 - - ipywidgets==8.1.5 - - typer==0.15.1 - - boto3==1.35.88 - - botocore==1.35.88 + # Framework + - kedro==1.2.0 + - kedro-viz>=12.3.0 + - kedro-datasets[pandas,s3fs,excel,files]>=9.1.0 + + # GUI + - nicegui>=1.4 + + # Core Stack + - rpy2>=3.6.4 + - pandas>=2.2.3 + - numpy>=2.1.0 + - pyarrow>=18.0.0 + + # Analysis Tools + - pyod>=1.1.5 + - pythresh>=1.0.3 + - tqdm>=4.67.0 + - boto3>=1.35.0 + - python-dotenv>=1.0.1 - -e . \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index fcb838d..0000000 --- a/main.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from omegaconf import DictConfig, OmegaConf -from hydra.core.hydra_config import HydraConfig -from rissk.unit_proccessing import * -import hydra -# from memory_profiler import memory_usage -import warnings - -warnings.simplefilter(action='ignore', category=Warning) - - -def manage_path(config): - root_path = HydraConfig.get().runtime.cwd - if config['export_path'] is not None: - if os.path.isabs(config['export_path']) is False: - config['export_path'] = os.path.join(root_path, config['export_path']) - config['environment']['data']['externals'] = os.path.dirname(config['export_path']) - for key, value in config['environment']['data'].items(): - # Check if the value is a relative path - if not os.path.isabs(value): - # Convert the relative path to an absolute path - config['environment']['data'][key] = os.path.join(root_path, value) - config['surveys'] = [os.path.basename(config['export_path'])] - if os.path.isabs(config['output_file']) is False: - - config['output_file'] = os.path.join(root_path, config['output_file']) - return config - - -@hydra.main(config_path='configuration', version_base='1.1', config_name='main.yaml') -def unit_risk_score(config: DictConfig) -> None: - # print(OmegaConf.to_yaml(config)) - print("*" * 12) - config = manage_path(config) - try: - survey_class = UnitDataProcessing(config) - df_item = survey_class.df_item - df_unit = survey_class.df_unit - survey_class.make_global_score() - survey_class.save() - except ValueError as e: - print(f"An error occurred: {e}") - - -if __name__ == "__main__": - unit_risk_score() - # mem_usage = memory_usage(unit_risk_score) - # print(f"Memory usage (in MB): {max(mem_usage)}") diff --git a/notebooks/exploration/.gitkeep b/notebooks/exploration/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pipeline.yaml b/pipeline.yaml deleted file mode 100644 index 4a041b5..0000000 --- a/pipeline.yaml +++ /dev/null @@ -1,29 +0,0 @@ -executor: serial -# clients: -# # configures a dag-level File client -# File: rissk.clients.get_s3 # you can switch to clients.get_s3 or clients.get_gcloud - -tasks: - - source: pipelines/ingestion/01_get_dataframes.py - product: - nb: outputs/01_get_dataframes.ipynb - paradata: data/{{SURVEY}}/latest/20_INTERIM/paradata.parquet - questionnaire: data/{{SURVEY}}/latest/30_PROCESSED/questionnaire.parquet - microdata: data/{{SURVEY}}/latest/30_PROCESSED/microdata.parquet - # TODO! Need to find a way to add a json file with metadata for automatic trigger i.e. right now need to do `dag.build(force=True)` to force the run. If there was a josn file that changes, there would no need of `force=True` - # params: - # resources_: - # # whenever the JSON file changes, my-script.py runs again - # file: data/{{SURVEY}}//10_RAW/{{QUESTIONAIRE[0].name}}_{{QUESTIONAIRE[0].VERSION[0]}}_Paradata_All/export__info.json - - source: pipelines/feature_engineering/10_process_paradata.py - product: - nb: outputs/10_process_paradata.ipynb - paradata: data/{{SURVEY}}/latest/30_PROCESSED/paradata.parquet - - source: pipelines/feature_engineering/11_process_paradata_active.py - product: - nb: outputs/11_process_paradata_active.ipynb - paradata_active: data/{{SURVEY}}/latest/30_PROCESSED/paradata_active.parquet - params: - limit_unit: '{{limit_unit}}' - - diff --git a/pipelines/.gitkeep b/pipelines/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pipelines/feature_engineering/.gitkeep b/pipelines/feature_engineering/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pipelines/feature_engineering/10_process_paradata.py b/pipelines/feature_engineering/10_process_paradata.py deleted file mode 100644 index 7bbf950..0000000 --- a/pipelines/feature_engineering/10_process_paradata.py +++ /dev/null @@ -1,91 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: tags,-all -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.11.2 -# --- - -# %% tags=["parameters"] -# declare a list tasks whose products you want to use as inputs -upstream = ['01_get_dataframes'] -product = None -limit_unit = None - -# %% -import pandas as pd -import numpy as np - -# %% -paradata = pd.read_parquet(upstream['01_get_dataframes']['paradata']) -#paradata.fillna('', inplace=True) - -paradata['f__answer_hour_set'] = (paradata['timestamp_local'].dt.hour + paradata['timestamp_local'].dt.round('30min').dt.minute / 60) - -# interviewing, True prior to Supervisor/HQ interaction, else False -events_split = ['RejectedBySupervisor', 'OpenedBySupervisor', 'OpenedByHQ', 'RejectedByHQ'] - -# Create a flag indicating whether each row has an event in `events_split` -paradata['flag'] = paradata['event'].isin(events_split) - -# %% -# Use `groupby` and `cumsum` to count how many flagged events occur for each group -# If the count is greater than 0, then the 'interviewing' column should be False -paradata['cumulative_flag'] = paradata.groupby('interview__id')['flag'].cumsum() -paradata['interviewing'] = np.where(paradata['cumulative_flag'] > 0, False, True) - - -# %% -def make_index_col(df): - - # Filter out columns with NaN and empty strings - mask = (~df[['interview__id', 'variable_name', 'roster_level']].isnull()) & \ - (df[['interview__id', 'variable_name', 'roster_level']] != '') - - # Use the mask to replace invalid values with an empty string - filtered_df = df.where(mask, '') - - # Concatenate the columns with an underscore separator - df['index_col'] = filtered_df['interview__id'].astype(str) + "_" + \ - filtered_df['variable_name'].astype(str) + "_" + \ - filtered_df['roster_level'].astype(str) - - # Remove trailing and leading underscores if they exist - df['index_col'] = df['index_col'].str.strip('_') - return df.copy() - - -# %% -# Cleanup the intermediate columns -paradata.drop(['flag', 'cumulative_flag'], axis=1, inplace=True) -paradata = paradata[(paradata['interviewing'] == True) & (paradata['role'] == 1)].copy() - -paradata = make_index_col(paradata) -paradata.sort_values(['interview__id', 'order'], inplace=True) -paradata.reset_index(inplace=True) - -# %% -# if limit_unit is not None: -# consent_variable = next(iter(limit_unit)) # Get the first (and only) key in the dictionary -# # Careful! Answer value is a string in paradata. -# # Therefore also consent_value must be set to a string. -# consent_value = str(imit_unit[consent_variable]) - -# cond1 = (paradata['variable_name'] == consent_variable) -# cond2 = (paradata['answer'] == consent_value) - -# filtered_interview_id = paradata[cond1 & cond2]['interview__id'].unique() - -# paradata = paradata[paradata['interview__id'].isin(filtered_interview_id)].copy() - -# %% -if 'answer_sequence' in paradata.columns: - paradata['answer_sequence'] = paradata['answer_sequence'].apply(str) -paradata_file = product['paradata'] -paradata.to_parquet(paradata_file) - -# %% \ No newline at end of file diff --git a/pipelines/feature_engineering/11_process_paradata_active.py b/pipelines/feature_engineering/11_process_paradata_active.py deleted file mode 100644 index 2655320..0000000 --- a/pipelines/feature_engineering/11_process_paradata_active.py +++ /dev/null @@ -1,47 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: tags,-all -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.11.2 -# --- - -# %% tags=["parameters"] -# declare a list tasks whose products you want to use as inputs -upstream = ['10_process_paradata'] -product = None -limit_unit = None - -# %% -import pandas as pd -import numpy as np - -# %% -paradata = pd.read_parquet(upstream['10_process_paradata']['paradata']) -# %% -# df_para_active, active events, prior rejection/review events, for questions with scope interviewer - -active_events = ['InterviewCreated', 'AnswerSet', 'Resumed', 'AnswerRemoved', 'CommentSet', 'Restarted'] -# only keep events done by interview (in most cases this should be all, after above filters, -# just in case supervisor or HQ answered something while interviewer answered on web mode) -# keep active events, prior rejection/review events, for questions with scope interviewer -active_mask = (paradata['event'].isin(active_events)) & \ - (paradata['question_scope'].isin([0, ''])) & \ - (paradata['role'] == 1) - -vars_needed = ['interview__id', 'order', 'event', 'responsible', 'role', 'tz_offset', - 'param', 'answer', 'roster_level', 'timestamp_local', 'variable_name', - 'question_sequence', 'question_scope', "qtype", 'question_type', - 'qnr', 'qnr_version', 'interviewing', 'yes_no_view', 'index_col', 'f__answer_hour_set' - ] - -df_para_active = paradata.loc[active_mask, vars_needed] - -# %% -paradata_active_file = product['paradata_active'] -with open(paradata_active_file, 'wb') as f: - df_para_active.to_parquet(f) diff --git a/pipelines/feature_engineering/12_process_items.py b/pipelines/feature_engineering/12_process_items.py deleted file mode 100644 index 5cf4d2d..0000000 --- a/pipelines/feature_engineering/12_process_items.py +++ /dev/null @@ -1,60 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: tags,-all -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.11.2 -# --- - -# %% tags=["parameters"] -# declare a list tasks whose products you want to use as inputs -upstream = ['01_get_dataframes', '10_process_paradata', '11_process_paradata_active'] -product = None -limit_unit = None - -# %% -import pandas as pd -import numpy as np - -# %% -microdata = pd.read_parquet(upstream['01_get_dataframes']['microdata']) -df_active_paradata = pd.read_parquet(upstream['11_process_paradata_active']['paradata_active']) - -# %% -allowed_features = ['f__' + k for k, v in config['features'].items() if v['use']] -item_level_columns = ['interview__id', 'variable_name', 'roster_level'] -df_paradata = self.process_paradata(paradata) -print('Paradata Processed') -_df_item = self.make_df_item(microdata) - -# %% [markdown] -# ### Make df_item - -# %% -microdata = make_index_col(microdata) -df_item = microdata[['value', "qtype", 'is_integer', 'qnr_seq', - 'n_answers', 'answer_sequence', - 'cascade_from_question_id', 'is_filtered_combobox', - 'index_col'] + item_level_columns] - -paradata_columns = ['responsible', 'f__answer_hour_set', 'interviewing', 'tz_offset'] -# merge microdata with active pardata and keep only the last answer set -answer_set_mask = (df_active_paradata['event'] == 'AnswerSet') -data = df_active_paradata[answer_set_mask].drop_duplicates(subset='index_col', keep='last') -df_item = df_item.merge(data[paradata_columns + ['index_col']], how='left', - on='index_col') -# Remove items that are not in interviewing -df_item = df_item[df_item['interviewing'] == True] -df_item = add_sequence_features(df_item) - -df_item = .add_item_time_features(df_item) - - -# %% -paradata_active_file = product['paradata_active'] -with open(paradata_active_file, 'wb') as f: - df_para_active.to_parquet(f) diff --git a/pipelines/generic/.gitkeep b/pipelines/generic/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pipelines/hooks.py b/pipelines/hooks.py deleted file mode 100644 index 6584af9..0000000 --- a/pipelines/hooks.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -DAG-level and task-level hooks. All arguments in the functions are optional -""" - - -def dag_level_on_render(my_param): - """Executed after the pipeline renders (before execution) - """ - print(f'rendered DAG! my_param={my_param}') - - -def dag_level_on_finish(dag, report): - """Executes after the pipeline runs all tasks - """ - print(f'Finished executing pipeline {dag}, report:\n{report}') - - -def dag_level_on_failure(traceback): - """Executes if the pipeline fails - """ - if traceback.get('build'): - print('Pipeline execution failed while running the tasks!') - - if traceback.get('on_finish'): - print('Pipeline execution failed while executing an on_finish hook!') - - -def on_render(my_param, task, client, product, params): - """Executed after the task renders (before execution) - """ - print(f'Finished rendering {task.name} with my_param {my_param}, ' - f'client {client}, product {product}, and task params {params}') - - -def on_finish(task, client, product, params): - """Executes after the task runs - """ - print(f'Finished running {task.name} with client {client}, ' - f'product {product} and params {params}') - - -def on_failure(task, client, product, params): - """Executes if the task fails - """ - print(f'{task.name} with client {client}, ' - f'product {product} and params {params} failed!') \ No newline at end of file diff --git a/pipelines/ingestion/.gitkeep b/pipelines/ingestion/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pipelines/ingestion/01_get_dataframes.py b/pipelines/ingestion/01_get_dataframes.py deleted file mode 100644 index ec87b98..0000000 --- a/pipelines/ingestion/01_get_dataframes.py +++ /dev/null @@ -1,80 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: tags,-all -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.11.2 -# kernelspec: -# display_name: rissk -# language: python -# name: python3 -# --- - -# %% tags=["parameters"] -# declare a list tasks whose products you want to use as inputs -upstream = None -product = None -# %% [markdown] -# # Get Paradata, Microdata and Questionaire - -# %% tags=[] -from rissk.config import SURVEY, QUESTIONAIRE, RAW_DATA_DIR -from rissk.utils.import_utils import get_zip_files, extract_zip, get_survey_info, get_dataframes - - -# %% [markdown] -# ## Extract Zip file - -# %% -zip_files = get_zip_files(RAW_DATA_DIR, SURVEY, QUESTIONAIRE) - -survey_paths = [] -for zip_file in zip_files: - project_path = zip_file.with_suffix('') - survey_paths.append(project_path) - extract_zip(zip_file, project_path) - -# %% [markdown] -# ## Map Questionaire to paths - -# %% - -survey_info = get_survey_info(survey_paths) - - -# %% [markdown] -# ## Get Dataframes - -# %% -dfs_paradata, dfs_questionnaires, dfs_microdata = get_dataframes(survey_info) - -# %% [markdown] -# ## Save Dataframes - -# %% - -paradata_file = product['paradata'] -questionnaire_file = product['questionnaire'] -microdata_file = product['microdata'] - - - - -if 'answer_sequence' in dfs_paradata.columns: - dfs_paradata['answer_sequence'] = dfs_paradata['answer_sequence'].apply(str) -dfs_paradata.to_parquet(paradata_file) - -if 'answer_sequence' in dfs_questionnaires.columns: - dfs_questionnaires['answer_sequence'] = dfs_questionnaires['answer_sequence'].apply(str) -dfs_questionnaires.to_parquet(questionnaire_file) - - -if 'answer_sequence' in dfs_microdata.columns: - dfs_microdata['answer_sequence'] = dfs_microdata['answer_sequence'].apply(str) -dfs_microdata.to_parquet(microdata_file) - -# %% diff --git a/pipelines/pipelines.rst b/pipelines/pipelines.rst deleted file mode 100644 index baa29d2..0000000 --- a/pipelines/pipelines.rst +++ /dev/null @@ -1,178 +0,0 @@ -Configuration (``dev``/``prod``) -============================================ - -In the previous guide (:doc:`../user-guide/parametrized`), we saw how to use an -``env.yaml`` file to parametrize our pipeline and switch parameters from the -command line. - -Sometimes we want to change all the parameters at once. The most common -scenario is to change configuration during development and production. - -For example, say you're working on a Machine Learning pipeline whose -``pipeline.yaml`` looks like this: - -.. code-block:: yaml - :class: text-editor - :name: pipeline-yaml - - tasks: - - - source: get.py - product: - nb: get.ipynb - data: raw.csv - params: - sample_pct: '' - - - source: get.py - product: - nb: get.ipynb - data: raw.csv - - - source: get.py - product: - nb: get.ipynb - data: raw.csv - - -The pipeline above has one placeholder ``''``, which controls -which percentage of raw data to download. You may want to develop locally with a -fraction of the data, say 20%, to iterate quickly. To -`smoke test `_ quickly, -you may run it with a smaller sample, say 1%. Finally, to train a model, you'll -use 100% of the data. - -.. tip:: - - You can use placeholders (e.g., ````) anywhere in the - ``pipeline.yaml`` file. Another typical use case is to switch the product - location (e.g., ``product: '/some-data.csv'``. - - -By default, Ploomber looks for an ``env.yaml``. To enable rapid local -development with 20% of the data, you may create an ``env.yaml`` file like this: - -.. code-block:: yaml - :class: text-editor - - sample_pct: 20 - -For smoke testing, ``env.test.yaml``: - -.. code-block:: yaml - :class: text-editor - - sample_pct: 1 - -And for training, ``env.train.yaml``: - -.. code-block:: yaml - :class: text-editor - - sample_pct: 100 - -To switch configurations, you can set the ``PLOOMBER_ENV_FILENAME`` environment variable -to ``env.test.yaml`` in the testing environment and to ``env.train.yaml`` in -the training environment. - -Whenever ``PLOOMBER_ENV_FILENAME`` has a value, Ploomber uses it and looks for a file -with such a name. Note that this must be a filename, not a path since Ploomber -expects ``env.yaml`` files to exist in the same folder as the ``pipeline.yaml`` -file. For example, if you're on Linux or macOS: - -.. code-block:: console - - export PLOOMBER_ENV_FILENAME=env.train.yaml && ploomber build - - -.. important:: - - If you're using the Jupyter integration and want to see the changes - reflected in the injected cell, you need to shut down Jupyter - set ``PLOOMBER_ENV_FILENAME``, and start Jupyter again. - - -Managing multiple pipelines ---------------------------- - -If your project has more than one pipeline, they'll likely need -different ``env.yaml`` files. - -Say you have two pipelines, one for training a model (``pipeline.yaml``) and -one for serving it (``pipeline.serve.yaml``). You can create an ``env.yaml`` -file to parametrize ``pipeline.yaml`` and an ``env.serve.yaml`` to parametrize -``pipeline.serve.yaml``: - -.. code-block:: sh - - project/ - pipeline.yaml - pipeline.serve.yaml - env.yaml - env.serve.yaml - -The general rule is as follows: When loading a ``pipeline.{name}.yaml``, -extract the ``{name}`` portion. Then look for a ``env.{name}.yaml`` file, if -such file doesn't exist, look for an ``env.yaml`` file. Note that the -``PLOOMBER_ENV_FILENAME`` environment variable overrides this process. - -Alternatively, you may separate the pipelines into different directories, and -put an ``env.yaml`` on each one: - -.. code-block:: sh - - project-a/ - pipeline.yaml - env.yaml - project-b/ - pipeline.yaml - env.yaml - - -``env.yaml`` composition (DRY) ------------------------------- - -.. note:: New in version 0.18 - -In many cases, your development and production environment configuration share -many values in common. To keep them simple, you may create an ``env.yaml`` -(development configuration) and have your ``env.prod.yaml`` (production -configuration) inherit from it: - -.. code-block:: yaml - :class: text-editor - :name: env-yaml - - key: value - key_another: dev-value - - -Then in your ``env.prod.yaml``: - -.. code-block:: yaml - :class: text-editor - - meta: - # import development config - import_from: env.yaml - - # no need to declare key: value here, it'll be imported from env.yaml - - # overwrite value - key_another: production-value - -Note that if the value in ``import_from`` is a relative path, it is considered -so relative to the location of the env file (in our case ``env.prod.yaml``). - -You can switch values in ``env.yaml`` from the command line, to see how: - -.. code-block:: console - - ploomber build --help - - -Example, if you have a ``key`` in your ``env.yaml``: - -.. code-block:: console - - ploomber build --env--key new-value \ No newline at end of file diff --git a/pipelines/processing/.gitkeep b/pipelines/processing/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index 037cfd8..a400393 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,11 +11,31 @@ authors = [ ] license = { file = "LICENSE" } readme = "README.md" +requires-python = ">=3.13" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License" ] -requires-python = "~=3.9" + +# Minimal runtime dependencies for the rissk ML package. +dependencies = [ + "pandas>=2.2.3", + "numpy>=2.1.0", + "pyod>=1.1.5", + "scipy>=1.10", + "scikit-learn>=1.5", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +# Install with: uv sync --extra gui +gui = [ + "nicegui>=1.4", +] + +# uv workspace: rissk_kedro/ is a member so `uv sync` installs both packages together. +[tool.uv.workspace] +members = ["rissk_kedro"] [tool.black] line-length = 99 diff --git a/reports/.gitkeep b/reports/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/reports/figures/.gitkeep b/reports/figures/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/reports/notebooks/.gitkeep b/reports/notebooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index 3f90f60..9a11d62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,39 @@ -hydra-core<=1.3.2 -numpy<=1.26.4 -pandas<=2.2.2 -openpyxl<=3.1.2 -scikit-learn<=1.4.2 -scipy<=1.13.0 -seaborn<=0.13.2 -pyod<=1.1.3 -pythresh<=0.3.6 -s3fs<=2024.3.1 -pyarrow<=15.0.2 -fsspec +# ============================================================ +# RISSK — Kedro pipeline dependencies +# Python >= 3.13 +# ============================================================ + +# --- Kedro framework --- +kedro==1.2.0 +kedro-datasets[pandas-csvdataset,pandas-exceldataset,pandas-parquetdataset,pandas-statadataset,s3fs]>=9.1.0 +kedro-viz>=12.3.0 + +# --- Core data --- +pandas>=2.2.3 +numpy>=2.1.0 +pyarrow>=18.0.0 + +# --- Scientific / ML --- +scipy>=1.13.0 +scikit-learn>=1.5.0 +pyod>=1.1.5 +pythresh>=0.3.6 + +# --- GUI --- +nicegui>=1.4 + +# --- Logging / progress --- +tqdm>=4.67.0 + +# --- Config --- +python-dotenv>=1.0.1 + +# --- Storage / cloud --- +boto3>=1.35.0 +s3fs>=2024.3.1 +openpyxl>=3.1.2 + +# --- Package itself --- +-e . +-e rissk_kedro/ diff --git a/results/.gitkeep b/results/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/rissk/__init__.py b/rissk/__init__.py index 7368f87..1babcfc 100644 --- a/rissk/__init__.py +++ b/rissk/__init__.py @@ -1 +1,3 @@ -from rissk import config # noqa: F401 +# config is not auto-imported here to avoid module-level side effects (loguru logs, +# env.yaml reads) when rissk.* modules are imported during Kedro runs. +# Import rissk.config explicitly in legacy scripts that need it. diff --git a/rissk/clients.py b/rissk/clients.py deleted file mode 100644 index c17a582..0000000 --- a/rissk/clients.py +++ /dev/null @@ -1,25 +0,0 @@ -from rissk.config import SURVEY, DATA_DIR -from ploomber.clients import LocalStorageClient, GCloudStorageClient, S3Client - - -def get_local(): - """Returns local client - """ - return LocalStorageClient(DATA_DIR, path_to_project_root=DATA_DIR) - - -def get_s3(): - """Returns S3 client - """ - # assumes your environment is already configured, you may also pass the - # json_credentials_path - return S3Client(bucket_name='surveytool', parent=f'{SURVEY}/latest') - - -def get_gcloud(): - """Returns google cloud storage client - """ - # assumes your environment is already configured, you may also pass the - # json_credentials_path - return GCloudStorageClient(bucket_name='surveytool', - parent=f'{SURVEY}/latest') \ No newline at end of file diff --git a/rissk/config.py b/rissk/config.py deleted file mode 100644 index d800307..0000000 --- a/rissk/config.py +++ /dev/null @@ -1,62 +0,0 @@ -from pathlib import Path -import yaml -import os -from dotenv import load_dotenv -from loguru import logger - -# Load environment variables from .env file if it exists -load_dotenv() - -# Paths -PROJ_ROOT = Path(__file__).parent.parent -logger.info(f"PROJ_ROOT path is: {PROJ_ROOT}") - -env_file_path = PROJ_ROOT / 'env.yaml' -def parse_questionnaire(env_var) -> list: - # Ensure QUESTIONAIRE is always a list of dictionaries - questionnaire = env_var.get('QUESTIONAIRE', []) - if isinstance(questionnaire, list): - parsed_questionnaire = [] - for item in questionnaire: - if isinstance(item, dict): - name = item.get('name') - version = item.get('VERSION', []) - if isinstance(version, int): - version = [version] - parsed_questionnaire.append({ - 'name': name, - 'VERSION': version - }) - return parsed_questionnaire - -with open(env_file_path, 'r') as file: - env_data = yaml.safe_load(file) - -# Load variables -SURVEY = env_data.get('SURVEY') -QUESTIONAIRE = parse_questionnaire(env_data) # Parse the new structure - -logger.info(f"Avaliable Questionnaires") -for item in QUESTIONAIRE: - name = item['name'] - versions = item['VERSION'] - logger.info(f"Questionnaire: {name} - Versions: {versions}") - - -DATA_DIR = PROJ_ROOT / "data" / SURVEY / "latest" - -EXTERNAL_DATA_DIR = DATA_DIR / "00_EXTERNAL" -RAW_DATA_DIR = DATA_DIR / "10_RAW" -INTERIM_DATA_DIR = DATA_DIR / "20_INTERIM" -PROCESSED_DATA_DIR = DATA_DIR / "30_PROCESSED" - - -# If tqdm is installed, configure loguru with tqdm.write -# https://github.com/Delgan/loguru/issues/135 -try: - from tqdm import tqdm - - logger.remove(0) - logger.add(lambda msg: tqdm.write(msg, end=""), colorize=True) -except ModuleNotFoundError: - pass diff --git a/rissk/dataset.py b/rissk/dataset.py deleted file mode 100644 index ac2e152..0000000 --- a/rissk/dataset.py +++ /dev/null @@ -1,29 +0,0 @@ -from pathlib import Path - -import typer -from loguru import logger -from tqdm import tqdm - -from rissk.config import PROCESSED_DATA_DIR, RAW_DATA_DIR - -app = typer.Typer() - - -@app.command() -def main( - # ---- REPLACE DEFAULT PATHS AS APPROPRIATE ---- - input_path: Path = RAW_DATA_DIR / "dataset.csv", - output_path: Path = PROCESSED_DATA_DIR / "dataset.csv", - # ---------------------------------------------- -): - # ---- REPLACE THIS WITH YOUR OWN CODE ---- - logger.info("Processing dataset...") - for i in tqdm(range(10), total=10): - if i == 5: - logger.info("Something happened for iteration 5.") - logger.success("Processing dataset complete.") - # ----------------------------------------- - - -if __name__ == "__main__": - app() diff --git a/rissk/detection_algorithms.py b/rissk/detection_algorithms_kedro.py similarity index 100% rename from rissk/detection_algorithms.py rename to rissk/detection_algorithms_kedro.py diff --git a/rissk/feature_processing.py b/rissk/feature_processing.py deleted file mode 100644 index 64c350c..0000000 --- a/rissk/feature_processing.py +++ /dev/null @@ -1,603 +0,0 @@ -#from rissk.import_manager import * -from rissk.utils.import_utils import * - - -class FeatureProcessing(object): - - def __init__(self, survey_info, config): - - #self.config = config = {k:v for k,v in config.items()} - - self._reload = self.config['environment']['reload'] - self._save_to_disk = self.config['environment']['save_to_disk'] - - self._source_path = self.config['environment']['data']['externals'] - self._raw_path = self.config['environment']['data']['raw'] - self._processed_path = self.config['environment']['data']['processed'] - self._final_path = self.config['environment']['data']['final'] - - self._limit_unit = self.config['limit_unit'] - - self._survey_names = self.config['surveys'] - self._survey_version = self.config['survey_version'] - - - - if self.config.get('zip_password'): - self.zip_password = self.config['password'].encode() - - survey_info = get_survey_info(config) - - - paradata, questionnaire, microdata = get_dataframes(survey_info, self._raw_path, self._processed_path, config, - reload=self._reload, - save_to_disk=self._save_to_disk) - - print('Data Loaded') - self._allowed_features = ['f__' + k for k, v in config['features'].items() if v['use']] - self.item_level_columns = ['interview__id', 'variable_name', 'roster_level'] - self._df_paradata = self.process_paradata(paradata) - print('Paradata Processed') - self._df_item = self.make_df_item(microdata) - print('Items Build') - self._df_unit = self.make_df_unit() - print('Unit Build') - self._df_resp = self.make_df_responsible() - # Define ask that get recurrently used - self.numeric_question_mask = ( - (self._df_item["qtype"] == 'NumericQuestion') & - (self._df_item['value'] != '') & - (~pd.isnull(self._df_item['value'])) & - (self._df_item['value'] != -999999999) - ) - - @staticmethod - def rename_feature(feature_name, starting_string='f', new_string='s'): - starting_string = starting_string + '__' - new_string = new_string + '__' - new_variable_name = feature_name.replace(starting_string, new_string) \ - if feature_name.startswith(starting_string) else feature_name - return new_variable_name - - @property - def df_item(self): - for method_name in self.get_make_methods(method_type='feature', level='item'): - feature_name = method_name.replace('make_feature_item', 'f') - if feature_name in self._allowed_features and feature_name not in self._df_item.columns: - try: - print(f"Processing {feature_name}...") - getattr(self, method_name)(feature_name) - # print(f"{feature_name} Processed") - except Exception as e: - print("WARNING: FEATURE ITEM: {} won't be used in further calculation".format(feature_name)) - return self._df_item - - @property - def df_unit(self): - for method_name in self.get_make_methods(method_type='feature', level='unit'): - feature_name = method_name.replace('make_feature_unit', 'f') - if feature_name in self._allowed_features and feature_name not in self._df_unit.columns: - try: - print(f"Processing {feature_name} ...") - getattr(self, method_name)(feature_name) - except Exception as e: - print("WARNING: FEATURE UNIT: {}, It won't be used in further calculation".format(feature_name)) - return self._df_unit - - @property - def df_active_paradata(self): - # df_para_active, active events, prior rejection/review events, for questions with scope interviewer - - active_events = ['InterviewCreated', 'AnswerSet', 'Resumed', 'AnswerRemoved', 'CommentSet', 'Restarted'] - # only keep events done by interview (in most cases this should be all, after above filters, - # just in case supervisor or HQ answered something while interviewer answered on web mode) - # keep active events, prior rejection/review events, for questions with scope interviewer - active_mask = (self.df_paradata['event'].isin(active_events)) & \ - (self.df_paradata['question_scope'].isin([0, ''])) & \ - (self.df_paradata['role'] == 1) - - vars_needed = ['interview__id', 'order', 'event', 'responsible', 'role', 'tz_offset', - 'param', 'answer', 'roster_level', 'timestamp_local', 'variable_name', - 'question_sequence', 'question_scope', "qtype", 'question_type', - 'survey_name', 'survey_version', 'interviewing', 'yes_no_view', 'index_col', 'f__answer_hour_set' - ] - - df_para_active = self.df_paradata.loc[active_mask, vars_needed] - return df_para_active - - @property - def df_paradata(self): - return self._df_paradata - - @property - def df_microdata(self): - paradata, questionnaire, microdata = self.get_dataframes(reload=self._reload, - save_to_disk=self._save_to_disk) - return microdata - - @property - def df_questionnaire(self): - paradata, questionnaire, microdata = self.get_dataframes(reload=self._reload, - save_to_disk=self._save_to_disk) - return questionnaire - - def make_index_col(self, df): - - # Filter out columns with NaN and empty strings - mask = (~df[['interview__id', 'variable_name', 'roster_level']].isnull()) & \ - (df[['interview__id', 'variable_name', 'roster_level']] != '') - - # Use the mask to replace invalid values with an empty string - filtered_df = df.where(mask, '') - - # Concatenate the columns with an underscore separator - df['index_col'] = filtered_df['interview__id'].astype(str) + "_" + \ - filtered_df['variable_name'].astype(str) + "_" + \ - filtered_df['roster_level'].astype(str) - - # Remove trailing and leading underscores if they exist - df['index_col'] = df['index_col'].str.strip('_') - return df - - def make_df_item(self, microdata): - - microdata = self.make_index_col(microdata) - df_item = microdata[['value', "qtype", 'is_integer', 'qnr_seq', - 'n_answers', 'answer_sequence', - 'cascade_from_question_id', 'is_filtered_combobox', - 'index_col'] + self.item_level_columns] - - paradata_columns = ['responsible', 'f__answer_hour_set', 'interviewing', 'tz_offset'] - # merge microdata with active pardata and keep only the last answer set - answer_set_mask = (self.df_active_paradata['event'] == 'AnswerSet') - data = self.df_active_paradata[answer_set_mask].drop_duplicates(subset='index_col', keep='last') - df_item = df_item.merge(data[paradata_columns + ['index_col']], how='left', - on='index_col') - # Remove items that are not in interviewing - df_item = df_item[df_item['interviewing'] == True] - df_item = self.add_sequence_features(df_item) - - df_item = self.add_item_time_features(df_item) - - return df_item.copy() - - def add_sequence_features(self, df_item): - # Define the list of features depending on sequences - sequence_features = ['f__previous_question', 'f__previous_answer', - 'f__previous_roster', 'f__sequence_jump'] - if any(col in self._allowed_features for col in sequence_features): - df_sequence = self.get_df_sequence() - # Remove non-selected features - sequence_features = ['index_col'] + [f for f in sequence_features if f in self._allowed_features] - df_sequence = df_sequence[sequence_features] - # Merge with df_item - - df_item = df_item.merge(df_sequence, how='left', on='index_col') - return df_item - - def add_item_time_features(self, df_item): - # Define the list of features depending on time - time_features = ['f__answer_duration', 'f__comment_duration'] - if any(col in self._allowed_features for col in time_features): - df_time = self.get_df_time() - # Remove records that have variable_name as empty string, i.e. Pauses - df_time = df_time[df_time['variable_name'] != ''] - # summarize on item level - df_time = df_time.groupby(self.item_level_columns + ['index_col']).agg( - f__answer_duration=('f__answer_duration', 'sum'), - f__comment_duration=('f__comment_duration', 'sum'), - ).reset_index() - - # Remove non-selected features - time_features = ['index_col'] + [f for f in time_features if f in self._allowed_features] - df_time = df_time[time_features] - # Merge with df_item - df_item = df_item.merge(df_time, how='left', on='index_col') - return df_item - - def get_df_time(self): - # f__answer_duration, total time spent to record answers, i.e., - # sum of all time-intervals from active events ending with the item being AnswerSet or AnswerRemoved - # f__comment_duration, total time spent to comment, i.e., - # sum of all time-intervals from active events ending with the item being CommentSet - ###### ITEM features - df_time = self.df_active_paradata - - # calculate time difference in seconds - df_time['time_difference'] = df_time.groupby('interview__id')['timestamp_local'].diff() - df_time['time_difference'] = df_time['time_difference'].dt.total_seconds() - df_time['f__time_changed'] = np.where(df_time['time_difference'] < -180, df_time['time_difference'], np.nan) - df_time.loc[df_time['time_difference'] < 0, 'time_difference'] = pd.NA - # time for answers/comments - df_time['f__answer_duration'] = df_time.loc[ - df_time['event'].isin(['AnswerSet', 'AnswerRemoved']), 'time_difference'] - df_time['f__comment_duration'] = df_time.loc[df_time['event'] == 'CommentSet', 'time_difference'] - - df_time['f__pause_duration'] = df_time.loc[df_time['event'].isin(['Resumed', 'Restarted']), 'time_difference'] - - ###### UNIT features - active_events = ['AnswerSet', 'AnswerRemoved', 'CommentSet', 'Resumed', 'Restarted'] - # Calculate the total duration of active events for all events with less than 30 minutes - df_time['f__total_duration'] = df_time.loc[(df_time['event'].isin(active_events) & ( - df_time['time_difference'] < 30 * 60)), 'time_difference'] - - # Get the min date from the min question sequesce as there might be some time setting - # change later that would change the starting date if just looking at the min of timestamp_local - starting_timestamp = df_time[df_time['event'].isin(['AnswerSet'])].groupby('interview__id')[ - 'timestamp_local'].min() - df_time['f__starting_timestamp'] = df_time['interview__id'].map(starting_timestamp) - - min_date = df_time['f__starting_timestamp'].min() - df_time['f__days_from_start'] = abs( - (df_time['timestamp_local'] - min_date).dt.days) # / (max_date-min_date).days - - return df_time - - def get_df_sequence(self): - - df_last = self.df_active_paradata[self.df_active_paradata['event'] == 'AnswerSet'].groupby( - 'index_col').last() - df_last = df_last.sort_values(['interview__id', 'order']).reset_index() - - # f__previous_question, f__previous_answer, f__previous_roster for previous answer set - df_last['f__previous_question'] = df_last.groupby('interview__id')['variable_name'].shift( - fill_value=pd.NA) - df_last['f__previous_answer'] = df_last.groupby('interview__id')['answer'].shift( - fill_value='') - df_last['f__previous_roster'] = df_last.groupby('interview__id')['roster_level'].shift( - fill_value='') - # f__sequence_jump, Difference between actual answer sequence and - # question sequence in the questionnaire, in difference to previous question - df_last['answer_sequence'] = df_last.groupby('interview__id').cumcount() + 1 - df_last['diff'] = df_last['question_sequence'] - df_last['answer_sequence'] - df_last['f__sequence_jump'] = df_last.groupby('interview__id')['diff'].diff() - - return df_last - - def process_paradata(self, paradata): - - # streamline missing (empty, NaN) to '', important to identify duplicates in terms of the roster below - paradata.fillna('', inplace=True) - - paradata['f__answer_hour_set'] = (paradata['timestamp_local'].dt.hour + paradata[ - 'timestamp_local'].dt.round( - '30min').dt.minute / 60) - - # interviewing, True prior to Supervisor/HQ interaction, else False - events_split = ['RejectedBySupervisor', 'OpenedBySupervisor', 'OpenedByHQ', 'RejectedByHQ'] - # Create a flag indicating whether each row has an event in `events_split` - paradata['flag'] = paradata['event'].isin(events_split) - - # Use `groupby` and `cumsum` to count how many flagged events occur for each group - # If the count is greater than 0, then the 'interviewing' column should be False - paradata['cumulative_flag'] = paradata.groupby('interview__id')['flag'].cumsum() - paradata['interviewing'] = np.where(paradata['cumulative_flag'] > 0, False, True) - - # Cleanup the intermediate columns - paradata.drop(['flag', 'cumulative_flag'], axis=1, inplace=True) - paradata = paradata[(paradata['interviewing'] == True) & (paradata['role'] == 1)].copy() - - paradata = self.make_index_col(paradata) - paradata.sort_values(['interview__id', 'order'], inplace=True) - paradata.reset_index(inplace=True) - - paradata = self.filter_by_consent(paradata) - - return paradata - - def filter_by_consent(self, paradata): - if self._limit_unit is not None: - consent_variable = next(iter(self._limit_unit)) # Get the first (and only) key in the dictionary - # Careful! Answer value is a string in paradata. - # Therefore also consent_value must be set to a string. - consent_value = str(self._limit_unit[consent_variable]) - - cond1 = (paradata['variable_name'] == consent_variable) - cond2 = (paradata['answer'] == consent_value) - - filtered_interview_id = paradata[cond1 & cond2]['interview__id'].unique() - - paradata = paradata[paradata['interview__id'].isin(filtered_interview_id)].copy() - - return paradata - - def make_df_unit(self): - df_unit = self.df_active_paradata[['interview__id', 'responsible', 'survey_name', 'survey_version']].copy() - df_unit.drop_duplicates(inplace=True) - df_unit = df_unit[(df_unit['responsible'] != '') & (~pd.isnull(df_unit['responsible']))] - df_unit = self.add_pause_features(df_unit) - df_unit = self.add_unit_time_features(df_unit) - return df_unit - - def make_df_responsible(self): - df_resp = self.df_active_paradata[['responsible']].copy() - df_resp.drop_duplicates(inplace=True) - df_resp = df_resp[(df_resp['responsible'] != '') & (~pd.isnull(df_resp['responsible']))] - return df_resp - - def save_data(self, df, file_name): - - target_dir = os.path.join(self._raw_path, self._survey_names) - survey_path = os.path.join(target_dir, self._survey_version) - processed_data_path = os.path.join(survey_path, 'processed_data') - df.to_pickle(os.path.join(processed_data_path, f'{file_name}.pkl')) - - def get_make_methods(self, method_type='feature', level='item'): - return [method for method in dir(self) if method.startswith(f"make_{method_type}_{level}__") - and callable(getattr(self, method))] - - ###### Feature item methods - def make_feature_item__string_length(self, feature_name): - # f__string_length, length of string answer, if TextQuestions else empty pd.NA - text_question_mask = (self._df_item["qtype"] == 'TextQuestion') - self._df_item[feature_name] = pd.NA - self._df_item.loc[text_question_mask, feature_name] = self._df_item.loc[ - text_question_mask, 'value'].str.len().astype('Int64') - - def make_feature_item__numeric_response(self, feature_name): - # f__numeric_response, response, if NumericQuestions, else empty pd.NA - self._df_item[feature_name] = np.nan - self._df_item.loc[self.numeric_question_mask, feature_name] = \ - self._df_item[self.numeric_question_mask]['value'].astype( - float) - - def make_feature_item__first_digit(self, feature_name): - # f__first_digit, first digit of the response if numeric question else empty pd.NA - self._df_item[feature_name] = pd.NA - self._df_item.loc[self.numeric_question_mask, feature_name] = \ - pd.to_numeric(self._df_item.loc[self.numeric_question_mask, 'value']).abs().astype(str).str[0].astype( - 'Int64') - - def make_feature_item__last_digit(self, feature_name): - # f__last_digit, modulus of 10 of the response if numeric question else empty pd.NA - self._df_item[feature_name] = pd.NA - - def extract_last_digit(x): - if x >= 1: # Check if the value has at least two digits - return x % 10 # Return the last digit - else: - return pd.NA - - self._df_item.loc[self.numeric_question_mask, feature_name] = pd.to_numeric( - self._df_item.loc[self.numeric_question_mask, 'value']).astype('int64') - - self._df_item.loc[self.numeric_question_mask, feature_name] = self._df_item.loc[ - self.numeric_question_mask, feature_name].apply(extract_last_digit) - - def make_feature_item__first_decimal(self, feature_name): - # f__first_decimal, first decimal digit if numeric question else empty pd.NA - decimal_question_mask = (self._df_item['is_integer'] == False) & (self._df_item['value'] != '') - self._df_item[feature_name] = pd.NA - values = self._df_item.loc[decimal_question_mask, 'value'].astype(float) - self._df_item.loc[decimal_question_mask, feature_name] = np.floor(values * 100) % 100 - self._df_item[feature_name] = self._df_item[feature_name].astype('Int64') - - def make_feature_item__answer_position(self, feature_name): - # f__rel_answer_position, relative position of the selected answer - # only questions with more than two answers - single_question_mask = ((self._df_item["qtype"] == 'SingleQuestion') - & (self._df_item['n_answers'] > 2) - & (self._df_item['is_filtered_combobox'] == False) - & (pd.isnull(self._df_item['cascade_from_question_id']))) - - def answer_position(row): - value = None - if (row['value'] in row['answer_sequence']) and pd.notnull(row['value']): - value = round(row['answer_sequence'].index(row['value']) / (row['n_answers'] - 1), 3) - return value - - self._df_item.loc[single_question_mask, feature_name] = ( - self._df_item.loc[single_question_mask].apply(answer_position, axis=1)) - - def get_feature_item__answer_removed(self, feature_name): - # This method cannot be used to directly insert the feature within df_item as the item - # might no longer exist in microdata, but only in paradta - # f__answer_removed, answers removed (by interviewer, or by system as a result of interviewer action). - removed_mask = (self.df_paradata['event'] == 'AnswerRemoved') & (self.df_paradata['role'] == 1) - df_item_removed = self.df_paradata[removed_mask] - - df_item_removed = df_item_removed.groupby(['interview__id', 'responsible', 'variable_name', 'qnr_seq', ]).agg( - f__answer_removed=('order', 'count'), - ) - return df_item_removed.reset_index() - - def make_feature_item__answer_changed(self, feature_name): - - df_changed_temp = self.df_active_paradata[self.df_active_paradata['event'] == 'AnswerSet'] - df_changed_temp[feature_name] = False - - # list and multi-select questions (without yes_no_mode) - list_mask = (df_changed_temp["qtype"] == 'TextListQuestion') - multi_mask = (df_changed_temp['yes_no_view'] == False) - df_changed_temp['answer_list'] = pd.NA - df_changed_temp.loc[list_mask, 'answer_list'] = df_changed_temp.loc[list_mask, 'answer'].str.split('|') - df_changed_temp.loc[multi_mask, 'answer_list'] = df_changed_temp.loc[multi_mask, 'answer'].str.split( - ', |\\|') - df_changed_temp['prev_answer_list'] = df_changed_temp.groupby(self.item_level_columns + ['index_col'])[ - 'answer_list'].shift() - answers_mask = df_changed_temp['prev_answer_list'].notna() - df_changed_temp.loc[answers_mask, feature_name] = df_changed_temp.loc[answers_mask].apply( - lambda row: not set(row['prev_answer_list']).issubset(set(row['answer_list'])), axis=1) - - # single answer question - df_changed_temp['prev_answer'] = df_changed_temp.groupby(self.item_level_columns + ['index_col'])[ - 'answer'].shift() - single_answer_mask = (~df_changed_temp["qtype"].isin(['MultyOptionsQuestion', 'TextListQuestion'])) & \ - (df_changed_temp['prev_answer'].notna()) & \ - (df_changed_temp['answer'] != df_changed_temp['prev_answer']) - df_changed_temp.loc[single_answer_mask, feature_name] = True - - # yes_no_view questions - yesno_mask = (df_changed_temp['yes_no_view'] == True) - df_filtered = df_changed_temp[yesno_mask].copy() - df_filtered[['yes_list', 'no_list']] = df_filtered['answer'].str.split('|', expand=True) - df_filtered['yes_list'] = df_filtered['yes_list'].str.split(', ').apply( - lambda x: [] if x == [''] or x is None else x) - df_filtered['no_list'] = df_filtered['no_list'].str.split(', ').apply( - lambda x: [] if x == [''] or x is None else x) - df_filtered['prev_yes_list'] = df_filtered.groupby(self.item_level_columns + ['index_col'])['yes_list'].shift( - fill_value=[]) - df_filtered['prev_no_list'] = df_filtered.groupby(self.item_level_columns + ['index_col'])['no_list'].shift( - fill_value=[]) - df_changed_temp.loc[yesno_mask, feature_name] = df_filtered.apply( - lambda row: not set(row['prev_yes_list']).issubset(set(row['yes_list'])), axis=1) - df_changed_temp.loc[yesno_mask, feature_name] = df_filtered.apply( - lambda row: not set(row['prev_no_list']).issubset(set(row['no_list'])), axis=1) - - # count on item level - df_changed_temp = df_changed_temp.groupby('index_col')[feature_name].sum().reset_index() - self._df_item[feature_name] = self._df_item['index_col'].map( - df_changed_temp.set_index('index_col')[feature_name]) - - def make_feature_item__answer_selected(self, feature_name): - # f__answers_selected, number of answers selected in a multi-answer or list question - multi_list_mask = self._df_item["qtype"].isin(['MultyOptionsQuestion']) - - # Function to calculate the number of elements in a list or return nan - def count_elements_or_nan(val): - if isinstance(val, list): - return len(val) - else: - return np.nan - - self._df_item.loc[multi_list_mask, feature_name] = self._df_item.loc[multi_list_mask, 'value'].apply( - count_elements_or_nan) - # f__share_selected, share between answers selected, and available answers (only for unlinked questions) - self._df_item[feature_name] = self._df_item[feature_name] / self._df_item['n_answers'] - - def make_feature_item__comment_length(self, feature_name): - # f__comment_length - comment_mask = (self.df_paradata['event'] == 'CommentSet') & \ - (self.df_paradata['role'] == 1) - - df_item_comment = self.df_paradata[comment_mask].copy() - df_item_comment[feature_name] = df_item_comment['answer'].str.len() - df_item_comment = df_item_comment.groupby('index_col').agg( - f__comment_length=(feature_name, 'sum'), - ) - self._df_item[feature_name] = self._df_item['index_col'].map( - df_item_comment[feature_name]) - - def make_feature_item__comment_set(self, feature_name): - # f__comments_set - comment_mask = (self.df_paradata['event'] == 'CommentSet') & \ - (self.df_paradata['role'] == 1) - - df_item_comment = self.df_paradata[comment_mask].copy() - df_item_comment = df_item_comment.groupby('index_col').agg( - f__comment_set=('order', 'count'), - ) - self._df_item[feature_name] = self._df_item['index_col'].map( - df_item_comment[feature_name]) - - def make_feature_item__gps(self, feature_name): - # f__gps_latitude, f__gps_longitude, f__gps_accuracy - gps_mask = self._df_item["qtype"] == 'GpsCoordinateQuestion' - gps_df = self._df_item.loc[gps_mask, 'value'].str.split(',', expand=True) - gps_df.columns = ['gps__Latitude', 'gps__Longitude', 'gps__Accuracy', 'gps__altitude', 'gps__timestamp_utc'] - self._df_item[feature_name] = False - self._df_item.loc[gps_mask, feature_name] = True - self._df_item.loc[gps_mask, 'f__gps_latitude'] = pd.to_numeric(gps_df['gps__Latitude'], errors='coerce') - self._df_item.loc[gps_mask, 'f__gps_longitude'] = pd.to_numeric(gps_df['gps__Longitude'], errors='coerce') - self._df_item.loc[gps_mask, 'f__gps_accuracy'] = pd.to_numeric(gps_df['gps__Accuracy'], errors='coerce') - drop_columns = [col for col in self._df_item.columns if col.startswith('gps__')] - self._df_item.drop(columns=drop_columns, inplace=True) - - ##### UNIT item methods - - def make_feature_unit__number_answered(self, feature_name): - answer_set_mask = ((~pd.isnull(self._df_item['value'])) - & (self._df_item['value'] != -999999999) - & (self._df_item['value'] != '##N/A##') - & (self._df_item['value'] != '') - & (self._df_item["qtype"] != 'Variable') - ) - df_answer_set = self._df_item[answer_set_mask] - df_answer_set = df_answer_set.groupby('interview__id').agg( - f__number_answered=('value', 'count') - ) - self._df_unit[feature_name] = self._df_unit['interview__id'].map( - df_answer_set[feature_name]) - self._df_unit[feature_name].fillna(0, inplace=True) - - def make_feature_unit__number_unanswered(self, feature_name): - answer_unset_mask = ( - (self._df_item['value'] == -999999999) - | (self._df_item['value'] == '##N/A##') - ) & (self._df_item["qtype"] != 'Variable') - df_answer_set = self._df_item[answer_unset_mask] - df_answer_set = df_answer_set.groupby('interview__id').agg( - f__number_unanswered=('value', 'count') - ) - self._df_unit[feature_name] = self._df_unit['interview__id'].map( - df_answer_set[feature_name]) - # Set to zero if not answered is not present - self._df_unit[feature_name].fillna(0, inplace=True) - - def make_feature_unit__translation_positions(self, feature_name): - - trans_mask = (self.df_paradata['event'].isin(['AnswerSet', 'TranslationSwitched'])) - - df_trans_temp = self.df_paradata.loc[ - trans_mask, ['interview__id', 'order', 'event', 'param']].copy().reset_index() - df_trans_temp['seq'] = df_trans_temp.groupby('interview__id').cumcount() + 1 - - # Define a function to calculate the relative positions - def relative_translation_positions(group): - total_rows = len(group) - translation_position = group.loc[group['event'] == 'TranslationSwitched', 'seq'] - relative_positions = [pos / total_rows for pos in translation_position] - return relative_positions - - # Group by 'interview__id' and apply the function - df_trans_temp = df_trans_temp.groupby('interview__id').apply( - relative_translation_positions).reset_index().rename(columns={0: feature_name}) - - self._df_unit[feature_name] = self._df_unit['interview__id'].map( - df_trans_temp.set_index('interview__id')[feature_name]) - - def add_pause_features(self, df_unit): - # Define the list of features depending on sequences - pause_features = ['f__pause_count', 'f__pause_duration', - 'f__pause_list'] - if any(col in self._allowed_features for col in pause_features): - df_pause = self.get_df_time() - df_pause = df_pause.groupby('interview__id').agg( - f__pause_count=('f__pause_duration', 'size'), # Count all occurrences - f__pause_duration=('f__pause_duration', 'sum'), # Sum non-null values - f__pause_list=('f__pause_duration', lambda x: x.tolist()) - ) - - df_pause = df_pause.reset_index() - # Remove non-selected features - pause_features = ['interview__id'] + [f for f in pause_features if f in self._allowed_features] - df_pause = df_pause[pause_features] - # Merge with df_item - - df_unit = df_unit.merge(df_pause, how='left', on='interview__id') - return df_unit - - def add_unit_time_features(self, df_unit): - # Define the list of features depending on time - time_features = ['f__total_duration', 'f__total_elapse', 'f__days_from_start', 'f__time_changed'] - if any(col in self._allowed_features for col in time_features): - df_time = self.get_df_time() - - df_dur = df_time.groupby('interview__id').agg( - f__total_duration=('f__total_duration', 'sum'), - f__total_elapse=('timestamp_local', lambda x: (x.max() - x.min()).total_seconds()), - f__time_changed=('f__time_changed', 'sum'), - f__days_from_start=('f__days_from_start', 'min'), - ) - - df_dur = df_dur.reset_index() - # Remove non-selected features - time_features = ['interview__id'] + [f for f in time_features if f in self._allowed_features] - df_dur = df_dur[time_features] - - # # convert total_duration and total_elapseinto minutes - # df_dur['f__total_duration'] = df_dur['f__total_duration'] / 60 - # df_dur['f__total_elapse'] = df_dur['f__total_elapse'] / 60 - # Merge with df_item - df_unit = df_unit.merge(df_dur, how='left', on='interview__id') - return df_unit diff --git a/rissk/feature_processing_kedro.py b/rissk/feature_processing_kedro.py new file mode 100644 index 0000000..ff1ea9f --- /dev/null +++ b/rissk/feature_processing_kedro.py @@ -0,0 +1,921 @@ +import math +import pandas as pd +import numpy as np +import ast +import logging + +from rissk.utils.stats_utils_kedro import first_digit + +logger = logging.getLogger(__name__) + +# --- Helper Functions --- + +def make_index_col(df: pd.DataFrame) -> pd.DataFrame: + """Creates a unique index column based on interview_id, variable_name, and roster_level.""" + # fillna('') normalises NaN roster_level (non-roster items) and NaN variable_name + # to empty string before concatenation, matching legacy make_index_col behaviour. + # Trailing/leading underscores are stripped so non-roster items don't get a trailing '_'. + df_temp = df[['interview__id', 'variable_name', 'roster_level']].fillna('').astype(str) + + # Concatenate columns + df['index_col'] = df_temp['interview__id'] + "_" + df_temp['variable_name'] + "_" + df_temp['roster_level'] + + # Remove trailing and leading underscores + df['index_col'] = df['index_col'].str.strip('_') + return df + +def get_numeric_mask(df_item: pd.DataFrame, filter_answer_values: bool) -> pd.Series: + """Returns a boolean mask for valid numeric question rows, matching the legacy numeric_question_mask.""" + sentinel_mask = _is_missing_numeric_sentinel(df_item['value']) + mask = ( + (df_item["qtype"] == 'NumericQuestion') & + (~pd.isnull(df_item['value'])) & + (~sentinel_mask) + ) + if filter_answer_values: + answer_mask = _is_answer_value(df_item['value'], df_item['answer_sequence']) + mask &= ~answer_mask + + return mask + + +def _is_missing_numeric_sentinel(values: pd.Series) -> pd.Series: + """Robustly detects the numeric missing-value sentinel across mixed object values.""" + return pd.to_numeric(values, errors='coerce').eq(-999999999) + +def _is_answer_value(values: pd.Series, answer_sequence: pd.Series) -> pd.Series: + """Returns True where the numeric value matches an item in the answer_sequence list. + + answer_sequence is expected to be the string-coerced form produced by + paradata['answer_sequence'].apply(str), e.g. "[1, 2]", "[0, -99]", "nan". + """ + def _row_is_answer(value, seq_str): + if not isinstance(seq_str, str) or seq_str in ('nan', 'None', ''): + return False + try: + items = ast.literal_eval(seq_str) + except (ValueError, SyntaxError): + return False + if not isinstance(items, list): + return False + numeric_val = pd.to_numeric(value, errors='coerce') + if pd.isna(numeric_val): + return False + return any(numeric_val == pd.to_numeric(item, errors='coerce') for item in items) + + return pd.Series( + [_row_is_answer(v, s) for v, s in zip(values, answer_sequence)], + index=values.index, + dtype=bool, + ) + + +def _coerce_numeric_with_warning(df_item: pd.DataFrame, numeric_mask: pd.Series, feature_name: str) -> pd.Series: + """Coerce numeric values and warn about rows that cannot be parsed.""" + values = df_item.loc[numeric_mask, 'value'] + coerced = pd.to_numeric(values, errors='coerce') + + failed_mask = coerced.isna() & values.notna() + failed_count = int(failed_mask.sum()) + if failed_count > 0: + sample_bad_values = values[failed_mask].astype(str).drop_duplicates().head(10).tolist() + logger.warning( + "%s: failed to parse %d numeric value(s); coerced to NaN. Sample values: %s", + feature_name, + failed_count, + sample_bad_values, + ) + + return coerced + +def get_df_time(df_paradata_full: pd.DataFrame) -> pd.DataFrame: + """Calculates time differences and durations from paradata. + + Mirrors the legacy df_active_paradata filter before computing time deltas: + - AnswerSet / AnswerRemoved / CommentSet: included only when question_scope == 0 + - InterviewCreated / Resumed / Restarted: no question scope (NaN); included regardless. + - All other event types (Completed, ApprovalRequested, etc.): excluded. + + """ + # Events that carry a question scope — keep only interviewer-scope (== 0). + # NaN scope (supervisor-originated or no-question events) is intentionally excluded here. + question_scope_events = ['AnswerSet', 'AnswerRemoved', 'CommentSet'] + # Events that have no question scope (pause / session events); always include. + no_scope_events = ['InterviewCreated', 'Resumed', 'Restarted'] + + active_mask = ( + (df_paradata_full['event'].isin(no_scope_events)) | + (df_paradata_full['event'].isin(question_scope_events) & (df_paradata_full['question_scope'] == 0)) + ) + df_time = df_paradata_full[active_mask].copy() + + # calculate time difference in seconds + df_time['time_difference'] = df_time.groupby('interview__id')['timestamp_local'].diff() + df_time['time_difference'] = df_time['time_difference'].dt.total_seconds() + + # Logic for f__time_changed (negative time diffs < -180s) + df_time['f__time_changed'] = np.where(df_time['time_difference'] < -180, df_time['time_difference'], np.nan) + + # Mask negative time differences for duration calculations + # Using pd.NA for nullable integers/floats in pandas if column allows, or np.nan + df_time.loc[df_time['time_difference'] < 0, 'time_difference'] = pd.NA + + # f__answer_duration: total time spent recording answers, i.e. the sum of all + # time-intervals from active events that conclude with AnswerSet or AnswerRemoved. + df_time['f__answer_duration'] = df_time.loc[ + df_time['event'].isin(['AnswerSet', 'AnswerRemoved']), 'time_difference'] + # f__comment_duration: total time spent on comments, i.e. the sum of all + # time-intervals from active events that conclude with CommentSet. + df_time['f__comment_duration'] = df_time.loc[df_time['event'] == 'CommentSet', 'time_difference'] + df_time['f__pause_duration'] = df_time.loc[df_time['event'].isin(['Resumed', 'Restarted']), 'time_difference'] + + # UNIT features helper logic + active_events = ['AnswerSet', 'AnswerRemoved', 'CommentSet', 'Resumed', 'Restarted'] + + # Calculate total duration (capped at 30 mins per event) + condition = (df_time['event'].isin(active_events)) & (df_time['time_difference'] < 30 * 60) + df_time['f__total_duration'] = df_time.loc[condition, 'time_difference'] + + # Starting timestamp per interview: minimum timestamp among AnswerSet events (not the + # global event minimum). If the device clock is adjusted later in the interview, the + # overall min of timestamp_local would return a misleadingly early start time. Anchoring + # to the first AnswerSet avoids this clock-adjustment artefact. + start_time_map = df_time[df_time['event'] == 'AnswerSet'].groupby('interview__id')['timestamp_local'].min() + df_time['f__starting_timestamp'] = df_time['interview__id'].map(start_time_map) + + min_date = df_time['f__starting_timestamp'].min() + if pd.notna(min_date): + df_time['f__days_from_start'] = (df_time['timestamp_local'] - min_date).dt.days.abs() + else: + df_time['f__days_from_start'] = np.nan + + return df_time + +def get_df_sequence(df_paradata_full: pd.DataFrame) -> pd.DataFrame: + """Calculates sequence-based features (jumps, previous answers).""" + # Filter for AnswerSet and get the last entry per index_col (filter is already applied in base item table creation) + # mask = df_paradata_full['event'] == 'AnswerSet' + df_last = df_paradata_full.groupby('index_col').last().copy() + + # The groupby puts index_col in the index. + # We need to sort by interview_id and order to reconstruct the sequence flow. + # 'order' column is assumed to exist from ingestion. + df_last = df_last.sort_values(['interview__id', 'order']).reset_index() + + # f__previous_question, f__previous_answer, f__previous_roster + # Using shift on the group + df_last['f__previous_question'] = df_last.groupby('interview__id')['variable_name'].shift() + df_last['f__previous_answer'] = df_last.groupby('interview__id')['answer'].shift().fillna(pd.NA) + df_last['f__previous_roster'] = df_last.groupby('interview__id')['roster_level'].shift().fillna(pd.NA) + + # f__sequence_jump: the change in the gap between questionnaire order and answer order + # from one answered question to the next. A non-zero value means the interviewer skipped + # questions or navigated backwards relative to the questionnaire sequence. + # Calculate answer sequence (1, 2, 3...) based on actual occurrence order. + df_last['answer_sequence'] = df_last.groupby('interview__id').cumcount() + 1 + + # diff = questionnaire position - answer position; a constant diff means sequential + # answering, while a change in diff indicates skipping ahead or going backwards. + df_last['question_sequence'] = pd.to_numeric(df_last['question_sequence'], errors='coerce').fillna(0) + df_last['diff'] = df_last['question_sequence'] - df_last['answer_sequence'] + + # The jump is how much the diff itself changed from one answer to the next. + df_last['f__sequence_jump'] = df_last.groupby('interview__id')['diff'].diff() + + return df_last + +def add_sequence_features(df_item: pd.DataFrame, df_sequence: pd.DataFrame, allowed_features: list) -> pd.DataFrame: + """Merge sequence-based features from df_sequence onto df_item for enabled features.""" + sequence_features = ['f__previous_question', 'f__previous_answer', + 'f__previous_roster', 'f__sequence_jump'] + + # Filter to only allowed features + selected_features = [f for f in sequence_features if f in allowed_features] + + if selected_features: + # Select columns to merge + cols_to_use = ['index_col'] + selected_features + # Ensure columns exist in df_sequence + cols_to_use = [c for c in cols_to_use if c in df_sequence.columns] + + if len(cols_to_use) > 1: # at least index_col + 1 feature + df_item = df_item.merge(df_sequence[cols_to_use], how='left', on='index_col') + + return df_item + +def add_item_time_features(df_item: pd.DataFrame, df_time: pd.DataFrame, allowed_features: list, item_level_columns: list) -> pd.DataFrame: + """Aggregate per-event answer/comment durations from df_time to item level and merge onto df_item.""" + time_features = ['f__answer_duration', 'f__comment_duration'] + + selected_features = [f for f in time_features if f in allowed_features] + + if selected_features: + # Filter out pause events (Resumed/Restarted) which have variable_name=NaN in Kedro + # (legacy used '' after global fillna(''), but NaN is the correct sentinel here) + df_time_filtered = df_time[df_time['variable_name'].notna()].copy() + # AnswerRemoved / CommentSet events have roster_level=None in paradata (no roster context + # is recorded on removal/comment events), while AnswerSet rows carry ''. Normalise to '' + # so they land in the same groupby bucket as the corresponding AnswerSet events, matching + # the legacy behaviour where process_paradata does fillna('') on the whole dataframe. + df_time_filtered['roster_level'] = df_time_filtered['roster_level'].fillna('') + + # Summarize on item level + # Note: df_time might have multiple events per item (e.g. AnswerRemoved then AnswerSet) + # We sum the durations. + agg_dict = {} + if 'f__answer_duration' in selected_features: + agg_dict['f__answer_duration'] = 'sum' + if 'f__comment_duration' in selected_features: + agg_dict['f__comment_duration'] = 'sum' + + if agg_dict: + # Ensure grouping columns exist + group_cols = [c for c in item_level_columns + ['index_col'] if c in df_time_filtered.columns] + + df_agg = df_time_filtered.groupby(group_cols).agg(agg_dict).reset_index() + + # Merge + df_agg = df_agg[['index_col'] + list(agg_dict.keys())] + df_item = df_item.merge(df_agg, how='left', on='index_col') + + return df_item + +def add_pause_features(df_unit: pd.DataFrame, df_time: pd.DataFrame, allowed_features: list) -> pd.DataFrame: + """Aggregate pause count, total duration, and duration list from df_time to the unit table.""" + pause_features = ['f__pause_count', 'f__pause_duration', 'f__pause_list'] + selected_features = [f for f in pause_features if f in allowed_features] + + if not selected_features: + return df_unit + + # Legacy-like flow: compute all pause aggregations once, then keep selected columns. + # Keep the correction vs legacy: count only non-null pauses. + df_pause = df_time.groupby('interview__id').agg( + f__pause_count=('f__pause_duration', 'count'), + f__pause_duration=('f__pause_duration', 'sum'), + # Keep only real pause durations; all-NaN groups become an empty list. + f__pause_list=('f__pause_duration', lambda x: [v for v in x.tolist() if pd.notna(v)]), + ).reset_index() + + df_pause = df_pause[['interview__id'] + selected_features] + df_unit = df_unit.merge(df_pause, how='left', on='interview__id') + + # Fill NaNs for pause features: no pauses -> count=0, duration=0, empty list. + if 'f__pause_count' in selected_features: + df_unit['f__pause_count'] = df_unit['f__pause_count'].fillna(0).astype(int) + if 'f__pause_duration' in selected_features: + df_unit['f__pause_duration'] = df_unit['f__pause_duration'].fillna(0) + if 'f__pause_list' in selected_features: + # Ensure interviews absent in df_time also get an empty list after merge. + df_unit['f__pause_list'] = df_unit['f__pause_list'].apply( + lambda x: x if isinstance(x, list) else [] + ) + + return df_unit + +def add_unit_time_features(df_unit: pd.DataFrame, df_time: pd.DataFrame, allowed_features: list) -> pd.DataFrame: + """Aggregate interview-level time features (total duration, elapse, days since start, clock shifts) onto df_unit.""" + time_features = ['f__total_duration', 'f__total_elapse', 'f__days_from_start', 'f__time_changed'] + selected_features = [f for f in time_features if f in allowed_features] + + if not selected_features: + return df_unit + + # Legacy-like flow: compute all unit-time aggregations once, then keep selected columns. + df_dur = df_time.groupby('interview__id').agg( + f__total_duration=('f__total_duration', 'sum'), + f__total_elapse=('timestamp_local', lambda x: (x.max() - x.min()).total_seconds()), + f__time_changed=('f__time_changed', 'sum'), + f__days_from_start=('f__days_from_start', 'min'), + ).reset_index() + + df_dur = df_dur[['interview__id'] + selected_features] + df_unit = df_unit.merge(df_dur, how='left', on='interview__id') + + return df_unit + + +# --- Base Table Creation --- + +def create_base_item_table(microdata: pd.DataFrame, paradata_full: pd.DataFrame, parameters: dict) -> pd.DataFrame: + """ + Creates the base item table by merging microdata with paradata information. + Equivalent to FeatureProcessing.make_df_item. + """ + logger.info("Creating base item table...") + + if microdata.empty: + logger.error( + "create_base_item_table: microdata is empty — all microdata files were missing " + "or contained no data rows. Cannot build item table. Returning empty DataFrame." + ) + return pd.DataFrame() + + item_level_columns = ['interview__id', 'variable_name', 'roster_level'] + allowed_features = ['f__' + k for k, v in parameters['features'].items() if v.get('use', False)] + + sequence_features = ['f__previous_question', 'f__previous_answer', 'f__previous_roster', 'f__sequence_jump'] + time_features = ['f__answer_duration', 'f__comment_duration'] + calculate_sequence = any(f in allowed_features for f in sequence_features) + calculate_time = any(f in allowed_features for f in time_features) + + # 1. Create Index Column on Microdata + df_item = make_index_col(microdata.copy()) + + # 2. Select initial columns + columns = ['value', "qtype", 'is_integer', 'qnr_seq', + 'n_answers', 'answer_sequence', + 'cascade_from_question_id', 'is_filtered_combobox', + 'index_col', 'qnr', 'qnr_version'] + item_level_columns + + # Intersect with available columns to avoid KeyErrors + df_item = df_item[columns].copy() + + # 3. Prepare Paradata for Merge + # We want the *last* AnswerSet for each item + paradata_columns = ['responsible', 'f__answer_hour_set', 'interviewing', 'tz_offset'] + # available_para_cols = [c for c in paradata_columns if c in paradata_full.columns] + + # Interviewer-scope AnswerSet events: scope==0 means interviewer, scope==1 means supervisor. + # Pause events (Resumed/Restarted) have NaN scope; no fillna needed — they are not AnswerSet events. + interviewer_answer_mask = ( + (paradata_full['event'] == 'AnswerSet') & + (paradata_full['question_scope'] == 0) + ) + + data_to_merge = ( + paradata_full[interviewer_answer_mask] + .dropna(subset=['index_col']) # drop rows without index_col + # keep the last AnswerSet per item, paradata is already sorted by interview__id and order in the processing node + .drop_duplicates(subset='index_col', keep='last') + [['index_col'] + paradata_columns] # select only necessary columns for merging + ) + + # 4. Merge + df_item = df_item.merge(data_to_merge[paradata_columns + ['index_col']], how='left', on='index_col') + + # 5. Filter for 'interviewing' == True (Supervisor Logic) + # Remove items that are not in interviewing + df_item = df_item[df_item['interviewing'] == True].copy() + + # 6. Add Sequence Features + if calculate_sequence: + df_sequence = get_df_sequence(paradata_full[interviewer_answer_mask]) + df_item = add_sequence_features(df_item, df_sequence, allowed_features) + + # 7. Add Time Features + if calculate_time: + # Pass full paradata; get_df_time filters by event type internally. + # This correctly includes pause events (Resumed/Restarted) which have NaN question_scope. + df_time = get_df_time(paradata_full) + df_item = add_item_time_features(df_item, df_time, allowed_features, item_level_columns) + + return df_item + +def create_base_unit_table(paradata_full: pd.DataFrame, parameters: dict) -> pd.DataFrame: + """ + Creates the base unit table (one row per interview). + Equivalent to FeatureProcessing.make_df_unit. + """ + logger.info("Creating base unit table...") + + if paradata_full.empty: + logger.error( + "create_base_unit_table: paradata_full is empty — no paradata to build unit table from. " + "Returning empty DataFrame." + ) + return pd.DataFrame() + + allowed_features = ['f__' + k for k, v in parameters['features'].items() if v.get('use', False)] + + # 1. Initialize from paradata + columns = ['interview__id', 'responsible', 'qnr', 'qnr_version'] + + # Match legacy code and use active paradata to seed the unit table. + question_scope_events = ['AnswerSet', 'AnswerRemoved', 'CommentSet'] + # Events that have no question scope (pause / session events); always include. + no_scope_events = ['InterviewCreated', 'Resumed', 'Restarted'] + + active_mask = ( + (paradata_full['event'].isin(no_scope_events)) | + (paradata_full['event'].isin(question_scope_events) & (paradata_full['question_scope'] == 0)) + ) + + df_unit = paradata_full[active_mask][columns].copy() + df_unit.drop_duplicates(inplace=True) + + # Filter valid responsible + df_unit = df_unit[df_unit['responsible'].notna()] + + pause_features = ['f__pause_count', 'f__pause_duration', 'f__pause_list'] + unit_time_features = ['f__total_duration', 'f__total_elapse', 'f__days_from_start', 'f__time_changed'] + calculate_pause = any(f in allowed_features for f in pause_features) + calculate_unit_time = any(f in allowed_features for f in unit_time_features) + + if calculate_pause or calculate_unit_time: + # Pass full paradata so get_df_time correctly includes pause events (Resumed/Restarted) + # which have NaN question_scope and would be dropped by any scope filter. + df_time = get_df_time(paradata_full) + if calculate_pause: + df_unit = add_pause_features(df_unit, df_time, allowed_features) + if calculate_unit_time: + df_unit = add_unit_time_features(df_unit, df_time, allowed_features) + + return df_unit + + +# --- Feature Enrichment Functions (Item) --- + +def feat_string_length(df_item, **kwargs): + # f__string_length, length of string answer, if TextQuestions else empty pd.NA + feature_name = 'f__string_length' + mask = df_item["qtype"] == 'TextQuestion' + df_item[feature_name] = pd.NA + if mask.any(): + # Use .str.len() directly to preserve NA (astype(str) would convert NaN -> "nan", length 3) + df_item.loc[mask, feature_name] = df_item.loc[mask, 'value'].str.len().astype('Int64') + return df_item + +def feat_numeric_response(df_item, **kwargs): + # f__numeric_response, response, if NumericQuestions, else empty pd.NA + feature_name = 'f__numeric_response' + # Use the same mask as legacy: excludes empty, null, and -999999999 + # filter_answer_values=True would exclude values that match the answer options (legacy did not apply this filter) + numeric_mask = get_numeric_mask(df_item=df_item, filter_answer_values=True) + df_item[feature_name] = np.nan + if numeric_mask.any(): + numeric_values = _coerce_numeric_with_warning(df_item, numeric_mask, feature_name) + df_item.loc[numeric_mask, feature_name] = numeric_values + return df_item + +def feat_first_digit(df_item, **kwargs): + # f__first_digit, first digit of the response if numeric question else empty pd.NA + feature_name = 'f__first_digit' + # Use the same mask as legacy: excludes empty, null, and -999999999 + # filter_answer_values=True would exclude values that match the answer options (legacy did not apply this filter) + numeric_mask = get_numeric_mask(df_item=df_item, filter_answer_values=True) + df_item[feature_name] = pd.NA + if numeric_mask.any(): + numeric_values = _coerce_numeric_with_warning(df_item, numeric_mask, feature_name) + vals = numeric_values.apply(first_digit) + df_item.loc[numeric_mask, feature_name] = pd.array(vals, dtype='Int64') + return df_item + +def feat_last_digit(df_item, **kwargs): + # f__last_digit, modulus of 10 of the response if numeric question else empty pd.NA + feature_name = 'f__last_digit' + # Use the same mask as legacy: excludes empty, null, and -999999999 + # filter_answer_values=True would exclude values that match the answer options (legacy did not apply this filter) + numeric_mask = get_numeric_mask(df_item=df_item, filter_answer_values=True) + df_item[feature_name] = pd.NA + + if numeric_mask.any(): + numeric_values = _coerce_numeric_with_warning(df_item, numeric_mask, feature_name) + # Legacy casts to int64 before extracting the last digit. + # Use truncation toward zero so decimals behave like integer casting. + vals = np.trunc(numeric_values).astype('Int64') + # .where(condition) keeps values where True, sets False to NA + df_item.loc[numeric_mask, feature_name] = (vals % 10).where(vals >= 1) + + return df_item + +def feat_first_decimals(df_item, **kwargs): + # f__first_decimals, first decimals if numeric question else empty pd.NA + feature_name = 'f__first_decimals' + # mask: not integer, not empty & not mumeric sentinel + numeric_mask = get_numeric_mask(df_item=df_item, filter_answer_values=True) + mask_integer = (df_item['is_integer'] == False) & (~pd.isnull(df_item['value'])) + mask = numeric_mask & mask_integer + df_item[feature_name] = pd.NA + + if mask.any(): + values = pd.to_numeric(df_item.loc[mask, 'value'], errors='coerce') + # Intentional: capture the first TWO decimal digits (e.g. 3.47 → 47) rather than just + # the first (e.g. 4). Using two digits gives the COF model a finer-grained signal and + # materially reduces hash-collisions for values like x.10, x.20 … x.90 that would be + # indistinguishable if only a single decimal were retained. The feature is therefore + # named f__first_decimals (plural) to make this design choice visible at a glance. + res = np.floor(values * 100) % 100 + df_item.loc[mask, feature_name] = res.astype('Int64') + + # Match legacy: ensure the full feature column uses nullable integer dtype. + df_item[feature_name] = df_item[feature_name].astype('Int64') + + return df_item + +def feat_answer_position(df_item, **kwargs): + """Compute f__answer_position: relative position of the selected answer within the answer list. + + Calculated only for SingleQuestion items with more than two options, excluding filtered + comboboxes and cascade children. Position is idx / (n_answers - 1), producing a value in [0, 1]. + """ + # f__answer_position, relative position of the selected answer + # only questions with more than two answers + feature_name = 'f__answer_position' + + # filters + mask = ((df_item["qtype"] == 'SingleQuestion') + & (df_item['n_answers'] > 2) + & (df_item['is_filtered_combobox'] == False) + & (df_item['cascade_from_question_id'].isna())) + df_item[feature_name] = np.nan + + if mask.any(): + # logic: index of value in answer_sequence / (n_answers - 1) + # answer_sequence is a list-like or serialized as string. + + def calc_pos(row): + try: + seq = ast.literal_eval(str(row['answer_sequence'])) + if not isinstance(seq, list) or len(seq) == 0: + return np.nan + + val = pd.to_numeric(row['value'], errors='coerce') + if pd.isna(val): + return np.nan + + # Align numeric types when seq is integer-coded. + if all(isinstance(x, (int, np.integer)) for x in seq) and float(val).is_integer(): + val = int(val) + + if val not in seq: + return np.nan + + n = row['n_answers'] + if pd.isna(n) or n <= 1: + return np.nan + + idx = seq.index(val) + return round(idx / (n - 1), 3) + except Exception: + return np.nan + + # Apply is slow but robust for list operations in cells + df_item.loc[mask, feature_name] = df_item.loc[mask].apply(calc_pos, axis=1) + + return df_item + +def feat_answer_removed(paradata_full): + """Aggregate AnswerRemoved event counts per item into f__answer_removed. + + Returns a standalone DataFrame (not merged into df_item) because AnswerRemoved events + may reference items that were subsequently deleted from microdata and are therefore absent + from df_item. Consuming code scores this separately via calculate_answer_removed_score_from_df. + """ + # f__answer_removed, answers removed (by interviewer, or by system as a result of interviewer action). + # Matches legacy get_feature_item__answer_removed which uses self.df_paradata, but it appends the + # feature to the item table instead of returning a separate dataframe. + # (all events, role=1, interviewing=True). + # The legacy method notes this feature may include items no longer in microdata. + feature_name = 'f__answer_removed' + + if paradata_full.empty or 'event' not in paradata_full.columns: + logger.warning( + "feat_answer_removed: paradata_full is empty or missing the 'event' column — " + "no AnswerRemoved events to process. Returning empty DataFrame." + ) + return pd.DataFrame() + + removed_mask = ( + (paradata_full['event'] == 'AnswerRemoved') & + (paradata_full['role'] == 1) # interviewer role is already filtered in paradata processing node + ) + + df_removed = paradata_full[removed_mask].copy() + if df_removed.empty: + return df_removed + + # Align grouping grain with legacy helper exactly. + # qnr and qnr_version are included so removed_answers carries questionnaire + # identity for per-questionnaire filtering downstream. + group_cols = ['interview__id', 'responsible', 'variable_name', 'qnr_seq'] + extra_cols = [c for c in ['qnr', 'qnr_version'] if c in df_removed.columns] + if any(c not in df_removed.columns for c in group_cols): + logger.warning( + "%s: missing one or more legacy group columns (%s); skipping feature.", + feature_name, + group_cols, + ) + return df_removed + + df_agg_removed = df_removed.groupby(group_cols + extra_cols).agg( + f__answer_removed=('order', 'count') + ).reset_index() + + # # Keep item table cardinality while assigning legacy-grain counts. + # df_item = df_item.merge(df_agg_removed[group_cols + [feature_name]], how='left', on=group_cols) + return df_agg_removed + + +def feat_answer_changed(df_item, **kwargs): + """ + Legacy bug fixed: the legacy code applied the yes_list change + check and immediately overwrote it with the no_list check (two separate .loc assignments + on the same mask), so yes_list changes were always ignored. This implementation + combines both checks using a bitwise OR. + """ + feature_name = 'f__answer_changed' + paradata_full = kwargs.get('paradata_full') + + if paradata_full is None: + return df_item + + item_level_columns = ['interview__id', 'variable_name', 'roster_level'] + df_changed = paradata_full[(paradata_full['event'] == 'AnswerSet') & (paradata_full['question_scope'] == 0)].copy() + + df_changed[feature_name] = False + group_cols = [c for c in item_level_columns + ['index_col'] if c in df_changed.columns] + has_yes_no = 'yes_no_view' in df_changed.columns + + # --- Case 1: TextListQuestion and MultyOptionsQuestion (without yes_no_view mode) --- + # Keep flow aligned with legacy while scoping masks to their intended qtypes. + # TextListQuestion don't have yes_no_view mode. + list_mask = df_changed["qtype"] == 'TextListQuestion' + + multi_mask = ( + (df_changed["qtype"] == 'MultyOptionsQuestion') & + (df_changed['yes_no_view'] == False) + ) if has_yes_no else (df_changed["qtype"] == 'MultyOptionsQuestion') + + df_changed['answer_list'] = pd.NA + df_changed.loc[list_mask, 'answer_list'] = df_changed.loc[list_mask, 'answer'].str.split('|') + df_changed.loc[multi_mask, 'answer_list'] = df_changed.loc[multi_mask, 'answer'].str.split(r', |\|') + + df_changed['prev_answer_list'] = df_changed.groupby(group_cols)['answer_list'].shift() + answers_mask = df_changed['prev_answer_list'].notna() + if answers_mask.any(): + df_changed.loc[answers_mask, feature_name] = df_changed.loc[answers_mask].apply( + lambda row: not set(row['prev_answer_list']).issubset(set(row['answer_list'])), axis=1 + ) + + # --- Case 2: Single-answer questions --- + df_changed['prev_answer'] = df_changed.groupby(group_cols)['answer'].shift() + single_answer_mask = ( + (~df_changed["qtype"].isin(['MultyOptionsQuestion', 'TextListQuestion'])) & + (df_changed['prev_answer'].notna()) & + (df_changed['answer'] != df_changed['prev_answer']) + ) + df_changed.loc[single_answer_mask, feature_name] = True + + # --- Case 3: Yes/No view questions --- + if has_yes_no: + yesno_mask = (df_changed['yes_no_view'] == True) + if yesno_mask.any(): + df_filtered = df_changed[yesno_mask].copy() + df_filtered[['yes_list', 'no_list']] = df_filtered['answer'].str.split('|', expand=True) + df_filtered['yes_list'] = df_filtered['yes_list'].str.split(', ').apply( + lambda x: [] if x == [''] or x is None else x) + df_filtered['no_list'] = df_filtered['no_list'].str.split(', ').apply( + lambda x: [] if x == [''] or x is None else x) + yesno_group_cols = [c for c in group_cols if c in df_filtered.columns] + df_filtered['prev_yes_list'] = df_filtered.groupby(yesno_group_cols)['yes_list'].shift(fill_value=[]) + df_filtered['prev_no_list'] = df_filtered.groupby(yesno_group_cols)['no_list'].shift(fill_value=[]) + # A change occurs if either yes or no selections have been removed + yes_changed = df_filtered.apply( + lambda row: not set(row['prev_yes_list']).issubset(set(row['yes_list'])), axis=1) + no_changed = df_filtered.apply( + lambda row: not set(row['prev_no_list']).issubset(set(row['no_list'])), axis=1) + df_changed.loc[yesno_mask, feature_name] = (yes_changed | no_changed).values + + # Sum changes per item and map back + changes_per_item = df_changed.groupby('index_col')[feature_name].sum() + df_item[feature_name] = df_item['index_col'].map(changes_per_item).fillna(0) + + return df_item + + +def feat_answer_selected(df_item, **kwargs): + # f__answers_selected, number of answers selected in a multi-answer or list question, + # divided by n_answers to get share selected (only for unlinked questions). + feature_name = 'f__answer_selected' + # Select only MultyOptionsQuestion as legacy does. + multi_list_mask = df_item["qtype"].isin(['MultyOptionsQuestion']) + # Include only rows where n_answers can be parsed as a positive number to avoid division issues. + n_answers_num = pd.to_numeric(df_item.loc[multi_list_mask, 'n_answers'], errors='coerce') + valid_denominator_mask = n_answers_num > 0 + # Combine masks to ensure we only calculate for valid MultyOptionsQuestion rows with a positive n_answers. + mask = multi_list_mask & valid_denominator_mask + + df_item[feature_name] = np.nan + + # Function to calculate the number of elements in a list or return nan + def count_elements_or_nan(val): + try: + val = ast.literal_eval(str(val)) + return len(val) + except (ValueError, SyntaxError, TypeError): + return np.nan + + if mask.any(): + df_item.loc[mask, feature_name] = df_item.loc[mask, 'value'].apply(count_elements_or_nan) + # f__share_selected, share between answers selected and available answers (only for unlinked questions). + # Linked questions will be implicitly excluded since they have nan n_answers after coercion. + df_item.loc[mask, feature_name] = ( + df_item.loc[mask, feature_name] / n_answers_num.loc[mask] + ) + + return df_item + + +def feat_comment_length(df_item, **kwargs): + ## Total character length of all comments left on each item. + feature_name = 'f__comment_length' + paradata_full = kwargs.get('paradata_full') + + df_item[feature_name] = pd.NA + + if paradata_full is None: + return df_item + + comment_mask = ( + (paradata_full['event'] == 'CommentSet') & + (paradata_full['role'] == 1) + ) + df_comment = paradata_full[comment_mask].copy() + if df_comment.empty: + return df_item + + df_comment[feature_name] = df_comment['answer'].str.len() + df_agg = df_comment.groupby('index_col').agg(f__comment_length=(feature_name, 'sum')) + df_item[feature_name] = df_item['index_col'].map(df_agg['f__comment_length']) + + return df_item + + +def feat_comment_set(df_item, **kwargs): + ## Count of CommentSet events per item. + feature_name = 'f__comment_set' + paradata_full = kwargs.get('paradata_full') + + df_item[feature_name] = pd.NA + + if paradata_full is None: + return df_item + + comment_mask = ( + (paradata_full['event'] == 'CommentSet') & + (paradata_full['role'] == 1) + ) + df_comment = paradata_full[comment_mask].copy() + if df_comment.empty: + return df_item + + df_agg = df_comment.groupby('index_col').agg(f__comment_set=('order', 'count')) + df_item[feature_name] = df_item['index_col'].map(df_agg['f__comment_set']) + return df_item + + +def feat_gps(df_item, **kwargs): + # Sets f__gps boolean flag plus f__gps_latitude, f__gps_longitude, f__gps_accuracy + feature_name = 'f__gps' + mask = df_item["qtype"] == 'GpsCoordinateQuestion' + df_item[feature_name] = False + if mask.any(): + df_item.loc[mask, feature_name] = True + # Split value "lat,lon,acc,alt,timestamp_utc" + gps_data = df_item.loc[mask, 'value'].str.split(',', expand=True) + if gps_data.shape[1] >= 3: + df_item.loc[mask, 'f__gps_latitude'] = pd.to_numeric(gps_data[0], errors='coerce') + df_item.loc[mask, 'f__gps_longitude'] = pd.to_numeric(gps_data[1], errors='coerce') + df_item.loc[mask, 'f__gps_accuracy'] = pd.to_numeric(gps_data[2], errors='coerce') + + return df_item + + +# Dispatcher +ITEM_FEATURE_MAP = { + 'string_length': feat_string_length, + 'numeric_response': feat_numeric_response, + 'first_digit': feat_first_digit, + 'last_digit': feat_last_digit, + 'first_decimals': feat_first_decimals, + 'answer_position': feat_answer_position, + 'answer_changed': feat_answer_changed, + 'answer_selected': feat_answer_selected, + # answer_removed is handled as a separate pipeline node outputting removed_answers parquet; + # it is NOT enriched into df_item here. + 'comment_length': feat_comment_length, + 'comment_set': feat_comment_set, + 'gps': feat_gps, +} + + +def enrich_item_features(df_item: pd.DataFrame, paradata_full: pd.DataFrame, parameters: dict) -> pd.DataFrame: + """ + Applies feature engineering logic to the item table. + paradata_full: all processed events, role=1, interviewing=True (self.df_paradata equivalent). + """ + logger.info("Enriching item features...") + allowed_features = parameters.get('features', {}) + + for feat_key, feat_cfg in allowed_features.items(): + if feat_cfg.get('use', False): + func = ITEM_FEATURE_MAP.get(feat_key) + if func: + logger.info(f"Calculating item feature: {feat_key}") + try: + df_item = func(df_item, paradata_full=paradata_full) + except Exception as e: + logger.warning(f"Failed to calculate {feat_key}: {e}") + + return df_item + + +# --- Feature Enrichment Functions (Unit) --- + +def feat_unit_number_answered(df_unit, item_features, **kwargs): + feature_name = 'f__number_answered' + # Match legacy make_feature_unit__number_answered: exclude null, -999999999, '##N/A##', + # empty string, and Variable-type questions + sentinel_mask = _is_missing_numeric_sentinel(item_features['value']) + mask = ( + (~pd.isnull(item_features['value'])) & + (~sentinel_mask) & + (item_features['value'] != '##N/A##') & + (item_features['qtype'] != 'Variable') + ) + df_agg = item_features[mask].groupby('interview__id').agg( + f__number_answered=('value', 'count') + ) + df_unit[feature_name] = df_unit['interview__id'].map(df_agg['f__number_answered']).fillna(0) + return df_unit + +def feat_unit_number_unanswered(df_unit, item_features, **kwargs): + feature_name = 'f__number_unanswered' + # Match legacy make_feature_unit__number_unanswered: -999999999 or '##N/A##', excluding Variable type + sentinel_mask = _is_missing_numeric_sentinel(item_features['value']) + mask = ( + ( + sentinel_mask | + (item_features['value'] == '##N/A##') + ) & + (item_features['qtype'] != 'Variable') + ) + df_agg = item_features[mask].groupby('interview__id').agg( + f__number_unanswered=('value', 'count') + ) + df_unit[feature_name] = df_unit['interview__id'].map(df_agg['f__number_unanswered']).fillna(0) + return df_unit + +def feat_unit_translation_positions(df_unit, item_features, **kwargs): + # Relative positions of TranslationSwitched events within each interview. + # Returns a list of relative positions per interview. + + feature_name = 'f__translation_positions' + paradata_full = kwargs.get('paradata_full') + df_unit[feature_name] = np.nan + if paradata_full is None: + return df_unit + + trans_mask = paradata_full['event'].isin(['AnswerSet', 'TranslationSwitched']) + df_trans = paradata_full.loc[trans_mask, ['interview__id', 'order', 'event', 'param']].copy() + if df_trans.empty: + return df_unit + + df_trans = df_trans.sort_values(['interview__id', 'order']).reset_index(drop=True) + df_trans['seq'] = df_trans.groupby('interview__id').cumcount() + 1 + + def relative_translation_positions(group): + total_rows = len(group) + translation_positions = group.loc[group['event'] == 'TranslationSwitched', 'seq'] + return [pos / total_rows for pos in translation_positions] + + # include_groups=False: 'interview__id' is not needed inside the function; suppresses FutureWarning + # in pandas 2.2+ where including the grouping column in the passed group is deprecated. + result = df_trans.groupby('interview__id').apply( + relative_translation_positions, include_groups=False + ).reset_index() + result.columns = ['interview__id', feature_name] + + df_unit[feature_name] = df_unit['interview__id'].map( + result.set_index('interview__id')[feature_name] + ) + return df_unit + + +UNIT_FEATURE_MAP = { + 'number_answered': feat_unit_number_answered, + 'number_unanswered': feat_unit_number_unanswered, + 'translation_positions': feat_unit_translation_positions, +} + +def enrich_unit_features(df_unit: pd.DataFrame, item_features: pd.DataFrame, paradata_full: pd.DataFrame, parameters: dict) -> pd.DataFrame: + """ + Applies feature engineering logic to the unit table. + paradata_full: all processed events, role=1, interviewing=True (self.df_paradata equivalent). + Required for f__translation_positions. + """ + logger.info("Enriching unit features...") + allowed_features = parameters.get('features', {}) + + for feat_key, feat_cfg in allowed_features.items(): + if feat_cfg.get('use', False): + func = UNIT_FEATURE_MAP.get(feat_key) + if func: + logger.info(f"Calculating unit feature: {feat_key}") + try: + df_unit = func(df_unit, item_features=item_features, paradata_full=paradata_full) + except Exception as e: + logger.warning(f"Failed to calculate {feat_key}: {e}") + + return df_unit diff --git a/rissk/features.py b/rissk/features.py deleted file mode 100644 index a93a0f6..0000000 --- a/rissk/features.py +++ /dev/null @@ -1,29 +0,0 @@ -from pathlib import Path - -import typer -from loguru import logger -from tqdm import tqdm - -from rissk.config import PROCESSED_DATA_DIR - -app = typer.Typer() - - -@app.command() -def main( - # ---- REPLACE DEFAULT PATHS AS APPROPRIATE ---- - input_path: Path = PROCESSED_DATA_DIR / "dataset.csv", - output_path: Path = PROCESSED_DATA_DIR / "features.csv", - # ----------------------------------------- -): - # ---- REPLACE THIS WITH YOUR OWN CODE ---- - logger.info("Generating features from dataset...") - for i in tqdm(range(10), total=10): - if i == 5: - logger.info("Something happened for iteration 5.") - logger.success("Features generation complete.") - # ----------------------------------------- - - -if __name__ == "__main__": - app() diff --git a/rissk/item_processing.py b/rissk/item_processing.py deleted file mode 100644 index 3ad1ab1..0000000 --- a/rissk/item_processing.py +++ /dev/null @@ -1,456 +0,0 @@ -from rissk.feature_processing import * -from rissk.detection_algorithms import * -from rissk.utils.stats_utils import * -from scipy.spatial import cKDTree -from sklearn.cluster import DBSCAN -from pyod.models.ecod import ECOD -from pyod.models.cof import COF -from pyod.models.lof import LOF -from pyod.models.inne import INNE -from scipy import stats -from sklearn.preprocessing import StandardScaler -from pyod.models.thresholds import FILTER - - -class ItemFeatureProcessing(FeatureProcessing): - - def __init__(self, config): - super().__init__(config) - - def get_contamination_parameter(self, feature_name, method='medfilt', random_state=42): - f_name = feature_name.replace('f__', '') - contamination = self.config.features.get(f_name, {}).get('parameters', {}).get('contamination') - if contamination is None or contamination == 'auto' or self.config.automatic_contamination is True: - return FILTER(method=method, random_state=random_state) - else: - return contamination - - @staticmethod - def filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3): - # Select only those variables that have at least 'min_unique_values' distinct values and more than one - # 'frequency' records - valid_variables = df.groupby('variable_name').filter(lambda group: - len(group[feature_name].unique()) >= min_unique_values - and len(group) > frequency) - # Get the unique variable names that meet the conditions - variables = valid_variables['variable_name'].unique() - return variables - - @staticmethod - def filter_columns(data, index_col, threshold=100): - drop_columns = [] - keep_columns = [] - for col in data.columns: - if (data[col].nunique() < 3 or data[col].count() < threshold) and col not in index_col: - drop_columns.append(col) - else: - keep_columns.append(col) - return keep_columns, drop_columns - - def get_clean_pivot_table(self, feature_name, remove_low_freq_col=True, filter_conditions=None, threshold=0.2): - index_col = ['interview__id', 'roster_level', 'responsible'] - data = self.df_item - if filter_conditions is not None: - data = data.loc[filter_conditions] - data = pd.pivot_table(data=data, index=index_col, columns='variable_name', - values=feature_name, fill_value=np.NAN) - data = data.reset_index() - # Define again index_col after pivoting in case of some column missing - if data.columns.nlevels > 1: - data.columns = [f'{col[0]}_{col[1]}'.rstrip('_') for col in data.columns] - - index_col = [col for col in index_col if col in data.columns] - keep_columns, drop_columns = self.filter_columns(data, index_col, threshold=threshold) - if remove_low_freq_col: - data = data[keep_columns] - - return data, index_col - - def make_score__gps(self): - feature_name = ['f__gps_latitude', 'f__gps_longitude', 'f__gps_accuracy'] - data, index_col = self.get_clean_pivot_table(feature_name, remove_low_freq_col=False) - - def replace_with_feature_name(columns, feature_names): - for i, s in enumerate(columns): - for sub in feature_names: - if sub in s: - columns[i] = sub - break - return columns - - data.columns = replace_with_feature_name(list(data.columns), feature_name) - data = data.reset_index() - # Everything that has 0,0 as coordinates is an outlier - data['s__gps_extreme_outlier'] = 0 - data['s__gps_extreme_outlier'] = data['f__gps_latitude'].apply(lambda x: 1 if x == 0.000000 else 0) - data['s__gps_extreme_outlier'] = data['f__gps_longitude'].apply(lambda x: 1 if x == 0.000000 else 0) - - - # Convert lat, lon to 3D cartesian coordinates - data['x'], data['y'], data['z'] = lat_lon_to_cartesian(data['f__gps_latitude'], - data['f__gps_longitude']) - - # Convert accuracy from meters to kilometers - data['accuracy'] = data['f__gps_accuracy'] / 1e6 - - # Create KDTree - tree = cKDTree(data[['x', 'y', 'z']]) - - # Convert 10 meters to kilometers, the unit of the Earth's radius - radius = 10 / 1e6 - - # Query for counts accounting for accuracy - counts = [len(tree.query_ball_point(xyz, r=radius + accuracy)) - 1 for xyz, accuracy in - zip(data[['x', 'y', 'z']].values, data['accuracy'])] - - data['s__gps_proximity_counts'] = counts - coords_columns = ['x', 'y'] - # Identify extreme spatial outliers - - mask = (data['s__gps_extreme_outlier'] < 1) - - median_x = data[mask].drop_duplicates(subset='x')['x'].median() - median_y = data[mask].drop_duplicates(subset='y')['y'].median() - median_z = data[mask].drop_duplicates(subset='z')['z'].median() - - # Calculate distances from the median point - data['distance_to_median'] = np.sqrt((data[mask]['x'] - median_x) ** 2 + - (data[mask]['y'] - median_y) ** 2 + - (data[mask]['z'] - median_z) ** 2 - ) - - # Set a threshold (e.g., 95th percentile of distances) - # Everything that is above 30 + the median distance is an outlier - p75 = data[mask]['distance_to_median'].quantile(0.75) - median = data[mask]['distance_to_median'].median() - range_75 = p75 - median - threshold = p75 + 3.5 * range_75 - - - data.loc[mask, 's__gps_extreme_outlier'] = data[mask]['distance_to_median'] > threshold - - # # Make a further cleaning with dbscan - # coords_columns = ['x', 'y'] - # model = DBSCAN(eps=0.5, min_samples=5) - # model.fit(data[mask][coords_columns]) - # data.loc[mask, 'outlier'] = model.fit_predict(data[mask][coords_columns]) - # data['s__gps_extreme_outlier'] = data.apply( - # lambda row: 1 if row['outlier'] == -1 or row['s__gps_extreme_outlier'] == 1 else 0, 1) - - # USE COF if dataset hase less than 20000 samples else use LOF - contamination = self.get_contamination_parameter('f__gps', method='medfilt', random_state=42) - if data[mask].shape[0] < 10000: - model = COF(contamination=contamination) - else: - model = LOF(contamination=contamination, n_neighbors=20) - model.fit(data[mask][coords_columns]) - data.loc[mask, 's__gps_outlier'] = model.predict(data[mask][coords_columns]) - - return data.drop(columns=['x', 'y', 'z', 'accuracy', 'distance_to_median', 'outlier'], errors='ignore') - - def make_score__sequence_jump(self): - feature_name = 'f__sequence_jump' - score_name = self.rename_feature(feature_name) - df = self.df_item[~pd.isnull(self.df_item[feature_name])].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3) - df[score_name] = 0 - contamination = self.get_contamination_parameter(feature_name) - for var in valid_variables: - mask = (df['variable_name'] == var) - model = INNE(contamination=contamination, random_state=42) - model.fit(df[mask][[feature_name]]) - df.loc[mask, score_name] = model.predict(df[mask][[feature_name]]) - return df - - def make_score__first_decimal(self): - - feature_name = 'f__first_decimal' - score_name = self.rename_feature(feature_name) - df = self.df_item[~pd.isnull(self.df_item[feature_name])].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3) - df[score_name] = 0 - for var in valid_variables: - mask = (df['variable_name'] == var) - contamination = self.get_contamination_parameter(feature_name, method='medfilt', random_state=42) - model = COF(contamination=contamination) - model.fit(df[mask][[feature_name]]) - df.loc[mask, score_name] = model.predict(df[mask][[feature_name]]) - return df - - def make_score__answer_hour_set(self): - # Detect time set anomalies using ECOD algorithm. - # ECOD is a parameter-free, highly interpretable outlier detection algorithm based on empirical CDF functions - feature_name = 'f__answer_hour_set' - score_name = self.rename_feature(feature_name) - df = self.df_item[~pd.isnull(self.df_item[feature_name])]#.copy() - - # Sorting the DataFrame based on the 'frequency' answer_hour_set in descending order - sorted_hours = df[feature_name].value_counts().index - hour_to_rank = {hour: rank for rank, hour in enumerate(sorted_hours)} - # Create a frequecy column - df['frequency'] = df[feature_name].map(hour_to_rank) - - # IDENTIFY Outliers by ECOD anomaly detection model - contamination = self.get_contamination_parameter(feature_name) - model = ECOD(contamination=contamination) - model.fit(df[[feature_name]]) - df[score_name] = model.predict(df[[feature_name]]) - - # In case has detected "high frequencies anomalies", set them to 0 - df.loc[df['frequency'] <= df[df[score_name] == 0]['frequency'].min(), score_name] = 0 - df.drop(columns=['frequency'], inplace=True) - return df - - def make_score__answer_changed(self): - feature_name = 'f__answer_changed' - score_name = self.rename_feature(feature_name) - df = self.df_item[~pd.isnull(self.df_item[feature_name])]#.copy() - # Select only those variables that have at least 1 distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=1) - df[score_name] = 0 - contamination = self.get_contamination_parameter(feature_name, method='medfilt', random_state=42) - for var in valid_variables: - mask = (df['variable_name'] == var) - - model = ECOD(contamination=contamination) - model.fit(df[mask][[feature_name]]) - df.loc[mask, score_name] = model.predict(df[mask][[feature_name]]) - return df - - def make_score__answer_removed(self): - feature_name = 'f__answer_removed' - score_name = self.rename_feature(feature_name) - df = self.get_feature_item__answer_removed(feature_name) - # Select only those variables that have at least 1 distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=1) - df[score_name] = 0 - contamination = self.get_contamination_parameter(feature_name, method='medfilt', random_state=42) - for var in valid_variables: - mask = (df['variable_name'] == var) - - model = ECOD(contamination=contamination) - model.fit(df[mask][[feature_name]]) - df.loc[mask, score_name] = model.predict(df[mask][[feature_name]]) - return df - - def make_score__answer_position(self): - # answer_position is calculated at responsible level - feature_name = 'f__answer_position' - score_name = self.rename_feature(feature_name) - - df = self.df_item[~pd.isnull(self.df_item[feature_name])].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3) - df[score_name] = 0 - for var in valid_variables: - mask = (df['variable_name'] == var) - unique_values = df[mask][feature_name].nunique() - entropy_df = df[mask].groupby('responsible')[feature_name].apply(calculate_entropy, - unique_values=unique_values, - min_record_sample=10) - entropy_df = entropy_df.reset_index() - entropy_df = entropy_df[~pd.isnull(entropy_df[feature_name])] - - if entropy_df.shape[0] > 0: - entropy_df.sort_values(feature_name, inplace=True, ascending=False) - - median_value = entropy_df[feature_name].median() - - median_value = entropy_df[feature_name].median() - entropy_df[score_name] = entropy_df[feature_name].apply( - lambda x: 1 if x < median_value - 50 / 100 * median_value else 0) - df.loc[mask, score_name] = df[mask]['responsible'].map(entropy_df.set_index('responsible')[score_name]) - return df - - def make_score__answer_selected(self): - feature_name = 'f__answer_selected' - score_name = self.rename_feature(feature_name) - df = self.df_item[~pd.isnull(self.df_item[feature_name])].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3) - df[score_name] = 0 - for var in valid_variables: - mask = (df['variable_name'] == var) - contamination = self.get_contamination_parameter(feature_name, method='medfilt', random_state=42) - model = ECOD(contamination=contamination) - model.fit(df[mask][[feature_name]]) - score_name1 = score_name + '_lower' - score_name2 = score_name + '_upper' - - df.loc[mask, score_name] = model.predict(df[mask][[feature_name]]) - min_good_value = df[(df[score_name] == 0) & mask][feature_name].min() - max_good_value = df[(df[score_name] == 0) & mask][feature_name].max() - - df.loc[mask, score_name1] = 0 - df.loc[mask, score_name2] = 0 - - df.loc[mask & (df[mask][feature_name] < min_good_value), score_name1] = 1 - df.loc[mask & (df[mask][feature_name] > max_good_value), score_name2] = 1 - df.drop(columns=[score_name], inplace=True) - return df - - def make_score__answer_duration(self): - feature_name = 'f__answer_duration' - score_name = self.rename_feature(feature_name) - df = self.df_item[~pd.isnull(self.df_item[feature_name])]#.copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3) - - score_name1 = score_name + '_lower' - score_name2 = score_name + '_upper' - - df[score_name1] = 0 - df[score_name2] = 0 - for var in valid_variables: - mask = (df['variable_name'] == var) - contamination = self.get_contamination_parameter(feature_name, method='medfilt', random_state=42) - model = ECOD(contamination=contamination) - model.fit(df[mask][[feature_name]]) - - df.loc[mask, score_name] = model.predict(df[mask][[feature_name]]) - - min_good_value = df[(df[score_name] == 0) & mask][feature_name].min() - max_good_value = df[(df[score_name] == 0) & mask][feature_name].max() - - df.loc[mask, score_name1] = 0 - df.loc[mask, score_name2] = 0 - - df.loc[mask & (df[mask][feature_name] < min_good_value), score_name1] = 1 - df.loc[mask & (df[mask][feature_name] > max_good_value), score_name2] = 1 - - df.drop(columns=[score_name], inplace=True) - - return df - - def make_score__single_question(self): - # Answer single question is calculated at responsible level - - feature_name = 'f__single_question' - score_name = self.rename_feature(feature_name) - - single_question_mask = ((self.df_item["qtype"] == 'SingleQuestion') - & (self.df_item['n_answers'] > 1) - & (self.df_item['is_filtered_combobox'] == False) - & (pd.isnull(self.df_item['cascade_from_question_id']))) - - df = self.df_item[single_question_mask].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - - variables = self.filter_variable_name_by_frequency(df, 'value', frequency=100, min_unique_values=3) - df[score_name] = 0 - for var in variables: - mask = (df['variable_name'] == var) - unique_values = df[mask]['value'].nunique() - entropy_df = df[mask].groupby('responsible')['value'].apply(calculate_entropy, unique_values=unique_values) - entropy_df = entropy_df.reset_index() - entropy_df = entropy_df[~pd.isnull(entropy_df['value'])] - - if entropy_df.shape[0] > 0: - entropy_df.sort_values('value', inplace=True, ascending=False) - - median_value = entropy_df['value'].median() - - median_value = entropy_df['value'].median() - entropy_df[score_name] = entropy_df['value'].apply( - lambda x: 1 if x < median_value - 50 / 100 * median_value else 0) - df.loc[mask, score_name] = df[mask]['responsible'].map(entropy_df.set_index('responsible')[score_name]) - - return df - - def make_score__multi_option_question(self): - feature_name = 'f__multi_option_question' - # Answer single question is calculated at responsible level - - score_name = self.rename_feature(feature_name) - - multi_question_mask = (self.df_item["qtype"] == 'MultyOptionsQuestion').copy() - - df = self.df_item[multi_question_mask].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = df.groupby('variable_name').filter(lambda x: len(x) >= 100) - # Get the unique variable names that meet the conditions - variables = valid_variables['variable_name'].unique() - - df.loc[score_name] = 0 - for var in variables: - mask = (df['variable_name'] == var) - unique_values = len([v for v in df[mask]['value'].explode().unique() if v != '##N/A##']) - entropy_df = df[mask].groupby('responsible')['value'].apply(calculate_list_entropy, - unique_values=unique_values, - min_record_sample=5) - entropy_df = entropy_df.reset_index() - entropy_df = entropy_df[~pd.isnull(entropy_df['value'])] - - if entropy_df.shape[0] > 0: - entropy_df.sort_values('value', inplace=True, ascending=False) - - median_value = entropy_df['value'].median() - - median_value = entropy_df['value'].median() - entropy_df[score_name] = entropy_df['value'].apply( - lambda x: 1 if x < median_value - 50 / 100 * median_value else 0) - df.loc[mask, score_name] = df[mask]['responsible'].map(entropy_df.set_index('responsible')[score_name]) - - return df - - def make_score__first_digit(self): - feature_name = 'f__numeric_response' - score_name = 's__first_digit' - df = self.df_item[~pd.isnull(self.df_item[feature_name])].copy() - # Select only those variables that have at least three distinct values and more than one hundred records - valid_variables = self.filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=3) - - # Select only those variables that have at least three different order of magnitude - valid_variables = filter_variables_by_magnitude(df, feature_name, valid_variables, min_order_of_magnitude=3) - - # Computes the Jensen divergence for each variable_name and responsible on the first digit distribution. - # Jensen's divergence returns a value between (0, 1) of how much the first digit distribution - # of specific responsible is similar to the first digit distribution of all others. - # Higher the value higher is the difference. - # The Bendford Jensen divergence is calculated only on those responsible and variable_name - # who have at least 50 records. - # Once it is calculated, values that diverge from more than 50% from the median value get marked as "anomalus." - benford_jensen_df = apply_benford_tests(df, valid_variables, 'responsible', feature_name, - apply_first_digit=True, minimum_sample=50) - - df[score_name] = 0 - variable_list = benford_jensen_df['variable_name'].unique() - for var in variable_list: - - bj_mask = (benford_jensen_df['variable_name'] == var) & (~pd.isnull(benford_jensen_df[feature_name])) - bj_df = benford_jensen_df[bj_mask].copy() - if bj_df.shape[0] > 0: - bj_df.sort_values(feature_name, inplace=True, ascending=True) - - median_value = bj_df[feature_name].median() - # If the distribution has a jensen difference grater than 50% - # from the median value, mark it as "anomalus" - bj_df[score_name] = bj_df[feature_name].apply( - lambda x: 1 if x > median_value + 50 / 100 * median_value else 0) - - - mask = (df['variable_name'] == var) - df.loc[mask, score_name] = df[mask]['responsible'].map(bj_df.set_index('responsible')[score_name]) - return df - - # def make_score__last_digit(self): - # feature = 'f__last_digit' - # pivot_table, index_col = self.get_clean_pivot_table('f__numeric_response', remove_low_freq_col=True) - # columns = [] - # for col in pivot_table.drop(columns=['interview__id', 'roster_level', 'responsible']).columns: - # data = pivot_table[~pd.isnull(pivot_table[col])].copy() - # new_col = filter_columns_by_magnitude(data.drop(columns=['interview__id', 'roster_level', 'responsible']), - # 3).columns - # columns += list(new_col) - # columns = list(set(columns)) - # - # data = pd.DataFrame(pivot_table.responsible.unique(), columns=['responsible']) - # for col in columns: - # results_df = apply_benford_tests(pivot_table[['responsible'] + columns], 'responsible', col) - # results_df[col + feature.replace('f__', '__')] = results_df['p-value'].apply(lambda x: x <= 0.05) - # data[col + feature.replace('f__', '__')] = results_df['responsible'].map( - # results_df.set_index('responsible')[col + feature.replace('f__', '__')]) - # - # return data diff --git a/rissk/item_processing_kedro.py b/rissk/item_processing_kedro.py new file mode 100644 index 0000000..e7add2c --- /dev/null +++ b/rissk/item_processing_kedro.py @@ -0,0 +1,867 @@ +import pandas as pd +import numpy as np +import logging +import warnings +from typing import List, Dict, Any, Tuple +from pyod.models.thresholds import FILTER +from pyod.models.ecod import ECOD +from pyod.models.cof import COF +from pyod.models.inne import INNE +from pyod.models.lof import LOF +from scipy.spatial import cKDTree + +from rissk.utils.stats_utils_kedro import ( + calculate_entropy, + calculate_list_entropy, + filter_variables_by_magnitude, + apply_benford_tests, +) +from rissk.detection_algorithms_kedro import lat_lon_to_cartesian + +logger = logging.getLogger(__name__) + +def rename_feature(feature_name: str, starting_string: str = 'f', new_string: str = 's') -> str: + """Rename feature correctly mapping to score (f__ -> s__).""" + starting_string = starting_string + '__' + new_string = new_string + '__' + if feature_name.startswith(starting_string): + return feature_name.replace(starting_string, new_string) + return feature_name + +def get_contamination_parameter( + config_features: dict, + feature_name: str, + automatic_contamination: bool = False, + method: str = 'medfilt', + random_state: int = 42 + ): + """Fetch contamination parameter from Kedro parameters/config features. + Returns a FILTER object for automatic contamination detection (matching legacy behaviour), + or a fixed float when a contamination value is explicitly configured. + """ + f_name = feature_name.replace('f__', '') + contamination = config_features.get(f_name, {}).get('parameters', {}).get('contamination') + if contamination is None or contamination == 'auto' or automatic_contamination is True: + return FILTER(method=method, random_state=random_state) + else: + return float(contamination) + +def filter_variable_name_by_frequency( + df: pd.DataFrame, + feature_name: str, + frequency: int = 100, + min_unique_values: int = 3 + ) -> List[str]: + """Filter variables by frequency and unique values.""" + if feature_name not in df.columns: + return [] + # Count non-null frequency and unique values for each variable + valid_data = df[~pd.isnull(df[feature_name])] + grouped_df = valid_data.groupby('variable_name')[feature_name].agg(['count', 'nunique']) + valid_variables = grouped_df[(grouped_df['count'] >= frequency) & (grouped_df['nunique'] >= min_unique_values)].index + # Return a list of unique variable names that meet the criteria + return valid_variables.tolist() + +def filter_columns( + data: pd.DataFrame, + index_col: List[str], + threshold: int = 100, + min_unique_values: int = 3, +) -> Tuple[List[str], List[str]]: + """Determine columns to keep/drop based on threshold and minimum unique values. + Keeps a column only if both the non-null count is >= `threshold` and the + number of unique (non-null) values is >= `min_unique_values`. + """ + # Prepare column set excluding index columns + data_cols = data.drop(columns=index_col, errors='ignore') + + # Count non-null values for each column + non_null_counts = data_cols.count() + + # Count unique non-null values for each column + unique_counts = data_cols.nunique(dropna=True) + + # Keep columns that meet both thresholds + keep_mask = (non_null_counts >= threshold) & (unique_counts >= min_unique_values) + keep_columns = non_null_counts[keep_mask].index.tolist() + drop_columns = non_null_counts[~keep_mask].index.tolist() + + return index_col + keep_columns, drop_columns + + +# --- SCORING FUNCTIONS BEGIN --- + +def calculate_gps_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score GPS coordinates for spatial outliers and coordinate proximity. + + Produces three scores on each row: + - s__gps_proximity_counts: number of other GPS points within 10 m (accounting for accuracy). + - s__gps_extreme_outlier: 1 if the point is a 0,0 fix or lies beyond p75 + 3.5*IQR of + the Cartesian distance distribution from the median survey location. + - s__gps_outlier: 1/0 from COF (< 10 000 points) or LOF (>= 10 000 points) fit on x/y coords. + + Also sets s__gps (integer flag: 1 = GPS question row, NaN = other) so that the unit-level + aggregation can count GPS questions per interview regardless of outlier model outcome. + """ + df = df_item.copy() + score_cols = ['s__gps_proximity_counts', 's__gps_outlier', 's__gps_extreme_outlier'] + required_columns = ['f__gps_latitude', 'f__gps_longitude', 'f__gps_accuracy'] + # variable_name is included so rows from different GPS questions remain distinct + # when multiple GPS variables exist for the same (interview, roster, responsible) + index_col = ['interview__id', 'roster_level', 'responsible', 'variable_name'] + + # s__gps: integer flag (1 = GPS question, NaN = other). Set unconditionally so that + # aggregate_item_to_unit_scores can always sum it to the interview-level GPS question + # count, matching legacy make_score_unit__gps which read f__gps from df_item directly + # regardless of whether the GPS outlier model ran successfully. + if 'f__gps' in df.columns: + df['s__gps'] = np.where(df['f__gps'].fillna(False).astype(bool), 1, np.nan) + + # If required GPS columns are missing, return original df + if any(col not in df.columns for col in required_columns + ['variable_name']): + return df + + gps_mask = (~pd.isnull(df['f__gps_latitude'])) & (~pd.isnull(df['f__gps_longitude'])) + if gps_mask.sum() == 0: + for col in score_cols: + df[col] = np.nan + return df + + # Keep each (interview, variable_name) as a separate GPS point so that + # questionnaires with multiple GPS variables pool all their points together + # for the outlier model without collapsing coordinates by mean. + data = df.loc[gps_mask, index_col + required_columns].copy() + + # Everything that has 0,0 as coordinates is considered an extreme outlier + # (devices sometimes report 0,0 when a fix failed); mark these explicitly + # so they can be excluded from median/distance calculations. + data['s__gps_extreme_outlier'] = ( + (data['f__gps_latitude'] == 0.0) & (data['f__gps_longitude'] == 0.0) + ).astype(int) + + # Convert lat/lon into 3D Cartesian coordinates on a sphere (units = km). + # Using Cartesian coords lets KDTree operate in Euclidean space instead of + # running great-circle calculations for every pair. + data['x'], data['y'], data['z'] = lat_lon_to_cartesian(data['f__gps_latitude'], data['f__gps_longitude']) + # Accuracy is expected to accompany a GPS fix (Survey Solutions provides it). + # We convert `f__gps_accuracy` from metres → kilometres to match `lat_lon_to_cartesian`. + # `fillna(0)` is intentional here: it is a computational guard — a NaN radius would + # cause `query_ball_point` to raise or silently return empty results. Zero accuracy + # means we only use the base 10m radius for that point, which is a safe fallback. + # This is a parameter value, not a score output, so it is not subject to the NaN policy. + data['accuracy'] = data['f__gps_accuracy'].fillna(0) / 1e3 + + # Build spatial index (KDTree) on 3D cartesian coords to count neighbours. + # Note: KDTree distances are Euclidean in the same units as x/y/z (km). + tree = cKDTree(data[['x', 'y', 'z']]) + # Radius (search distance) passed to `query_ball_point` — same units as x/y/z (kilometres) + # Legacy code converted 10 metres into the same units; keep that behaviour. + radius = 10 / 1e3 + counts = [ + len(tree.query_ball_point(xyz, r=radius + acc)) - 1 + for xyz, acc in zip(data[['x', 'y', 'z']].values, data['accuracy']) + ] + data['s__gps_proximity_counts'] = counts + + # Exclude explicitly-marked extreme outliers (e.g., 0,0 fixes) from + # median/distance computations so they don't skew the central location. + mask = data['s__gps_extreme_outlier'] < 1 + data['distance_to_median'] = np.nan + if mask.sum() > 0: + median_x = data.loc[mask].drop_duplicates(subset='x')['x'].median() + median_y = data.loc[mask].drop_duplicates(subset='y')['y'].median() + median_z = data.loc[mask].drop_duplicates(subset='z')['z'].median() + + data.loc[mask, 'distance_to_median'] = np.sqrt( + (data.loc[mask, 'x'] - median_x) ** 2 + + (data.loc[mask, 'y'] - median_y) ** 2 + + (data.loc[mask, 'z'] - median_z) ** 2 + ) + + # Set a threshold for extreme spatial outliers. Legacy code used a + # percentile + scaled IQR-like range; keep that heuristic here. + p75 = data.loc[mask, 'distance_to_median'].quantile(0.75) + median_dist = data.loc[mask, 'distance_to_median'].median() + range_75 = p75 - median_dist + threshold = p75 + 3.5 * range_75 + data.loc[mask, 's__gps_extreme_outlier'] = ( + data.loc[mask, 'distance_to_median'] > threshold + ).astype(int) + + contamination = get_contamination_parameter( + parameters.get('features', {}), + 'f__gps', + automatic_contamination=parameters.get('automatic_contamination', False), + method='medfilt', + random_state=42, + ) + # We use only ['x', 'y'] to match legacy 2D behaviour for the COF/LOF + # model (a planar approximation). For larger geographic extents consider + # switching to ['x','y','z'] or a geodesic distance measure. + coords_columns = ['x', 'y'] + + # USE COF if dataset has less than 10000 samples else use LOF + if data.loc[mask].shape[0] < 10000: + model = COF(contamination=contamination) + else: + model = LOF(contamination=contamination, n_neighbors=20) + model.fit(data.loc[mask, coords_columns]) + data.loc[mask, 's__gps_outlier'] = model.predict(data.loc[mask, coords_columns]) + # Extreme outlier rows excluded from model fitting keep NaN for s__gps_outlier — + # they are already classified as extreme outliers and the spatial model cannot + # evaluate them; NaN signals that no evaluation was possible for those points. + else: + # All GPS points are extreme outliers (e.g. all 0,0). The spatial outlier model + # cannot run because there are no valid points to fit. s__gps_outlier = NaN + # for all, since no evaluation was possible. + data['s__gps_outlier'] = np.nan + + # Merge interview-level scores back to every row in the full long-format df. + # Rows for interviews that had no GPS answers are left as NaN — they are not + # scored, matching legacy behaviour where those interviews simply had no entry + # in the returned pivot output. + score_data = data[index_col + score_cols] + df = df.merge(score_data, on=index_col, how='left') + + return df + + +def calculate_sequence_jump_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score sequence-jump anomalies per variable using the INNE isolation-based model. + + Only variables with at least 100 records and 3 distinct jump values are scored. + Rows for variables that don't meet the threshold keep s__sequence_jump = NaN. + """ + feature_name = 'f__sequence_jump' + score_name = rename_feature(feature_name) + df = df_item.copy() + + if feature_name not in df.columns: + return df + + if df[feature_name].dropna().empty: + df[score_name] = np.nan + return df + + valid_data = df[~pd.isnull(df[feature_name])].copy() + valid_variables = filter_variable_name_by_frequency(valid_data, feature_name, frequency=100, min_unique_values=3) + df[score_name] = np.nan + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + ) + + for var in valid_variables: + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + model = INNE(contamination=contamination, random_state=42) + model.fit(df.loc[mask, [feature_name]]) + df.loc[mask, score_name] = model.predict(df.loc[mask, [feature_name]]) + + return df + + +def calculate_first_decimals_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score anomalies in the first two decimal digits per variable using the COF density model. + + f__first_decimals stores the first TWO decimal digits of each numeric response (e.g. 3.47 → 47). + """ + # s__first_decimals is the anomaly score derived from f__first_decimals (two decimal digits). + feature_name = 'f__first_decimals' + score_name = rename_feature(feature_name) + df = df_item.copy() + + if feature_name not in df.columns: + return df + if df[feature_name].dropna().empty: + df[score_name] = np.nan + return df + + valid_data = df[~pd.isnull(df[feature_name])].copy() + # Select only those variables that have at least three distinct values and more than one hundred records + valid_variables = filter_variable_name_by_frequency(valid_data, feature_name, frequency=100, min_unique_values=3) + df[score_name] = np.nan + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + method='medfilt', + random_state=42, + ) + + for var in valid_variables: + # logger.info(f"Calculating {score_name} for variable: {var}") + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + model = COF(contamination=contamination) + # COF on f__first_decimals produces several expected RuntimeWarnings due to + # degenerate neighbourhoods (many identical values, e.g. x.00): + # - pyod.models.cof: "divide by zero / invalid value encountered in scalar divide" + # — zero chaining distance causes a 0/0 in the COF score formula. + # - numpy._core._methods: "overflow encountered in multiply/reduce" + # — intermediate squared-distance arithmetic overflows before being clipped. + # COF handles these cases gracefully (producing NaN/inf scores that it then + # clips or ignores). They are intentional side-effects of applying a + # distance-based algorithm to heavily-tied data, not coding errors. + # We suppress all RuntimeWarnings from both modules for the duration of + # fit/predict to keep the log clean. + with warnings.catch_warnings(): + warnings.filterwarnings( + 'ignore', + category=RuntimeWarning, + module=r'pyod\.models\.cof', + ) + warnings.filterwarnings( + 'ignore', + category=RuntimeWarning, + module=r'numpy\._core\._methods', + ) + model.fit(df.loc[mask, [feature_name]]) + df.loc[mask, score_name] = model.predict(df.loc[mask, [feature_name]]) + + return df + + +def calculate_answer_hour_set_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score hour-of-day anomalies using ECOD on f__answer_hour_set. + + ECOD is a parameter-free outlier detection algorithm based on empirical CDF functions. + Hours that ECOD flags but which are the most frequent in the dataset are reverted to 0 + (non-anomalous) because high-frequency hours cannot represent interviewer-level anomalies. + """ + # Detect time set anomalies using ECOD algorithm. + # ECOD is a parameter-free, highly interpretable outlier detection algorithm based on empirical CDF functions + feature_name = 'f__answer_hour_set' + score_name = rename_feature(feature_name) + df_out = df_item.copy() + + if feature_name not in df_out.columns: + return df_out + + df_out[score_name] = np.nan + df_out[feature_name] = pd.to_numeric(df_out[feature_name], errors='coerce') + + mask = ~pd.isnull(df_out[feature_name]) + df = df_out[mask].copy() + + if df.empty: + return df_out + # Sorting the DataFrame based on the 'frequency' answer_hour_set in descending order + sorted_hours = df[feature_name].value_counts().index + hour_to_rank = {hour: rank for rank, hour in enumerate(sorted_hours)} + # Create a frequency column + df['frequency'] = df[feature_name].map(hour_to_rank) + + # IDENTIFY Outliers by ECOD anomaly detection model + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + ) + + model = ECOD(contamination=contamination) + model.fit(df[[feature_name]]) + df[score_name] = model.predict(df[[feature_name]]) + + # Revert high-frequency hours that ECOD incorrectly flagged as anomalies. + # Guard against the degenerate case where every row is an outlier (no inliers), + # which would make the unguarded expression return NaN and silently skip the + # correction. Legacy code has this silent failure; Kedro uses the explicit guard. + inlier_mask = df[score_name] == 0 + if inlier_mask.any(): + min_inlier_rank = df.loc[inlier_mask, 'frequency'].min() + df.loc[df['frequency'] <= min_inlier_rank, score_name] = 0 + # If no inliers exist (all rows flagged), scores remain as predicted — NaN was + # never introduced here since ECOD always returns 0/1, so no further action needed. + + # Assign scores back using index labels — safe regardless of index type or value + df_out.loc[df.index, score_name] = df[score_name].values + return df_out + + +def calculate_answer_changed_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score answer-change anomalies per variable using ECOD. + + Only variables with at least 100 records and at least 1 distinct change value are scored. + """ + feature_name = 'f__answer_changed' + score_name = rename_feature(feature_name) + df = df_item.copy() + + if feature_name not in df.columns: + return df + if df[feature_name].dropna().empty: + df[score_name] = np.nan + return df + + valid_data = df[~pd.isnull(df[feature_name])] + # Select only those variables that have at least 1 distinct values and more than one hundred records + valid_variables = filter_variable_name_by_frequency(valid_data, feature_name, frequency=100, min_unique_values=1) + df[score_name] = np.nan + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + method='medfilt', + random_state=42, + ) + + for var in valid_variables: + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + model = ECOD(contamination=contamination) + model.fit(df.loc[mask, [feature_name]]) + df.loc[mask, score_name] = model.predict(df.loc[mask, [feature_name]]) + + return df + +# NOTE: s__answer_removed is NOT computed at item level. +# +# Legacy `make_score__answer_removed` operated on a frame derived directly from +# df_paradata (via get_feature_item__answer_removed), which includes AnswerRemoved +# events for items that were subsequently deleted from microdata and are therefore +# absent from df_item. In the Kedro pipeline, df_item is built from microdata and +# f__answer_removed is merged back with how='left' — so those deleted items are +# silently dropped, making an item-level s__answer_removed on df_item structurally +# incomplete and potentially misleading. + + +def calculate_answer_removed_score_from_df( + removed_answers: pd.DataFrame, + parameters: Dict[str, Any], +) -> pd.Series: + """Score answer-removal anomalies from the pre-aggregated removed_answers DataFrame. + + Takes the output of feat_answer_removed (columns: interview__id, responsible, + variable_name, qnr_seq, f__answer_removed) and applies the same ECOD scoring + logic as calculate_answer_removed_unit_score, without re-filtering paradata. + + Returns a Series indexed by interview__id → mean s__answer_removed score. + """ + feature_name = 'f__answer_removed' + score_name = rename_feature(feature_name) + + if removed_answers is None or removed_answers.empty: + return pd.Series(dtype=float) + + required_cols = ['interview__id', 'variable_name', feature_name] + if any(c not in removed_answers.columns for c in required_cols): + logger.warning( + "calculate_answer_removed_score_from_df: removed_answers is missing one or more " + "required columns %s; returning empty Series.", required_cols + ) + return pd.Series(dtype=float) + + df = removed_answers.copy() + + valid_variables = filter_variable_name_by_frequency(df, feature_name, frequency=100, min_unique_values=1) + + df[score_name] = np.nan + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + method='medfilt', + random_state=42, + ) + + for var in valid_variables: + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + model = ECOD(contamination=contamination) + model.fit(df.loc[mask, [feature_name]]) + df.loc[mask, score_name] = model.predict(df.loc[mask, [feature_name]]) + + return df.groupby('interview__id')[score_name].mean() + + +def calculate_answer_position_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score answer-position entropy per responsible per variable. + + Flags enumerators whose selected-answer-position distribution differs by more than 50% + from the median entropy across all enumerators for each variable. + Only variables with at least 100 records and 3 distinct position values are scored. + """ + feature_name = 'f__answer_position' + score_name = rename_feature(feature_name) + df = df_item.copy() + + if feature_name not in df.columns: + return df + if df[feature_name].dropna().empty: + df[score_name] = np.nan + return df + + # Score is computed at the responsible level: entropy of answer-position distributions + # measures whether an enumerator systematically favours certain positions (e.g. always + # first or always last option). This bias is only detectable across many interviews for + # the same enumerator, not within a single interview. + valid_variables = filter_variable_name_by_frequency( + df[~pd.isnull(df[feature_name])], feature_name, frequency=100, min_unique_values=3) + df[score_name] = np.nan + + for var in valid_variables: + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + unique_values = df[mask][feature_name].nunique() + entropy_df = df[mask].groupby('responsible')[feature_name].apply( + calculate_entropy, unique_values=unique_values, min_record_sample=10 + ).reset_index() + entropy_df = entropy_df[~pd.isnull(entropy_df[feature_name])] + + if entropy_df.shape[0] > 0: + entropy_df.sort_values(feature_name, inplace=True, ascending=False) + median_value = entropy_df[feature_name].median() + entropy_df[score_name] = entropy_df[feature_name].apply( + lambda x: 1 if x < median_value - 0.5 * median_value else 0) + + # Apply map safely + responsible_map = entropy_df.set_index('responsible')[score_name].to_dict() + df.loc[mask, score_name] = df.loc[mask, 'responsible'].map(responsible_map) + return df + +def calculate_answer_selected_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score the share of selected options in multi-option questions using ECOD. + + Splits the output into s__answer_selected_lower (too few options selected) and + s__answer_selected_upper (too many selected) based on the inlier range for each variable. + Only variables with at least 100 records and 3 distinct share values are scored. + """ + feature_name = 'f__answer_selected' + score_name = rename_feature(feature_name) + df = df_item.copy() + + if feature_name not in df.columns: + return df + if df[feature_name].dropna().empty: + df[score_name + '_lower'] = np.nan + df[score_name + '_upper'] = np.nan + return df + + valid_variables = filter_variable_name_by_frequency( + df[~pd.isnull(df[feature_name])], feature_name, frequency=100, min_unique_values=3) + + score_name1 = score_name + '_lower' + score_name2 = score_name + '_upper' + df[score_name1] = np.nan + df[score_name2] = np.nan + df[score_name] = np.nan + + + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + method='medfilt', + random_state=42, + ) + + for var in valid_variables: + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + model = ECOD(contamination=contamination) + model.fit(df.loc[mask, [feature_name]]) + + df.loc[mask, score_name] = model.predict(df.loc[mask, [feature_name]]) + non_anomalies = df.loc[mask & (df[score_name] == 0), feature_name] + + if not non_anomalies.empty: + min_good_value = non_anomalies.min() + max_good_value = non_anomalies.max() + + df.loc[mask, score_name1] = 0 + df.loc[mask, score_name2] = 0 + + df.loc[mask & (df[feature_name] < min_good_value), score_name1] = 1 + df.loc[mask & (df[feature_name] > max_good_value), score_name2] = 1 + + df.drop(columns=[score_name], errors='ignore', inplace=True) + return df + + +def calculate_answer_duration_score(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Score answer-duration anomalies per variable using ECOD. + + Splits output into s__answer_duration_lower (unusually fast) and s__answer_duration_upper + (unusually slow) relative to the inlier range for each variable. + Only variables with at least 100 records and 3 distinct duration values are scored. + """ + feature_name = 'f__answer_duration' + score_name = rename_feature(feature_name) + df = df_item.copy() + + if feature_name not in df.columns: + return df + if df[feature_name].dropna().empty: + df[score_name + '_lower'] = np.nan + df[score_name + '_upper'] = np.nan + return df + + # Select only those variables that have at least three distinct values and more than one hundred records + valid_variables = filter_variable_name_by_frequency( + df[~pd.isnull(df[feature_name])], feature_name, frequency=100, min_unique_values=3) + + score_name1 = score_name + '_lower' + score_name2 = score_name + '_upper' + df[score_name1] = np.nan + df[score_name2] = np.nan + df[score_name] = np.nan + + contamination = get_contamination_parameter( + parameters.get('features', {}), + feature_name, + automatic_contamination=parameters.get('automatic_contamination', False), + method='medfilt', + random_state=42, + ) + + for var in valid_variables: + mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) + if mask.sum() > 0: + model = ECOD(contamination=contamination) + model.fit(df.loc[mask, [feature_name]]) + df.loc[mask, score_name] = model.predict(df.loc[mask, [feature_name]]) + + non_anomalies = df.loc[mask & (df[score_name] == 0), feature_name] + if not non_anomalies.empty: + min_good_value = non_anomalies.min() + max_good_value = non_anomalies.max() + + df.loc[mask, score_name1] = 0 + df.loc[mask, score_name2] = 0 + + df.loc[mask & (df[feature_name] < min_good_value), score_name1] = 1 + df.loc[mask & (df[feature_name] > max_good_value), score_name2] = 1 + + df.drop(columns=[score_name], errors='ignore', inplace=True) + return df + +def calculate_single_question_score(df_item: pd.DataFrame) -> pd.DataFrame: + """Score single-answer question entropy per responsible per variable. + + Flags enumerators whose selected-answer distribution for a variable differs by more than 50% + from the median entropy across all enumerators, indicating a potential acquiescence bias. + Excludes filtered comboboxes and cascade questions. Only variables with at least 100 + records and 3 distinct answer values are scored. + """ + feature_name = 'f__single_question' + score_name = rename_feature(feature_name) + df = df_item.copy() + columns = ['qtype', 'n_answers', 'is_filtered_combobox', 'cascade_from_question_id'] + + # f__single_question is not a separately computed feature column — scoring works + # directly on 'value' with a qtype mask, matching legacy make_score__single_question. + if any(col not in df.columns for col in columns): + return df + + # Score is computed at the responsible level: entropy of the selected-answer distribution + # reveals whether an enumerator consistently picks the same option across interviews. + # This acquiescence bias only emerges when comparing many interviews per enumerator. + + # Mask specific for single questions without filter rules bypassing cascades + single_question_mask = ( + (df["qtype"] == 'SingleQuestion') & + (df['n_answers'] > 1) & + (df['is_filtered_combobox'] == False) & + (pd.isnull(df['cascade_from_question_id'])) + ) + + df[score_name] = np.nan + valid_data = df[single_question_mask].copy() + if valid_data.empty: + return df + + variables = filter_variable_name_by_frequency(valid_data, 'value', frequency=100, min_unique_values=3) + + for var in variables: + mask = (df['variable_name'] == var) & single_question_mask + if mask.sum() > 0: + unique_values = df.loc[mask, 'value'].nunique() + + entropy_df = df[mask].groupby('responsible')['value'].apply( + calculate_entropy, unique_values=unique_values + ).reset_index() + entropy_df = entropy_df[~pd.isnull(entropy_df['value'])] + + if entropy_df.shape[0] > 0: + entropy_df.sort_values('value', inplace=True, ascending=False) + median_value = entropy_df['value'].median() + entropy_df[score_name] = entropy_df['value'].apply( + lambda x: 1 if x < median_value - 0.5 * median_value else 0) + + responsible_map = entropy_df.set_index('responsible')[score_name].to_dict() + df.loc[mask, score_name] = df.loc[mask, 'responsible'].map(responsible_map) + + return df + + +def calculate_multi_option_question_score(df_item: pd.DataFrame) -> pd.DataFrame: + """Score multi-option question entropy per responsible per variable. + + Flags enumerators whose combination of selected answers for a variable differs by more than + 50% from the median list-entropy across all enumerators, indicating systematic subset selection. + Only variables with at least 100 records and 3 distinct answer combinations are scored. + """ + feature_name = 'f__multi_option_question' + score_name = rename_feature(feature_name) + df = df_item.copy() + + # f__multi_option_question is not a separately computed feature column — scoring works + # directly on 'value' with a qtype mask, matching legacy make_score__multi_option_question. + if 'qtype' not in df.columns: + return df + + # Score is computed at the responsible level: entropy of the combination of options + # selected across interviews exposes enumerators who systematically pick the same + # subset of answers for every respondent. + + multi_question_mask = (df["qtype"] == 'MultyOptionsQuestion') + valid_data = df[multi_question_mask].copy() + + df[score_name] = np.nan + if valid_data.empty: + return df + + variables = filter_variable_name_by_frequency(valid_data, 'value', frequency=100, min_unique_values=3) + + for var in variables: + mask = (df['variable_name'] == var) & multi_question_mask + if mask.sum() > 0: + # Need safely explode nested lists in values + exploded_vals = df.loc[mask, 'value'].explode() + unique_values = len([v for v in exploded_vals.unique() if v != '##N/A##']) + + entropy_df = df[mask].groupby('responsible')['value'].apply( + calculate_list_entropy, unique_values=unique_values, min_record_sample=5 + ).reset_index() + + + entropy_df = entropy_df[~pd.isnull(entropy_df['value'])] + + if entropy_df.shape[0] > 0: + entropy_df.sort_values('value', inplace=True, ascending=False) + median_value = entropy_df['value'].median() + entropy_df[score_name] = entropy_df['value'].apply( + lambda x: 1 if x < median_value - 0.5 * median_value else 0) + + responsible_map = entropy_df.set_index('responsible')[score_name].to_dict() + df.loc[mask, score_name] = df.loc[mask, 'responsible'].map(responsible_map) + + return df + +def calculate_first_digit_score(df_item: pd.DataFrame) -> pd.DataFrame: + """Score first-digit Benford's Law deviations per responsible per variable. + + Computes the Jensen divergence between an enumerator's first-digit distribution and + that of all other enumerators. Only numeric variables spanning at least 3 orders of + magnitude and enumerators with at least 50 records per variable are evaluated. + Enumerators whose divergence exceeds the median by more than 50% are flagged. + """ + feature_name = 'f__numeric_response' + first_digit_feature = 'f__first_digit' + score_name = 's__first_digit' + df = df_item.copy() + + if feature_name not in df.columns or first_digit_feature not in df.columns: + return df + + valid_data = df[ + ~pd.isnull(df[feature_name]) & ( + ~pd.isnull(df[first_digit_feature])) & ( + df[first_digit_feature] != 0) + ].copy() + + if valid_data.empty: + df[score_name] = np.nan + return df + # we need both f__numeric_response and f__first_digit to apply Benford tests, + # so filter to rows where both are present + df[score_name] = np.nan + + # f__first_digit is already computed by the feature pipeline + valid_variables = filter_variable_name_by_frequency( + valid_data, first_digit_feature, frequency=100, min_unique_values=3 + ) + + # Additionally, Benford's Law is most applicable to variables that span several orders of magnitude, + valid_variables = filter_variables_by_magnitude(valid_data, feature_name, valid_variables, min_order_of_magnitude=3) + + # Computes the Jensen divergence for each variable_name and responsible on the first digit distribution. + # Jensen's divergence returns a value between (0, 1) of how much the first digit distribution + # of specific responsible is similar to the first digit distribution of all others. + # Higher the value higher is the difference. + # The Bendford Jensen divergence is calculated only on those responsible and variable_name + # who have at least 50 records. + # Once it is calculated, values that diverge from more than 50% from the median value get marked as "anomalous." + + benford_jensen_df = apply_benford_tests( + valid_data, valid_variables, 'responsible', feature_name, apply_first_digit=True, minimum_sample=50 + ) + + if not benford_jensen_df.empty: + variable_list = benford_jensen_df['variable_name'].unique() + for var in variable_list: + bj_mask = (benford_jensen_df['variable_name'] == var) & (~pd.isnull(benford_jensen_df[feature_name])) + bj_df = benford_jensen_df[bj_mask].copy() + if bj_df.shape[0] > 0: + bj_df.sort_values(feature_name, inplace=True, ascending=True) + median_value = bj_df[feature_name].median() + bj_df[score_name] = bj_df[feature_name].apply( + lambda x: 1 if x > median_value + 0.5 * median_value else 0) + + mask = (df['variable_name'] == var) + responsible_map = bj_df.set_index('responsible')[score_name].to_dict() + # Responsibles absent from the map (below 50-record Benford threshold) + # keep NaN — evaluation was not possible for them. + df.loc[mask, score_name] = df.loc[mask, 'responsible'].map(responsible_map) + + return df + + +# def calculate_last_digit_score(df_item: pd.DataFrame) -> pd.DataFrame: +# """Score last-digit anomalies per responsible per variable (NOT YET PORTED). +# +# Legacy `make_score__last_digit` in ItemFeatureProcessing applied Benford-style +# p-value tests on the last-digit distribution of numeric responses using a +# pivot-table approach. The item-level scoring was already commented out in the +# legacy codebase (see make_score__last_digit in item_processing.py) and the +# corresponding unit aggregation make_score_unit__last_digit was a no-op stub +# (pass). Kept commented until the approach is redesigned for the long-format +# Kedro pipeline. +# """ +# feature_name = 'f__numeric_response' +# score_name = 's__last_digit' +# df = df_item.copy() +# +# if feature_name not in df.columns: +# return df +# +# valid_data = df[~pd.isnull(df[feature_name])].copy() +# # Filter by frequency and order of magnitude, matching legacy pivot-table filtering +# valid_variables = filter_variable_name_by_frequency( +# valid_data, feature_name, frequency=100, min_unique_values=3 +# ) +# valid_variables = filter_variables_by_magnitude( +# valid_data, feature_name, valid_variables, min_order_of_magnitude=3 +# ) +# +# df[score_name] = np.nan +# for var in valid_variables: +# mask = (df['variable_name'] == var) & (~pd.isnull(df[feature_name])) +# if mask.sum() == 0: +# continue +# # Legacy used apply_benford_tests with p-value threshold of 0.05 to flag +# # enumerators whose last-digit distribution deviates significantly from uniform. +# results_df = apply_benford_tests( +# df[mask], [var], 'responsible', feature_name, +# apply_first_digit=False, minimum_sample=50, +# ) +# if results_df is not None and not results_df.empty and 'p-value' in results_df.columns: +# score_col = var + '_last_digit' +# results_df[score_col] = results_df['p-value'].apply(lambda x: 1 if x <= 0.05 else 0) +# responsible_map = results_df.set_index('responsible')[score_col].to_dict() +# df.loc[mask, score_name] = df.loc[mask, 'responsible'].map(responsible_map) +# +# return df + diff --git a/rissk/plots.py b/rissk/plots.py deleted file mode 100644 index 15486f0..0000000 --- a/rissk/plots.py +++ /dev/null @@ -1,29 +0,0 @@ -from pathlib import Path - -import typer -from loguru import logger -from tqdm import tqdm - -from rissk.config import FIGURES_DIR, PROCESSED_DATA_DIR - -app = typer.Typer() - - -@app.command() -def main( - # ---- REPLACE DEFAULT PATHS AS APPROPRIATE ---- - input_path: Path = PROCESSED_DATA_DIR / "dataset.csv", - output_path: Path = FIGURES_DIR / "plot.png", - # ----------------------------------------- -): - # ---- REPLACE THIS WITH YOUR OWN CODE ---- - logger.info("Generating plot from data...") - for i in tqdm(range(10), total=10): - if i == 5: - logger.info("Something happened for iteration 5.") - logger.success("Plot generation complete.") - # ----------------------------------------- - - -if __name__ == "__main__": - app() diff --git a/rissk/unit_proccessing.py b/rissk/unit_proccessing.py deleted file mode 100644 index 13bed79..0000000 --- a/rissk/unit_proccessing.py +++ /dev/null @@ -1,345 +0,0 @@ -from rissk.item_processing import * -from rissk.detection_algorithms import * -from sklearn.preprocessing import StandardScaler, MinMaxScaler, Normalizer -from sklearn.preprocessing import normalize -# from sklearn.decomposition import PCA -from pyod.models.pca import PCA -from pyod.models.iforest import IForest - - -def windsorize_95_percentile(df): - """ - Windsorize values in all columns of the DataFrame that are above the 95th percentile. - - Args: - - df (pd.DataFrame): Input DataFrame - - Returns: - - pd.DataFrame: Windsorized DataFrame - """ - for column in df.columns: - # Calculate the 95th percentile for the column - percentile_95 = df[column].quantile(0.95) - - # Set values above the 95th percentile to the value at the 95th percentile - df[column] = df[column].apply(lambda x: min(x, percentile_95)) - - return df - - -class UnitDataProcessing(ItemFeatureProcessing): - - def __init__(self, config): - super().__init__(config) - self._score_columns = None - - @property - def df_unit_score(self): - for method_name in self.get_make_methods(method_type='score', level='unit'): - feature_name = method_name.replace('make_score_unit__', 'f__') - score_name = self.rename_feature(feature_name) - if feature_name in self._allowed_features and self._score_columns is None: - try: - print('Processing Score {}...'.format(score_name)) - getattr(self, method_name)(feature_name) - # print('Score{} Processed'.format(feature_name)) - except Exception as e: - print("WARNING: SCORE: {} won't be used in further calculation".format(score_name)) - - score_columns = [col for col in self._df_unit if - col.startswith('s__')] # and col.replace('s__','f__') in self._allowed_features] - # Remove columns with only nan or constant values - self._score_columns = self._df_unit[score_columns].columns[self._df_unit[score_columns].nunique() > 1].tolist() - return self._df_unit[['interview__id', 'responsible', 'survey_name', 'survey_version', ] + self._score_columns] - - def make_global_score(self, combine_resp_score=True, restricted_columns=None): - self._df_unit['unit_risk_score'] = 0 - scaler = StandardScaler() - df = self.df_unit_score[self._score_columns] - columns = self._score_columns - if restricted_columns is not None: - columns = [col for col in self._score_columns if col not in restricted_columns] - # df = windsorize_95_percentile(self.df_unit_score[columns].copy()) - df = df[columns].copy() - df = pd.DataFrame(scaler.fit_transform(df), columns=columns) - model = IForest(random_state=42) - model.fit(df.fillna(0)) - scaler = MinMaxScaler(feature_range=(0, 100)) - self._df_unit['unit_risk_score'] = model.decision_scores_ - - self._df_unit['unit_risk_score'] = windsorize_95_percentile(self.df_unit[['unit_risk_score']].copy()) - - self._df_unit['unit_risk_score'] = scaler.fit_transform(self._df_unit[['unit_risk_score']]) - - # Merge unit score with responsible score - if combine_resp_score: - # Make responsible Score - self.make_responsible_score(restricted_columns=columns) - merged_df = self._df_unit.merge(self._df_resp[['responsible', 'responsible_score']], how='left', - on='responsible') - self._df_unit['unit_risk_score'] = merged_df['unit_risk_score'] * merged_df['responsible_score'] - - self._df_unit['unit_risk_score'] = scaler.fit_transform(self._df_unit[['unit_risk_score']]) - - def make_responsible_score(self, restricted_columns): - scaler = StandardScaler() - columns = [col for col in self._df_resp.columns - if col.startswith('responsible') is False and col not in restricted_columns] - - self._df_resp = self._df_resp.groupby('responsible')[columns].mean() - self._df_resp = self._df_resp.reset_index() - - df_resp = self._df_resp[columns].fillna(0) - # Remove columns with constant values - df_resp = df_resp.loc[:, df_resp.nunique() != 1] - df_resp = pd.DataFrame(scaler.fit_transform(df_resp), columns=df_resp.columns) - - model = PCA(random_state=42) - model.fit(df_resp) - self._df_resp['responsible_score'] = model.decision_scores_ # function(df1) - # scaler = MinMaxScaler(feature_range=(0, 1)) - # self._df_resp['responsible_score'] = scaler.fit_transform(self._df_resp[['responsible_score']]) - self._df_resp['responsible_score'] = normalize(self._df_resp[['responsible_score']], norm='l1', axis=0) - - def save(self): - df = self._df_unit[['interview__id', 'responsible', 'unit_risk_score']] # .copy() - df['unit_risk_score'] = df['unit_risk_score'].round(2) - df.sort_values('unit_risk_score', inplace=True) - file_name = "_".join([self.config.surveys[0], self.config.survey_version[0], 'unit_risk_score']) + ".csv" - output_path = self.config['output_file'].split('.')[0] + '.csv' - df.to_csv(output_path, index=False) - print(f'SUCCESS! you can find the unit risk score output file in {output_path}') - if self.config['feature_score']: - - columns = [col for col in self._df_resp.columns if col.startswith('responsible') is False] - - sorted_columns = sorted(self._score_columns + columns) - - merged_df = self._df_unit.merge(self._df_resp, how='left', - on='responsible') - - merged_df = merged_df[['interview__id', 'responsible', 'survey_name', 'survey_version'] + sorted_columns] - output_path = self.config['output_file'].split('.')[0] + '_feature_score.csv' - merged_df.to_csv(output_path, index=False) - print(f'You can find the unit feature score file in {output_path}') - - def make_score_unit__numeric_response(self, feature_name): - pass - - def make_score_unit__last_digit(self, feature_name): - pass - - def make_score_unit__single_question(self, feature_name): - score_name = self.rename_feature(feature_name) - # single_question is calculated at responsible level - data = self.make_score__single_question() - data = data.groupby(['responsible', 'variable_name']).agg({score_name: 'mean'}) - data = data.reset_index() - data = data.groupby('responsible').agg({score_name: 'mean'}) - self._df_resp[score_name] = self._df_resp['responsible'].map(data[score_name]) - # Fill with 0's for missing values - self._df_resp[score_name].fillna(0, inplace=True) - - def make_score_unit__multi_option_question(self, feature_name): - score_name = self.rename_feature(feature_name) - # multi_option_question is calculated at responsible level - data = self.make_score__multi_option_question() - data = data.groupby(['responsible', 'variable_name']).agg({score_name: 'mean'}) - data = data.reset_index() - data = data.groupby('responsible').agg({score_name: 'mean'}) - self._df_resp[score_name] = self._df_resp['responsible'].map(data[score_name]) - # Fill with 0's for missing values - self._df_resp[score_name].fillna(0, inplace=True) - - def make_score_unit__answer_hour_set(self, feature_name): - data = self.make_score__answer_hour_set() - score_name = self.rename_feature(feature_name) - # Get the ratio of anomalies per interview__id over the total number of answer set - data = data.groupby(['interview__id']).agg({score_name: 'mean'}) - self._df_unit[score_name] = self._df_unit['interview__id'].map(data[score_name]) - - def make_score_unit__answer_removed(self, feature_name): - data = self.make_score__answer_removed() - score_name = self.rename_feature(feature_name) - data = data.groupby(['interview__id']).agg({score_name: 'mean'}) - self._df_unit[score_name] = self._df_unit['interview__id'].map(data[score_name]) - # Fill with 0's for missing values - self._df_unit[score_name].fillna(0, inplace=True) - - def make_score_unit__answer_changed(self, feature_name): - data = self.make_score__answer_changed() - score_name = self.rename_feature(feature_name) - # take the max number of anomaly for each question, i.e. 'roster_level' + 'variable_name' - data = data.groupby(['interview__id']).agg({score_name: 'mean'}) - self._df_unit[score_name] = self._df_unit['interview__id'].map(data[score_name]) - # Fill with 0's for missing values - self._df_unit[score_name].fillna(0, inplace=True) - - def make_score_unit__answer_position(self, feature_name): - score_name = self.rename_feature(feature_name) - # answer_position is calculated at responsible level - data = self.make_score__answer_position() - data = data.groupby(['responsible', 'variable_name']).agg({score_name: 'mean'}) - data = data.reset_index() - data = data.groupby('responsible')[score_name].mean() - self._df_resp[score_name] = self._df_resp['responsible'].map(data) - # Fill with 0's for missing values - self._df_resp[score_name].fillna(0, inplace=True) - - def make_score_unit__answer_selected(self, feature_name): - score_name = self.rename_feature(feature_name) - score_name1 = score_name + '_lower' - score_name2 = score_name + '_upper' - data = self.make_score__answer_selected() - data = data.groupby(['interview__id']).agg({score_name1: 'mean', score_name2: 'mean'}) - data = data.reset_index() - self._df_unit[score_name1] = self._df_unit['interview__id'].map(data.set_index('interview__id')[score_name1]) - self._df_unit[score_name2] = self._df_unit['interview__id'].map(data.set_index('interview__id')[score_name2]) - # Fill with 0's for missing values - self._df_unit[score_name1].fillna(0, inplace=True) - self._df_unit[score_name2].fillna(0, inplace=True) - - def make_score_unit__answer_duration(self, feature_name): - score_name = self.rename_feature(feature_name) - score_name1 = score_name + '_lower' - score_name2 = score_name + '_upper' - data = self.make_score__answer_duration() - data = data.groupby(['interview__id']).agg({score_name1: 'mean', score_name2: 'mean'}) - data = data.reset_index() - self._df_unit[score_name1] = self._df_unit['interview__id'].map(data.set_index('interview__id')[score_name1]) - self._df_unit[score_name2] = self._df_unit['interview__id'].map(data.set_index('interview__id')[score_name2]) - # Fill with 0's for missing values - self._df_unit[score_name1].fillna(0, inplace=True) - self._df_unit[score_name2].fillna(0, inplace=True) - - def make_score_unit__first_decimal(self, feature_name): - score_name = self.rename_feature(feature_name) - data = self.make_score__first_decimal() - data = data.groupby(['interview__id']).agg({score_name: 'mean'}) - - self._df_unit[score_name] = self._df_unit['interview__id'].map(data[score_name]) - # Fill with 0's for missing values. It means "No anomalies detected" - self._df_unit[score_name].fillna(0, inplace=True) - - def make_score_unit__first_digit(self, feature_name): - score_name = self.rename_feature(feature_name) - data = self.make_score__first_digit() - data = data.groupby(['responsible']).agg({score_name: 'mean'}) - - self._df_resp[score_name] = self._df_resp['responsible'].map(data[score_name]) - # Fill with 0's for missing values - self._df_resp[score_name].fillna(0, inplace=True) - - def make_score_unit__sequence_jump(self, feature_name): - score_name = feature_name.replace('f__', 's__') - data = self.make_score__sequence_jump() - data = data.groupby(['interview__id']).agg({score_name: 'mean'}) - - self._df_unit[score_name] = self._df_unit['interview__id'].map(data[score_name]) - # Fill with 0's for missing values. It means "No anomalies detected" - self._df_unit[score_name].fillna(0, inplace=True) - - def make_score_unit__time_changed(self, feature_name): - score_name = self.rename_feature(feature_name) - # round to 10 min - self._df_unit[score_name] = round(self._df_unit['f__time_changed'].abs()/600) - - def make_score_unit__total_duration(self, feature_name): - score_name = self.rename_feature(feature_name) - # transform Total duration into 10 minutes values - self._df_unit[score_name] = round(self._df_unit[feature_name] / 300) # / self._df_unit['f__number_answered'] - - def make_score_unit__days_from_start(self, feature_name): - score_name = self.rename_feature(feature_name) - self._df_unit[score_name] = (self._df_unit[feature_name] / 7).astype(int) - - def make_score_unit__total_elapse(self, feature_name): - score_name = self.rename_feature(feature_name) - self._df_unit[feature_name] = round(self._df_unit[feature_name] / 300) - contamination = self.get_contamination_parameter(feature_name, method='medfilt', random_state=42) - - model = ECOD(contamination=contamination) - model.fit(self._df_unit[[feature_name]]) - self._df_unit[score_name] = model.predict(self._df_unit[[feature_name]]) - - score_name1 = score_name + '_lower' - score_name2 = score_name + '_upper' - min_good_value = self._df_unit[(self._df_unit[score_name] == 0)][feature_name].min() - max_good_value = self._df_unit[(self._df_unit[score_name] == 0)][feature_name].max() - - self._df_unit[score_name1] = 0 - self._df_unit[score_name2] = 0 - - self._df_unit.loc[(self._df_unit[feature_name] < min_good_value), score_name1] = 1 - self._df_unit.loc[(self._df_unit[feature_name] > max_good_value), score_name2] = 1 - - self._df_unit.drop(columns=[score_name], inplace=True) - - def make_score_unit__pause_duration(self, feature_name): - - score_name = self.rename_feature(feature_name) - # transform Total duration into 10 minutes values - self._df_unit[score_name] = self._df_unit[feature_name] / self._df_unit['f__total_elapse'] - - def make_score_unit__pause_count(self, feature_name): - score_name = self.rename_feature(feature_name) - pause_mask = ~pd.isnull(self._df_unit[feature_name]) - self._df_unit[score_name] = self._df_unit[feature_name] / self._df_unit['f__number_answered'] - - def make_score_unit__number_answered(self, feature_name): - score_name = self.rename_feature(feature_name) - self._df_unit[score_name] = self._df_unit[feature_name] - - def make_score_unit__number_unanswered(self, feature_name): - score_name = self.rename_feature(feature_name) - self._df_unit[score_name] = self._df_unit[feature_name] - - def make_score_unit__gps(self, feature_name): - data = self.make_score__gps() - features = ['s__gps_proximity_counts', 's__gps_outlier', 's__gps_extreme_outlier'] - - data = data.groupby('interview__id')[features].sum() - data = data.reset_index() - - self._df_unit['s__gps_proximity_counts'] = self._df_unit['interview__id'].map( - data.set_index('interview__id')['s__gps_proximity_counts'] - ) - - self._df_unit['s__gps_outlier'] = self._df_unit['interview__id'].map( - data.set_index('interview__id')['s__gps_outlier'] - ) - self._df_unit['s__gps_extreme_outlier'] = self._df_unit['interview__id'].map( - data.set_index('interview__id')['s__gps_extreme_outlier'] - ) - - data = self.df_item.groupby('interview__id')[feature_name].sum() - score_name = feature_name.replace('f__', 's__') - self._df_unit[score_name] = self._df_unit['interview__id'].map(data) - - self._df_unit['s__gps_proximity_counts'].fillna(0, inplace=True) - self._df_unit['s__gps_outlier'].fillna(0, inplace=True) - self._df_unit['s__gps_extreme_outlier'].fillna(0, inplace=True) - - # def make_feature_unit__comments(self): - # columns_to_check = ['f__comments_set', 'f__comment_length'] - # if any(col not in self._df_unit.columns for col in columns_to_check): - # # f__comments_set, f_comment_length - # df_unit_comment = self.df_item.groupby('interview__id').agg( - # f__comments_set=('f__comments_set', 'sum'), - # f__comment_length=('f__comment_length', 'sum') - # ).reset_index() - # - # self._df_unit['f__comments_set'] = self._df_unit['interview__id'].map( - # df_unit_comment.set_index('interview__id')['f__comments_set'] - # ) - # - # self._df_unit['f__comment_length'] = self._df_unit['interview__id'].map( - # df_unit_comment.set_index('interview__id')['f__comment_length'] - # ) - # - # def make_feature_unit__number_answers(self): - # answer_per_interview_df = self.df_active_paradata.groupby('interview__id').variable_name.nunique() - # answer_per_interview_df = answer_per_interview_df.reset_index() - # total_questions = self.df_questionnaire[self.df_questionnaire["qtype"].str.contains('Question')]["qtype"].count() - # self._df_unit['f__number_answers'] = self.df_item['interview__id'].map( - # answer_per_interview_df.set_index('interview__id')['variable_name'] / total_questions) diff --git a/rissk/unit_processing_kedro.py b/rissk/unit_processing_kedro.py new file mode 100644 index 0000000..db8c9e6 --- /dev/null +++ b/rissk/unit_processing_kedro.py @@ -0,0 +1,350 @@ +import pandas as pd +import numpy as np +import logging +from typing import List, Dict, Any, Tuple +from pyod.models.pca import PCA +from pyod.models.iforest import IForest +from pyod.models.ecod import ECOD +from rissk.item_processing_kedro import get_contamination_parameter +from sklearn.preprocessing import StandardScaler, MinMaxScaler, normalize + +logger = logging.getLogger(__name__) + +def windsorize_95_percentile(df: pd.DataFrame) -> pd.DataFrame: + """ + Windsorize values in all columns of the DataFrame that are above the 95th percentile. + + Args: + - df (pd.DataFrame): Input DataFrame + + Returns: + - pd.DataFrame: Windsorized DataFrame + """ + df_out = df.copy() + for column in df_out.columns: + if pd.api.types.is_numeric_dtype(df_out[column]): + # Calculate the 95th percentile for the column + percentile_95 = df_out[column].quantile(0.95) + + # Set values above the 95th percentile to the value at the 95th percentile + df_out[column] = df_out[column].apply(lambda x: min(x, percentile_95)) + + return df_out + +# -- Note: Aggregation methods map closely to make_score_unit__* in legacy -- + +def aggregate_unit_score_mean(df_item_scores: pd.DataFrame, df_unit: pd.DataFrame, score_source_name: str, score_target_name: str) -> pd.DataFrame: + """Helper purely taking interview__id mapped scores and doing mean aggregation into unit df.""" + if score_source_name not in df_item_scores.columns: + return df_unit + + data = df_item_scores.groupby(['interview__id']).agg({score_source_name: 'mean'}) + df_out = df_unit.copy() + df_out[score_target_name] = df_out['interview__id'].map(data[score_source_name]) + df_out[score_target_name] = df_out[score_target_name].fillna(0) + return df_out + +def calculate_global_score(df_unit_scores: pd.DataFrame, df_resp_scores: pd.DataFrame, score_columns: List[str], combine_resp_score: bool = True, restricted_columns: List[str] = None) -> pd.DataFrame: + """ + Calculate the global unit risk score. + Maps legacy `make_global_score` inside UnitDataProcessing. + """ + df_unit = df_unit_scores.copy() + df_unit['unit_risk_score'] = 0 + scaler = StandardScaler() + + # Select columns + columns = score_columns if score_columns else [] + if restricted_columns is not None: + columns = [col for col in columns if col not in restricted_columns] + + available_cols = [c for c in columns if c in df_unit.columns] + + if not available_cols: + logger.warning("No score columns available to compute global risk score.") + return df_unit + + df = df_unit[available_cols].copy() + + # Drop constant columns before StandardScaler — a constant column produces NaN after + # z-scoring (division by zero std), which would make IForest scores meaningless and + # MinMaxScaler produce NaN unit_risk_score for every interview. + # This mirrors legacy's `nunique() > 1` filter in df_unit_score. + varying_cols = [c for c in available_cols if df[c].nunique() > 1] + if not varying_cols: + logger.warning("All score columns are constant — cannot compute meaningful global risk score.") + return df_unit + + df = df[varying_cols] + df = pd.DataFrame(scaler.fit_transform(df), columns=varying_cols) + + model = IForest(random_state=42) + model.fit(df.fillna(0)) + + scaler = MinMaxScaler(feature_range=(0, 100)) + df_unit['unit_risk_score'] = model.decision_scores_ + + # Windsorize + df_unit['unit_risk_score'] = windsorize_95_percentile(df_unit[['unit_risk_score']])['unit_risk_score'] + + # Scale to 0-100 + df_unit['unit_risk_score'] = scaler.fit_transform(df_unit[['unit_risk_score']]) + + # Merge unit score with responsible score. + # Only apply the multiplication when responsible_score was successfully computed + # (not all-NaN) and has actual variance. If PCA couldn't run (too few columns or + # all scores constant), responsible_score is NaN — multiplying would wipe out all + # IForest-derived unit scores. + if combine_resp_score and 'responsible' in df_unit.columns and df_resp_scores is not None and 'responsible_score' in df_resp_scores.columns: + resp_score_series = df_resp_scores['responsible_score'] + if resp_score_series.notna().any() and resp_score_series.nunique() > 1: + df_resp_map = df_resp_scores.set_index('responsible')['responsible_score'].to_dict() + df_unit['responsible_score'] = df_unit['responsible'].map(df_resp_map) + df_unit['unit_risk_score'] = df_unit['unit_risk_score'] * df_unit['responsible_score'] + df_unit['unit_risk_score'] = scaler.fit_transform(df_unit[['unit_risk_score']]) + else: + logger.warning( + "responsible_score is NaN or has no variance (likely too few enumerators or all scores constant); " + "skipping responsible-score multiplication to preserve interview-level unit_risk_score." + ) + + return df_unit + +def aggregate_item_to_unit_scores(df_unit: pd.DataFrame, df_item_scores: pd.DataFrame) -> pd.DataFrame: + """Aggregates item-level scores up to the unit (interview) level.""" + df_out = df_unit.copy() + + # 1. Simple mean aggregations + # Note: s__answer_removed is intentionally excluded here — it is scored + # at unit level directly from paradata_full by calculate_answer_removed_unit_score + # in calculate_unit_scores, so that items deleted from microdata are included. + # fillna(0): an interview absent from df_item_scores for a given feature has no + # scorable items, which means no anomaly was detected — the absence is not unknown. + mean_scores = [ + 's__answer_hour_set', 's__answer_changed', + 's__first_decimals', 's__sequence_jump' + ] + for score in mean_scores: + if score in df_item_scores.columns: + data = df_item_scores.groupby('interview__id')[score].mean() + df_out[score] = df_out['interview__id'].map(data).fillna(0) + + # 2. Lower/Upper mean aggregations + lower_upper_scores = [ + 's__answer_selected', 's__answer_duration' + ] + for score_base in lower_upper_scores: + for suffix in ['_lower', '_upper']: + score = score_base + suffix + if score in df_item_scores.columns: + data = df_item_scores.groupby('interview__id')[score].mean() + df_out[score] = df_out['interview__id'].map(data).fillna(0) + + # 3. GPS specifics (if gps scores exist) + # s__gps is the per-interview count of GPS-type questions (sum of the item-level + # boolean flag converted to int in calculate_gps_score), matching legacy + # make_score_unit__gps which summed f__gps from df_item. + gps_features = ['s__gps_proximity_counts', 's__gps_outlier', 's__gps_extreme_outlier', 's__gps'] + for score in gps_features: + if score in df_item_scores.columns: + data = df_item_scores.groupby('interview__id')[score].sum() + df_out[score] = df_out['interview__id'].map(data).fillna(0) + + return df_out + +def calculate_unit_level_scores(df_unit: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """Calculate unit-level scores derived directly from unit-feature columns. + + These scores do not involve item-level aggregation; each is a simple transformation + of an existing unit feature (rescaling, rate normalisation, or ECOD outlier detection). + Only columns present in df_unit are processed; missing features are skipped silently. + """ + df = df_unit.copy() + + if 'f__time_changed' in df.columns: + # Divide by 600 (seconds) to express device clock shifts in 10-minute units. + df['s__time_changed'] = round(df['f__time_changed'].abs() / 600) + + if 'f__total_duration' in df.columns: + # Divide by 300 (seconds) to express total active interview time in 5-minute units. + df['s__total_duration'] = round(df['f__total_duration'] / 300) + + if 'f__days_from_start' in df.columns: + # Convert days elapsed since the first interview in the dataset to weeks. + df['s__days_from_start'] = (df['f__days_from_start'] / 7).astype(int) + + if 'f__total_elapse' in df.columns: + score_name = 's__total_elapse' + df['f__total_elapse_scaled'] = round(df['f__total_elapse'] / 300) + + # contamination from parameters or fallback + contamination = get_contamination_parameter( + parameters.get('features', {}), 'f__total_elapse', method='medfilt', random_state=42 + ) + + model = ECOD(contamination=contamination) + valid_mask = ~df['f__total_elapse_scaled'].isnull() + if valid_mask.sum() > 0: + model.fit(df.loc[valid_mask, ['f__total_elapse_scaled']]) + df.loc[valid_mask, score_name] = model.predict(df.loc[valid_mask, ['f__total_elapse_scaled']]) + + score_name1 = score_name + '_lower' + score_name2 = score_name + '_upper' + df[score_name1] = 0 + df[score_name2] = 0 + + non_anomalies = df.loc[(df[score_name] == 0) & valid_mask, 'f__total_elapse_scaled'] + if not non_anomalies.empty: + min_val = non_anomalies.min() + max_val = non_anomalies.max() + df.loc[valid_mask & (df['f__total_elapse_scaled'] < min_val), score_name1] = 1 + df.loc[valid_mask & (df['f__total_elapse_scaled'] > max_val), score_name2] = 1 + df.drop(columns=[score_name, 'f__total_elapse_scaled'], inplace=True, errors='ignore') + + if 'f__pause_duration' in df.columns and 'f__total_elapse' in df.columns: + # Express pause time as a fraction of the total elapsed interview time. + df['s__pause_duration'] = np.where(df['f__total_elapse'] != 0, + df['f__pause_duration'] / df['f__total_elapse'], 0) + + if 'f__pause_count' in df.columns and 'f__number_answered' in df.columns: + # Express pause count as a rate per answered question, normalising for interview length. + df['s__pause_count'] = np.where(df['f__number_answered'] != 0, + df['f__pause_count'] / df['f__number_answered'], 0) + + if 'f__number_answered' in df.columns: + df['s__number_answered'] = df['f__number_answered'] + + if 'f__number_unanswered' in df.columns: + df['s__number_unanswered'] = df['f__number_unanswered'] + + return df + +def aggregate_item_to_responsible_scores(df_resp: pd.DataFrame, df_item_scores: pd.DataFrame) -> pd.DataFrame: + """Aggregates item-level scores to the responsible (enumerator) level.""" + df_out = df_resp.copy() + if df_out.empty and 'responsible' in df_item_scores.columns: + df_out = pd.DataFrame({'responsible': df_item_scores['responsible'].unique()}) + + if df_out.empty: + return df_out + + # s__single_question, s__multi_option_question, and s__answer_position are computed at + # the responsible level: they measure how uniformly an enumerator distributes answers + # across categories or positions, a pattern that only becomes detectable when pooling + # many interviews. Scores are averaged first within each variable, then across variables, + # to prevent high-answer-count questions from dominating the responsible-level signal. + scores_double_mean = ['s__single_question', 's__multi_option_question', 's__answer_position'] + for score in scores_double_mean: + if score in df_item_scores.columns: + data = df_item_scores.groupby(['responsible', 'variable_name'])[score].mean().reset_index() + data = data.groupby('responsible')[score].mean() + if 'responsible' in df_out.columns: + df_out[score] = df_out['responsible'].map(data).fillna(0) + + # s__first_digit uses Jensen divergence from Benford's Law, which requires a large + # sample of numeric responses per enumerator to be statistically meaningful and is + # therefore aggregated at the responsible level rather than per interview. + if 's__first_digit' in df_item_scores.columns: + data = df_item_scores.groupby('responsible')['s__first_digit'].mean() + if 'responsible' in df_out.columns: + df_out['s__first_digit'] = df_out['responsible'].map(data).fillna(0) + + return df_out + +def calculate_responsible_score(df_resp_features: pd.DataFrame, restricted_columns: List[str] = None) -> pd.DataFrame: + """ + Calculate the global responsible (enumerator) score using PCA. + Maps legacy `make_responsible_score`. + """ + df_resp = df_resp_features.copy() + if df_resp.empty or 'responsible' not in df_resp.columns: + return df_resp + + scaler = StandardScaler() + columns = [col for col in df_resp.columns if not col.startswith('responsible') and (not restricted_columns or col not in restricted_columns)] + + if not columns: + df_resp['responsible_score'] = np.nan + return df_resp + + df_grouped = df_resp.groupby('responsible')[columns].mean().reset_index() + + df_pca_input = df_grouped[columns].fillna(0) + df_pca_input = df_pca_input.loc[:, df_pca_input.nunique() != 1] + + if df_pca_input.empty: + df_resp['responsible_score'] = np.nan + return df_resp + + # PCA-based outlier scoring requires at least 2 varying columns to be meaningful: + # with only 1 component there are no minor eigenvectors to compute weighted + # reconstruction error against, so all scores would be identical. + if df_pca_input.shape[1] < 2: + df_resp['responsible_score'] = np.nan + return df_resp + + df_pca_scaled = pd.DataFrame(scaler.fit_transform(df_pca_input), columns=df_pca_input.columns) + + model = PCA(random_state=42) + model.fit(df_pca_scaled) + df_grouped['responsible_score'] = model.decision_scores_ + + df_grouped['responsible_score'] = normalize(df_grouped[['responsible_score']], norm='l1', axis=0) + + # Merge back to original resp mapping + return df_resp.merge(df_grouped[['responsible', 'responsible_score']], on='responsible', how='left') + + +# def aggregate_feature_unit__comments( +# df_item: pd.DataFrame, +# df_unit: pd.DataFrame, +# ) -> pd.DataFrame: +# """Aggregate comment-related features from item level to unit level (NOT YET PORTED). +# +# Legacy `make_feature_unit__comments` in UnitDataProcessing populated f__comments_set +# and f__comment_length on the unit frame by summing item-level values per interview__id. +# The function was already commented out in the legacy codebase and the features were +# not active in the pipeline. Kept commented here for reference. +# """ +# df_out = df_unit.copy() +# columns_to_check = ['f__comments_set', 'f__comment_length'] +# if any(col not in df_out.columns for col in columns_to_check): +# if any(col in df_item.columns for col in columns_to_check): +# df_unit_comment = df_item.groupby('interview__id').agg( +# f__comments_set=('f__comments_set', 'sum'), +# f__comment_length=('f__comment_length', 'sum'), +# ).reset_index() +# df_out['f__comments_set'] = df_out['interview__id'].map( +# df_unit_comment.set_index('interview__id')['f__comments_set'] +# ) +# df_out['f__comment_length'] = df_out['interview__id'].map( +# df_unit_comment.set_index('interview__id')['f__comment_length'] +# ) +# return df_out + + +# def aggregate_feature_unit__number_answers( +# df_unit: pd.DataFrame, +# df_active_paradata: pd.DataFrame, +# df_questionnaire: pd.DataFrame, +# ) -> pd.DataFrame: +# """Aggregate number-of-distinct-answers feature to unit level (NOT YET PORTED). +# +# Legacy `make_feature_unit__number_answers` in UnitDataProcessing computed the ratio +# of distinct variable_names answered per interview over the total question count in +# the questionnaire, sourced from df_active_paradata. The function was already +# commented out in the legacy codebase. Kept commented here for reference. +# """ +# df_out = df_unit.copy() +# answer_per_interview_df = ( +# df_active_paradata.groupby('interview__id')['variable_name'] +# .nunique() +# .reset_index() +# ) +# total_questions = df_questionnaire[ +# df_questionnaire['qtype'].str.contains('Question') +# ]['qtype'].count() +# df_out['f__number_answers'] = df_out['interview__id'].map( +# answer_per_interview_df.set_index('interview__id')['variable_name'] / total_questions +# ) +# return df_out diff --git a/rissk/utils/chart_utils.py b/rissk/utils/chart_utils.py deleted file mode 100644 index ee869b0..0000000 --- a/rissk/utils/chart_utils.py +++ /dev/null @@ -1,102 +0,0 @@ -import seaborn as sns -import pandas as pd -import matplotlib.pyplot as plt - - -import seaborn as sns -import pandas as pd -import matplotlib.pyplot as plt - - -def make_top_perc_chart(df, target_label, plot_first_percentiles=False, plot_perc_overall=False): - - df = df.sort_values(by='unit_risk_score', ascending=False) - - df['cumulative_true'] = df[target_label].cumsum() - df['cumulative_count'] = range(1, len(df) + 1) - - # 4. Calculate percentages - df['percentage_of_true'] = df['cumulative_true'] / df['cumulative_count'] * 100 - percentage_of_true_overall = df['survey_label'].sum() / df['survey_label'].count() * 100 - df['percentage_of_records'] = df['cumulative_count'] / len(df) * 100 - fig, ax1 = plt.subplots() - - # 5. Plotting - ax1.plot(df['percentage_of_records'], df['percentage_of_true']) - ax1.axhline(y=percentage_of_true_overall, color='orange', linestyle='--') - - if plot_first_percentiles: - mask_5 = df['percentage_of_records'] <= 5 - percentage_of_true_5 = df[mask_5]['survey_label'].sum() / df[mask_5]['survey_label'].count() * 100 - mask_10 = df['percentage_of_records'] <= 10 - percentage_of_true_10 = df[mask_10]['survey_label'].sum() / df[mask_10]['survey_label'].count() * 100 - mask_15 = df['percentage_of_records'] <= 15 - percentage_of_true_15 = df[mask_15]['survey_label'].sum() / df[mask_15]['survey_label'].count() * 100 - mask_20 = df['percentage_of_records'] <= 20 - percentage_of_true_20 = df[mask_20]['survey_label'].sum() / df[mask_20]['survey_label'].count() * 100 - mask_25 = df['percentage_of_records'] <= 25 - percentage_of_true_25 = df[mask_25]['survey_label'].sum() / df[mask_25]['survey_label'].count() * 100 - - ax1.axvline(x=5, color='c', linestyle='--', alpha=0.3) - ax1.axvline(x=10, color='c', linestyle='--', alpha=0.3) - ax1.axvline(x=15, color='c', linestyle='--', alpha=0.3) - ax1.axvline(x=20, color='c', linestyle='--', alpha=0.3) - #ax1.axvline(x=25, color='c', linestyle='--', alpha=0.3) - - # Add text near the vertical line - ax1.text(50, percentage_of_true_overall - 5, 'Total Percentage of Artificial Fakes', rotation=0, - verticalalignment='center', color='black') - - ax1.text(2, 20, f'{str(round(percentage_of_true_5))}% within 5%', rotation=90, verticalalignment='center', - color='black') - ax1.text(7, 30, f'{str(round(percentage_of_true_10))}% within 10%', rotation=90, verticalalignment='center', - color='black') - ax1.text(15, 70, f'{str(round(percentage_of_true_15))}% within 15%', rotation=90, verticalalignment='center', - color='black') - ax1.text(22, 75, f'{str(round(percentage_of_true_20))}% within 20%', rotation=90, verticalalignment='center', - color='black') - - if plot_perc_overall: - ax2 = ax1.twinx() - ax2.plot(df['percentage_of_records'], df['cumulative_true'] / df[target_label].sum() * 100, color='green', alpha=0.3) - ax2.set_ylim([0, 100]) - ax2.set_ylabel('Percentage overall of artificial fakes (%)') - - ax1.set_ylim([0, 100]) - ax1.set_xlabel('Top N% of Interviews (%)') - ax1.set_ylabel('Percentage of artificial fakes (%)') - plt.title('Percentage of Artificial Fakes in Top Records') - ax1.grid(True) - plt.show() - - - - - -def make_score_perc_chart(df, target_label, plot_first_percentiles=False, plot_perc_overall=False): - df = df.sort_values(by='unit_risk_score', ascending=False) - - - df['cumulative_count'] = range(1, len(df) + 1) - - df['percentage_of_records'] = df['cumulative_count'] / len(df) * 100 - fig, ax1 = plt.subplots() - - # 5. Plotting - ax1.plot(df['percentage_of_records'], df['percentage_of_true']) - - - - - if plot_perc_overall: - ax2 = ax1.twinx() - ax2.plot(df['percentage_of_records'], df['cumulative_true'] / df[target_label].sum() * 100, color='green', alpha=0.3) - ax2.set_ylim([0, 100]) - ax2.set_ylabel('Percentage overall of artificial fakes (%)') - - ax1.set_ylim([0, 100]) - ax1.set_xlabel('Top N% of Interviews (%)') - ax1.set_ylabel('Percentage of artificial fakes (%)') - plt.title('Percentage of Artificial Fakes in Top Records') - ax1.grid(True) - plt.show() diff --git a/rissk/utils/file_manager_utils.py b/rissk/utils/file_manager_utils.py deleted file mode 100644 index c0597c1..0000000 --- a/rissk/utils/file_manager_utils.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import s3fs - - -def fs_isdir(path, key=None, secret=None, is_local=True): - """ - Check if a file or directory exists at the specified path. - - This function supports both local and S3 file systems. For S3 like file system, it requires credentials (key and secret). - - Parameters: - path (str): The path of the file or directory to check. - key (str, optional): The AWS access key ID. Default is None. - secret (str, optional): The AWS secret access key. Default is None. - is_local (bool, optional): A flag indicating if the file system is local or S3. Default is True. - **kwargs: Arbitrary keyword arguments for s3fs.S3FileSystem. - - Returns: - bool: True if the file or directory exists, False otherwise. - """ - if is_local is True: - return os.path.isdir(path) - else: - fs = s3fs.S3FileSystem(anon=False, key=key, secret=secret) - return fs.isdir(path) - - - -def fs_exists(path, key=None, secret=None, is_local=True): - """ - Check if a file or directory exists at the specified path. - - This function supports both local and S3 file systems. For S3 like file system, it requires credentials (key and secret). - - Parameters: - path (str): The path of the file or directory to check. - key (str, optional): The AWS access key ID. Default is None. - secret (str, optional): The AWS secret access key. Default is None. - is_local (bool, optional): A flag indicating if the file system is local or S3. Default is True. - **kwargs: Arbitrary keyword arguments for s3fs.S3FileSystem. - - Returns: - bool: True if the file or directory exists, False otherwise. - """ - if is_local is True: - return os.path.exists(path) - else: - fs = s3fs.S3FileSystem(anon=False, key=key, secret=secret) - return fs.exists(path) - - -def fs_mkrdir(path, key=None, secret=None, is_local=True): - if is_local is True: - os.makedirs(path, exist_ok=True) - else: - fs = s3fs.S3FileSystem(anon=False, key=key, secret=secret) - fs.mkdir(path, exist_ok=True) - - -def fs_listdir(path, key=None, secret=None, is_local=True): - if is_local is True: - return os.listdir(path) - else: - fs = s3fs.S3FileSystem(anon=False, key=key, secret=secret) - return fs.ls(path) - - -def fs_open(file_path, key=None, secret=None, mode='r', is_local=True): - if is_local is True: - return open(file_path, mode) - else: - fs = s3fs.S3FileSystem(anon=False, key=key, secret=secret) - return fs.open(file_path, mode) - - diff --git a/rissk/utils/file_process_utils.py b/rissk/utils/file_process_utils_kedro.py similarity index 77% rename from rissk/utils/file_process_utils.py rename to rissk/utils/file_process_utils_kedro.py index de6900a..ebedae4 100644 --- a/rissk/utils/file_process_utils.py +++ b/rissk/utils/file_process_utils_kedro.py @@ -3,6 +3,8 @@ import pandas as pd from typing import Dict import re +import numpy as np +import unicodedata @@ -90,13 +92,42 @@ def transform_multi(df, variable_list, transformation_type): else '') for i, x in enumerate(transformation)] def remove_unset_value(sub_list): - sub = list(filter(lambda v: v not in [-999999999, '##N/A##'], sub_list)) - sub = [ele if ele != [] else '##N/A##' for ele in sub] - sub = sub if sub != [] and list(set(sub)) != ['##N/A##'] else '##N/A##' - return sub + # Normalize numeric types (float -> int if integer) inside the list construction + # This ensures lists like [1.0, 2.0] become [1, 2], and np.float64(nan) -> float('nan') + def normalize(v): + if isinstance(v, (np.floating, np.integer)): + if np.isnan(v): + return float('nan') + return v.item() + if isinstance(v, float) and v.is_integer(): + return int(v) + return v + + # Filter explicit Survey Solutions structural missing values + # We do not filter 'true' NaNs yet, as they represent system-missing/skipped + sub = [x for x in sub_list if x not in [-999999999, '##N/A##']] + sub = [normalize(ele) for ele in sub] + + # Check for empty list safely, avoiding numpy array ambiguity + # Do NOT filter out NaNs (system missing/skipped) as per requirements to distinguish them from explicit missing + clean_sub = [] + for ele in sub: + if isinstance(ele, list): + if len(ele) > 0: + clean_sub.append(ele) + else: + clean_sub.append(ele) + + # If the cleaning results in an empty list, it implies all values were + # missing, skipped, or invalid. We return '##N/A##' to match legacy behavior. + if len(clean_sub) == 0: + return '##N/A##' + + return clean_sub transformation = [remove_unset_value(x) - if x else float('nan') for x in transformation] if transformation_type != 'gps' else [ + if x else ('##N/A##' if transformation_type == 'list' else float('nan')) + for x in transformation] if transformation_type != 'gps' else [ x if x else '' for x in transformation] transformed_df[var] = transformation # Add the transformation to the transformed DataFrame df = df.drop(related_cols, axis=1) # Drop the original columns @@ -184,7 +215,7 @@ def get_categories(directory: Path) -> Dict[str, Dict[str, list]]: df = pd.read_excel(file) n_answers = df.shape[0] answer_sequence = df['id'].tolist() - categories[file.name] = {'n_answers': n_answers, 'answer_sequence': answer_sequence} + categories[file.stem] = {'n_answers': n_answers, 'answer_sequence': answer_sequence} return categories @@ -194,16 +225,36 @@ def update_df_categories(row, categories): This function updates a DataFrame row with category information if applicable. Parameters: - row (Series): The Questioner DataFrame row to be updated. + row (Series): The Questionnaire DataFrame row to be updated. categories (dict): A dictionary containing category data, keys are 'CategoriesId'. Returns: Series: The updated DataFrame row. """ - if row['CategoriesId'] in categories: - row['n_answers'] = categories[row['CategoriesId']]['n_answers'] - row['answer_sequence'] = categories[row['CategoriesId']]['answer_sequence'] + cid = row.get('CategoriesId') + if pd.isna(cid) or cid in (None, ''): + return row + + # Normalize ID: strip all unicode dash characters + cid_str = str(cid) + cid_clean = ''.join(c for c in cid_str if unicodedata.category(c) != 'Pd') + + # Try direct match with cleaned ID (assuming keys might be cleaned/filenames) + match = categories.get(cid_clean) + + if match is None: + # Fallback: Compare against cleaned keys from the dictionary + # This handles cases where filenames might still have dashes or other formatting + for key, val in categories.items(): + key_clean = ''.join(c for c in key if unicodedata.category(c) != 'Pd') + if key_clean == cid_clean: + match = val + break + + if match: + row['n_answers'] = match['n_answers'] + row['answer_sequence'] = match['answer_sequence'] return row def parse_filename(filename: str): diff --git a/rissk/utils/import_utils.py b/rissk/utils/import_utils.py deleted file mode 100644 index 74a2df6..0000000 --- a/rissk/utils/import_utils.py +++ /dev/null @@ -1,492 +0,0 @@ -import numpy as np -import json -import pyarrow as pa -import pandas as pd -import zipfile -from io import BytesIO -from loguru import logger -from pathlib import Path -import re -import os -from typing import List, Dict, Optional -from rissk.utils.file_process_utils import (get_file_parts, transform_multi, - set_qnr_version, normalize_column_name, - process_json_structure, get_categories, - update_df_categories) - - - - -def get_zip_files(data_dir: Path, survey: str, questionnaires: List[Dict[str, List[int]]]) -> List[Path]: - """ - Retrieves a list of zip files from the specified directory that match the given pattern. - - Parameters: - - data_dir (Path): The directory to search for zip files. - - survey (str): The survey name to match in the file names. - - questionnaires (List[Dict[str, List[int]]]): A list of dictionaries, each containing a - 'name' of the questionnaire and a 'VERSION' list to match in the file names. - - Returns: - - List[Path]: A list of matching zip file paths. - """ - matching_files = [] - - # Iterate through each questionnaire and its associated versions - for questionnaire in questionnaires: - name = questionnaire.get('name') - versions = questionnaire.get('VERSION', []) - - # Compile a regex pattern for matching files - version_pattern = "|".join(map(str, versions)) - pattern = re.compile(rf"{name}_({version_pattern})_.*\.zip") - - # List and filter files in the specified directory - matching_files.extend( - file_path - for file_path in data_dir.iterdir() - if pattern.match(file_path.name) - ) - - return matching_files - - -def extract_zip(file_source_path: Path, file_dest_path: Path): - """ - Extracts a zip file to the specified destination path. - If nested zip files are encountered, they are extracted recursively. - - Parameters: - - file_source_path (Path): Path to the source zip file. - - file_dest_path (Path): Destination directory where files will be extracted. - """ - password = os.getenv('PASSWORD', None) - - try: - with file_source_path.open(mode='rb') as f: - zip_data = BytesIO(f.read()) - - with zipfile.ZipFile(zip_data) as zip_ref: - for file_info in zip_ref.infolist(): - file_name = file_info.filename - file_path = file_dest_path / file_name - - if file_info.is_dir(): - file_path.mkdir(parents=True, exist_ok=True) - else: - extracted_data = zip_ref.read(file_name, pwd=password.encode() if password else None) - if file_name.endswith('.zip'): - nested_dir = file_path.with_suffix('') - nested_dir.mkdir(parents=True, exist_ok=True) - nested_zip_path = nested_dir / file_path.name - with nested_zip_path.open(mode='wb') as nested_f: - nested_f.write(extracted_data) - extract_zip(nested_zip_path, nested_dir) # Recursively extract nested zip file - else: - file_path.parent.mkdir(parents=True, exist_ok=True) - with file_path.open(mode='wb') as extracted_f: - extracted_f.write(extracted_data) - - logger.info(f'Zip file {file_source_path} extracted successfully to {file_dest_path}') - except zipfile.BadZipFile: - logger.error(f'Error: The file {file_source_path} is not a zip file or it is corrupted.') - except RuntimeError as e: - logger.error(f'Error: A runtime error occurred - {e}') - except Exception as e: - logger.error(f'An unexpected error occurred: {e}') - - -def get_from_dir(dir_name: str, info: str) -> str: - """ - Extract information from a directory name formatted as '___'. - - Parameters: - dir_name (str): The directory name to parse. - info (str): The type of information to extract ('questionaire', 'version', 'format', 'status'). - - Returns: - str: The extracted information. - - Raises: - ValueError: If the info parameter is not one of 'questionaire', 'version', 'format', 'status'. - IndexError: If the directory name does not have the expected format. - """ - # Map info to the corresponding index - info_index = { - 'questionaire': 0, - 'version': 1, - 'format': 2, - 'status': 3 - } - - if info not in info_index: - raise ValueError("info parameter must be one of 'questionaire', 'version', 'format', 'status'") - - # Reverse split to handle potential underscores in QUESTIONAIRE - parts = dir_name.rsplit('_', 3) - - if len(parts) < 4: - raise IndexError("Directory name does not have the expected format '___'") - - return parts[info_index[info]] - -def assign_type(df, dtypes): - for column in dtypes.index: - df[column] = df[column].astype(dtypes[column]) - return df - - -def get_survey_info(survey_files): - - survey_info = {} - - for survey_path in survey_files: - filename = survey_path.name - questionnaire, version, file_format, interview_status = get_file_parts(filename) - qnr_version = f"{questionnaire}_{str(version)}" - - survey_info[questionnaire] = survey_info.get(questionnaire, {}) - survey_info[questionnaire][qnr_version] = survey_info[questionnaire].get(qnr_version, {}) - survey_info[questionnaire][qnr_version][file_format] = survey_path - return survey_info - - - -def save_parquet(df, file_path): - with open(file_path, 'wb') as f: - if 'answer_sequence' in df.columns: - df['answer_sequence'] = df['answer_sequence'].apply(str) - df.to_parquet(f) - - -def read_microdata_files(s_path, file_name): - file_path = os.path.join(s_path, file_name) - if file_name.endswith('.dta'): - try: - with open(file_path, 'rb') as f: - df = pd.read_stata(f, convert_categoricals=False, convert_missing=True) - # Manage missing values - df = df.where(df.astype(str) != '.a', -999999999) # replace '.a' with -999999999 to match tabular export - df = df.where(df.astype(str) != '.', np.nan) # replace '.' with np.nan - except Exception as e: - print(f"Error reading {file_path}: {e}") - else: - with open(file_path) as f: - df = pd.read_csv(f, delimiter='\t') - return df - - -def get_microdata_file_list(data_path: Path) -> List[str]: - """ - Get a list of microdata files in the specified directory, excluding certain files and extensions. - - Parameters: - data_path (Path): The directory path to search for files. - - Returns: - List[str]: A list of file names that match the criteria. - """ - excluded_files = ('interview__', 'assignment__', 'paradata.tab') - excluded_extensions = ('.dta', '.tab') - - # List comprehension to filter files - file_names = [ - file.name for file in data_path.iterdir() - if file.is_file() and file.suffix in excluded_extensions and not any(file.name.startswith(prefix) for prefix in excluded_files) - ] - - return file_names - - -def get_microdata(data_path, df_questionnaires): - drop_list = ['interview__key', 'sssys_irnd', 'has__errors', 'interview__status', 'assignment__id'] - - file_names = get_microdata_file_list(data_path) - - # define multi/list question conditions - if df_questionnaires.empty is False: - unlinked_mask = (df_questionnaires["qtype"] == 'MultyOptionsQuestion') & ( - df_questionnaires['is_linked'] == False) - linked_mask = (df_questionnaires["qtype"] == 'MultyOptionsQuestion') & (df_questionnaires['is_linked'] == True) - list_mask = (df_questionnaires["qtype"] == 'TextListQuestion') - gps_mask = (df_questionnaires["qtype"] == 'GpsCoordinateQuestion') - - # extract multi/list question lists from conditions - multi_unlinked_vars = df_questionnaires.loc[unlinked_mask, 'variable_name'].tolist() - multi_linked_vars = df_questionnaires.loc[linked_mask, 'variable_name'].tolist() - list_vars = df_questionnaires.loc[list_mask, 'variable_name'].tolist() - gps_vars = df_questionnaires.loc[gps_mask, 'variable_name'].tolist() - - # Iterate over each file - all_dfs = [] - for file_name in file_names: - - df = read_microdata_files(data_path, file_name) - # drop system-generated columns - df.drop(columns=[col for col in drop_list if col in df.columns], inplace=True) - - # transform multi/list questions - if df_questionnaires.empty is False: - df = transform_multi(df, multi_unlinked_vars, 'unlinked') - df = transform_multi(df, multi_linked_vars, 'linked') - df = transform_multi(df, list_vars, 'list') - df = transform_multi(df, gps_vars, 'gps') - - # create roster_level from __id columns if on roster level, else '' if main questionnaire file - roster_ids = [col for col in df.columns if col.endswith("__id") and col != "interview__id"] - if roster_ids: - df['roster_level'] = df[roster_ids].apply(lambda row: ",".join(map(str, row)), axis=1) - df.drop(columns=roster_ids, inplace=True) - else: - df['roster_level'] = '' - - id_vars = ['interview__id', 'roster_level'] - value_vars = [col for col in df.columns if col not in id_vars] - df_long = df.melt(id_vars=id_vars, value_vars=value_vars, var_name='variable', value_name='value') - df_long['filename'] = file_name - - all_dfs.append(df_long) - if len(all_dfs) > 0: - - combined_df = pd.concat(all_dfs, ignore_index=True) - else: - combined_df = pd.DataFrame() - - # Drop column with null or empty string in value - # Function to check if the value is not an empty string or NaN - def is_valid(value): - if isinstance(value, list): - return True # bool(value) # Not an empty list - return value != '' and pd.notna(value) # Not an empty string or NaN - - # Keep rows where the 'value' column passes the is_valid check - combined_df = combined_df[combined_df['value'].apply(is_valid)] - - - questionaire_name = get_from_dir(data_path.name, 'questionaire') - qnr_version = get_from_dir(data_path.name, 'version') - combined_df = set_qnr_version(combined_df, questionaire_name, qnr_version) - - # Manage the case questionnaires are not available for the survey - if df_questionnaires.empty is False: - roster_columns = [c for c in combined_df.columns if '__id' in c and c != 'interview__id'] - combined_df = combined_df.merge(df_questionnaires, how='left', - left_on=['variable', 'qnr', 'qnr_version'], - right_on=['variable_name', 'qnr', 'qnr_version']).sort_values( - ['interview__id', 'qnr_seq'] + roster_columns) - - combined_df.reset_index(drop=True, inplace=True) - - # Normalize columns - combined_df.columns = [normalize_column_name(c) for c in combined_df.columns] - - # Set value column to string for type compatibility - combined_df['value'] = combined_df['value'].astype(str) - return combined_df - - -def get_questionnaire_map(raw_path): - questionnaire_map = {} - questionnaire_list = os.listdir(raw_path) - for questionnaire in questionnaire_list: - if questionnaire.endswith('.json'): - file_name = os.path.basename(questionnaire) - questionnaire_id = file_name.split('_')[0].replace('-', '') - qnr_version = questionnaire.split('_')[1].replace('.json', '') - questionnaire_map[questionnaire_id] = { - 'file_name': file_name, - 'qnr_version': qnr_version, - 'file_path': os.path.join(raw_path, file_name) - } - return questionnaire_map - - -def get_questionnaire_id(extracted_path): - file_path = os.path.join(extracted_path, 'export__info.json') - with open(file_path, mode='r') as f: - data = json.load(f) - return data.get('QuestionnaireId').split("$")[0] - - -def read_json_questionnaire(survey_path, questionnaire_path=None): - if questionnaire_path is None: - file_path = os.path.join(survey_path, 'Questionnaire/content/document.json') - else: - questionnaire_id = get_questionnaire_id(survey_path) - questionnaire_map = get_questionnaire_map(questionnaire_path) - file_path = questionnaire_map.get(questionnaire_id).get('file_path') - with open(file_path, 'r') as f: - data = json.load(f) - return data - - -def read_paradata(survey_path, delimiter='\t'): - file_path = os.path.join(survey_path, 'paradata.tab') - with open(file_path, 'r') as f: - df = pd.read_csv(f, delimiter=delimiter) - return df - -def get_questionnaire(data_path: Path, questionnaire_path: Optional[Path] = None) -> pd.DataFrame: - """ - This function loads and processes a questionnaire from a JSON file located at the specified path. - It also handles the categorization of the data. - - Parameters: - data_path (Path): The path to the directory containing the questionnaire and categories data. - questionnaire_path (Optional[Path]): The path to the questionnaire JSON file. - - Returns: - pd.DataFrame: A processed DataFrame containing the questionnaire data. - """ - q_data = read_json_questionnaire(data_path, questionnaire_path=questionnaire_path) - - qnr_df = pd.DataFrame() - - if q_data is not None: - question_data = [] - question_counter = 0 - - process_json_structure(q_data["Children"], "", question_counter, question_data) - - qnr_df = pd.DataFrame(question_data) - qnr_df['answer_sequence'] = qnr_df['Answers'].apply( - lambda x: [int(item['AnswerValue']) for item in x] if x else np.nan) - qnr_df['n_answers'] = qnr_df['Answers'].apply(lambda x: len(x) if x else np.nan) - qnr_df['is_linked'] = (qnr_df['LinkedToRosterId'].notna()) | (qnr_df['LinkedToQuestionId'].notna()) - qnr_df['parents'] = qnr_df['parents'].str.lstrip(' > ') - split_columns = qnr_df['parents'].str.split(' > ', expand=True) - split_columns.columns = [f"parent_{i + 1}" for i in range(split_columns.shape[1])] - qnr_df = pd.concat([qnr_df, split_columns], axis=1) - qmask = qnr_df['QuestionScope'] == 0 - qnr_df['question_sequence'] = qmask.cumsum() - qnr_df.loc[~qmask, 'question_sequence'] = None - - categories_path = data_path / 'Questionnaire' / 'content' / 'Categories' - - if categories_path.exists(): - categories = get_categories(categories_path) - qnr_df = qnr_df.apply(lambda row: update_df_categories(row, categories), axis=1) - - qnr_df.reset_index(drop=True, inplace=True) - # Normalize columns - qnr_df.columns = [normalize_column_name(c) for c in qnr_df.columns] - - questionaire_name = get_from_dir(data_path.name, 'questionaire') - qnr_version = get_from_dir(data_path.name, 'version') - qnr_df = set_qnr_version(qnr_df, questionaire_name, qnr_version) - return qnr_df - - -def get_paradata(data_path, df_questionnaires): - """ - This function loads and processes a paradata file from the provided path and merges it with the questionnaire dataframe. - The function also generates a date-time column from the timestamp and marks whether the answer has changed. - - Parameters: - para_path (str): A string path to the paradata .csv file. - df_questionnaires (DataFrame): A Pandas DataFrame containing the questionnaire data. - - Returns: - df_para (DataFrame): A processed DataFrame containing the merged data from the paradata file and the questionnaire DataFrame. - - """ - df_para = read_paradata(data_path, delimiter='\t') - - # split the parameter column, first from the left, then from the right to avoid potential data entry issues - df_para[['param', 'answer']] = df_para['parameters'].str.split('\|\|', n=1, expand=True) - df_para[['answer', 'roster_level']] = df_para['answer'].str.rsplit('||', n=1, expand=True) - - #df_para['roster_level'] = df_para['roster_level'].str.replace("|","") # if yes/no questions are answered with yes for the first time, "|" will appear in roster - - # generate date-time, TZ not yet considered - df_para['timestamp_utc'] = pd.to_datetime(df_para['timestamp_utc']) - df_para['tz_offset'] = pd.to_timedelta(df_para['tz_offset'].str.replace(':', ' hours ') + ' minutes') - # Adjust the date column by the timezone offset - df_para['timestamp_local'] = df_para['timestamp_utc'] + df_para['tz_offset'] - - - questionaire_name = get_from_dir(data_path.name, 'questionaire') - qnr_version = get_from_dir(data_path.name, 'version') - - df_para = set_qnr_version(df_para, questionaire_name, qnr_version) - - #Merge with questionnaire data - if df_questionnaires.empty is False: - q_columns = ['qnr_seq', 'variable_name', "qtype", 'question_type', - 'answers', 'question_scope', - 'yes_no_view', 'is_filtered_combobox', - 'is_integer', 'cascade_from_question_id', - 'answer_sequence', 'n_answers', 'question_sequence', - 'qnr', 'qnr_version'] - df_para = df_para.merge(df_questionnaires[q_columns], how='left', - left_on=['param', 'qnr', 'qnr_version'], - right_on=['variable_name', 'qnr', 'qnr_version']) - - # Normalize column names - df_para.columns = [normalize_column_name(c) for c in df_para.columns] - return df_para - - -def get_dataframes(survey_info): - """ - Returns dataframes of the paradata, questionnaires, and microdata. - - Parameters: - save_to_disk: A boolean indicating whether to save the dataframes to disk. - reload: A boolean indicating whether to reload the data. - - Returns: - df_paradata, df_questionnaires, df_microdata: Dataframes containing the paradata, questionnaires, and microdata from the different surveys defined in the config. - """ - dfs_paradata = [] - dfs_questionnaires = [] - dfs_microdata = [] - - for survey_questionnaire, questionnaires_details in survey_info.items(): - for questionnaires_version, file_paths in questionnaires_details.items(): - tabular_path = file_paths['Tabular'] - paradata_path = file_paths['Paradata'] - - try: - df_questionnaires = get_questionnaire(tabular_path) - except Exception as e: - logger.error(f"Failed to load questionnaire for {survey_questionnaire} version {questionnaires_version} from {tabular_path}: {str(e)}") - raise - - try: - df_paradata = get_paradata(paradata_path, df_questionnaires) - except Exception as e: - logger.error(f"Failed to load paradata for {survey_questionnaire} version {questionnaires_version} from {paradata_path}: {str(e)}") - raise - - try: - df_microdata = get_microdata(tabular_path, df_questionnaires) - except Exception as e: - logger.error(f"Failed to load microdata for {survey_questionnaire} version {questionnaires_version} from {tabular_path}: {str(e)}") - raise - - logger.info(f"{survey_questionnaire} with version {questionnaires_version} loaded. " - f"\n" - f"Paradata shape: {df_paradata.shape} " - f"Questionnaires shape: {df_questionnaires.shape} " - f"Microdata shape: {df_microdata.shape} ") - - dfs_paradata.append(df_paradata) - dfs_questionnaires.append(df_questionnaires) - dfs_microdata.append(df_microdata) - - # create unique dataframe with all surveys - try: - dfs_paradata = pd.concat(dfs_paradata) - dfs_questionnaires = pd.concat(dfs_questionnaires) - dfs_microdata = pd.concat(dfs_microdata) - except Exception as e: - logger.error(f"Failed to concatenate dataframes: {str(e)}") - raise - - dfs_paradata.reset_index(drop=True, inplace=True) - dfs_questionnaires.reset_index(drop=True, inplace=True) - dfs_microdata.reset_index(drop=True, inplace=True) - - return dfs_paradata, dfs_questionnaires, dfs_microdata - diff --git a/rissk/utils/import_utils_kedro.py b/rissk/utils/import_utils_kedro.py new file mode 100644 index 0000000..2a2a678 --- /dev/null +++ b/rissk/utils/import_utils_kedro.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Optional, Dict, List +import re +import os +import zipfile +import shutil +import json # Added json import +import logging +import pandas as pd # Added pandas import +import numpy as np # Added numpy import + +logger = logging.getLogger(__name__) + +from rissk.utils.file_process_utils_kedro import ( + get_file_parts, + transform_multi, + set_qnr_version, + normalize_column_name, + process_json_structure, + get_categories, + update_df_categories, + parse_filename +) + +def extract_zip(file_source_path: Path, file_dest_path: Path, password: Optional[str] = None): + """Memory-efficient recursive extraction.""" + pwd_bytes = password.encode() if password else None + file_dest_path.mkdir(parents=True, exist_ok=True) + + try: + with zipfile.ZipFile(file_source_path, 'r') as zip_ref: + for file_info in zip_ref.infolist(): + target_path = (file_dest_path / file_info.filename).resolve() + + # Security: Prevent ZipSlip/Path Traversal + if not str(target_path).startswith(str(file_dest_path.resolve())): + continue + + if file_info.is_dir(): + target_path.mkdir(parents=True, exist_ok=True) + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Stream content to file to keep memory usage low + with zip_ref.open(file_info, pwd=pwd_bytes) as source, \ + open(target_path, "wb") as target: + shutil.copyfileobj(source, target) + + # Recursive call for nested zips + if target_path.suffix.lower() == '.zip': + extract_zip(target_path, target_path.with_suffix(''), password=password) + + except Exception as e: + logger.error(f"Failed to extract {file_source_path.name}: {e}") + + +def filter_matching_folders(partitions: Dict[str, Callable[[], Path]], questionnaires: List[Dict]) -> List[Path]: + """ + Filters partition paths to return only directories that match + specific questionnaire name and version patterns. + """ + if not partitions: + logger.warning("No partitions found while filtering extracted folders.") + return [] + + # 1. Pre-compile patterns for efficiency + # We use \b or strict string termination to ensure version 1 doesn't match 10 + patterns = [] + for q in questionnaires: + name = q.get("name") + if not name: + continue + versions = q.get("VERSION", []) + # Empty VERSION list means "accept any integer version" + version_pattern = "|".join(map(str, versions)) if versions else r"\d+" + # Pattern: Matches start of string, the name, an underscore, + # one of the versions (or any integer), and then an underscore. + # Example: ^slbhies_listing_(1|2|6)_.* or ^slbhies_listing_(\d+)_.* + regex = re.compile(rf"^{re.escape(name)}_({version_pattern})_.*") + patterns.append(regex) + + matching_folders: List[Path] = [] + seen_paths = set() + + # 2. Iterate and validate + # FIX: Use partition keys to strictly identify the top-level folder relative to the root. + # Keys in PartitionedDataset are relative paths like "SurveyFolder/Sub/File.ext". + # We only check the first component ("SurveyFolder") against the regex. + + for partition_id, loader in partitions.items(): + try: + # partition_id is the relative path (e.g. "folder/sub/file.txt") + # We normalize it to a Path object to handle OS separators safely + relative_path = Path(partition_id) + + # We expect at least a folder and a file (parts > 1) + # If the zip extracted to flat files at root, this checks prevents errors. + if len(relative_path.parts) < 2: + continue + + # The top-level folder name is the first part of the relative path + top_level_name = relative_path.parts[0] + + # Check if this top-level folder matches our patterns + is_match = False + for pattern in patterns: + if pattern.match(top_level_name): + is_match = True + break + + if is_match: + # Calculate the absolute path of the top-level folder + # We do this by taking the file's full path and stripping the + # sub-directories indicated by the relative path key. + file_path = loader() + + # We need to go up N levels where N = number of parts in relative path - 1 + # Example: Key="A/B/file" (3 parts). Path=".../A/B/file". + # We want ".../A". We need to go up 2 levels (file->B, B->A). + levels_up = len(relative_path.parts) - 1 + + # parents[0] is the directory containing the file. + # parents[levels_up-1] is the directory we want. + # Path.parents sequence: [parent, parent.parent, ...] + # Index 0 is the immediate parent. + + if levels_up > 0 and len(file_path.parents) >= levels_up: + # -1 because parents is 0-indexed (0 is 1 level up) + survey_folder = file_path.parents[levels_up - 1] + + # Double check name consistency (sanity check) + if survey_folder.name == top_level_name: + resolved_path = survey_folder.resolve() + if resolved_path not in seen_paths: + seen_paths.add(resolved_path) + matching_folders.append(survey_folder) + except Exception as e: + logger.error(f"Error processing partition {partition_id}: {e}") + + logger.info(f"Successfully matched {len(matching_folders)} survey directories.") + return matching_folders + + +# --- Legacy Functions Migrated from import_utils.py --- + +def get_survey_info(survey_files: list[Path]) -> dict[str, dict[str, dict[str, Path]]]: + """ + Organizes survey files into a structured dictionary. + + Structure: + { + 'questionnaire_name': { + 'qnr_version_string': { + 'file_format': Path(/path/to/folder) + } + } + } + """ + survey_info = {} + + for survey_path in survey_files: + filename = survey_path.name + try: + questionnaire, version, file_format, interview_status = get_file_parts(filename) + except ValueError as e: + logger.warning(f"Skipping {filename}: {e}") + continue + + qnr_version = f"{questionnaire}_{str(version)}" + + survey_info.setdefault(questionnaire, {}) + survey_info[questionnaire].setdefault(qnr_version, {}) + survey_info[questionnaire][qnr_version][file_format] = survey_path + + return survey_info + + +def read_json_questionnaire(survey_path: Path) -> dict: + """Reads the questionnaire JSON definition.""" + # Try to open the JSON file + file_path = survey_path / 'Questionnaire' / 'content' / 'document.json' + try: + with file_path.open('r', encoding='utf-8') as f: + return json.load(f) + except (Exception) as e: + logger.warning(f"Questionnaire document not found or invalid at {file_path}: {e}") + return None + + +def get_questionnaire(data_path: Path, questionnaire_path: Optional[Path] = None) -> pd.DataFrame: + """ + Loads and processes a questionnaire from a JSON file located at the specified path. + Also handles categorization of data. + """ + q_data = read_json_questionnaire(data_path) + + qnr_df = pd.DataFrame() + + if q_data is not None: + question_data = [] + question_counter = 0 + + # process_json_structure modifies question_data list in-place + process_json_structure(q_data.get("Children", []), "", question_counter, question_data) + + if question_data: + qnr_df = pd.DataFrame(question_data) + + # Type-safe transformations + qnr_df['answer_sequence'] = qnr_df['Answers'].apply( + lambda x: [int(item['AnswerValue']) for item in x] if x else np.nan + ) + qnr_df['n_answers'] = qnr_df['Answers'].apply(lambda x: len(x) if x else np.nan) + qnr_df['is_linked'] = (qnr_df['LinkedToRosterId'].notna()) | (qnr_df['LinkedToQuestionId'].notna()) + + if 'parents' in qnr_df.columns: + qnr_df['parents'] = qnr_df['parents'].str.lstrip(' > ') + split_columns = qnr_df['parents'].str.split(' > ', expand=True) + split_columns.columns = [f"parent_{i + 1}" for i in range(split_columns.shape[1])] + qnr_df = pd.concat([qnr_df, split_columns], axis=1) + + if 'QuestionScope' in qnr_df.columns: + qmask = qnr_df['QuestionScope'] == 0 + qnr_df['question_sequence'] = qmask.cumsum() + qnr_df.loc[~qmask, 'question_sequence'] = None + + categories_path = data_path / 'Questionnaire' / 'content' / 'Categories' + + if categories_path.exists(): + categories = get_categories(categories_path) + if not qnr_df.empty: + qnr_df = qnr_df.apply(lambda row: update_df_categories(row, categories), axis=1) + + if not qnr_df.empty: + qnr_df.reset_index(drop=True, inplace=True) + # Normalize columns + qnr_df.columns = [normalize_column_name(c) for c in qnr_df.columns] + + try: + parts = parse_filename(data_path.name) + # parts is a list: [questionnaire, version, format, status] + # set_qnr_version expects (df, questionaire_name, qnr_version) + qnr_df = set_qnr_version(qnr_df, parts[0], parts[1]) # parts[0]=name, parts[1]=version + except ValueError: + logger.warning(f"Could not parse filename '{data_path.name}' for version info") + + return qnr_df + + +def read_paradata(survey_path: Path, delimiter='\t') -> pd.DataFrame: + file_path = survey_path / 'paradata.tab' + if not file_path.exists(): + raise FileNotFoundError(f"Paradata file not found: {file_path}") + + with file_path.open('r', encoding='utf-8') as f: + # low_memory=False to avoid DtypeWarnings on large files, standard pandas practice + df = pd.read_csv(f, delimiter=delimiter, low_memory=False) + return df + + +def get_paradata_raw(data_path: Path) -> pd.DataFrame: + """ + Loads and parses a raw paradata file from the provided path. + + Performs parameter splitting, timestamp computation, and questionnaire-version + tagging, but does NOT merge questionnaire metadata. Column names are + normalised before returning. + """ + try: + df_para = read_paradata(data_path, delimiter='\t') + except Exception as e: + logger.error(f"Error reading paradata from {data_path}: {e}") + return pd.DataFrame() + + if 'parameters' in df_para.columns: + # split the parameter column + split_param = df_para['parameters'].str.split(r'\|\|', n=1, expand=True) + if split_param.shape[1] == 2: + df_para['param'] = split_param[0] + df_para['answer'] = split_param[1] + else: + df_para['param'] = df_para['parameters'] + df_para['answer'] = None + + if 'answer' in df_para.columns and df_para['answer'].notna().any(): + split_answer = df_para['answer'].str.rsplit(r'||', n=1, expand=True) + if split_answer.shape[1] == 2: + df_para['answer'] = split_answer[0] + df_para['roster_level'] = split_answer[1] + else: + df_para['roster_level'] = None + + if 'timestamp_utc' in df_para.columns and 'tz_offset' in df_para.columns: + df_para['timestamp_utc'] = pd.to_datetime(df_para['timestamp_utc']) + # Only apply if tz_offset is string + if pd.api.types.is_string_dtype(df_para['tz_offset']): + df_para['tz_offset'] = pd.to_timedelta(df_para['tz_offset'].str.replace(':', ' hours ') + ' minutes') + df_para['timestamp_local'] = df_para['timestamp_utc'] + df_para['tz_offset'] + + try: + parts = parse_filename(data_path.name) + qnr_name = parts[0] + qnr_version = parts[1] + df_para = set_qnr_version(df_para, qnr_name, qnr_version) + except ValueError: + logger.warning(f"Could not parse filename '{data_path.name}' for version info") + + # Normalize column names + df_para.columns = [normalize_column_name(c) for c in df_para.columns] + + return df_para + + +def get_paradata(data_path: Path, df_questionnaires: pd.DataFrame) -> pd.DataFrame: + """ + Loads and processes a paradata file from the provided path and merges it with the questionnaire dataframe. + + This is a backward-compatible wrapper around get_paradata_raw that additionally + merges questionnaire metadata columns onto the result. + """ + df_para = get_paradata_raw(data_path) + + if df_para.empty or df_questionnaires.empty: + return df_para + + q_columns = ['qnr_seq', 'variable_name', "qtype", 'question_type', + 'answers', 'question_scope', + 'yes_no_view', 'is_filtered_combobox', + 'is_integer', 'cascade_from_question_id', + 'answer_sequence', 'n_answers', 'question_sequence', + 'qnr', 'qnr_version'] + + # Ensure columns exist in questionnaire df before selecting + q_columns = [c for c in q_columns if c in df_questionnaires.columns] + + df_para = df_para.merge(df_questionnaires[q_columns], how='left', + left_on=['param', 'qnr', 'qnr_version'], + right_on=['variable_name', 'qnr', 'qnr_version']) + + return df_para + + +def get_microdata_file_list(data_path: Path) -> list[str]: + """ + Get a list of microdata files in the specified directory. + """ + excluded_prefixes = ('interview__', 'assignment__') + excluded_files = {'paradata.tab'} + valid_extensions = {'.dta', '.tab'} + + file_names = [] + if data_path.exists(): + for file in data_path.iterdir(): + if file.is_file() and file.suffix in valid_extensions: + if file.name not in excluded_files and not file.name.startswith(excluded_prefixes): + file_names.append(file.name) + return file_names + + +def read_microdata_file(data_path: Path, file_name: str) -> pd.DataFrame: + file_path = data_path / file_name + + if file_path.suffix == '.dta': + try: + # Using 'with' open ensures file handle closure + with file_path.open('rb') as f: + # convert_categoricals=False matches legacy beahvior + df = pd.read_stata(f, convert_categoricals=False, convert_missing=True) + + # Handle StataMissingValue objects which are unhashable + # Replace '.a' with -999999999 and '.' with NaN + + def replace_stata_missing(val): + # Duck typing check for StataMissingValue to avoid direct import dependency + if type(val).__name__ == 'StataMissingValue': + s_val = str(val) + if s_val == '.a': + return -999999999 + elif s_val == '.': + return np.nan + return np.nan # defaulting other missing values to NaN + return val + + # Apply only to object columns where StataMissingValue might exist + for col in df.select_dtypes(include=['object']).columns: + df[col] = df[col].apply(replace_stata_missing) + + except Exception as e: + logger.error(f"Error reading {file_path}: {e}") + return pd.DataFrame() + else: # .tab file + try: + with file_path.open('r', encoding='utf-8') as f: + df = pd.read_csv(f, delimiter='\t', low_memory=False) + except Exception as e: + logger.error(f"Error reading csv {file_path}: {e}") + return pd.DataFrame() + + return df + + +def get_microdata(data_path: Path, df_questionnaires: pd.DataFrame) -> pd.DataFrame: + raw = get_microdata_raw(data_path, df_questionnaires) + return merge_microdata_questionnaire(raw, df_questionnaires) + + +def get_microdata_raw(data_path: Path, df_questionnaires: pd.DataFrame) -> pd.DataFrame: + """Load microdata with transform_multi applied but without questionnaire metadata merge. + + Performs all processing steps of get_microdata (including multi-question transformation, + filtering, column normalisation and value stringification) up to but not including + the merge with questionnaire metadata. + """ + drop_list = ['interview__key', 'sssys_irnd', 'has__errors', 'interview__status', 'assignment__id'] + + file_names = get_microdata_file_list(data_path) + + if not df_questionnaires.empty: + unlinked_mask = (df_questionnaires["qtype"] == 'MultyOptionsQuestion') & ( + df_questionnaires['is_linked'] == False) + linked_mask = (df_questionnaires["qtype"] == 'MultyOptionsQuestion') & ( + df_questionnaires['is_linked'] == True) + list_mask = (df_questionnaires["qtype"] == 'TextListQuestion') + gps_mask = (df_questionnaires["qtype"] == 'GpsCoordinateQuestion') + + multi_unlinked_vars = df_questionnaires.loc[unlinked_mask, 'variable_name'].tolist() + multi_linked_vars = df_questionnaires.loc[linked_mask, 'variable_name'].tolist() + list_vars = df_questionnaires.loc[list_mask, 'variable_name'].tolist() + gps_vars = df_questionnaires.loc[gps_mask, 'variable_name'].tolist() + + all_dfs = [] + for file_name in file_names: + df = read_microdata_file(data_path, file_name) + if df.empty: + continue + + cols_to_drop = [col for col in drop_list if col in df.columns] + if cols_to_drop: + df.drop(columns=cols_to_drop, inplace=True) + + if not df_questionnaires.empty: + df = transform_multi(df, multi_unlinked_vars, 'unlinked') + df = transform_multi(df, multi_linked_vars, 'linked') + df = transform_multi(df, list_vars, 'list') + df = transform_multi(df, gps_vars, 'gps') + + roster_ids = [col for col in df.columns if col.endswith("__id") and col != "interview__id"] + if roster_ids: + df['roster_level'] = df[roster_ids].apply(lambda row: ",".join(map(str, row)), axis=1) + df.drop(columns=roster_ids, inplace=True) + else: + df['roster_level'] = '' + + id_vars = ['interview__id', 'roster_level'] + value_vars = [col for col in df.columns if col not in id_vars] + + if not value_vars: + continue + + df_long = df.melt(id_vars=id_vars, value_vars=value_vars, var_name='variable', value_name='value') + df_long['filename'] = file_name + all_dfs.append(df_long) + + if all_dfs: + combined_df = pd.concat(all_dfs, ignore_index=True) + else: + return pd.DataFrame() + + def is_valid_fast(val): + if val is None: return False + if isinstance(val, (list, tuple)): + if len(val) == 0: return False + return any(pd.notna(x) and x != '' for x in val) + if isinstance(val, (np.ndarray,)): return val.size > 0 + if isinstance(val, str) and val == '': return False + if hasattr(val, 'size') and hasattr(val, 'shape'): + return val.size > 0 + try: + if pd.isna(val): return False + except: + pass + if str(val) == '': return False + return True + + combined_df = combined_df[combined_df['value'].apply(is_valid_fast)] + + try: + parts = parse_filename(data_path.name) + questionaire_name = parts[0] + qnr_version = parts[1] + combined_df = set_qnr_version(combined_df, questionaire_name, qnr_version) + except ValueError: + logger.warning(f"Could not set version for {data_path.name}") + + combined_df.reset_index(drop=True, inplace=True) + combined_df.columns = [normalize_column_name(c) for c in combined_df.columns] + + def normalize_and_stringify(val): + if isinstance(val, float) and val.is_integer(): + return str(int(val)) + if isinstance(val, (list, tuple, np.ndarray)): + return str(val) + return str(val) + + combined_df['value'] = combined_df['value'].apply(normalize_and_stringify) + + return combined_df + + +def merge_microdata_questionnaire(microdata_raw: pd.DataFrame, df_questionnaires: pd.DataFrame) -> pd.DataFrame: + """Merge raw microdata with questionnaire metadata and normalize column names. + + Produces output identical to get_microdata when combined with get_microdata_raw. + """ + if microdata_raw.empty or df_questionnaires.empty: + return microdata_raw + + merge_on_left = ['variable', 'qnr', 'qnr_version'] + merge_on_right = ['variable_name', 'qnr', 'qnr_version'] + + merged = microdata_raw.merge( + df_questionnaires, + how='left', + left_on=merge_on_left, + right_on=merge_on_right + ) + + sort_cols = [c for c in ['interview__id', 'qnr_seq', 'roster_level'] if c in merged.columns] + merged.sort_values(sort_cols, inplace=True) + merged.reset_index(drop=True, inplace=True) + merged.columns = [normalize_column_name(c) for c in merged.columns] + # Stringify properties dicts so serialisation is consistent regardless of whether + # the questionnaire was used from memory (1-step) or via a parquet round-trip (2-step). + # Pyarrow's struct union type adds extra None keys during the parquet round-trip; + # stringifying here prevents that discrepancy from propagating into the microdata output. + if 'properties' in merged.columns: + merged['properties'] = merged['properties'].apply( + lambda x: str(x) if isinstance(x, dict) else x + ) + return merged diff --git a/rissk/utils/stats_utils.py b/rissk/utils/stats_utils.py deleted file mode 100644 index 63f5f27..0000000 --- a/rissk/utils/stats_utils.py +++ /dev/null @@ -1,248 +0,0 @@ -import math -import pandas as pd -import numpy as np -from scipy.stats import entropy -from scipy import stats -from sklearn.preprocessing import StandardScaler -from scipy.stats import chisquare, fisher_exact -from collections import Counter -from scipy.stats.mstats import winsorize - - -def jensen_shannon_divergence(p, q): - m = 0.5 * (p + q) - return 0.5 * (entropy(p, m) + entropy(q, m)) - - -def jensen_shannon_distance(p, q): - return np.sqrt(jensen_shannon_divergence(p, q)) - - -def get_digit_frequecies(df, feature_name, apply_first_digit, minimum_sample=50): - digit_mask = (df[feature_name] != 0) - if apply_first_digit: - total_digit_values = df[digit_mask][feature_name].apply(first_digit) - else: - total_digit_values = df[digit_mask][feature_name].apply(last_digit) - total_digit_count = Counter(total_digit_values) - total_digit_count = [total_digit_count.get(i, 0) for i in range(1, 10)] - if sum(total_digit_count) < minimum_sample: - total_digit_freq = None - else: - total_digit_freq = [v / sum(total_digit_count) for v in total_digit_count] - # DO not consider samples with size less than minimum_sample - - return total_digit_freq - - -def first_digit(val): - """Extract the first digit from a value.""" - val = abs(val) - return int(str(val)[0]) - - -def last_digit(val): - """Extract the first digit from a value.""" - return int(str(int(val))[-1]) - - -def apply_benford_tests(df, valid_variables, responsible_col, feature_name, apply_first_digit=True, minimum_sample=50): - responsible_list = df[responsible_col].unique() - results = [] - for var in valid_variables: - variable_mask = df['variable_name'] == var - for resp in responsible_list: - score = None - resp_mask = (df[responsible_col] == resp) - total_digit_count = get_digit_frequecies(df[variable_mask & (~resp_mask)], feature_name, apply_first_digit, - minimum_sample=minimum_sample) - resp_digit_count = get_digit_frequecies(df[variable_mask & resp_mask], feature_name, apply_first_digit, - minimum_sample=minimum_sample) - if resp_digit_count is not None and total_digit_count is not None: - # _, p_value = chisquare(total_digit_count, resp_digit_count) - # score = p_value < 0.05 - score = jensen_shannon_distance(np.array(total_digit_count), np.array(resp_digit_count)) - results.append((resp, var, score)) - return pd.DataFrame(results, columns=[responsible_col, 'variable_name', feature_name]) - - -def get_outlier_by_magnitude(series, mode_deviation=3, threshold_freq=0.02): - """ - Detects values that are anomalies based on their order of magnitude. - - Args: - - series (pd.Series): Series of numeric values. - - mode_deviation (int): Maximum allowable deviation from the mode's order of magnitude. - - threshold_freq (int): Maximum frequency for an order of magnitude to be considered anomalous. - - Returns: - - pd.Series: Boolean Series with True for anomalies and False for normal values. - """ - - # Compute order of magnitude for each value - min_value = series.min() - if min_value <= 0: - order_of_magnitude = np.floor(np.log10(series + abs(min_value) + 1)) - else: - order_of_magnitude = np.floor(np.log10(series)) - - # Using Mode-based method - mode_order = max(order_of_magnitude.mode().iloc[0], 1) - - mode_based_anomalies = ( - (order_of_magnitude < mode_order - mode_deviation) | (order_of_magnitude > mode_order + mode_deviation)) - - # Using Histogram/Frequency count-based method - freq_count = order_of_magnitude.value_counts() / series.count() - anomalous_orders = freq_count[freq_count <= threshold_freq].index - freq_based_anomalies = order_of_magnitude.isin(anomalous_orders) - - # Combine results - anomalies = mode_based_anomalies | freq_based_anomalies - - return anomalies - - -def get_outlier_iqr(data, column_name): - q_high = data[column_name].quantile(0.75) - q_low = data[column_name].quantile(0.25) - iqr = q_high - q_low - lower_outlier = (data[column_name] < q_low - 1.5 * iqr) & (~pd.isnull(data[column_name])) - upper_outlier = (data[column_name] > q_high + 1.5 * iqr) & (~pd.isnull(data[column_name])) - return lower_outlier, upper_outlier - - -def get_outlier_z_score(data, column_name, threshold=2.5): - # Compute the limits - lower_limit = data[column_name].mean() - threshold * data[column_name].std() - upper_limit = data[column_name].mean() + threshold * data[column_name].std() - - lower_outlier = (data[column_name] < lower_limit) & (~pd.isnull(data[column_name])) - upper_outlier = (data[column_name] > upper_limit) & (~pd.isnull(data[column_name])) - - return lower_outlier, upper_outlier - - -def filter_variables_by_magnitude(df, feature_name, variables, min_order_of_magnitude=3): - # Define a function to calculate order of magnitude - def order_of_magnitude(num): - if num == 0: - return 0 - elif num < 0: - num = -num - return int(math.floor(math.log10(num))) - - # Find columns that span at least min_order_of_magnitude - valid_variables = [] - for var in variables: - var_values = df[df['variable_name'] == var][feature_name] # Remove NaNs to avoid issues - max_magnitude = order_of_magnitude(var_values.max()) - min_magnitude = order_of_magnitude(var_values.min()) - - if max_magnitude - min_magnitude >= min_order_of_magnitude: - valid_variables.append(var) - - return valid_variables - - -def get_box_cox_rescaled(series): - scaler = StandardScaler() - min_value = series.min() - box_cox = series - if series.nunique() > 1: - if min_value <= 0: - box_cox = box_cox + abs(min_value) + 1 - box_cox, _ = stats.boxcox(box_cox) - box_cox = scaler.fit_transform(box_cox.reshape(-1, 1)) - return box_cox - - -def calculate_list_entropy(column, unique_values, min_record_sample=10): - """ - Calculate the normalized entropy of a given column. - - Parameters: - - column (pd.Series): The column for which the entropy is calculated. - - unique_values (int): The number of unique values in the column. - - min_record_sample (int, optional): The minimum sample size required - relative to the number of unique values. Defaults to 10. - - Returns: - - float or None: Returns normalized entropy if conditions are met, - 0 for single value distributions with enough samples, - otherwise None. - """ - - column = column[column != '##N/A##'] - flattened_series = column.explode() - # Compute the probability distribution of unique values in the column - # This uses value counts and then normalizes the counts to get probabilities - prob_distribution = flattened_series.value_counts(normalize=True) - - # Check conditions to calculate normalized entropy: - # 1. There should be more than one unique value - # 2. The number of records should be above a certain threshold - # based on the number of unique values - if unique_values > 1 and flattened_series.shape[0] >= min_record_sample * unique_values: - entropy_ = entropy(prob_distribution.values) / np.log2(unique_values) - # Check conditions where entropy is 0: - # 1. Only one unique value is present in the distribution - # 2. The number of records meets the required threshold - elif unique_values == 1 and flattened_series.shape[0] >= min_record_sample * unique_values: - entropy_ = 0 - # If none of the above conditions are met, return None - else: - entropy_ = None - - return entropy_ - - -def calculate_entropy(column, unique_values, min_record_sample=10): - """ - Calculate the normalized entropy of a given column. - - Parameters: - - column (pd.Series): The column for which the entropy is calculated. - - unique_values (int): The number of unique values in the column. - - min_record_sample (int, optional): The minimum sample size required - relative to the number of unique values. Defaults to 10. - - Returns: - - float or None: Returns normalized entropy if conditions are met, - 0 for single value distributions with enough samples, - otherwise None. - """ - - # Compute the probability distribution of unique values in the column - # This uses value counts and then normalizes the counts to get probabilities - prob_distribution = column.value_counts(normalize=True) - - # Check conditions to calculate normalized entropy: - # 1. There should be more than one unique value - # 2. The number of records should be above a certain threshold - # based on the number of unique values - if unique_values > 1 and column.shape[0] >= min_record_sample * unique_values: - entropy_ = entropy(prob_distribution.values) / np.log2(unique_values) - # Check conditions where entropy is 0: - # 1. Only one unique value is present in the distribution - # 2. The number of records meets the required threshold - elif unique_values == 1 and column.shape[0] >= min_record_sample * unique_values: - entropy_ = 0 - # If none of the above conditions are met, return None - else: - entropy_ = None - - return entropy_ - - -def adjustable_winsorize(data, initial_lower=0.05, initial_upper=0.05, step=0.01): - lower_limit = initial_lower - upper_limit = initial_upper - winsorized_data = winsorize(data, limits=[lower_limit, upper_limit]) - - while len(np.unique(winsorized_data)) <= 1 and (lower_limit > 0 or upper_limit > 0): - lower_limit = max(0, lower_limit - step) - upper_limit = max(0, upper_limit - step) - winsorized_data = winsorize(data, limits=[lower_limit, upper_limit]) - - return winsorized_data diff --git a/rissk/utils/stats_utils_kedro.py b/rissk/utils/stats_utils_kedro.py new file mode 100644 index 0000000..61932c1 --- /dev/null +++ b/rissk/utils/stats_utils_kedro.py @@ -0,0 +1,324 @@ +""" +stats_utils_kedro.py +==================== +Kedro-pipeline equivalent of stats_utils.py. + +Changelog vs stats_utils.py +---------------------------- +[FIX] calculate_entropy: normalisation divisor corrected from np.log2(unique_values) + to np.log(unique_values). scipy.stats.entropy uses the natural logarithm (nats) + by default, so the maximum entropy for n categories is ln(n) nats. Dividing by + log2(n) instead of ln(n) introduced a constant scale factor of ln(2) ≈ 0.693, + capping the output at ~0.693 instead of 1.0 and making the values uninterpretable + as a true [0, 1] normalised measure. Binary anomaly-flag outputs were unaffected + (the factor cancelled in relative comparisons), but the raw entropy values stored + or logged were misleading. + +[FIX] calculate_list_entropy: same normalisation correction as calculate_entropy. + +[REMOVED] Duplicate median_value computation that appeared twice on consecutive lines + in the caller functions (item_processing.py lines 259–261, 353–355, 389–391). + Those callers have been cleaned in item_processing_kedro.py; the note is kept + here for traceability. + +No algorithmic or interface changes; all function signatures are identical to legacy. +""" + +import math +import pandas as pd +import numpy as np +from scipy.stats import entropy +from scipy import stats +from sklearn.preprocessing import StandardScaler +from scipy.stats import chisquare, fisher_exact +from collections import Counter +from scipy.stats.mstats import winsorize + + +# --------------------------------------------------------------------------- +# Jensen-Shannon helpers (unchanged from legacy) +# --------------------------------------------------------------------------- + +def jensen_shannon_divergence(p, q): + m = 0.5 * (p + q) + return 0.5 * (entropy(p, m) + entropy(q, m)) + + +def jensen_shannon_distance(p, q): + return np.sqrt(jensen_shannon_divergence(p, q)) + + +# --------------------------------------------------------------------------- +# Digit helpers (unchanged from legacy) +# --------------------------------------------------------------------------- + +def get_digit_frequecies(df, feature_name, apply_first_digit, minimum_sample=50): + digit_mask = (df[feature_name] != 0) + if apply_first_digit: + total_digit_values = df[digit_mask][feature_name].apply(first_digit) + else: + total_digit_values = df[digit_mask][feature_name].apply(last_digit) + total_digit_count = Counter(total_digit_values) + total_digit_count = [total_digit_count.get(i, 0) for i in range(1, 10)] + if sum(total_digit_count) < minimum_sample: + total_digit_freq = None + else: + total_digit_freq = [v / sum(total_digit_count) for v in total_digit_count] + return total_digit_freq + + +def first_digit(val): + """ + Extract the first significant digit from a value using log10. + Follow legacy behaviour of setting values with abs(val) < 1 to 0 + (non-Benford domain) and applying absolute value to negatives. + """ + val = abs(val) + if val < 1: + return 0 + power = math.floor(math.log10(val)) + return int(val / 10**power) + + +def last_digit(val): + """Extract the last digit from a value.""" + return int(str(int(val))[-1]) + + +def apply_benford_tests(df, valid_variables, responsible_col, feature_name, + apply_first_digit=True, minimum_sample=50): + responsible_list = df[responsible_col].unique() + results = [] + for var in valid_variables: + variable_mask = df['variable_name'] == var + for resp in responsible_list: + score = None + resp_mask = (df[responsible_col] == resp) + total_digit_count = get_digit_frequecies( + df[variable_mask & (~resp_mask)], feature_name, apply_first_digit, + minimum_sample=minimum_sample, + ) + resp_digit_count = get_digit_frequecies( + df[variable_mask & resp_mask], feature_name, apply_first_digit, + minimum_sample=minimum_sample, + ) + if resp_digit_count is not None and total_digit_count is not None: + score = jensen_shannon_distance( + np.array(total_digit_count), np.array(resp_digit_count) + ) + results.append((resp, var, score)) + return pd.DataFrame(results, columns=[responsible_col, 'variable_name', feature_name]) + + +# --------------------------------------------------------------------------- +# Outlier helpers (unchanged from legacy) +# --------------------------------------------------------------------------- + +def get_outlier_by_magnitude(series, mode_deviation=3, threshold_freq=0.02): + """ + Detects values that are anomalies based on their order of magnitude. + + Args: + - series (pd.Series): Series of numeric values. + - mode_deviation (int): Maximum allowable deviation from the mode's order of magnitude. + - threshold_freq (float): Maximum relative frequency for an order of magnitude to be + considered anomalous. + + Returns: + - pd.Series: Boolean Series with True for anomalies and False for normal values. + """ + min_value = series.min() + if min_value <= 0: + order_of_magnitude = np.floor(np.log10(series + abs(min_value) + 1)) + else: + order_of_magnitude = np.floor(np.log10(series)) + + mode_order = max(order_of_magnitude.mode().iloc[0], 1) + mode_based_anomalies = ( + (order_of_magnitude < mode_order - mode_deviation) + | (order_of_magnitude > mode_order + mode_deviation) + ) + + freq_count = order_of_magnitude.value_counts() / series.count() + anomalous_orders = freq_count[freq_count <= threshold_freq].index + freq_based_anomalies = order_of_magnitude.isin(anomalous_orders) + + return mode_based_anomalies | freq_based_anomalies + + +def get_outlier_iqr(data, column_name): + q_high = data[column_name].quantile(0.75) + q_low = data[column_name].quantile(0.25) + iqr = q_high - q_low + lower_outlier = (data[column_name] < q_low - 1.5 * iqr) & (~pd.isnull(data[column_name])) + upper_outlier = (data[column_name] > q_high + 1.5 * iqr) & (~pd.isnull(data[column_name])) + return lower_outlier, upper_outlier + + +def get_outlier_z_score(data, column_name, threshold=2.5): + lower_limit = data[column_name].mean() - threshold * data[column_name].std() + upper_limit = data[column_name].mean() + threshold * data[column_name].std() + lower_outlier = (data[column_name] < lower_limit) & (~pd.isnull(data[column_name])) + upper_outlier = (data[column_name] > upper_limit) & (~pd.isnull(data[column_name])) + return lower_outlier, upper_outlier + + +def filter_variables_by_magnitude(df, feature_name, variables, min_order_of_magnitude=3): + """Return variables whose nonzero absolute values span at least `min_order_of_magnitude` orders. + + Zeros are excluded because they are not part of the Benford domain and would + anchor min_magnitude at 0, distorting the apparent range. Negative values are + treated by absolute value: using raw min/max with sign inversion would reverse + the comparison for all-negative series (e.g. min=-1000, max=-0.01 would give + magnitude(max) - magnitude(min) = -2 - 3 = -5, always failing). + """ + def order_of_magnitude(num): + # num is guaranteed positive (abs applied by caller) + return int(math.floor(math.log10(num))) + + valid_variables = [] + for var in variables: + var_values = df[df['variable_name'] == var][feature_name] + nonzero_abs = var_values[var_values != 0].abs() + if nonzero_abs.empty: + continue + max_magnitude = order_of_magnitude(nonzero_abs.max()) + min_magnitude = order_of_magnitude(nonzero_abs.min()) + if max_magnitude - min_magnitude >= min_order_of_magnitude: + valid_variables.append(var) + return valid_variables + + +def get_box_cox_rescaled(series): + scaler = StandardScaler() + min_value = series.min() + box_cox = series + if series.nunique() > 1: + if min_value <= 0: + box_cox = box_cox + abs(min_value) + 1 + box_cox, _ = stats.boxcox(box_cox) + box_cox = scaler.fit_transform(box_cox.reshape(-1, 1)) + return box_cox + + +# --------------------------------------------------------------------------- +# Entropy helpers +# --------------------------------------------------------------------------- + +def calculate_list_entropy(column, unique_values, min_record_sample=10): + """ + Calculate the normalised entropy of a multi-value (list) column. + + Parameters + ---------- + column : pd.Series + Series of lists for a single responsible group. + unique_values : int + Global number of distinct answer options for the variable (used as + the normalisation denominator, so entropy is relative to the maximum + possible diversity across all responsibles, not just this group). + min_record_sample : int, optional + Minimum records required per unique value before entropy is computed. + Groups below this threshold return None (insufficient data). + + Returns + ------- + float or None + Normalised entropy in [0, 1] if conditions are met. + 0 for single-value distributions with enough samples. + None when the sample is too small. + + Notes + ----- + [FIX] Legacy used `np.log2(unique_values)` as the divisor, producing a + range of [0, ln(2)] ≈ [0, 0.693] instead of [0, 1]. scipy.stats.entropy + returns nats (natural log base), so the correct divisor is np.log(unique_values). + The binary anomaly flags produced by callers were unaffected because the + ln(2) factor cancelled in relative (median-based) comparisons, but the raw + entropy values were uninterpretable as a normalised measure. + """ + column = column[column != '##N/A##'] + flattened_series = column.explode() + prob_distribution = flattened_series.value_counts(normalize=True) + + if unique_values > 1 and flattened_series.shape[0] >= min_record_sample * unique_values: + # [FIX] Corrected: np.log(unique_values) matches scipy's natural-log base, + # yielding a true [0, 1] normalised entropy. + # Legacy used np.log2(unique_values), capping values at ln(2) ≈ 0.693. + entropy_ = entropy(prob_distribution.values) / np.log(unique_values) + elif unique_values == 1 and flattened_series.shape[0] >= min_record_sample * unique_values: + entropy_ = 0 + else: + entropy_ = None + + return entropy_ + + +def calculate_entropy(column, unique_values, min_record_sample=10): + """ + Calculate the normalised entropy of a column for a single responsible group. + + Parameters + ---------- + column : pd.Series + Feature values for a single responsible group; must be null-free + (callers are responsible for pre-filtering nulls before groupby). + unique_values : int + Global number of distinct values for the variable (used as the + normalisation denominator so that entropy is expressed relative to + the maximum possible diversity across all responsibles, not just + within this group). + min_record_sample : int, optional + Minimum records required per unique value before entropy is computed. + Groups below this threshold return None (insufficient data). + + Returns + ------- + float or None + Normalised entropy in [0, 1] if conditions are met. + 0 for single-value distributions with enough samples. + None when the sample is too small. + + Notes + ----- + [FIX] Legacy used `np.log2(unique_values)` as the divisor, producing a + range of [0, ln(2)] ≈ [0, 0.693] instead of [0, 1]. scipy.stats.entropy + returns nats (natural log base), so the correct divisor is np.log(unique_values). + The binary anomaly flags produced by callers were unaffected because the + ln(2) factor cancelled in relative (median-based) comparisons, but the raw + entropy values were uninterpretable as a normalised measure. + + [NOTE] column.shape[0] must reflect only non-null rows. Callers must apply + a null-excluding mask before groupby so that the sample-size threshold + (min_record_sample * unique_values) is not inflated by null entries. + """ + prob_distribution = column.value_counts(normalize=True) + + if unique_values > 1 and column.shape[0] >= min_record_sample * unique_values: + # [FIX] Corrected: np.log(unique_values) matches scipy's natural-log base, + # yielding a true [0, 1] normalised entropy. + # Legacy used np.log2(unique_values), capping values at ln(2) ≈ 0.693. + entropy_ = entropy(prob_distribution.values) / np.log(unique_values) + elif unique_values == 1 and column.shape[0] >= min_record_sample * unique_values: + entropy_ = 0 + else: + entropy_ = None + + return entropy_ + + +# --------------------------------------------------------------------------- +# Winsorize helper (unchanged from legacy) +# --------------------------------------------------------------------------- + +def adjustable_winsorize(data, initial_lower=0.05, initial_upper=0.05, step=0.01): + lower_limit = initial_lower + upper_limit = initial_upper + winsorized_data = winsorize(data, limits=[lower_limit, upper_limit]) + + while len(np.unique(winsorized_data)) <= 1 and (lower_limit > 0 or upper_limit > 0): + lower_limit = max(0, lower_limit - step) + upper_limit = max(0, upper_limit - step) + winsorized_data = winsorize(data, limits=[lower_limit, upper_limit]) + + return winsorized_data diff --git a/rissk_kedro/.gitignore b/rissk_kedro/.gitignore new file mode 100644 index 0000000..c10c9ca --- /dev/null +++ b/rissk_kedro/.gitignore @@ -0,0 +1,161 @@ +########################## +# KEDRO PROJECT + +# ignore all local configuration +conf/local/** +!conf/local/.gitkeep + +# ignore potentially sensitive credentials files +conf/**/*credentials* + +# ignore everything in the following folders +data/** + +# except their sub-folders +!data/**/ + +# also keep all .gitkeep files +!.gitkeep + +# also keep the example dataset +!data/01_raw/companies.csv +!data/01_raw/reviews.csv +!data/01_raw/shuttles.xlsx + +# ignore kedro-viz metadata +.viz + +# ignore file based logs +*.log + +########################## +# Common files + +# IntelliJ +.idea/ +*.iml +out/ +.idea_modules/ + +### macOS +*.DS_Store +.AppleDouble +.LSOverride +.Trashes + +# Vim +*~ +.*.swo +.*.swp + +# emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc + +# JIRA plugin +atlassian-ide-plugin.xml + +# C extensions +*.so + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +.static_storage/ +.media/ +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# mlflow local runs +mlruns/* diff --git a/rissk_kedro/README.md b/rissk_kedro/README.md new file mode 100644 index 0000000..aade981 --- /dev/null +++ b/rissk_kedro/README.md @@ -0,0 +1,3 @@ +# RISSK Kedro Pipeline + +See [SETUP.md](SETUP.md) for installation and usage instructions. diff --git a/rissk_kedro/SETUP.md b/rissk_kedro/SETUP.md new file mode 100644 index 0000000..391c30c --- /dev/null +++ b/rissk_kedro/SETUP.md @@ -0,0 +1,189 @@ +# RISSK — Getting Started + +RISSK uses machine learning to score interviews from Survey Solutions export files, +flagging individual interviews most likely to contain unwanted interviewer behaviour. + +--- + +## Prerequisites + +- **Python 3.13** installed on your machine +- An internet connection for the initial install +- Survey Solutions export files (Main Survey Data + Paradata ZIPs) + +Verify your Python version: + +```bash +python --version +``` + +--- + +## Option A — uv (recommended for new users) + +[uv](https://docs.astral.sh/uv/) is a fast, self-contained Python package manager. +You do **not** need to manage virtual environments manually. + +### 1. Install uv + +**macOS / Linux:** +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows (PowerShell):** +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +### 2. Get the RISSK code + +Clone with Git: +```bash +git clone https://github.com/rowsquared/rissk.git +cd rissk +``` + +Or download the ZIP from GitHub, unzip it, and navigate to the `rissk/` folder. + +### 3. Install dependencies + +```bash +uv sync --extra gui +``` + +### 4. Launch the GUI + +From the `rissk_kedro/` directory: + +**macOS / Linux:** +```bash +bash rissk_kedro/run_gui.sh +``` + +**Windows:** +```bat +rissk_kedro\run_gui.bat +``` + +Your browser will open automatically at **http://localhost:8080**. + +--- + +## Option B — conda (for experienced users) + +### 1. Get the code + +```bash +git clone https://github.com/rowsquared/rissk.git +cd rissk +``` + +### 2. Create and activate the conda environment + +```bash +conda env create -f environment.yml +conda activate rissk_kedro +``` + +This installs Python 3.13, all pipeline dependencies, and the RISSK package in one step. + +### 3. Launch the GUI + +From the `rissk_kedro/` directory: + +```bash +bash rissk_kedro/run_gui.sh # macOS / Linux +rissk_kedro\run_gui.bat # Windows +``` + +--- + +## Using the GUI + +### Step 1 — Data folder + +Choose where RISSK will read and write survey data. + +- **Default (`data`):** keeps everything inside the `rissk_kedro/` project folder. +- **Absolute path:** point to any folder on your machine, e.g. `/Users/jane/surveys`. + +The GUI shows you the exact subfolder where ZIP files must be placed, e.g.: + +``` +/Users/jane/surveys/pmpmd_household/latest/10_RAW/ +``` + +Click **Create folder & Open** to create that folder and open it in your file manager. + +### Step 2 — Prepare your Survey Solutions exports + +Export from Survey Solutions and place the **unmodified ZIP files** in the folder shown: + +1. **Main Survey Data** — choose *Tab separated* or *Stata 14*, tick *Include meta information about questionnaire*. +2. **Paradata** — under *Data Type* select *Paradata*. + +> Export both files from the **same questionnaire version** consecutively. +> For multiple compatible versions, export each separately and place all ZIPs in the same folder. + +Do **not** rename, modify, or unzip the files. + +### Step 3 — Questionnaire configuration + +- **Questionnaire name:** the template name exactly as it appears in Survey Solutions (e.g. `pmpmd_household`). This is also used as the data folder name. +- **Versions:** comma-separated list of version numbers to process, e.g. `4, 5, 6`. +- **Consent filter (optional):** score only interviews where a specific paradata variable equals a required value (useful for surveys with a consent question). + +To switch to a different questionnaire, update the name in the Setup tab and save — or edit `questionnaire.name` directly in `conf/local/globals.yml`. + +### Step 4 — Save & Run + +1. Click **Save configuration** on the Setup tab. +2. Switch to the **Run** tab. +3. Choose a pipeline stage (leave as *All* for a full run). +4. Click **Run RISSK** and monitor the live log. + +Results are written to: +``` +//latest/40_SCORED/unit_rissk_scores.csv +``` + +### Advanced settings + +Access the **Advanced** tab to: +- Set a ZIP password (if your exports are password-protected) +- Toggle automatic contamination estimation +- Enable/disable individual features and adjust contamination thresholds + +--- + +## Running without the GUI (command line) + +Experienced users can run Kedro directly from the `rissk_kedro/` directory: + +```bash +cd rissk_kedro + +# Full pipeline +kedro run + +# Individual stages +kedro run --pipeline data_ingestion +kedro run --pipeline feature_creation +kedro run --pipeline rissk_scoring +``` + +Configuration overrides go in `rissk_kedro/conf/local/globals.yml` and `rissk_kedro/conf/local/parameters.yml` +(these files are ignored by git). + +--- + +## Troubleshooting + +| Problem | Solution | +|---|---| +| `ModuleNotFoundError: nicegui` | Run `pip install "nicegui>=1.4"` in your active environment | +| Browser does not open | Open http://localhost:8080 manually | +| Pipeline fails with "No data found" | Check that ZIP files are in the correct subfolder (see Setup tab) | +| `kedro: command not found` | Activate your environment first (`conda activate rissk` or `source .venv/bin/activate`) | +| ZIPs not extracted | Make sure filenames are not modified; check the ZIP password setting if exports are protected | diff --git a/configuration/__init__.py b/rissk_kedro/app/__init__.py similarity index 100% rename from configuration/__init__.py rename to rissk_kedro/app/__init__.py diff --git a/rissk_kedro/app/main.py b/rissk_kedro/app/main.py new file mode 100644 index 0000000..5e3f18c --- /dev/null +++ b/rissk_kedro/app/main.py @@ -0,0 +1,668 @@ +"""RISSK GUI — browser-based configuration and launcher for the Kedro pipeline. + +Run from the rissk_kedro/ directory: + python app/main.py +Then open http://localhost:8080 in your browser. +""" + +from __future__ import annotations + +import asyncio +import os +import platform +import sys +from copy import deepcopy +from pathlib import Path + +import yaml +from nicegui import app, ui + +# ── Paths ────────────────────────────────────────────────────────────────── +PROJECT_ROOT = Path(__file__).resolve().parent.parent # rissk_kedro/ +BASE_CONF = PROJECT_ROOT / "conf" / "base" +LOCAL_CONF = PROJECT_ROOT / "conf" / "local" + +# ── Pipeline choices ──────────────────────────────────────────────────────── +PIPELINES: dict[str, str | None] = { + "All (full run)": None, + "Data Ingestion only": "data_ingestion", + "Feature Creation only": "feature_creation", + "Scoring only": "rissk_scoring", +} + +# ── Feature definitions ───────────────────────────────────────────────────── +# Each tuple: (key, has_contamination, default_contamination, extra_param) +# extra_param is (param_name, default_value) or None +FEATURE_DEFS: list[tuple[str, bool, float | None, tuple[str, int] | None]] = [ + ("answer_hour_set", True, 0.10, None), + ("answer_changed", True, 0.10, None), + ("answer_removed", True, 0.10, None), + ("answer_selected", True, 0.10, None), + ("answer_duration", True, 0.10, None), + ("first_decimals", True, 0.10, None), + ("first_digit", False, None, None), + ("numeric_response", False, None, None), + ("sequence_jump", True, 0.10, None), + ("time_changed", False, None, None), + ("gps", True, 0.10, None), + ("pause_count", True, 0.10, None), + ("pause_duration", True, 0.10, None), + ("number_unanswered", False, None, None), + ("number_answered", True, 0.10, None), + ("total_duration", True, 0.10, None), + ("total_elapse", True, 0.10, None), + ("single_question", False, None, None), + ("multi_option_question", False, None, None), + ("days_from_start", False, None, None), + ("answer_position", False, None, None), +] + +# ── Config I/O helpers ────────────────────────────────────────────────────── + +def _deep_merge(base: dict, override: dict) -> dict: + out = dict(base) + for k, v in override.items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +def _read_globals() -> dict: + base = yaml.safe_load((BASE_CONF / "globals.yml").read_text()) or {} + local_path = LOCAL_CONF / "globals.yml" + if local_path.exists(): + local = yaml.safe_load(local_path.read_text()) or {} + return _deep_merge(base, local) + return base + + +def _read_parameters() -> dict: + base = yaml.safe_load((BASE_CONF / "parameters.yml").read_text()) or {} + local_path = LOCAL_CONF / "parameters.yml" + if local_path.exists(): + local = yaml.safe_load(local_path.read_text()) or {} + return _deep_merge(base, local) + return base + + +def _write_local_globals(data: dict) -> None: + LOCAL_CONF.mkdir(exist_ok=True) + with open(LOCAL_CONF / "globals.yml", "w") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +def _write_local_parameters(data: dict) -> None: + LOCAL_CONF.mkdir(exist_ok=True) + with open(LOCAL_CONF / "parameters.yml", "w") as f: + f.write("# Generated by RISSK GUI — do not edit manually\n") + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +def _load_feature_state(params: dict) -> dict: + """Parse features block from a parameters dict into a flat state dict.""" + feats_raw = params.get("features", {}) + state: dict[str, dict] = {} + for key, has_cont, default_cont, extra in FEATURE_DEFS: + raw = feats_raw.get(key, {}) + entry: dict = {"use": raw.get("use", True)} + if has_cont: + entry["contamination"] = ( + raw.get("parameters", {}).get("contamination") or default_cont + ) + if extra: + pname, pdefault = extra + entry[pname] = raw.get("parameters", {}).get(pname, pdefault) + state[key] = entry + return state + + +def _feature_state_to_params(state: dict) -> dict: + """Serialise feature state back to the parameters.yml features block format.""" + features: dict = {} + for key, has_cont, _, extra in FEATURE_DEFS: + s = state[key] + entry: dict = {"use": s["use"]} + feat_params: dict = {} + if has_cont: + cont = s.get("contamination") or 0.10 + feat_params["contamination"] = ( + "auto" if str(cont).strip().lower() == "auto" + else round(float(cont), 4) + ) + if extra: + pname, pdefault = extra + feat_params[pname] = int(s.get(pname) or pdefault) + if feat_params: + entry["parameters"] = feat_params + if key == "gps": + entry["sub_features"] = ["gps_latitude", "gps_longitude", "gps_accuracy"] + features[key] = entry + return features + + +def _open_folder(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + system = platform.system() + if system == "Darwin": + os.system(f'open "{path}"') + elif system == "Windows": + os.startfile(str(path)) + else: + os.system(f'xdg-open "{path}"') + + +# ── Initial state ─────────────────────────────────────────────────────────── + +def _initial_state() -> dict: + glb = _read_globals() + params = _read_parameters() + return { + "data_root": glb.get("data_root", "data"), + "questionnaire": deepcopy(glb.get("questionnaire", {"name": "", "VERSION": [], "filter_var": None})), + "zip_password": params.get("zip_password") or "", + "features": _load_feature_state(params), + "pipeline": "All (full run)", + } + + +# ── UI ────────────────────────────────────────────────────────────────────── + +def main() -> None: + state = _initial_state() + + # ── config builders ────────────────────────────────────────────────── + def _build_globals() -> dict: + return { + "data_root": state["data_root"], + "questionnaire": { + "name": state["questionnaire"].get("name", ""), + "VERSION": state["questionnaire"].get("VERSION", []), + "filter_var": state["questionnaire"].get("filter_var"), + }, + } + + def _build_parameters() -> dict: + return { + "zip_password": state["zip_password"].strip() or None, + "features": _feature_state_to_params(state["features"]), + } + + def save_config() -> None: + _write_local_globals(_build_globals()) + _write_local_parameters(_build_parameters()) + ui.notify("Configuration saved to conf/local/", type="positive", position="top") + + def _zip_path() -> Path: + root = Path(state["data_root"]) + name = state["questionnaire"].get("name") or "" + return root / name / "latest" / "10_RAW" + + # ── page ───────────────────────────────────────────────────────────── + with ui.header(elevated=True).classes("items-center gap-3 px-6 bg-indigo-800 text-white"): + ui.icon("analytics", size="lg") + ui.label("RISSK — Survey Quality Control").classes("text-xl font-bold tracking-wide") + ui.space() + ui.label(f"Project: {PROJECT_ROOT.name}").classes("text-sm opacity-70") + ui.button(icon="refresh", on_click=ui.navigate.reload).props( + "flat round color=white size=sm" + ).tooltip("Reload config from disk") + ui.button(icon="power_settings_new", on_click=app.shutdown).props( + "flat round color=white size=sm" + ).tooltip("Shut down RISSK (stops the server)") + + with ui.tabs().classes("w-full bg-indigo-50") as tabs: + tab_setup = ui.tab("Setup", icon="folder_open") + tab_features = ui.tab("Features", icon="tune") + tab_run = ui.tab("Run", icon="play_arrow") + tab_advanced = ui.tab("Advanced", icon="settings") + + with ui.tab_panels(tabs, value=tab_setup).classes("w-full"): + + # ── SETUP TAB ──────────────────────────────────────────────────── + with ui.tab_panel(tab_setup): + with ui.column().classes("max-w-3xl mx-auto py-6 px-4 gap-6 w-full"): + + # ── Step 1: Data folder ────────────────────────────────── + with ui.card().classes("w-full"): + ui.label("Step 1 — Data folder").classes("text-base font-semibold") + ui.label( + "Root folder where RISSK stores survey data. " + "Default is 'data' (inside the rissk_kedro/ project folder). " + "Use an absolute path to store data elsewhere, e.g. /Users/jane/surveys." + ).classes("text-sm text-gray-500 mb-3") + + data_root_inp = ( + ui.input("Data root folder", value=state["data_root"]) + .classes("w-full") + .tooltip( + "Relative path resolved from rissk_kedro/. " + "Or absolute, e.g. /Users/jane/survey_exports" + ) + ) + + # ── Step 2: Questionnaire configuration ────────────────── + with ui.card().classes("w-full"): + ui.label("Step 2 — Questionnaire configuration").classes("text-base font-semibold") + ui.label( + "Configure the questionnaire to score. " + "Use only letters, numbers, or underscores (no spaces or special characters) for the name." + ).classes("text-sm text-gray-500 mb-3") + + import re as _re + + _SAFE_NAME = _re.compile(r'^[A-Za-z0-9_-]+$') + + qnr_name_error = ui.label("").classes("text-sm text-red-500 hidden") + + with ui.row().classes("w-full items-start gap-3 flex-wrap"): + qnr_name_inp = ( + ui.input( + "Questionnaire name", + value=state["questionnaire"].get("name", ""), + placeholder="e.g. pmpmd_household", + ) + .classes("flex-1 min-w-60") + .tooltip("Template name in Survey Solutions") + ) + + ver_str = ", ".join(str(v) for v in state["questionnaire"].get("VERSION", [])) + ver_inp = ( + ui.input( + "Versions", + value=ver_str, + placeholder="4, 5, 6 (empty = all)", + ) + .classes("w-44") + .tooltip( + "Comma-separated integers, e.g. 4, 5, 6. " + "Leave empty to include all versions found in the folder." + ) + ) + + has_filter = state["questionnaire"].get("filter_var") is not None + filter_sw = ui.switch("Consent filter", value=has_filter).tooltip( + "Only score interviews where a specific paradata variable matches a required answer" + ) + + fv = state["questionnaire"].get("filter_var") or {} + fv_key = list(fv.keys())[0] if fv else "" + fv_val = list(fv.values())[0] if fv else "" + filter_row = ui.row().classes("items-center gap-2 mt-1") + with filter_row: + fv_key_inp = ( + ui.input("Variable name", value=fv_key) + .classes("w-44") + .tooltip("Paradata variable to check for consent") + ) + fv_val_inp = ( + ui.input("Required answer (string)", value=fv_val) + .classes("w-44") + .tooltip("Answer value must be a string, e.g. '1'") + ) + filter_row.set_visibility(has_filter) + + # live zip-path preview + zip_path_label = ui.label(str(_zip_path())).classes( + "font-mono text-sm bg-gray-100 rounded px-3 py-2 w-full mt-3" + ) + + with ui.row().classes("mt-2 gap-2"): + ui.button( + "Create folder & Open", + icon="folder_open", + on_click=lambda: _open_folder(_zip_path()), + ).props("outline size=sm") + + def _refresh_zip_path() -> None: + zip_path_label.set_text(str(_zip_path())) + + def _on_data_root(e) -> None: + state["data_root"] = e.value.strip() + _refresh_zip_path() + _write_local_globals(_build_globals()) + + def _on_qnr_name(e) -> None: + raw = e.value.strip() + if raw and not _SAFE_NAME.match(raw): + qnr_name_error.set_text( + "⚠ Avoid spaces and special characters — use letters, numbers, _ or - only." + ) + qnr_name_error.classes(remove="hidden") + else: + qnr_name_error.classes(add="hidden") + state["questionnaire"]["name"] = raw + _refresh_zip_path() + _write_local_globals(_build_globals()) + + def _on_ver(e) -> None: + try: + state["questionnaire"]["VERSION"] = [ + int(x.strip()) for x in e.value.split(",") if x.strip() + ] + except ValueError: + ui.notify("Versions must be integers separated by commas.", type="warning") + + def _on_fv_key(e) -> None: + fv_current = state["questionnaire"].get("filter_var") or {} + old_val = list(fv_current.values())[0] if fv_current else "" + new_key = e.value.strip() + state["questionnaire"]["filter_var"] = {new_key: old_val} if new_key else None + + def _on_fv_val(e) -> None: + fv_current = state["questionnaire"].get("filter_var") or {} + old_key = list(fv_current.keys())[0] if fv_current else "" + state["questionnaire"]["filter_var"] = {old_key: e.value} if old_key else None + + def _on_filter_sw(e) -> None: + filter_row.set_visibility(e.value) + if not e.value: + state["questionnaire"]["filter_var"] = None + elif state["questionnaire"].get("filter_var") is None: + state["questionnaire"]["filter_var"] = {} + + data_root_inp.on_value_change(_on_data_root) + qnr_name_inp.on_value_change(_on_qnr_name) + ver_inp.on_value_change(_on_ver) + fv_key_inp.on_value_change(_on_fv_key) + fv_val_inp.on_value_change(_on_fv_val) + filter_sw.on_value_change(_on_filter_sw) + + with ui.expansion( + "How to export from Survey Solutions", icon="help_outline" + ).classes("w-full mt-3"): + ui.markdown( + """ +**1. Export Main Survey Data** + +In Survey Solutions go to *Survey → Export Data → Main survey data*. +Choose format **Tab separated** or **Stata 14** and tick +**Include meta information about questionnaire**. Download the ZIP. + +**2. Export Paradata** + +In Survey Solutions go to *Survey → Export Data*. +Under *Data Type* select **Paradata**. Download the ZIP. + +> Export both ZIPs from the **same version** of the questionnaire consecutively. + +**3. Place the ZIP files** + +Put **both** ZIPs into the folder shown above. +Do **not** rename, modify, or unzip the files. + +For **multiple questionnaire versions** (e.g. versions 4, 5, 6): +export each version separately and place all ZIPs in the same folder. +List the version numbers above. + """ + ) + + with ui.row().classes("w-full justify-end"): + ui.button("Save configuration", icon="save", on_click=save_config).props( + "color=primary" + ) + + # ── RUN TAB ────────────────────────────────────────────────────── + with ui.tab_panel(tab_run): + with ui.column().classes("max-w-3xl mx-auto py-6 px-4 gap-4 w-full"): + + ui.label("Run the pipeline").classes("text-base font-semibold") + + with ui.card().classes("w-full bg-amber-50"): + with ui.row().classes("items-center gap-2"): + ui.icon("info", color="amber-8") + ui.label( + "Save your configuration first (Setup tab), " + "and make sure your ZIP files are in the folder shown there " + "before running Data Ingestion." + ).classes("text-sm") + + pipeline_sel = ui.select( + list(PIPELINES.keys()), + value=state["pipeline"], + label="Pipeline stage", + ).classes("w-72") + pipeline_sel.on_value_change(lambda e: state.update({"pipeline": e.value})) + + with ui.row().classes("items-center gap-3"): + run_btn = ui.button("Run RISSK", icon="play_arrow").props("color=green") + stop_btn = ui.button("Stop", icon="stop").props("color=red outline") + stop_btn.set_visibility(False) + + log_output = ui.log(max_lines=800).classes( + "w-full h-96 font-mono text-xs border rounded" + ) + + # process holder (list used as mutable container in closures) + _proc: list[asyncio.subprocess.Process] = [] + + async def _do_run() -> None: + save_config() + pipeline_id = PIPELINES[state["pipeline"]] + cmd = [sys.executable, "-m", "kedro", "run"] + if pipeline_id: + cmd += ["--pipeline", pipeline_id] + + log_output.clear() + log_output.push(f"$ {' '.join(cmd)}\n") + + run_btn.set_enabled(False) + stop_btn.set_visibility(True) + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(PROJECT_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + _proc.clear() + _proc.append(proc) + + async for line in proc.stdout: + log_output.push( + line.decode("utf-8", errors="replace").rstrip() + ) + + await proc.wait() + if proc.returncode == 0: + log_output.push("\n✓ Pipeline completed successfully.") + ui.notify("Done!", type="positive", position="top") + else: + log_output.push( + f"\n✗ Pipeline failed (exit code {proc.returncode})." + ) + ui.notify( + "Pipeline failed — check the log above.", + type="negative", + position="top", + ) + except Exception as exc: + log_output.push(f"\nError: {exc}") + ui.notify(str(exc), type="negative", position="top") + finally: + run_btn.set_enabled(True) + stop_btn.set_visibility(False) + _proc.clear() + + def _do_stop() -> None: + if _proc: + _proc[0].terminate() + ui.notify("Stop signal sent.", type="warning", position="top") + + run_btn.on("click", _do_run) + stop_btn.on("click", _do_stop) + + # ── FEATURES TAB ───────────────────────────────────────────────── + with ui.tab_panel(tab_features): + with ui.column().classes("max-w-3xl mx-auto py-6 px-4 gap-4 w-full"): + + ui.label("Score selection").classes("text-base font-semibold") + ui.label( + "Choose which scores to calculate and adjust contamination thresholds " + "(expected proportion of outliers in clean data, 0.01–0.50, or 'auto'). " + "Default: all enabled, contamination = 0.10." + ).classes("text-sm text-gray-500") + + with ui.card().classes("w-full"): + # bulk toggle row + with ui.row().classes("items-center gap-4 pb-2 border-b"): + ui.label("Select all").classes("text-sm font-medium") + + def _set_all(value: bool) -> None: + for k in state["features"]: + state["features"][k]["use"] = value + render_features.refresh() + + ui.button("All on", icon="check_box", on_click=lambda: _set_all(True)).props("outline size=sm") + ui.button("All off", icon="check_box_outline_blank", on_click=lambda: _set_all(False)).props("outline size=sm") + + # header + with ui.row().classes( + "w-full items-center gap-2 px-2 py-1 font-medium text-sm text-gray-600 border-b" + ): + ui.label("Feature").classes("flex-1") + ui.label("Use").classes("w-14 text-center") + ui.label("Contamination").classes("w-32 text-center") + ui.label("Extra param").classes("w-24 text-center") + + # Features that must always share the same on/off state. + # Format: {key: partner_key} + _LINKED_PAIRS = { + "first_digit": "numeric_response", + "numeric_response": "first_digit", + } + + @ui.refreshable + def render_features() -> None: + for key, has_cont, _, extra in FEATURE_DEFS: + fs = state["features"][key] + row_bg = "" if fs["use"] else "bg-gray-50 opacity-60" + with ui.row().classes( + f"w-full items-center gap-2 px-2 py-1 border-b border-gray-100 {row_bg}" + ): + label_text = key.replace("_", " ").title() + if key in _LINKED_PAIRS: + with ui.row().classes("flex-1 items-center gap-1"): + ui.label(label_text).classes("text-sm") + ui.icon("link", size="xs", color="indigo-4").tooltip( + f"Linked with {_LINKED_PAIRS[key].replace('_', ' ').title()} — " + "both must be enabled together." + ) + else: + ui.label(label_text).classes("flex-1 text-sm") + + sw = ui.switch(value=fs["use"]).props("dense color=indigo") + + def _on_sw(e, k=key): + state["features"][k]["use"] = e.value + partner = _LINKED_PAIRS.get(k) + if partner: + state["features"][partner]["use"] = e.value + action = "enabled" if e.value else "disabled" + ui.notify( + f"{k.replace('_', ' ').title()} and " + f"{partner.replace('_', ' ').title()} " + f"have both been {action} — they are required together.", + type="warning", + position="top", + timeout=4000, + ) + render_features.refresh() + + sw.on_value_change(_on_sw) + + if has_cont: + cont_inp = ( + ui.input( + value=str(fs["contamination"]), + placeholder="0.10 or auto", + ) + .classes("w-32") + .props("dense") + ) + + def _on_cont(e, k=key): + v = e.value.strip().lower() if e.value else "" + if v == "auto": + state["features"][k]["contamination"] = "auto" + else: + try: + state["features"][k]["contamination"] = float(v) + except ValueError: + pass # ignore invalid input mid-typing + + cont_inp.on_value_change(_on_cont) + else: + ui.label("—").classes("w-32 text-center text-gray-400 text-sm") + + if extra: + pname, _ = extra + extra_inp = ( + ui.number( + value=fs.get(pname, extra[1]), + min=1, + step=1, + format="%.0f", + ) + .classes("w-24") + .props("dense") + .tooltip(pname) + ) + + def _on_extra(e, k=key, pn=pname): + if e.value is not None: + state["features"][k][pn] = int(e.value) + + extra_inp.on_value_change(_on_extra) + else: + ui.label("").classes("w-24") + + render_features() + + with ui.row().classes("w-full justify-end"): + ui.button("Save configuration", icon="save", on_click=save_config).props( + "color=primary" + ) + + # ── ADVANCED TAB ───────────────────────────────────────────────── + with ui.tab_panel(tab_advanced): + with ui.column().classes("max-w-3xl mx-auto py-6 px-4 gap-4 w-full"): + + ui.label("Advanced settings").classes("text-base font-semibold") + + # Zip password + with ui.card().classes("w-full"): + ui.label("ZIP password").classes("font-medium") + ui.label( + "Only required if your Survey Solutions export ZIPs are password-protected. " + "Leave blank if not applicable." + ).classes("text-sm text-gray-500 mb-2") + pwd_inp = ( + ui.input( + "Password", + value=state["zip_password"], + password=True, + password_toggle_button=True, + ) + .classes("w-72") + ) + pwd_inp.on_value_change(lambda e: state.update({"zip_password": e.value})) + + with ui.row().classes("w-full justify-end"): + ui.button("Save configuration", icon="save", on_click=save_config).props( + "color=primary" + ) + + ui.run( + title="RISSK", + port=8080, + reload=False, + show=True, + favicon="📊", + ) + + +if __name__ in {"__main__", "__mp_main__"}: + main() diff --git a/rissk_kedro/conf/README.md b/rissk_kedro/conf/README.md new file mode 100644 index 0000000..b135e80 --- /dev/null +++ b/rissk_kedro/conf/README.md @@ -0,0 +1,20 @@ +# What is this for? + +This folder should be used to store configuration files used by Kedro or by separate tools. + +This file can be used to provide users with instructions for how to reproduce local configuration with their own credentials. You can edit the file however you like, but you may wish to retain the information below and add your own section in the section titled **Instructions**. + +## Local configuration + +The `local` folder should be used for configuration that is either user-specific (e.g. IDE configuration) or protected (e.g. security keys). + +> *Note:* Please do not check in any local configuration to version control. + +## Base configuration + +The `base` folder is for shared configuration, such as non-sensitive and project-related configuration that may be shared across team members. + +WARNING: Please do not put access credentials in the base configuration folder. + +## Find out more +You can find out more about configuration from the [user guide documentation](https://docs.kedro.org/en/stable/configuration/configuration_basics.html). diff --git a/rissk_kedro/conf/base/catalog.yml b/rissk_kedro/conf/base/catalog.yml new file mode 100644 index 0000000..b7a226a --- /dev/null +++ b/rissk_kedro/conf/base/catalog.yml @@ -0,0 +1,79 @@ +# Here you can define all your data sets by using simple YAML syntax. +# +# === RAW DATA === +# The source partitions (Zips) +survey_zip_partitions: + type: partitions.PartitionedDataset + path: ${globals:data_root}/${globals:questionnaire.name}/latest/10_RAW + dataset: + type: rissk_kedro.datasets.PathDataset + filename_suffix: ".zip" + +# The extracted result (Folders) +# Used by downstream nodes to find the directories +extracted_survey_folders: + type: partitions.PartitionedDataset + path: ${globals:data_root}/${globals:questionnaire.name}/latest/10_RAW + dataset: + type: rissk_kedro.datasets.PathDataset + +# === INGESTED DataFrames === +paradata_raw: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/20_INTERIM/paradata_raw.parquet + +raw_questionnaire: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/30_PROCESSED/questionnaire.parquet + +raw_microdata: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/20_INTERIM/microdata_raw.parquet + +microdata: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/30_PROCESSED/microdata.parquet + +paradata_processed: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/30_PROCESSED/paradata_processed.parquet + + +# === FEATURE CREATION DataFrames === +item_features_base: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/20_INTERIM/item_features_base.parquet + +unit_features_base: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/20_INTERIM/unit_features_base.parquet + + # Final Feature Tables (Input to Risk Scoring) +item_features: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/30_PROCESSED/item_features.parquet + +unit_features: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/30_PROCESSED/unit_features.parquet + +# Aggregated AnswerRemoved events (includes items deleted from microdata). +# Used by the rissk_scoring pipeline to compute s__answer_removed at unit level, +# matching legacy get_feature_item__answer_removed / make_score_unit__answer_removed. +removed_answers: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/30_PROCESSED/removed_answers.parquet + + +# === SCORING DataFrames === +item_scores: + type: pandas.ParquetDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/40_SCORED/item_scores.parquet + +unit_rissk_scores: + type: pandas.CSVDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/40_SCORED/unit_rissk_scores.csv + +responsible_scores: + type: pandas.CSVDataset + filepath: ${globals:data_root}/${globals:questionnaire.name}/latest/40_SCORED/responsible_scores.csv diff --git a/rissk_kedro/conf/base/globals.yml b/rissk_kedro/conf/base/globals.yml new file mode 100644 index 0000000..129a3f3 --- /dev/null +++ b/rissk_kedro/conf/base/globals.yml @@ -0,0 +1,40 @@ +# Questionnaire Configuration + +# Root folder that contains all questionnaire data subfolders. +# Can be a relative path (relative to rissk_kedro/) or an absolute path. +# Override this in conf/local/globals.yml to point to your data on disk. +data_root: "data" + +questionnaire: + name: "slbhies_listing" + VERSION: [5, 6, 7] + filter_var: null # Set to a single-key dict to filter by consent, e.g.: + # filter_var: {consent_q: "1"} + # Only interviews where paradata variable 'consent_q' has answer "1" are scored. + # The answer value must be a string (paradata answers are always strings). + + +# questionnaire: +# name: "snb_hies_hh" +# VERSION: [9, 10, 11] +# filter_var: null + +# questionnaire: +# name: "pmpmd_community" +# VERSION: [2, 3, 4, 5] +# filter_var: null + +# questionnaire: +# name: "pmpmd_household" +# VERSION: [4, 5, 6] +# filter_var: null + +# questionnaire: +# name: "slchbs_saintlucia_2025" +# VERSION: [6, 7] # 5 is for testing empty data handling +# filter_var: null + +# questionnaire: +# name: "fbf_household" +# VERSION: [13] +# filter_var: null \ No newline at end of file diff --git a/rissk_kedro/conf/base/parameters.yml b/rissk_kedro/conf/base/parameters.yml new file mode 100644 index 0000000..46fbd23 --- /dev/null +++ b/rissk_kedro/conf/base/parameters.yml @@ -0,0 +1,92 @@ +# Questionnaire configuration — defined in globals.yml +questionnaire: ${globals:questionnaire} + +# Set the password for zip files in local/parameters.yml if needed. +# It will override the base/parameters.yml setting. +zip_password: null + +# Feature Engineering Configuration (from configuration/main.yaml) +# contamination: expected proportion of outliers in clean data (0.01–0.50). +# Set to 'auto' for any feature to let the algorithm estimate the threshold +# automatically from the data instead of using a fixed value. +features: + # ── Item-level scored features ────────────────────────────────────────── + answer_hour_set: + use: true + parameters: + contamination: auto + answer_changed: + use: true + parameters: + contamination: auto + answer_removed: + use: true + parameters: + contamination: auto # scored at unit level from removed_answers dataset + answer_selected: + use: true + parameters: + contamination: 0.1 + answer_duration: + use: true + parameters: + contamination: 0.1 + first_decimals: + use: true + parameters: + contamination: 0.1 + first_digit: + use: true # requires numeric_response: true + sequence_jump: + use: true + parameters: + contamination: 0.1 + gps: + use: true + sub_features: [gps_latitude, gps_longitude, gps_accuracy] + parameters: + contamination: 0.1 + answer_position: + use: true + single_question: + use: true + multi_option_question: + use: true + + # ── Unit-level scored features ────────────────────────────────────────── + # Note: time_changed, pause_count, pause_duration are scored whenever the + # feature column is present; use: false only suppresses feature creation. + time_changed: + use: true + pause_count: + use: true + pause_duration: + use: true + number_unanswered: + use: true + number_answered: + use: true + total_duration: + use: true + total_elapse: + use: true + parameters: + contamination: 0.1 + days_from_start: + use: true + + # ── Feature creation only — not scored ───────────────────────────────── + numeric_response: + use: true # required for first_digit; does not produce a score itself + last_digit: + use: false # feature built but no scoring function implemented + pause_list: + use: false # feature built but no scoring function implemented + comment_length: + use: false # feature built but no scoring function implemented + comment_set: + use: false # feature built but no scoring function implemented + comment_duration: + use: false # feature built but no scoring function implemented + string_length: + use: false # feature built but no scoring function implemented diff --git a/notebooks/experiments/.gitkeep b/rissk_kedro/conf/local/.gitkeep similarity index 100% rename from notebooks/experiments/.gitkeep rename to rissk_kedro/conf/local/.gitkeep diff --git a/rissk_kedro/conf/logging.yml b/rissk_kedro/conf/logging.yml new file mode 100644 index 0000000..cda556c --- /dev/null +++ b/rissk_kedro/conf/logging.yml @@ -0,0 +1,44 @@ +# To enable this custom logging configuration, set KEDRO_LOGGING_CONFIG to the path of this file. +# More information available at https://docs.kedro.org/en/stable/logging/logging.html +version: 1 + +disable_existing_loggers: False + +formatters: + simple: + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: logging.StreamHandler + level: INFO + formatter: simple + stream: ext://sys.stdout + + info_file_handler: + class: logging.handlers.RotatingFileHandler + level: INFO + formatter: simple + filename: info.log + maxBytes: 10485760 # 10MB + backupCount: 20 + encoding: utf8 + delay: True + + rich: + class: kedro.logging.RichHandler + rich_tracebacks: True + # Advance options for customisation. + # See https://docs.kedro.org/en/stable/logging/logging.html#project-side-logging-configuration + # tracebacks_show_locals: False + +loggers: + kedro: + level: INFO + + rissk: + level: INFO + +root: + handlers: [rich, info_file_handler] + level: INFO diff --git a/rissk_kedro/pyproject.toml b/rissk_kedro/pyproject.toml new file mode 100644 index 0000000..a3c4808 --- /dev/null +++ b/rissk_kedro/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +# NOTE: named "rissk-pipeline" to avoid collision with the root "rissk" ML package +# in the uv workspace. The installed Python package is still "rissk_kedro". +name = "rissk-pipeline" +version = "0.1.2" +description = "Kedro pipeline for RISSK — Survey Quality Control." +authors = [{ name = "rowsquared" }] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.13" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] + +# Minimal runtime dependencies — only what the pipeline actually imports. +dependencies = [ + "rissk", # ML logic (workspace root package) + "kedro==1.2.0", + "kedro-datasets[pandas]>=9.1.0", # parquet + CSV + PartitionedDataset + "pandas>=2.2.3", + "numpy>=2.1.0", + "pyod>=1.1.5", + "scipy>=1.10", + "scikit-learn>=1.5", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "pytest-cov", +] + +# Tell uv where to find the "rissk" package (workspace sibling at repo root). +[tool.uv.sources] +rissk = { workspace = true } + +[tool.kedro] +package_name = "rissk_kedro" +project_name = "rissk" +kedro_init_version = "1.2.0" +source_dir = "src" + +[tool.pytest.ini_options] +testpaths = ["tests"] +[tool.kedro_telemetry] +project_id = "63718528078e46d1bd5fa456d349edb2" diff --git a/rissk_kedro/requirements.txt b/rissk_kedro/requirements.txt new file mode 100644 index 0000000..c4ac5bf --- /dev/null +++ b/rissk_kedro/requirements.txt @@ -0,0 +1,24 @@ +# RISSK Kedro — runtime dependencies +# Python >= 3.13 +# +# For uv users: uv sync (reads pyproject.toml, ignores this file) +# For conda/pip: pip install -r requirements.txt + +# --- Kedro framework --- +kedro==1.2.0 +# pandas extra covers ParquetDataset, CSVDataset and PartitionedDataset +kedro-datasets[pandas]>=9.1.0 + +# --- Core ML / data --- +pandas>=2.2.3 +numpy>=2.1.0 +scipy>=1.10 # cKDTree, stats, mstats, winsorize, entropy, distance_matrix +scikit-learn>=1.5 # StandardScaler, LabelEncoder, IsolationForest, PCA, NearestNeighbors +pyod>=1.1.5 # ECOD, COF, INNE, LOF, IForest, FILTER + +# --- Config --- +pyyaml>=6.0 + +# --- GUI --- +nicegui>=1.4 + diff --git a/rissk_kedro/run_gui.bat b/rissk_kedro/run_gui.bat new file mode 100644 index 0000000..6af74db --- /dev/null +++ b/rissk_kedro/run_gui.bat @@ -0,0 +1,12 @@ +@echo off +REM RISSK GUI launcher for Windows +cd /d "%~dp0" + +python -c "import nicegui" 2>nul || pip install "nicegui>=1.4" + +echo. +echo Starting RISSK GUI... +echo Open your browser at: http://localhost:8080 +echo (Press Ctrl+C to stop) +echo. +python app\main.py diff --git a/rissk_kedro/run_gui.sh b/rissk_kedro/run_gui.sh new file mode 100755 index 0000000..0a0bb5b --- /dev/null +++ b/rissk_kedro/run_gui.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# RISSK GUI launcher +# Run this script from any directory — it always executes from rissk_kedro/. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Warn if running in the conda base environment +if [[ "${CONDA_DEFAULT_ENV}" == "base" ]]; then + echo "WARNING: You are running in the conda 'base' environment." + echo "It is strongly recommended to activate your project environment first:" + echo " conda activate rissk_py3_13_macos" + echo "" + read -r -p "Continue anyway? [y/N] " confirm + [[ "${confirm,,}" == "y" ]] || exit 1 +fi + +# Check for nicegui; install automatically if missing. +if ! python -c "import nicegui" 2>/dev/null; then + echo "NiceGUI not found. Installing..." + pip install "nicegui>=1.4" +fi + +echo "" +echo "Starting RISSK GUI..." +echo "Open your browser at: http://localhost:8080" +echo "(Press Ctrl+C to stop)" +echo "" +python app/main.py diff --git a/rissk_kedro/src/rissk_kedro/__init__.py b/rissk_kedro/src/rissk_kedro/__init__.py new file mode 100644 index 0000000..4d4e1f2 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/__init__.py @@ -0,0 +1,8 @@ +"""RISSK: Automatically identify at-risk interviews.""" + +import warnings + +# Filter out Kedro deprecation warning about pipeline_name +warnings.filterwarnings("ignore", message="`pipeline_name` is deprecated") + +__version__ = "0.1.2" \ No newline at end of file diff --git a/rissk_kedro/src/rissk_kedro/__main__.py b/rissk_kedro/src/rissk_kedro/__main__.py new file mode 100644 index 0000000..07e898c --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/__main__.py @@ -0,0 +1,24 @@ +"""rissk_kedro file for ensuring the package is executable +as `rissk-kedro` and `python -m rissk_kedro` +""" +import sys +from pathlib import Path +from typing import Any + +from kedro.framework.cli.utils import find_run_command +from kedro.framework.project import configure_project + + +def main(*args, **kwargs) -> Any: + package_name = Path(__file__).parent.name + configure_project(package_name) + + interactive = hasattr(sys, 'ps1') + kwargs["standalone_mode"] = not interactive + + run = find_run_command(package_name) + return run(*args, **kwargs) + + +if __name__ == "__main__": + main() diff --git a/rissk_kedro/src/rissk_kedro/datasets/__init__.py b/rissk_kedro/src/rissk_kedro/datasets/__init__.py new file mode 100644 index 0000000..dd98a7a --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/datasets/__init__.py @@ -0,0 +1 @@ +from .path import PathDataset \ No newline at end of file diff --git a/rissk_kedro/src/rissk_kedro/datasets/path.py b/rissk_kedro/src/rissk_kedro/datasets/path.py new file mode 100644 index 0000000..711f398 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/datasets/path.py @@ -0,0 +1,35 @@ +from __future__ import annotations +from pathlib import Path +from typing import Any +from kedro.io import AbstractDataset + +class PathDataset(AbstractDataset[Path, Path]): + """ + A Kedro dataset that returns the Path to a file or directory. + Perfect for PartitionedDatasets where the node needs the file path + to perform custom operations (like unzipping). + + Note: the Kedro implementation that uses this dataset performs a + "walk" over the target path and therefore returns all file paths + underneath the directory (i.e., a recursive listing). This + dataset exposes the `Path` object; downstream nodes should handle + whether the path is a file or directory and act accordingly. + """ + def __init__(self, filepath: str, **kwargs: Any): + self._filepath = Path(filepath) + # Store metadata (like suffix) for the _describe method + self._metadata = kwargs + + def _exists(self) -> bool: + return self._filepath.exists() + + def _load(self) -> Path: + # Simply return the path object to the node + return self._filepath + + def _save(self, data: Any = None) -> None: + # Ensure the directory exists; ignore 'data' if passed + self._filepath.mkdir(parents=True, exist_ok=True) + + def _describe(self) -> dict[str, Any]: + return dict(filepath=str(self._filepath), **self._metadata) diff --git a/rissk_kedro/src/rissk_kedro/pipeline_registry.py b/rissk_kedro/src/rissk_kedro/pipeline_registry.py new file mode 100644 index 0000000..ed77bb4 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipeline_registry.py @@ -0,0 +1,22 @@ +"""Project pipelines.""" + +from kedro.pipeline import Pipeline + + +def register_pipelines() -> dict[str, Pipeline]: + """Register the project's pipelines.""" + from rissk_kedro.pipelines.data_ingestion import create_pipeline as ingestion_pipeline + from rissk_kedro.pipelines.feature_creation import create_pipeline as feature_creation_pipeline + from rissk_kedro.pipelines.rissk_scoring import create_pipeline as scoring_pipeline + + ingestion = ingestion_pipeline() + feat_creation = feature_creation_pipeline() + scoring = scoring_pipeline() + + return { + "__default__": ingestion + feat_creation + scoring, + "data_ingestion": ingestion, + "feature_creation": feat_creation, + "rissk_scoring": scoring, + } + diff --git a/notebooks/__init__.py b/rissk_kedro/src/rissk_kedro/pipelines/__init__.py similarity index 100% rename from notebooks/__init__.py rename to rissk_kedro/src/rissk_kedro/pipelines/__init__.py diff --git a/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/__init__.py b/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/__init__.py new file mode 100644 index 0000000..4cfe25a --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/__init__.py @@ -0,0 +1,5 @@ +"""Data ingestion pipeline.""" + +from .pipeline import create_pipeline + +__all__ = ["create_pipeline"] diff --git a/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/nodes.py b/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/nodes.py new file mode 100644 index 0000000..9433603 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/nodes.py @@ -0,0 +1,279 @@ +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional +import logging +import numpy as np +import pandas as pd + +logger = logging.getLogger(__name__) +from rissk.utils.import_utils_kedro import ( + extract_zip, + filter_matching_folders, + get_survey_info, + get_questionnaire, + get_paradata_raw, + get_microdata_raw, + merge_microdata_questionnaire +) +from rissk.feature_processing_kedro import make_index_col + + +def extract_zip_files_node(survey_zip_partitions: Dict[str, Callable[[], Path]], zip_password: str) -> None: + """ + Node that iterates through partitions and triggers extraction. + Note: The type hint shows the loader returns a Path. + """ + if not survey_zip_partitions: + logger.warning("No zip partitions found to extract.") + return + + for partition_id, loader in survey_zip_partitions.items(): + # 1. LOAD THE PATH (This calls PathDataset._load) + zip_path = loader() + + # 2. VALIDATE & EXTRACT + if zip_path.suffix.lower() == ".zip" and zip_path.exists(): + destination = zip_path.with_suffix("") + logger.info(f"Extracting partition [{partition_id}] from {zip_path}") + extract_zip(zip_path, destination, password=zip_password) + # else: + # logger.debug(f"Skipping non-zip partition: {partition_id}") + + +def filter_extracted_survey_paths_node(survey_partitions: Dict[str, Callable[[], Any]], questionnaire: Dict) -> List[Path]: + """ + Return extracted folder paths matching questionnaire/version patterns + using partition entries. + This node does not perform extraction. + """ + ver_list = questionnaire.get("VERSION", []) + versions = ", ".join(str(v) for v in ver_list) if ver_list else "all" + lines = [ + "=" * 55, + " DATA INGESTION — Questionnaire to process", + "=" * 55, + f" • {questionnaire['name']} | versions: [{versions}]", + "=" * 55, + ] + logger.info("\n" + "\n".join(lines)) + + logger.info(f"Collecting matching folders from {len(survey_partitions)} partition entries") + return filter_matching_folders(survey_partitions, [questionnaire]) + + +def load_paradata_node(file_paths: List[Path]) -> pd.DataFrame: + """ + Loads raw paradata from extracted folders. + No questionnaire metadata is merged at this stage; column splitting, + timestamp parsing and version tagging are performed by get_paradata_raw. + """ + logger.info(f"Processing raw paradata for {len(file_paths)} paths") + survey_info = get_survey_info(file_paths) + + dfs_paradata = [] + + for survey_questionnaire, questionnaires_details in survey_info.items(): + for questionnaires_version, file_paths_detail in questionnaires_details.items(): + paradata_path = file_paths_detail.get('Paradata') + + if not paradata_path: + logger.warning( + f"Skipping paradata load for {survey_questionnaire} v{questionnaires_version}: " + "missing Paradata export" + ) + continue + + try: + df_paradata = get_paradata_raw(paradata_path) + dfs_paradata.append(df_paradata) + if df_paradata.empty: + logger.warning( + f"Empty data returned for paradata {survey_questionnaire} v{questionnaires_version}: " + "file may be empty or corrupt" + ) + else: + logger.info(f"Loaded raw paradata for {survey_questionnaire} v{questionnaires_version}") + except Exception as e: + logger.error(f"Failed to load paradata for {survey_questionnaire} v{questionnaires_version}. Skipping. Error: {str(e)}") + continue + + if not dfs_paradata: + return pd.DataFrame() + + combined_df = pd.concat(dfs_paradata) + combined_df.reset_index(drop=True, inplace=True) + return combined_df + +def process_paradata_node( + paradata_raw: pd.DataFrame, + questionnaire: pd.DataFrame, + parameters: Dict, +) -> pd.DataFrame: + """ + Merges questionnaire metadata onto raw paradata, processes timestamps and + interviewing flags, makes the index column, and filters to active interviewer + events - producing the paradata_processed dataset consumed by feature creation. + """ + if paradata_raw.empty: + logger.error( + "process_paradata_node: paradata_raw is empty — all paradata files were missing " + "or contained no data rows. Cannot process paradata. Returning empty DataFrame." + ) + return pd.DataFrame() + + paradata = paradata_raw.copy() + + # 1. Merge questionnaire metadata + if not questionnaire.empty: + q_columns = [ + 'qnr_seq', 'variable_name', 'qtype', 'question_type', + 'answers', 'question_scope', + 'yes_no_view', 'is_filtered_combobox', + 'is_integer', 'cascade_from_question_id', + 'answer_sequence', 'n_answers', 'question_sequence', + 'qnr', 'qnr_version', + ] + q_columns = [c for c in q_columns if c in questionnaire.columns] + paradata = paradata.merge( + questionnaire[q_columns], + how='left', + left_on=['param', 'qnr', 'qnr_version'], + right_on=['variable_name', 'qnr', 'qnr_version'], + ) + + # 2. Stringify answer_sequence for parquet serialization (matches legacy behaviour) + if 'answer_sequence' in paradata.columns: + paradata['answer_sequence'] = paradata['answer_sequence'].apply(str) + + # 3. Calculate f__answer_hour_set + paradata['f__answer_hour_set'] = ( + paradata['timestamp_local'].dt.hour + + paradata['timestamp_local'].dt.round('30min').dt.minute / 60 + ) + + # 4. Calculate interviewing flag and filter to first-pass interviewer events. + # interviewing=True for all events that occurred before any Supervisor/HQ interaction, + # False for everything after. A rejection or review event resets the interpretive + # context: answers recorded afterwards belong to a different (post-review) pass and + # should not be scored as if they were the original interviewing session. + # The cumsum trick propagates the flag forward so every subsequent row in the same + # interview automatically receives interviewing=False once the first flagged event fires. + events_split = ['RejectedBySupervisor', 'OpenedBySupervisor', 'OpenedByHQ', 'RejectedByHQ'] + paradata['flag'] = paradata['event'].isin(events_split) + paradata['cumulative_flag'] = paradata.groupby('interview__id')['flag'].cumsum() + paradata['interviewing'] = np.where(paradata['cumulative_flag'] > 0, False, True) + paradata.drop(['flag', 'cumulative_flag'], axis=1, inplace=True) + paradata = paradata[(paradata['interviewing'] == True) & (paradata['role'] == 1)].copy() + + # 5. Make index column + paradata = make_index_col(paradata) + + # 6. Sort + paradata.sort_values(['interview__id', 'order'], inplace=True) + paradata.reset_index(drop=True, inplace=True) + + return paradata + + +def load_questionnaire_node(file_paths: List[Path]) -> pd.DataFrame: + """ + Loads questionnaire metadata from extracted folders. + """ + logger.info(f"Processing questionnaires for {len(file_paths)} paths") + survey_info = get_survey_info(file_paths) + + dfs_questionnaires = [] + + for survey_questionnaire, questionnaires_details in survey_info.items(): + for questionnaires_version, file_paths in questionnaires_details.items(): + tabular_path = file_paths.get('Tabular') + + if not tabular_path: + logger.warning( + f"Skipping questionnaire load for {survey_questionnaire} v{questionnaires_version}: " + "missing Tabular export" + ) + continue + + try: + df_questionnaires = get_questionnaire(tabular_path) + dfs_questionnaires.append(df_questionnaires) + if df_questionnaires.empty: + logger.warning( + f"Empty data returned for questionnaire {survey_questionnaire} v{questionnaires_version}: " + "file may be empty or corrupt" + ) + else: + logger.info(f"Loaded questionnaire for {survey_questionnaire} v{questionnaires_version}") + except Exception as e: + logger.error(f"Failed to load questionnaire for {survey_questionnaire} v{questionnaires_version}. Skipping. Error: {str(e)}") + continue + + if not dfs_questionnaires: + return pd.DataFrame() + + combined_df = pd.concat(dfs_questionnaires) + combined_df.reset_index(drop=True, inplace=True) + + if 'answer_sequence' in combined_df.columns: + combined_df['answer_sequence'] = combined_df['answer_sequence'].apply(str) + if 'properties' in combined_df.columns: + combined_df['properties'] = combined_df['properties'].apply( + lambda x: str(x) if isinstance(x, dict) else x + ) + + return combined_df + + +def load_raw_microdata_node(file_paths: List[Path], questionnaire: pd.DataFrame) -> pd.DataFrame: + """ + Loads raw microdata (answers) from extracted folders. + Applies multi-question transformation using questionnaire metadata but does not + merge questionnaire columns into the output. Values are normalized and stringified. + """ + logger.info(f"Processing raw microdata for {len(file_paths)} paths") + survey_info = get_survey_info(file_paths) + + dfs_microdata = [] + + for survey_questionnaire, questionnaires_details in survey_info.items(): + for questionnaires_version, file_paths_detail in questionnaires_details.items(): + tabular_path = file_paths_detail.get('Tabular') + + if not tabular_path: + logger.warning( + f"Skipping raw microdata load for {survey_questionnaire} v{questionnaires_version}: " + "missing Tabular export" + ) + continue + + try: + df_questionnaires = questionnaire[questionnaire['qnr'] == survey_questionnaire] + df_microdata = get_microdata_raw(tabular_path, df_questionnaires) + dfs_microdata.append(df_microdata) + if df_microdata.empty: + logger.warning( + f"Empty data returned for microdata {survey_questionnaire} v{questionnaires_version}: " + "file may be empty or corrupt" + ) + else: + logger.info(f"Loaded raw microdata for {survey_questionnaire} v{questionnaires_version}") + except Exception as e: + logger.error(f"Failed to load raw microdata for {survey_questionnaire} v{questionnaires_version}. Skipping. Error: {str(e)}") + continue + + if not dfs_microdata: + return pd.DataFrame() + + combined_df = pd.concat(dfs_microdata) + combined_df.reset_index(drop=True, inplace=True) + return combined_df + + +def merge_microdata_questionnaire_node(raw_microdata: pd.DataFrame, questionnaire: pd.DataFrame) -> pd.DataFrame: + """ + Merges raw microdata with questionnaire metadata and normalizes column names. + """ + logger.info("Merging raw microdata with questionnaire metadata") + merged = merge_microdata_questionnaire(raw_microdata, questionnaire) + merged.reset_index(drop=True, inplace=True) + return merged diff --git a/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/pipeline.py b/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/pipeline.py new file mode 100644 index 0000000..b2802d2 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/data_ingestion/pipeline.py @@ -0,0 +1,63 @@ +from kedro.pipeline import Pipeline, node, pipeline +from .nodes import ( + extract_zip_files_node, + filter_extracted_survey_paths_node, + load_paradata_node, + process_paradata_node, + load_questionnaire_node, + load_raw_microdata_node, + merge_microdata_questionnaire_node +) +# catalog for path +def create_pipeline(**kwargs) -> Pipeline: + return pipeline([ + node( + func=extract_zip_files_node, + inputs=[ + "survey_zip_partitions", + "params:zip_password" + ], + outputs=None, + name="extract_zip_files_node", + tags=["unzip_files"] + ), + node( + func=filter_extracted_survey_paths_node, + inputs=[ + "extracted_survey_folders", + "params:questionnaire", + ], + outputs="file_paths", + name="filter_extracted_survey_paths_node" + ), + node( + func=load_paradata_node, + inputs="file_paths", + outputs="paradata_raw", + name="load_paradata_node" + ), + node( + func=load_questionnaire_node, + inputs="file_paths", + outputs="raw_questionnaire", + name="load_questionnaire_node" + ), + node( + func=process_paradata_node, + inputs=["paradata_raw", "raw_questionnaire", "parameters"], + outputs="paradata_processed", + name="process_paradata_node" + ), + node( + func=load_raw_microdata_node, + inputs=["file_paths", "raw_questionnaire"], + outputs="raw_microdata", + name="load_raw_microdata_node" + ), + node( + func=merge_microdata_questionnaire_node, + inputs=["raw_microdata", "raw_questionnaire"], + outputs="microdata", + name="merge_microdata_questionnaire_node" + ) + ]) diff --git a/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/__init__.py b/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/__init__.py new file mode 100644 index 0000000..beba04d --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/__init__.py @@ -0,0 +1,5 @@ +"""Feature Creation pipeline for generating item and unit tables.""" + +from .pipeline import create_pipeline + +__all__ = ["create_pipeline"] diff --git a/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/nodes.py b/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/nodes.py new file mode 100644 index 0000000..9a0493d --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/nodes.py @@ -0,0 +1,137 @@ +"""Nodes for the Feature Creation pipeline.""" +import logging +import pandas as pd +from typing import Dict, Any + +# Assuming rissk is importable as a package +# If running kedro from rissk_kedro root, ensure PYTHONPATH includes ../rissk +from rissk.feature_processing_kedro import ( + create_base_item_table, + create_base_unit_table, + enrich_item_features, + enrich_unit_features, + feat_answer_removed, +) + +logger = logging.getLogger(__name__) + +def create_base_item_table_node( + microdata: pd.DataFrame, + paradata_full: pd.DataFrame, + parameters: Dict[str, Any] +) -> pd.DataFrame: + """Node wrapper for create_base_item_table.""" + questionnaire = parameters.get('questionnaire', {}) + lines = [ + "=" * 55, + " FEATURE CREATION — Configuration", + "=" * 55, + f" Questionnaire: {questionnaire.get('name', 'unknown')}", + "=" * 55, + ] + logger.info("\n" + "\n".join(lines)) + return create_base_item_table(microdata, paradata_full, parameters) + +def create_base_unit_table_node( + paradata_full: pd.DataFrame, + parameters: Dict[str, Any] +) -> pd.DataFrame: + """Node wrapper for create_base_unit_table.""" + return create_base_unit_table(paradata_full, parameters) + +def enrich_item_features_node( + item_features_base: pd.DataFrame, + paradata_full: pd.DataFrame, + parameters: Dict[str, Any] +) -> pd.DataFrame: + """Node wrapper for enrich_item_features. + paradata_full: all processed events, role=1, interviewing=True (equivalent to self.df_paradata). + """ + return enrich_item_features(item_features_base, paradata_full, parameters) + +def enrich_unit_features_node( + unit_features_base: pd.DataFrame, + item_features: pd.DataFrame, + paradata_full: pd.DataFrame, + parameters: Dict[str, Any] +) -> pd.DataFrame: + """Node wrapper for enrich_unit_features. + paradata_full: all processed events, role=1, interviewing=True (equivalent to self.df_paradata). + """ + return enrich_unit_features(unit_features_base, item_features, paradata_full, parameters) + +def build_removed_answers_node( + paradata_full: pd.DataFrame, + parameters: Dict[str, Any] +) -> pd.DataFrame: + """Extract and aggregate all AnswerRemoved events as a standalone dataset. + + This node produces the removed_answers parquet which captures AnswerRemoved + events for items that may no longer exist in microdata (deleted items), matching + legacy get_feature_item__answer_removed behaviour. The output is consumed by + the rissk_scoring pipeline to score s__answer_removed at unit level. + """ + return feat_answer_removed(paradata_full) + + +def filter_by_consent( + item_features: pd.DataFrame, + unit_features: pd.DataFrame, + removed_answers: pd.DataFrame, + paradata: pd.DataFrame, + filter_var, +): + """Filter feature tables to interviews that match the consent variable. + + ``filter_var`` must be a dict with exactly one key-value pair + ``{variable_name: answer_value}`` (matching the legacy ``limit_unit`` shape), + or ``None`` to skip filtering entirely. + + When set, only interviews where ``variable_name == key`` and + ``str(value) == str(answer_value)`` are retained across all three feature + tables. A WARNING is emitted so operators know filtering is active. + """ + if filter_var is None: + return item_features, unit_features, removed_answers + + consent_variable = next(iter(filter_var)) + # Careful: paradata answer column is always a string, so cast the + # configured value to str — matching legacy filter_by_consent behaviour. + consent_value = str(filter_var[consent_variable]) + + logger.warning( + "filter_by_consent: consent filtering is ACTIVE — " + "keeping only interviews where '%s' == '%s'", + consent_variable, consent_value, + ) + + cond1 = paradata["variable_name"] == consent_variable + cond2 = paradata["answer"] == consent_value + approved_ids = paradata.loc[cond1 & cond2, "interview__id"].unique() + + if len(approved_ids) == 0: + total_interviews = unit_features["interview__id"].nunique() + raise ValueError( + f"filter_by_consent: filter_var " + f"{{'{consent_variable}': '{consent_value}'}} matched 0 interviews " + f"out of {total_interviews}. " + f"Check that the variable name and answer value are correct. " + f"Note: paradata answer values are always strings." + ) + + item_filtered = item_features[item_features["interview__id"].isin(approved_ids)].copy() + unit_filtered = unit_features[unit_features["interview__id"].isin(approved_ids)].copy() + + if removed_answers is not None and not removed_answers.empty: + removed_filtered = removed_answers[ + removed_answers["interview__id"].isin(approved_ids) + ].copy() + else: + removed_filtered = removed_answers + + logger.info( + "filter_by_consent: retained %d / %d interviews (%d item rows)", + len(unit_filtered), len(unit_features), len(item_filtered), + ) + + return item_filtered, unit_filtered, removed_filtered diff --git a/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/pipeline.py b/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/pipeline.py new file mode 100644 index 0000000..94844a9 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/feature_creation/pipeline.py @@ -0,0 +1,58 @@ +"""Feature Creation pipeline definition.""" +from kedro.pipeline import Pipeline, node, pipeline +from .nodes import ( + create_base_item_table_node, + create_base_unit_table_node, + enrich_item_features_node, + enrich_unit_features_node, + build_removed_answers_node, +) + +def create_pipeline(**kwargs) -> Pipeline: + """Create the feature creation pipeline. + + Returns: + A pipeline that builds item and unit feature tables, and a removed_answers dataset. + """ + return pipeline([ + node( + func=create_base_item_table_node, + inputs=["microdata", "paradata_processed", "parameters"], + # Legacy test data: + # inputs=["legacy_microdata", "legacy_paradata_processed", "parameters"], + outputs="item_features_base", + name="create_base_item_table_node", + ), + node( + func=create_base_unit_table_node, + inputs=["paradata_processed", "parameters"], + # Legacy test data: + # inputs=["legacy_paradata_processed", "parameters"], + outputs="unit_features_base", + name="create_base_unit_table_node", + ), + node( + func=enrich_item_features_node, + inputs=["item_features_base", "paradata_processed", "parameters"], + # Legacy test data: + # inputs=["item_features_base", "legacy_paradata_processed", "parameters"], + outputs="item_features", + name="enrich_item_features_node", + ), + node( + func=enrich_unit_features_node, + inputs=["unit_features_base", "item_features", "paradata_processed", "parameters"], + # Legacy test data: + # inputs=["unit_features_base", "item_features", "legacy_paradata_processed", "parameters"], + outputs="unit_features", + name="enrich_unit_features_node", + ), + node( + func=build_removed_answers_node, + inputs=["paradata_processed", "parameters"], + # Legacy test data: + # inputs=["legacy_paradata_processed", "parameters"], + outputs="removed_answers", + name="build_removed_answers_node", + ), + ]) diff --git a/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/__init__.py b/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/__init__.py new file mode 100644 index 0000000..3b71ef3 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/__init__.py @@ -0,0 +1,9 @@ +""" +This is a boilerplate pipeline 'rissk_scoring' +generate with Kedro 0.19.x or later. +""" + +from .pipeline import create_pipeline + +__all__ = ["create_pipeline"] +__version__ = "0.1" diff --git a/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/nodes.py b/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/nodes.py new file mode 100644 index 0000000..f778945 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/nodes.py @@ -0,0 +1,250 @@ +import pandas as pd +import numpy as np +from typing import Dict, Any, Tuple +import logging + +from rissk.item_processing_kedro import ( + calculate_answer_hour_set_score, + calculate_sequence_jump_score, + calculate_first_decimals_score, + calculate_answer_changed_score, + # calculate_answer_removed_score is intentionally absent: s__answer_removed is + # computed at unit level from the removed_answers dataset by calculate_answer_removed_score_from_df + # so that AnswerRemoved events for items deleted from microdata are not missed. + calculate_answer_removed_score_from_df, + calculate_answer_position_score, + calculate_answer_selected_score, + calculate_answer_duration_score, + calculate_single_question_score, + calculate_multi_option_question_score, + calculate_first_digit_score, + calculate_gps_score +) +from rissk.unit_processing_kedro import ( + calculate_global_score, + aggregate_unit_score_mean, + aggregate_item_to_unit_scores, + calculate_unit_level_scores, + aggregate_item_to_responsible_scores, + calculate_responsible_score +) + +logger = logging.getLogger(__name__) + +def calculate_item_scores(df_item: pd.DataFrame, parameters: Dict[str, Any]) -> pd.DataFrame: + """ + Run item level scoring applying various mathematical models. + f__answer_removed is already present in df_item from the feature creation pipeline. + Each scoring function is only executed when its corresponding feature has use: true + in parameters['features'], matching the feature creation pipeline behaviour. + """ + questionnaire = parameters.get('questionnaire', {}) + lines = [ + "=" * 55, + " RISSK SCORING", + "=" * 55, + f" Questionnaire: {questionnaire.get('name', 'unknown')}", + "=" * 55, + ] + logger.info("\n" + "\n".join(lines)) + logger.info("Calculating Item Scores...") + + if df_item.empty: + logger.warning("calculate_item_scores: item features DataFrame is empty — no items to score. Returning empty DataFrame.") + return df_item + + features = parameters.get('features', {}) + df_scored = df_item + + if features.get('answer_hour_set', {}).get('use', False): + logger.info("Calculating answer_hour_set_score") + df_scored = calculate_answer_hour_set_score(df_scored, parameters) + + if features.get('sequence_jump', {}).get('use', False): + logger.info("Calculating sequence_jump_score") + df_scored = calculate_sequence_jump_score(df_scored, parameters) + + if features.get('first_decimals', {}).get('use', False): + logger.info("Calculating first_decimals_score") + df_scored = calculate_first_decimals_score(df_scored, parameters) + + if features.get('answer_changed', {}).get('use', False): + logger.info("Calculating answer_changed_score") + df_scored = calculate_answer_changed_score(df_scored, parameters) + + # s__answer_removed is not computed here — see calculate_answer_removed_score_from_df + # in calculate_unit_scores, which scores from the removed_answers dataset to match legacy coverage. + + if features.get('answer_position', {}).get('use', False): + logger.info("Calculating answer_position_score") + df_scored = calculate_answer_position_score(df_scored, parameters) + + if features.get('answer_selected', {}).get('use', False): + logger.info("Calculating answer_selected_score") + df_scored = calculate_answer_selected_score(df_scored, parameters) + + if features.get('answer_duration', {}).get('use', False): + logger.info("Calculating answer_duration_score") + df_scored = calculate_answer_duration_score(df_scored, parameters) + + if features.get('single_question', {}).get('use', False): + logger.info("Calculating single_question_score") + df_scored = calculate_single_question_score(df_scored) + + if features.get('multi_option_question', {}).get('use', False): + logger.info("Calculating multi_option_question_score") + df_scored = calculate_multi_option_question_score(df_scored) + + if features.get('first_digit', {}).get('use', False): + logger.info("Calculating first_digit_score") + df_scored = calculate_first_digit_score(df_scored) + + if features.get('gps', {}).get('use', False): + logger.info("Calculating gps_score") + df_scored = calculate_gps_score(df_scored, parameters) + + # Keep only the columns needed for downstream unit/responsible scoring and output. + # - responsible: required by aggregate_item_to_responsible_scores (groupby + init) + # - s__gps is produced by calculate_gps_score (f__gps.astype(int)) when GPS is + # enabled and is picked up naturally by the s__ filter below. + id_cols = ['qnr', 'qnr_version', 'index_col', 'interview__id', 'variable_name', 'roster_level', 'responsible'] + score_cols = [c for c in df_scored.columns if c.startswith('s__')] + keep_cols = [c for c in id_cols + score_cols if c in df_scored.columns] + df_scored = df_scored[keep_cols] + + return df_scored + +def calculate_unit_scores( + df_unit: pd.DataFrame, + df_item_scores: pd.DataFrame, + parameters: Dict[str, Any], removed_answers: pd.DataFrame = None + ) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + Aggregate item scores to unit, extract responsible scores, and calculate global risk. + + This node receives data for a single questionnaire — the pipeline_registry filters + item_features / unit_features per questionnaire before invoking the scoring pipeline, + so no internal qnr loop is needed here. + + removed_answers is the pre-aggregated AnswerRemoved dataset produced by build_removed_answers_node. + It is used to compute s__answer_removed at unit level, matching legacy behaviour where + items deleted from microdata (absent from df_item) are still counted. + """ + logger.info("Calculating Unit Scores and Global Risk...") + + if df_unit.empty: + logger.warning("calculate_unit_scores: unit features DataFrame is empty — no units to score. Returning empty DataFrames.") + return pd.DataFrame(), pd.DataFrame() + + features = parameters.get('features', {}) + + # 1. Aggregate item-level scores up to unit level. + # s__answer_removed is excluded from this aggregation (see aggregate_item_to_unit_scores); + # it is handled below using removed_answers to match legacy coverage. + df_unit_scored = aggregate_item_to_unit_scores(df_unit, df_item_scores) + + # 2a. Score answer_removed at unit level from removed_answers. + # This replicates legacy make_score_unit__answer_removed which read from df_paradata + # directly and therefore included AnswerRemoved events for items later deleted from + # microdata. Falling back to the df_item-based mean when removed_answers is unavailable. + if features.get('answer_removed', {}).get('use', False): + if removed_answers is not None and not removed_answers.empty: + unit_removed = calculate_answer_removed_score_from_df(removed_answers, parameters) + df_unit_scored['s__answer_removed'] = df_unit_scored['interview__id'].map(unit_removed).fillna(0) + elif 's__answer_removed' in df_item_scores.columns: + logger.warning( + "removed_answers not available; falling back to df_item-based s__answer_removed " + "aggregation (may undercount removals for deleted items)." + ) + data = df_item_scores.groupby('interview__id')['s__answer_removed'].mean() + df_unit_scored['s__answer_removed'] = df_unit_scored['interview__id'].map(data).fillna(0) + + # 2b. Add pure unit-level calculations (row-wise or by interview__id). + df_unit_scored = calculate_unit_level_scores(df_unit_scored, parameters) + + qnr_name = df_unit_scored['qnr'].iloc[0] if 'qnr' in df_unit_scored.columns and not df_unit_scored.empty else None + logger.info(f"Scoring questionnaire: {qnr_name!r} ({len(df_unit_scored)} interviews)") + + if df_unit_scored.empty: + logger.warning(f"No units found for questionnaire '{qnr_name}' — returning empty.") + return df_unit_scored, pd.DataFrame() + + # 3. Aggregate item scores to responsible level. + # Seed df_resp from unit_features responsibles (all responsibles with any interview + # activity), matching legacy which seeds _df_resp from df_active_paradata. + # Responsibles present in unit_features but absent from item_scores (no scoreable + # items) will have NaN in all score columns → filled to 0 before PCA, exactly as + # legacy make_responsible_score does via fillna(0). + df_resp_init = ( + df_unit_scored[['responsible']] + .drop_duplicates() + .loc[lambda d: (d['responsible'] != '') & d['responsible'].notna()] + .reset_index(drop=True) + .copy() + ) + df_resp = aggregate_item_to_responsible_scores(df_resp_init, df_item_scores) + + # 4. PCA-based responsible score. + # restricted_columns = ALL unit-level s__ columns (matching legacy make_responsible_score + # which receives restricted_columns=_score_columns, the full set including constant cols). + # This ensures any responsible-level feature that also appears at unit level (e.g. + # s__single_question, s__answer_position) is excluded from the resp PCA regardless of + # whether it has variance — exactly as legacy does. + score_columns = [c for c in df_unit_scored.columns if c.startswith('s__')] + df_resp = calculate_responsible_score(df_resp, score_columns) + + # 5. IForest global unit risk score. + df_unit_final = calculate_global_score( + df_unit_scores=df_unit_scored, + df_resp_scores=df_resp, + score_columns=score_columns, + combine_resp_score=True, + restricted_columns=None, + ) + + # 6. Merge responsible-level s__ columns back onto unit output. + # Legacy save() merges _df_resp (s__single_question, s__multi_option_question, + # s__answer_position, s__first_digit) back onto _df_unit by responsible. + resp_s_cols = [c for c in df_resp.columns if c.startswith('s__')] + if resp_s_cols and 'responsible' in df_resp.columns: + new_resp_cols = [c for c in resp_s_cols if c not in df_unit_final.columns] + if new_resp_cols: + df_unit_final = df_unit_final.merge( + df_resp[['responsible'] + new_resp_cols], + on='responsible', + how='left', + ) + df_unit_final[new_resp_cols] = df_unit_final[new_resp_cols].fillna(0) + + # Drop feature columns (f__*) from unit output — only scores and identifiers needed. + feature_cols = [c for c in df_unit_final.columns if c.startswith('f__')] + df_unit_final = df_unit_final.drop(columns=feature_cols) + + # Always ensure responsible_score is present (may be absent when PCA was skipped). + if 'responsible_score' not in df_unit_final.columns: + df_unit_final['responsible_score'] = np.nan + + # Apply column ordering for unit_rissk_scores: + # interview__id, qnr, responsible, qnr_version, unit_risk_score, responsible_score, + # IForest s__ cols, responsible s__ cols, any remaining cols. + lead_cols = ['interview__id', 'qnr', 'responsible', 'qnr_version', 'unit_risk_score', 'responsible_score'] + iforest_s = [c for c in score_columns if c in df_unit_final.columns] + resp_s_ordered_unit = [c for c in resp_s_cols if c in df_unit_final.columns] + ordered_unit = [c for c in lead_cols if c in df_unit_final.columns] + ordered_unit += iforest_s + ordered_unit += [c for c in resp_s_ordered_unit if c not in ordered_unit] + ordered_unit += [c for c in df_unit_final.columns if c not in ordered_unit] + df_unit_final = df_unit_final[ordered_unit] + + # Apply column ordering for responsible_scores: responsible, responsible_score, s__ cols. + # qnr is intentionally excluded from the responsible_scores output. + if 'responsible_score' not in df_resp.columns: + df_resp['responsible_score'] = np.nan + resp_lead = ['responsible', 'responsible_score'] + resp_s_ordered = [c for c in df_resp.columns if c.startswith('s__')] + ordered_resp = [c for c in resp_lead if c in df_resp.columns] + ordered_resp += resp_s_ordered + ordered_resp += [c for c in df_resp.columns if c not in ordered_resp and c != 'qnr'] + df_resp = df_resp[ordered_resp] + + return df_unit_final, df_resp diff --git a/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/pipeline.py b/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/pipeline.py new file mode 100644 index 0000000..5916c0f --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/pipelines/rissk_scoring/pipeline.py @@ -0,0 +1,44 @@ +"""Rissk scoring pipeline definition.""" +from kedro.pipeline import Pipeline, node, pipeline +from .nodes import calculate_item_scores, calculate_unit_scores +from rissk_kedro.pipelines.feature_creation.nodes import filter_by_consent + +def create_pipeline(**kwargs) -> Pipeline: + """Create the scoring pipeline. + + Returns: + A pipeline that calculates item and unit risk scores. + """ + return pipeline([ + node( + func=filter_by_consent, + inputs=[ + "item_features", + "unit_features", + "removed_answers", + "paradata_processed", + "params:questionnaire.filter_var", + ], + outputs=[ + "item_features_filtered", + "unit_features_filtered", + "removed_answers_filtered", + ], + name="filter_consent_node", + ), + node( + func=calculate_item_scores, + inputs=["item_features_filtered", "parameters"], + outputs="item_scores", + name="calculate_item_scores_node", + ), + node( + func=calculate_unit_scores, + # removed_answers gives calculate_unit_scores access to ALL AnswerRemoved events, + # including those for items deleted from microdata, + # matching legacy make_score_unit__answer_removed behaviour. + inputs=["unit_features_filtered", "item_scores", "parameters", "removed_answers_filtered"], + outputs=["unit_rissk_scores", "responsible_scores"], + name="calculate_unit_scores_node", + ), + ]) diff --git a/rissk_kedro/src/rissk_kedro/settings.py b/rissk_kedro/src/rissk_kedro/settings.py new file mode 100644 index 0000000..8b31499 --- /dev/null +++ b/rissk_kedro/src/rissk_kedro/settings.py @@ -0,0 +1,54 @@ +"""Project settings. There is no need to edit this file unless you want to change values +from the Kedro defaults. For further information, including these default values, see +https://docs.kedro.org/en/stable/kedro_project_setup/settings.html.""" + +# Instantiated project hooks. + +# Hooks are executed in a Last-In-First-Out (LIFO) order. +HOOKS = () + +# Installed plugins for which to disable hook auto-registration. +DISABLE_HOOKS_FOR_PLUGINS = ("kedro-viz",) + +# Class that manages storing KedroSession data. +# from kedro.framework.session.store import BaseSessionStore +# SESSION_STORE_CLASS = BaseSessionStore +# Keyword arguments to pass to the `SESSION_STORE_CLASS` constructor. +# SESSION_STORE_ARGS = { +# "path": "./sessions" +# } + +# Directory that holds configuration. +# CONF_SOURCE = "conf" + +# Class that manages how configuration is loaded. +from kedro.config import OmegaConfigLoader # noqa: E402 + +try: # noqa: E402 + from kedro_viz.integrations.kedro import hooks as kedro_viz_hooks + + if ( + hasattr(kedro_viz_hooks, "dataset_stats_hook") + and not hasattr(kedro_viz_hooks.dataset_stats_hook, "datasets") + ): + kedro_viz_hooks.dataset_stats_hook.datasets = {} +except Exception: + pass + +CONFIG_LOADER_CLASS = OmegaConfigLoader +# Keyword arguments to pass to the `CONFIG_LOADER_CLASS` constructor. +CONFIG_LOADER_ARGS = { + "base_env": "base", + "default_run_env": "local", +} + +# # The folder where the source code is located +# CONF_SOURCE = "conf" + +# Class that manages Kedro's library components. +# from kedro.framework.context import KedroContext +# CONTEXT_CLASS = KedroContext + +# Class that manages the Data Catalog. +# from kedro.io import DataCatalog +# DATA_CATALOG_CLASS = DataCatalog diff --git a/rissk_readme.ipynb b/rissk_readme.ipynb deleted file mode 100644 index e3e60d1..0000000 --- a/rissk_readme.ipynb +++ /dev/null @@ -1,164 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "6172834c", - "metadata": {}, - "source": [ - "# Generate Paradata, Microdata in long format." - ] - }, - { - "cell_type": "markdown", - "id": "7f19542f", - "metadata": {}, - "source": [ - "### Download Data from storage system" - ] - }, - { - "cell_type": "markdown", - "id": "973b2cfa", - "metadata": {}, - "source": [ - "Please Note that you need [aws cli](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html#getting-started-install-instructions) installed and set up with credentials to download and uplaod the data. If you do not need the sync from S3, you can simply comment with `#` the next line.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "abe307ea", - "metadata": {}, - "outputs": [], - "source": [ - "! make sync_data_down " - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "125e07c4", - "metadata": {}, - "outputs": [], - "source": [ - "from rissk.config import PROJ_ROOT, DATA_DIR\n", - "from ploomber.executors import Serial\n", - "from ploomber.spec import DAGSpec\n", - "import shutil\n", - "from pathlib import Path\n", - "from loguru import logger" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "63193e35-6fc4-4a6a-b823-d0f277bb7143", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "spec = DAGSpec(PROJ_ROOT / 'pipeline.yaml')\n", - "dag = spec.to_dag()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "e4e5e4a7", - "metadata": {}, - "outputs": [], - "source": [ - "dag.executor = Serial(build_in_subprocess=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5d49aa3c", - "metadata": {}, - "outputs": [], - "source": [ - "build = dag.build(force=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b6d330f3", - "metadata": {}, - "outputs": [], - "source": [ - "dag.plot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "841e608c-e493-4b28-87c9-2dee5dcc7cc7", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "build" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5ca16421", - "metadata": {}, - "outputs": [], - "source": [ - "# Upload new data to S3\n", - "! make sync_data_up " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "59236ca8", - "metadata": {}, - "outputs": [], - "source": [ - "# Clean Local file to avoid disk overusage\n", - "if DATA_DIR.is_dir():\n", - " # Iterate over the directory's contents\n", - " for item in DATA_DIR.iterdir():\n", - " if item.is_dir():\n", - " shutil.rmtree(DATA_DIR / item) # Remove directory and all its contents\n", - " logger.info(f\"Successfully cleared local directory: {item}\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "rissk", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.21" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 404bbd8..0000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -ignore = E731,E266,E501,C901,W503 -max-line-length = 99 -exclude = .git,notebooks,references,models,data -[ploomber] -entry-point = pipeline.yaml \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 40cafa1..0000000 --- a/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -from setuptools import find_packages, setup - -# Read requirements from requirements.txt file -with open('requirements.txt') as f: - requirements = f.read().splitlines() - -setup( - name='rissk', - version='0.1.2', - description='Automatically identify at-risk interviews from your Survey Solutions export files.', - author='rowsquared', - long_description=open('README.md').read(), - long_description_content_type='text/markdown', - license='MIT', - packages=find_packages(), - #install_requires=requirements, - setup_requires=['pytest-runner'], - tests_require=['pytest'], - test_suite='tests', -) \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bd601df --- /dev/null +++ b/uv.lock @@ -0,0 +1,3221 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[manifest] +members = [ + "rissk", + "rissk-pipeline", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "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, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "arro3-core" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/e7/d84370ea85be641a8c57f4f8296e8465d30e46938cc9480d384a3ee0084c/arro3_core-0.8.0.tar.gz", hash = "sha256:b75d8281b87a87d3b66836bab89951ae06421970e5f880717723a93e38743f40", size = 93557, upload-time = "2026-02-23T15:12:20.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/61/a6a33a24bc4eccfbf168d7765d96488193789b48d8a916d8d42aae3a8e75/arro3_core-0.8.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:051b1c46b424c207b7ee2f5ae50f8f88cb79d167c3e4000adf59a0e3e3994331", size = 2901125, upload-time = "2026-02-23T15:10:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/cfe8b327ea30d8183e9b9eaca9668a8e6ce7c6e187701dc83a0820ddc0fb/arro3_core-0.8.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:c6b0e0b8914e634096fb377046bfcd21420b50141394e8cc1b12d43a98df1a43", size = 2632882, upload-time = "2026-02-23T15:10:04.335Z" }, + { url = "https://files.pythonhosted.org/packages/c0/99/71d9e31022d68c8cf104ed9c744291657c6a5fe94348869edfdaf1e8dab2/arro3_core-0.8.0-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e4c20b6a55016ecd3f37f7dadf4d13d5a03dd51b7385e8f4130931447d110700", size = 3108341, upload-time = "2026-02-23T14:48:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/c067cc12b306b8a0dbec1e24a9c9e32dc5b5f3f9179466873d5c5666f124/arro3_core-0.8.0-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90dbbde6294d7349b2713e308cd3ef284de75003e8b5ad927f1716e7062525ce", size = 3216570, upload-time = "2026-02-23T14:49:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/f253dd3281e2d980c81e1526f9386b24c6a55e9bd152dd259032f94aceee/arro3_core-0.8.0-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee6693d496ab733fce43b2e83f9f7b5147db6906b3fbeba3b2d4108ffae5fbec", size = 3422198, upload-time = "2026-02-23T14:50:50.472Z" }, + { url = "https://files.pythonhosted.org/packages/2e/66/70786ee1cfdd03d36d456c4ef02a35506b7ae256c70a74bd7abf135daba0/arro3_core-0.8.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d882481e2f739fe449ca9bf724f4b33185fc48ba87dd82a26a64e6a23f5ed2f8", size = 2996395, upload-time = "2026-02-23T14:51:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b6/adf08e655df3ea07c460f3e441736face4de29277fdd753d5ba1fd89a43e/arro3_core-0.8.0-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:d56d08a3e08864512d343a4d75e468beba743abc3a9d139e14bf3e81d0d8d79b", size = 2777566, upload-time = "2026-02-23T14:47:46.817Z" }, + { url = "https://files.pythonhosted.org/packages/07/9b/3d0b811a143372398b4c31eb58a9011774f20d184a1ba3d6dff99023205d/arro3_core-0.8.0-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02c47e2d87f063e04c12c074f4cc66efd65fc9c6b14db7f80934827ec46c589d", size = 3203472, upload-time = "2026-02-23T14:51:16.938Z" }, + { url = "https://files.pythonhosted.org/packages/77/88/987517aa8902f93e6395bafa1ade91fadae3aef49474199de5e1f75e42c7/arro3_core-0.8.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:72fa13702df4698884900e60824fecda434f61ffecb5ff0d914bf9f0afa79fe9", size = 2950379, upload-time = "2026-02-23T15:10:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3a/e059061b6ace4090b8ec4f9170811a3fdcca3181ff126c6714c382b144ed/arro3_core-0.8.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:8ab0bc6ad9b449b8a939e13ce94f6cacfea1d21953d437a8aa2ff8b4622512e0", size = 3386585, upload-time = "2026-02-23T15:10:18.51Z" }, + { url = "https://files.pythonhosted.org/packages/f8/80/7161d0d0326597775784db854e58b88d748127df7e072a099ec36c1fb355/arro3_core-0.8.0-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:975a3e3dea90789608d40c54b4176b9b72c9664a4cd2c842914ac62c489b1f06", size = 3313967, upload-time = "2026-02-23T15:10:20.993Z" }, + { url = "https://files.pythonhosted.org/packages/3b/62/13fbb9fdfae011513f944e45804e528a041c0e35efab9363ccdd716cde65/arro3_core-0.8.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c3658fda04e0816333c8dda702c777d305b581876cd4176b15877726231b350", size = 3215978, upload-time = "2026-02-23T15:10:24.593Z" }, + { url = "https://files.pythonhosted.org/packages/bf/81/c0983e56969d8039116ffcf1bb3eafc17f8f34b2b63229970562bba6b52c/arro3_core-0.8.0-cp311-abi3-win_amd64.whl", hash = "sha256:a988c6cb74f97df4d276d5496f8667b6d5d95311d453ef32b28fb933b5ae96c4", size = 3176374, upload-time = "2026-02-23T15:10:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b6/08f088efd3737bcdaed98057b51c9d20d622e62e5b7dd626c6d60e67bd93/arro3_core-0.8.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:3cfa6b5c3981711a602c357afae1f16a6daa380cac8365100365560852e51d4a", size = 2890907, upload-time = "2026-02-23T15:10:32.408Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/2f1e20b879587a0419699a50e60aed9d2802423f8e5df844f31fa81f64d6/arro3_core-0.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4438167e4c357bafe66e8716adf5a55d73d79cf31bd4f7db465491605ee4afbc", size = 2625446, upload-time = "2026-02-23T15:10:36.324Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/92dbdf38de67435f04b5e2d013460e5a12ccac8edabd6a47a159c2f8acf7/arro3_core-0.8.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ddc9a49b04ff179e1f6281164ee88008e73a0a72a931449c24ad0f8897be220", size = 3108513, upload-time = "2026-02-23T14:48:32.841Z" }, + { url = "https://files.pythonhosted.org/packages/16/a8/b8e7c8b64f0df4fd9c0f0e2faa2753658664d2dec9109d4e2ae2d470fb14/arro3_core-0.8.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85dfb4df87cd7e9adc17798e4468d5ea4f3e5dbd7845abebe1c85bba2a092ba3", size = 3211045, upload-time = "2026-02-23T14:49:14.962Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e8/657194c4cfc8516984ec560cd326c1b6ab8e83becc6bdb761508019704b1/arro3_core-0.8.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d4abad932811cadc1ae3e4976c4bb797e025c2451ae551edc60cf34a807edcf", size = 3424840, upload-time = "2026-02-23T14:50:52.742Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/0ceb8490347f3317cee4a902d3999a1d729cf9a074310d89a046fd93fb18/arro3_core-0.8.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c8a80c8ece04cb45328eba5667dacdef553dbe379443920f98b25d8ce3db761", size = 2994109, upload-time = "2026-02-23T14:51:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/a5/82/1ef508fd796d341898a55f9c86f48ffa5d74a658159faad096d03929b419/arro3_core-0.8.0-cp313-cp313t-manylinux_2_24_aarch64.whl", hash = "sha256:12fc8c7133102c77661051a5e55c331a84dc58a3a8fe58fd18c38fcb61fa80d8", size = 2775585, upload-time = "2026-02-23T14:47:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ac/7e23539e5ba39a6534eb374a3a0e0178d25e8278cdf3d531bca89bd2bd82/arro3_core-0.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:278f2d35b4144ef7c77a037fd68dccacd651eda462cf2e739a85043109749cd3", size = 3204688, upload-time = "2026-02-23T14:51:18.986Z" }, + { url = "https://files.pythonhosted.org/packages/f0/cc/e2788c16f383a82d75a273bfe6a741e647d5ba4615c884c462e0e8a7d53e/arro3_core-0.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b7173b44e8809eb772a8bdb51dd866edb32682aac0c80055ea8e3c79077ad8c5", size = 2950218, upload-time = "2026-02-23T15:10:48.828Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ba5ad9dcd69f8465011eef8558b7536eeb90384fa6f054874e2252d5a707/arro3_core-0.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:bc69ca8cbd02a2a0d63d8278182380ba79d62c798ada8768fd700e8e5168b4c1", size = 3386355, upload-time = "2026-02-23T15:10:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/58/59/5369b3575af4093633f894206d94f3102a19b6e7f07c17f1c8035c78542e/arro3_core-0.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bc02ce82e8681d87c1d9fa27c0bc8322c982d93ba12a317dce33756cee79f285", size = 3312564, upload-time = "2026-02-23T15:10:54.502Z" }, + { url = "https://files.pythonhosted.org/packages/08/d3/d3da1020627d6d9408979e4dd7f466a66cc08e41a1f2b778d8cdaf7725df/arro3_core-0.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e66450987724a1f71bdfa1f721486af09bd07cb86227f575805e6f94f764b4f", size = 3213371, upload-time = "2026-02-23T15:10:56.666Z" }, + { url = "https://files.pythonhosted.org/packages/c9/47/dddb6852b57403a306a477d64befb2c0d0536baba8700581d785f0fef6e7/arro3_core-0.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:03fc7a1348a9d42f48061d45825e823985ee10c80aa509bafc0e84b10e7ecbb4", size = 3164236, upload-time = "2026-02-23T15:11:00.222Z" }, + { url = "https://files.pythonhosted.org/packages/68/3f/c15e183e63504c86e81d28c3672a9c3d01f48b7f9691a78c0e47cab831d3/arro3_core-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:be7dd0088bbab7b528d8d754b0fa05506e26da62f4a5d2f741fe94d7548e724e", size = 2890665, upload-time = "2026-02-23T15:11:04.753Z" }, + { url = "https://files.pythonhosted.org/packages/a1/45/b808cd7b1ba7afe6de4223414ca8191c030266d437ee69cce269b76e8a23/arro3_core-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:396496e96e4b86ac73aef32263c607c2161b878f334cf6ef954aaa74c8f1267f", size = 2625876, upload-time = "2026-02-23T15:11:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/a1/63/cbb9f41624b6301dac4540e6fd5b6d18e6fe16c47bda0534330e6b22999e/arro3_core-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:828032a416906af1d84702301885098ab0bc2aa9f956f677b676161aeabeb06d", size = 3108175, upload-time = "2026-02-23T14:48:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/75/f3/b9cf731acb9a910091518da1234d51904a1d0b615f16a13fc883331c627d/arro3_core-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d56b263bbc747691d08b3902a5f0d77adfb180d0544f9c52d622b2b79cd21f", size = 3211409, upload-time = "2026-02-23T14:49:17.204Z" }, + { url = "https://files.pythonhosted.org/packages/24/f8/30992bf19380285a9bc1a0c52aae26802679911c3787e804952505e7c4e5/arro3_core-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f08c07be0ff8d893d756ba20381b4fcbdf50af3c2bcec677529664920c07cf5", size = 3425205, upload-time = "2026-02-23T14:50:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/04/51/44de5c60e3058947d8733cae3c916e33f96b875b05ac795188def5542680/arro3_core-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34b280c70fe6bd6ca4c236f552d09b51ac551dc1c24793c9142ce89087346371", size = 2994668, upload-time = "2026-02-23T14:51:07.771Z" }, + { url = "https://files.pythonhosted.org/packages/1e/79/447e62f939183216361c6bfc8e3445e21835c2ae1a31e4ab817eb5d7cdc4/arro3_core-0.8.0-cp314-cp314t-manylinux_2_24_aarch64.whl", hash = "sha256:37202b826dd9695fc775064806bc07897c04caacef9403ea9d6706635f95ebdd", size = 2775761, upload-time = "2026-02-23T14:47:50.944Z" }, + { url = "https://files.pythonhosted.org/packages/58/d7/aa6572d46908e2986968887cec55d6c771ceea6a0ab14c7d219365a4ee09/arro3_core-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b70530b95d36e1409023f7bde3e9aeb75e3048852beb44263d98685c9f0d8f37", size = 3204821, upload-time = "2026-02-23T14:51:21.002Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/3c14108c13872b4143ffec3cddde56921caab04e45bf3a473769e8ff5b59/arro3_core-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:faf03d46e0a1817bf3959c21f2ca4d2bd2d61277b5319439df3044082e10effa", size = 2950512, upload-time = "2026-02-23T15:11:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/b4e1b9f90543eb560683f05520abced6ca9b236f12b147490da538d6028f/arro3_core-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a120ee05477c7e28565ce0b7572413a093745bb314195c4206c0ef578abea1b", size = 3386434, upload-time = "2026-02-23T15:11:23.584Z" }, + { url = "https://files.pythonhosted.org/packages/f1/55/4c7fc0e9f4e816c49ba3b520d87478b4900db3ae3e5186d0d333300918cc/arro3_core-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a19842cfa196f07c7fd7398d08eec5bdeed331b522dcbbf9d53830180f8d6d66", size = 3312814, upload-time = "2026-02-23T15:11:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/e7/fc/a4209e468b87bec36ee41afe9a01848f6ac2855055fcefad57da04c8896a/arro3_core-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6ceab802cc609498e47dc214967d282af8c3104c7a83aff008739192cf821e8", size = 3213623, upload-time = "2026-02-23T15:11:29.263Z" }, + { url = "https://files.pythonhosted.org/packages/c6/84/61882d6491f38d9362d9382a914a47fd3992c57ee76b35646ea01d65b0bb/arro3_core-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:355e22a8845cbc6379e705f71a08c9cdaab6a7facc63a863e43ee5dc56ed7976", size = 3163287, upload-time = "2026-02-23T15:11:31.69Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "binaryornot" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/4755b85101f37707c71526a301c1203e413c715a0016ecb592de3d2dcfff/binaryornot-0.6.0.tar.gz", hash = "sha256:cc8d57cfa71d74ff8c28a7726734d53a851d02fad9e3a5581fb807f989f702f0", size = 478718, upload-time = "2026-03-08T16:26:28.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/0c/31cfaa6b56fe23488ecb993bc9fc526c0d84d89607decdf2a10776426c2e/binaryornot-0.6.0-py3-none-any.whl", hash = "sha256:900adfd5e1b821255ba7e63139b0396b14c88b9286e74e03b6f51e0200331337", size = 14185, upload-time = "2026-03-08T16:26:27.466Z" }, +] + +[[package]] +name = "blosc2" +version = "4.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "ndindex" }, + { name = "numexpr", marker = "platform_machine != 'wasm32'" }, + { name = "numpy" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/fa/d72f624903dad1f2e95cb97d4e3777284f7eb398792f0d3380fdd73c1fc4/blosc2-4.1.2.tar.gz", hash = "sha256:c127342d976de44fee242137e83660097e0b072779f4164a34e149ac9f693c8a", size = 4341120, upload-time = "2026-03-03T11:05:14.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/b2/3d0a6711f9376ed2e84e420c3c74656e51803420ed2d0df997b027b6fd2d/blosc2-4.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:572fda198a250ee5e2c6b52d0067805ffa0d46d7e22213fcc23917164c33b8e5", size = 4686973, upload-time = "2026-03-03T11:04:51.321Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5d/caa4c7eeac59664dcce968c69823e2416bf4f184af0b89507f52c085a98e/blosc2-4.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:192f3508522ce8867cd9aee70782450eeb89eb2de882f16d563320362ddf145a", size = 4116819, upload-time = "2026-03-03T11:04:52.66Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/e038eec32caaf498f8d95e276c9a294895bf18419ba2504cee77bfec0008/blosc2-4.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45075f00eb92e8d1abed1ea89038c9827ebd846d47e53c5c9988e22f7044f01f", size = 5071700, upload-time = "2026-03-03T11:04:53.856Z" }, + { url = "https://files.pythonhosted.org/packages/59/74/394d53ac3b3583163f7cc5b43d59d457e6398d8f1b51b85bc9f7bd7cf430/blosc2-4.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f453b76764753c7c0ba3ce13ffcf0cefa191b0668adb28979f88cb9093ad7ae", size = 5208120, upload-time = "2026-03-03T11:04:55.413Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e2/d5b09cec0383381026c41fd071ae6a9342dfd70d0584aeae672e77dda82f/blosc2-4.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a72cc1fdc74744723092ccb63d03cf49c64f911450d2c9296182ce7bcda45d04", size = 3147727, upload-time = "2026-03-03T11:04:57.506Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/20bc86e3eef536cf077be84c2b52583620ac877852962cf2d6c0281052ed/blosc2-4.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d8b7c45d537bfeb4b4c6d93c042ae4c07fe5aa6ce47d1acccb028802b2091d7", size = 4689092, upload-time = "2026-03-03T11:04:59.094Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/c0e9a30bdd151294203c933a2d612559548bdbd21e3ebfc4671982117f3d/blosc2-4.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9303b3e4a503a15cb4c42eb9c194a75a41603b879d89945967d72b5606857395", size = 4119002, upload-time = "2026-03-03T11:05:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/59a2b35ae875198528b2bd89015fc4f143e40f859749735395877d7fdf96/blosc2-4.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0dcd142b6ec74b69f9ccfc006a98ea3e91617b245c0455f894a41a03cd88bd73", size = 5076726, upload-time = "2026-03-03T11:05:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/c8c1e711d65e45c7109cd1ea90dd98d30dd2bc5d1c8d670fa91a5c563137/blosc2-4.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05551c7111e96095b88f7070ec36dacb892a7f8c52c7550c019c93f892c511a9", size = 5209021, upload-time = "2026-03-03T11:05:03.813Z" }, + { url = "https://files.pythonhosted.org/packages/a9/85/4457050893f21c0b3237ce2c279a63f7e6cbf9b86126a42f17f5b83cafe6/blosc2-4.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:68d04c8ea0ed1798baf0921b34434b564197c8a11569f5c64d9bea195329987c", size = 3220427, upload-time = "2026-03-03T11:05:05.689Z" }, + { url = "https://files.pythonhosted.org/packages/85/1c/18c47a98ba38a618f0cd3a1872d71b3db8553ce5466e7b5fd74b03dbe377/blosc2-4.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:52f69fd854cf2d9ce83cb0f6f214c6c9fb7f9149c24bd9af929482cbe95d3ff1", size = 4705783, upload-time = "2026-03-03T11:05:07.2Z" }, + { url = "https://files.pythonhosted.org/packages/8a/97/72ddd8146f8bd77026c1c28813e113c6b8a40b4f9bd4fe064f3618cebcd8/blosc2-4.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cdfb208850c082e629dbed2aa8ff0328b64bfca691fcfdd89141af20f5fcc908", size = 4141025, upload-time = "2026-03-03T11:05:08.781Z" }, + { url = "https://files.pythonhosted.org/packages/cc/43/537635bf12f258db17a1a80e56c39bfefce218e1baab5459c05a4ff9739f/blosc2-4.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:df3e78642af359f3bdc46f4446f0517f2deca2b3d4c9c92caf49d4abf6ce2a9c", size = 5061103, upload-time = "2026-03-03T11:05:10.475Z" }, + { url = "https://files.pythonhosted.org/packages/36/e3/ad7dff6eaf0e36a0959865ebd5a16026929f5a919cf0158858c307d6971d/blosc2-4.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:482e2f1447d47241af1952a563573cf12f67fcb86a2d87227dc28e427b29f865", size = 5195395, upload-time = "2026-03-03T11:05:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9e/b028eed46dfa45def2ca9c3e66aa3b8a3188a8a4998d017c699caf2bf0d9/blosc2-4.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9ee2217b03ecca4e823ff22701f423b7630f2b0a44773e0486ddbaa953ed39e9", size = 3243706, upload-time = "2026-03-03T11:05:13.294Z" }, +] + +[[package]] +name = "build" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1d/ab15c8ac57f4ee8778d7633bc6685f808ab414437b8644f555389cdc875e/build-1.4.2.tar.gz", hash = "sha256:35b14e1ee329c186d3f08466003521ed7685ec15ecffc07e68d706090bf161d1", size = 83433, upload-time = "2026-03-25T14:20:27.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/57/3b7d4dd193ade4641c865bc2b93aeeb71162e81fc348b8dad020215601ed/build-1.4.2-py3-none-any.whl", hash = "sha256:7a4d8651ea877cb2a89458b1b198f2e69f536c95e89129dbf5d448045d60db88", size = 24643, upload-time = "2026-03-25T14:20:26.568Z" }, +] + +[[package]] +name = "cachetools" +version = "7.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cookiecutter" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/03/f4c96d8fd4f5e8af0210bf896eb63927f35d3014a8e8f3bf9d2c43ad3332/cookiecutter-2.7.1.tar.gz", hash = "sha256:ca7bb7bc8c6ff441fbf53921b5537668000e38d56e28d763a1b73975c66c6138", size = 142854, upload-time = "2026-03-04T04:06:02.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a9/8c855c14b401dc67d20739345295af5afce5e930a69600ab20f6cfa50b5c/cookiecutter-2.7.1-py3-none-any.whl", hash = "sha256:cee50defc1eaa7ad0071ee9b9893b746c1b3201b66bf4d3686d0f127c8ed6cf9", size = 41317, upload-time = "2026-03-04T04:06:01.221Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "db-dtypes" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/75/7cbd2af9f3bed29c74df4c6512243b94e0dc17ca03bf82a33e45ee75501b/db_dtypes-1.4.4.tar.gz", hash = "sha256:26f53db5df1acd746b88c5647913a1b20f731c0af1b11abcb6bec5365f31098a", size = 34471, upload-time = "2025-11-11T17:21:59.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/46/398af286861992d98f5ca7e1b4662b5d6f1d29978ddc0849c52fb130e8e9/db_dtypes-1.4.4-py3-none-any.whl", hash = "sha256:32c13039982656a8598a0835f25f0e07e34c9a423e471ee60c2553240b7fcf1e", size = 18255, upload-time = "2025-11-11T17:21:57.93Z" }, +] + +[[package]] +name = "deltalake" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arro3-core" }, + { name = "deprecated" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/bf/906ff8f875847bb2d2cf9f612d4de6e775ace366c04ad6356b6666504e6a/deltalake-1.5.0.tar.gz", hash = "sha256:cdea832ebcadd9f6ccedfcf023f244f2830152fd82b2f78b42e701989dd73b2d", size = 5326885, upload-time = "2026-03-12T14:59:22.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/75/ae5593e1836ea81ab14ab9a58e81e25f351597cb6a66d9e84e9d40a99d21/deltalake-1.5.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b13c693989f50b3ec6e6a7ebeb3ca4ef7cb3f340b8fe8e1a0e0767319c5f0bf5", size = 37946411, upload-time = "2026-03-12T15:06:43.069Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b6/2c983a79593b5fdda60fc49b4f15be360b102212561bcf7a6bf05e12ed61/deltalake-1.5.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:db388bd519c327953e6ccd688f0cf132c9186362b54d0323d0d5ffeb00cfcde1", size = 34817619, upload-time = "2026-03-12T15:25:22.443Z" }, + { url = "https://files.pythonhosted.org/packages/14/6a/e0d363f25e422a185d3b771da4b7eecb230a77c37260f13ddc0c31dafef1/deltalake-1.5.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2fe5d6fe4eb20781ae593659f77a382079503c06f3525691c8fee2815de2322", size = 38744214, upload-time = "2026-03-12T14:59:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/4c/fc68c0c053f3acc53264e84e1447f70d4a06a7489df78161a0d0fc786c47/deltalake-1.5.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7baa94c7f8234c0840627e8f2f5e3f88a02ff011a2991b8e034c187ffafcb3a0", size = 37338903, upload-time = "2026-03-12T14:47:41.005Z" }, + { url = "https://files.pythonhosted.org/packages/a9/20/82929cf32aab56ad8f8350279b4c42cd14e8d0db97826d5bea1d246b9262/deltalake-1.5.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfc7b124dc22e885c0af413c9a3f1c4a5fd52ec78bce6fd957a78a90c7943e1b", size = 38742962, upload-time = "2026-03-12T14:58:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/6dd4fb8d0fee8e2533a80afd8b9c57dc442138152e57a41c7b8f986b8a64/deltalake-1.5.0-cp310-abi3-win_amd64.whl", hash = "sha256:2ad8f11a64c0477be57d310aa9b470a7c3c3ba2a4e4e86ad92c7ca3554c539f2", size = 41044010, upload-time = "2026-03-12T15:25:13.975Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "dynaconf" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/0e/05927cf459e73f8bf9a9277cbea6f2d5b7db8a5cc9dc1e20e7a5fbac1b90/dynaconf-3.2.13.tar.gz", hash = "sha256:d79e0189d97b3f226b8ebb1717e2ce05d1a05cdf6ea05de66d24625fdb5a0cbd", size = 283507, upload-time = "2026-03-17T19:38:47.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/43/11d6e5d2c00bf000b5329717c74563bf76a9193f4a41cb0c4ef277dde4fa/dynaconf-3.2.13-py2.py3-none-any.whl", hash = "sha256:4305527aef4834bdba3e39479b23c005186e83fb85f65bcaa4bcea58fa26759b", size = 238041, upload-time = "2026-03-17T19:38:45.337Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.30.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/2e/83ca41eb400eb228f9279ec14ed66f6475218b59af4c6daec2d5a509fe83/google_api_core-2.30.2.tar.gz", hash = "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", size = 176862, upload-time = "2026-04-02T21:23:44.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/e1/ebd5100cbb202e561c0c8b59e485ef3bd63fa9beb610f3fdcaea443f0288/google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594", size = 173236, upload-time = "2026-04-02T21:23:06.395Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, +] + +[[package]] +name = "google-cloud-bigquery" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-resumable-media" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "kedro" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "build" }, + { name = "cachetools" }, + { name = "click" }, + { name = "cookiecutter" }, + { name = "dynaconf" }, + { name = "fsspec" }, + { name = "gitpython" }, + { name = "kedro-telemetry" }, + { name = "more-itertools" }, + { name = "omegaconf" }, + { name = "parse" }, + { name = "pluggy" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli-w" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/70/41c1b8a31a06d0b28a7d7991594b19a797f0e639f79a98a5c602bb84fe65/kedro-1.2.0.tar.gz", hash = "sha256:822cbc132bd654a33ce9b467ba76160f71df80e546dbc0e3b6821e476aab4b28", size = 164230, upload-time = "2026-01-29T14:25:59.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/46/69266d69ee410d7e088772425a8cfbc28f0d7a7ca9fe9ae6cf9a5fe3b4d2/kedro-1.2.0-py3-none-any.whl", hash = "sha256:2dcc59ec430af8ecee8e085aa33a3c6064ea4afd782fa53152bb8450f5024306", size = 191784, upload-time = "2026-01-29T14:25:58.301Z" }, +] + +[[package]] +name = "kedro-datasets" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "kedro" }, + { name = "lazy-loader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/23/d252c0c4b84f320b3c052bd8b6040b7dc1404f7f99869eb996d8fee76d14/kedro_datasets-9.3.0.tar.gz", hash = "sha256:afb07b567736e3bd4008cd44ca8fa6a052e6f51686cb0dc9e592f5c7ac9907f3", size = 202987, upload-time = "2026-04-02T15:55:40.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/00/10948ec45d55303670af611575370465153eec2d7a5d95d344673680b03c/kedro_datasets-9.3.0-py3-none-any.whl", hash = "sha256:6638feb4c6932c513d18f1a6537975f73832687ac148e21e0a0390a99176d270", size = 322222, upload-time = "2026-04-02T15:55:38.426Z" }, +] + +[package.optional-dependencies] +pandas = [ + { name = "deltalake" }, + { name = "lxml" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "pandas-gbq" }, + { name = "pyarrow" }, + { name = "pyodbc" }, + { name = "sqlalchemy" }, + { name = "tables" }, +] + +[[package]] +name = "kedro-telemetry" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "kedro" }, + { name = "requests" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/07/d695108b36945123a2dedf725bec3e828809669d803b2c52db43d76dd703/kedro_telemetry-0.7.0.tar.gz", hash = "sha256:76a82fe2716f3532ce97441f5a7781d3ad8894fb2096da65a9f1c6f23d4cb43b", size = 16739, upload-time = "2025-12-16T15:43:38.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/a8/8f3bd33750a5b06f7d9fca8590303caf6e15da6ce9ca6b368b9e673bea24/kedro_telemetry-0.7.0-py3-none-any.whl", hash = "sha256:fbec6981781dbc6bedda74a1287865c6fab699d19c0a0f5940434c1613a97066", size = 10162, upload-time = "2025-12-16T15:43:37.161Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "lxml" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/5c62acfacd69ff4f5db395100f5cfb9b54e7ac8c69a235e4e939fd13f021/lxml_html_clean-0.4.4.tar.gz", hash = "sha256:58f39a9d632711202ed1d6d0b9b47a904e306c85de5761543b90e3e3f736acfb", size = 23899, upload-time = "2026-02-27T09:35:52.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markdown2" +version = "2.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/24/e0acc4bf54cba50c1d432c70a72a3df96db4a321b2c4c68432a60759044f/more_itertools-11.0.1.tar.gz", hash = "sha256:fefaf25b7ab08f0b45fa9f1892cae93b9fc0089ef034d39213bce15f1cc9e199", size = 144739, upload-time = "2026-04-02T16:17:45.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl", hash = "sha256:eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d", size = 72182, upload-time = "2026-04-02T16:17:43.724Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "ndindex" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/92/4b9d2f4e0f3eabcfc7b02b48261f6e5ad36a3e2c1bbdcc4e3b7b6c768fa6/ndindex-1.10.1.tar.gz", hash = "sha256:0f6113c1f031248f8818cbee1aa92aa3c9472b7701debcce9fddebcd2f610f11", size = 271395, upload-time = "2025-11-19T20:40:08.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/ea/03676266cb38cc671679a9d258cc59bfc58c69726db87b0d6eeafb308895/ndindex-1.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:157b5c34a1b779f5d27b790d9bd7e7b156d284e76be83c591a3ba003984f4956", size = 176323, upload-time = "2025-11-19T20:38:53.528Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/2d350439031b108b0bb8897cad315390c5ad88c14d87419a54c2ffa95c80/ndindex-1.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f99b3e89220da3244d03c9c5473669c7107d361c129fd9b064622744dee1ce15", size = 175584, upload-time = "2025-11-19T20:38:57.968Z" }, + { url = "https://files.pythonhosted.org/packages/77/34/a51b7c6f7159718a6a0a694fc1058b94d793c416d9a4fd649f1924cce5f8/ndindex-1.10.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6928e47fb008903f2e41309b7ff1e59b16abbcd59e2e945454571c28b2433c9e", size = 524127, upload-time = "2025-11-19T20:38:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/d8f19f0b8fc9c5585b50fda44c05415da0bdc5fa9c9c69011015dac27880/ndindex-1.10.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69a2cb1ac7be955c3c77f1def83f410775a81525c9ce2d4c0a3f2a61589ed47", size = 528213, upload-time = "2025-11-19T20:39:00.882Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/77d9d037e871a3faa8579b354ca2dd09cc5bbf3e085d9e3c67f786d55ee3/ndindex-1.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cb76e0f3f235d8b1c768b17e771de48775d281713795c3aa045e8114ad61bdda", size = 1492172, upload-time = "2025-11-19T20:39:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/ac/29/ad13676fc9312e0aa1a80a7c04bcb0b502b877ed4956136117ad663eced0/ndindex-1.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7da34a78410c14341d5fff73be5ce924bd36500bf7f640fc59b8607d3a0df95e", size = 1552614, upload-time = "2025-11-19T20:39:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/e6e6fd81423810c07ae623c4d36e099f42a812994977e8e3bfa182c02472/ndindex-1.10.1-cp313-cp313-win32.whl", hash = "sha256:9599fcb7411ffe601c367f0a5d4bc0ed588e3e7d9dc7604bdb32c8f669456b9e", size = 149330, upload-time = "2025-11-19T20:39:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d3/830a20626e2ec0e31a926be90e67068a029930f99e6cfebf2f9768e7b7b1/ndindex-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:ef3ef22390a892d16286505083ee5b326317b21c255a0c7f744b1290a0b964a6", size = 157309, upload-time = "2025-11-19T20:39:07.394Z" }, + { url = "https://files.pythonhosted.org/packages/4a/73/3bdeecd1f6ec0ad81478a53d96da4ba9be74ed297c95f2b4fbe2b80843e1/ndindex-1.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:72af787dcee3661f36fff9d144d989aacefe32e2c8b51ceef9babd46afb93a18", size = 181022, upload-time = "2025-11-19T20:39:10.487Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b1/0d97ba134b5aa71b5ed638fac193a7ec4d987e091e2f4e4162ebdaacbda1/ndindex-1.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa60637dfae1ee3fc057e420a52cc4ace38cf2c0d1a0451af2a3cba84d281842", size = 181289, upload-time = "2025-11-19T20:39:11.793Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d7/1df02df24880ce3f3c8137b6f3ca5a901a58d9079dcfd8c818419277ff87/ndindex-1.10.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ebdba2fade3f6916fe21fd49e2a0935af4f58c56100a60f3f2eb26e20baee7", size = 632517, upload-time = "2025-11-19T20:39:13.259Z" }, + { url = "https://files.pythonhosted.org/packages/34/96/b509c2b14e9b10710fe6ab6ba8bda1ee6ce36ab16397ff2f5bbb33bbbba3/ndindex-1.10.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:346a4bf09f5771548665c8206e81daadb6b9925d409746e709894bdd98adc701", size = 616179, upload-time = "2025-11-19T20:39:14.757Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/f89d60cf351c33a484bf1a4546a5dee6f4e7a6a973613ffa12bd316b14ad/ndindex-1.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:23d35696f802548143b5cc199bf2f171efb0061aa7934959251dd3bae56d038c", size = 1588373, upload-time = "2025-11-19T20:39:16.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/002fc1e6a4abeef8d92e9aa2e43aea4d462f6b170090f7752ea8887f4897/ndindex-1.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a91e1a0398120233d5c3b23ccb2d4b78e970d66136f1a7221fa9a53873c3d5c5", size = 1636436, upload-time = "2025-11-19T20:39:18.266Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/28b1ad78c787ac8fafd6e26419a80366617784b1779e3857fa687492f6bc/ndindex-1.10.1-cp313-cp313t-win32.whl", hash = "sha256:78bfe25941d2dac406391ddd9baf0b0fce163807b98ecc2c47a3030ee8466319", size = 158780, upload-time = "2025-11-19T20:39:20.454Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/b81060607a19865bb8be8d705b1b3e8aefb8747c0fbd383e38b4cae4bd71/ndindex-1.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:08bfdc1f7a0b408d15b3ce61d141ebbebdb47a25341967e425e104c5bd512a5c", size = 167485, upload-time = "2025-11-19T20:39:21.733Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/aac1131e9f3a5635ba7b0312c3bfa610511ab4108f85c0d914a32887aa00/ndindex-1.10.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b5297f207ebc068c7cdf9e3cd7b95aa5c9ec04295d0a7e56b529f66787d4685", size = 176478, upload-time = "2025-11-19T20:39:23.747Z" }, + { url = "https://files.pythonhosted.org/packages/1a/05/a0d8ca0432c84550bc17af6d6479a803936895b8b8403a1216c5a55475fb/ndindex-1.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c5e9762452b163e33cfb6e821f86e45ba0b53bdfcd23ab5d57b48a8f566898cb", size = 175480, upload-time = "2025-11-19T20:39:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/09/4a/028ab78a9f29fd2a7e86a90337cde4658eaa77b425c63045d83a1d2e4f26/ndindex-1.10.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf80241b40adffdc3276b2c9fb63a96c6c98b4a9d941892738de8add65083962", size = 528125, upload-time = "2025-11-19T20:39:26.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/bd823b345fb06c83ade6ef1c1933521d4357cd04490e684d4fa30126926c/ndindex-1.10.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf5855881884b8467dfcf45764ccf2e4279075be14b155b89c96994bb08d2e6f", size = 527328, upload-time = "2025-11-19T20:39:28.292Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/40b9c15588cbf9dde43c4fb88a31dd1f636a913fa29649f18f8e3ebca36a/ndindex-1.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e81a9bd36fe054b6c9fcc53d26bc9a28cf15d1ab52a0f5b854f894116f3a54e1", size = 1497508, upload-time = "2025-11-19T20:39:30.735Z" }, + { url = "https://files.pythonhosted.org/packages/24/8f/b8048f7837d2e9dff0af507b398307fa84a2aa9ea3db71b4aa800b21da4a/ndindex-1.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:588e8875d836a93b3cd9af482c8074bb02288ae1aff92cf277e1f02d9ae0f992", size = 1552625, upload-time = "2025-11-19T20:39:32.404Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/0ecb53c7e690a44769f2f92a843723ccb1d0ce080d93ba1ea811304cca12/ndindex-1.10.1-cp314-cp314-win32.whl", hash = "sha256:28741daca5926adff402247cd406f453ed5bb6042e82d6855938f805190e5ce9", size = 151237, upload-time = "2025-11-19T20:39:34.847Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4e/197982fa8b4e6e6b9d15c38505c41076d1c552921f09f4d35acbbbbc0b70/ndindex-1.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:59a3222befc0f7cdc85fb9b90a567ae890f70a864bdeb660517e9ebcb36bf1bc", size = 158925, upload-time = "2025-11-19T20:39:37.149Z" }, + { url = "https://files.pythonhosted.org/packages/24/ad/116b6154046a69fc04e2d4490905801d3839a3f21290c0b4d49b1044e251/ndindex-1.10.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967b87b88dadb62555ec1039695c347254eccb8ca3d124c0e5dbe084c525fa93", size = 181724, upload-time = "2025-11-19T20:39:38.635Z" }, + { url = "https://files.pythonhosted.org/packages/c4/00/3ce4351366c890bcc87a5e9f1f90102547962eef356ac7c799bfdd0dddce/ndindex-1.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c67dde588c0fb89d872931a4ed5f9b4d21c1c70a3d92fdf0812a1de154239816", size = 181653, upload-time = "2025-11-19T20:39:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/a6fda696a2f02a3f8dd2ee9d816cb2edff6423bf0110a4876cc3b1259732/ndindex-1.10.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65ca639a7abf72d79f22424f4abd18dece1f289a2b7b028a0ca455edd2168d4", size = 630898, upload-time = "2025-11-19T20:39:41.495Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/eb2e5d067d4c054451e33eaece74cbdcb58236dc60516e73d783dae34c7e/ndindex-1.10.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c3634a8df43e7928122225a3d64d850c8957bd1edf2e403907deacb478af27b", size = 614419, upload-time = "2025-11-19T20:39:43.254Z" }, + { url = "https://files.pythonhosted.org/packages/78/51/261bfb49eb7920c2a7314cacba5821930a529911dce48c7c6cd786096a5a/ndindex-1.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9d581f931e61f182478f18bdf5edd3955899df5da4892ed0d5de547a4cfd5b6f", size = 1587517, upload-time = "2025-11-19T20:39:44.809Z" }, + { url = "https://files.pythonhosted.org/packages/ec/37/084a332ecdf8b0049151bd78001a7baf2daf7f500d043beb8a1f95d0f4e3/ndindex-1.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:78ce45106ebf67aeba99714818c721d8fd5fb9534daebd2565665a2d64b50fc9", size = 1635372, upload-time = "2025-11-19T20:39:47.231Z" }, + { url = "https://files.pythonhosted.org/packages/28/f4/716580fbb03018ab1daa86ed12c1925c67e79689db5fee82393e840758a2/ndindex-1.10.1-cp314-cp314t-win32.whl", hash = "sha256:fe5341e24dc992b09c258456ac90a09a6d25efdc2cb86dcc91d32c8891e1df9a", size = 162186, upload-time = "2025-11-19T20:39:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/4d/20/28f669c09a470e7f523b0cc10b94336664d9648594015e3f2a1ec29047b1/ndindex-1.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:37f87f0e7690ae0324334740e0661d6297f2e62c9bf925127d249fb7eddd0ad8", size = 171077, upload-time = "2025-11-19T20:39:50.108Z" }, +] + +[[package]] +name = "nicegui" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "docutils" }, + { name = "fastapi" }, + { name = "h11" }, + { name = "httpx" }, + { name = "ifaddr" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "lxml-html-clean" }, + { name = "markdown2" }, + { name = "orjson", marker = "platform_machine != 'i386' and platform_machine != 'i686' and platform_python_implementation != 'PyPy'" }, + { name = "pydantic-core" }, + { name = "pygments" }, + { name = "python-engineio" }, + { name = "python-multipart" }, + { name = "python-socketio", extra = ["asyncio-client"] }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/ed046018db555c34ebc17738284d2f85bf9a544734cd44a87311128619a5/nicegui-3.9.0.tar.gz", hash = "sha256:7ae9046b321d029c438f7cd54a697838ed1962cecb92c622912283c66c8bf8f6", size = 19031869, upload-time = "2026-03-19T09:51:52.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/11/f7f911f284ceb1b038c26d6f4833bc86d6583d5280156274fdb79be7dcfe/nicegui-3.9.0-py3-none-any.whl", hash = "sha256:4adfdb87a55e30b7fef05ab782efc030534ae6ad9afa330db856dfbb258e23c9", size = 19613351, upload-time = "2026-03-19T09:51:48.769Z" }, +] + +[[package]] +name = "numba" +version = "0.65.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/61/7299643b9c18d669e04be7c5bcb64d985070d07553274817b45b049e7bfe/numba-0.65.0.tar.gz", hash = "sha256:edad0d9f6682e93624c00125a471ae4df186175d71fd604c983c377cdc03e68b", size = 2764131, upload-time = "2026-04-01T03:52:01.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/f8/eee0f1ff456218db036bfc9023995ec1f85a9dc8f2422f1594f6a87829e0/numba-0.65.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c6334094563a456a695c812e6846288376ca02327cf246cdcc83e1bb27862367", size = 2680679, upload-time = "2026-04-01T03:51:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8f/3d116e4b8e92f6abace431afa4b2b944f4d65bdee83af886f5c4b263df95/numba-0.65.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8a9008411615c69d083d1dcf477f75a5aa727b30beb16e139799e2be945cdfd", size = 3809537, upload-time = "2026-04-01T03:51:41.42Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/6a3ca4128e253cb67affe06deb47688f51ce968f5111e2a06d010e6f1fa6/numba-0.65.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af96c0cba53664efcb361528b8c75e011a6556c859c7e08424c2715201c6cf7a", size = 3508615, upload-time = "2026-04-01T03:51:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/96/0e/267f9a36fb282c104a971d7eecb685b411c47dce2a740fe69cf5fc2945d9/numba-0.65.0-cp313-cp313-win_amd64.whl", hash = "sha256:6254e73b9c929dc736a1fbd3d6f5680789709a5067cae1fa7198707385129c04", size = 2749938, upload-time = "2026-04-01T03:51:45.218Z" }, + { url = "https://files.pythonhosted.org/packages/56/a4/90edb01e9176053578e343d7a7276bc28356741ee67059aed8ed2c1a4e59/numba-0.65.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:ee336b398a6fca51b1f626034de99f50cb1bd87d537a166275158a3cee744b82", size = 2680878, upload-time = "2026-04-01T03:51:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/24/8d/e12d6ff4b9119db3cbf7b2db1ce257576441bd3c76388c786dea74f20b02/numba-0.65.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05c0a9fdf75d85f57dee47b719e8d6415707b80aae45d75f63f9dc1b935c29f7", size = 3778456, upload-time = "2026-04-01T03:51:48.552Z" }, + { url = "https://files.pythonhosted.org/packages/17/89/abcd83e76f6a773276fe76244140671bcc5bf820f6e2ae1a15362ae4c8c9/numba-0.65.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:583680e0e8faf124d362df23b4b593f3221a8996341a63d1b664c122401bec2f", size = 3478464, upload-time = "2026-04-01T03:51:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/fbce55ce3d933afbc7ade04df826853e4a846aaa47d58d2fbb669b8f2d08/numba-0.65.0-cp314-cp314-win_amd64.whl", hash = "sha256:add297d3e1c08dd884f44100152612fa41e66a51d15fdf91307f9dde31d06830", size = 2752012, upload-time = "2026-04-01T03:51:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ab/af705f4257d9388fb2fd6d7416573e98b6ca9c786e8b58f02720978557bd/numba-0.65.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:194a243ba53a9157c8538cbb3166ec015d785a8c5d584d06cdd88bee902233c7", size = 2683961, upload-time = "2026-04-01T03:51:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e5/8267b0adb0c01b52b553df5062fbbb42c30ed5362d08b85cc913a36f838f/numba-0.65.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7fa502960f7a2f3f5cb025bc7bff888a3551277b92431bfdc5ba2f11a375749", size = 3816373, upload-time = "2026-04-01T03:51:56.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f5/b8397ca360971669a93706b9274592b6864e4367a37d498fbbcb62aa2d48/numba-0.65.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5046c63f783ca3eb6195f826a50797465e7c4ce811daa17c9bea47e310c9b964", size = 3532782, upload-time = "2026-04-01T03:51:58.387Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1e73fa16bf0393ebb74c5bb208d712152ffdfc84600a8e93a3180317856e/numba-0.65.0-cp314-cp314t-win_amd64.whl", hash = "sha256:46fd679ae4f68c7a5d5721efbd29ecee0b0f3013211591891d79b51bfdf73113", size = 2757611, upload-time = "2026-04-01T03:52:00.083Z" }, +] + +[[package]] +name = "numexpr" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/2f/fdba158c9dbe5caca9c3eca3eaffffb251f2fb8674bf8e2d0aed5f38d319/numexpr-2.14.1.tar.gz", hash = "sha256:4be00b1086c7b7a5c32e31558122b7b80243fe098579b170967da83f3152b48b", size = 119400, upload-time = "2025-10-13T16:17:27.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b4/9f6d637fd79df42be1be29ee7ba1f050fab63b7182cb922a0e08adc12320/numexpr-2.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09078ba73cffe94745abfbcc2d81ab8b4b4e9d7bfbbde6cac2ee5dbf38eee222", size = 162794, upload-time = "2025-10-13T16:16:38.291Z" }, + { url = "https://files.pythonhosted.org/packages/35/ae/d58558d8043de0c49f385ea2fa789e3cfe4d436c96be80200c5292f45f15/numexpr-2.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dce0b5a0447baa7b44bc218ec2d7dcd175b8eee6083605293349c0c1d9b82fb6", size = 152203, upload-time = "2025-10-13T16:16:39.907Z" }, + { url = "https://files.pythonhosted.org/packages/13/65/72b065f9c75baf8f474fd5d2b768350935989d4917db1c6c75b866d4067c/numexpr-2.14.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06855053de7a3a8425429bd996e8ae3c50b57637ad3e757e0fa0602a7874be30", size = 455860, upload-time = "2025-10-13T16:13:35.811Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f9/c9457652dfe28e2eb898372da2fe786c6db81af9540c0f853ee04a0699cc/numexpr-2.14.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f9366d23a2e991fd5a8b5e61a17558f028ba86158a4552f8f239b005cdf83c", size = 446574, upload-time = "2025-10-13T16:15:17.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/99/8d3879c4d67d3db5560cf2de65ce1778b80b75f6fa415eb5c3e7bd37ba27/numexpr-2.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c5f1b1605695778896534dfc6e130d54a65cd52be7ed2cd0cfee3981fd676bf5", size = 1417306, upload-time = "2025-10-13T16:13:42.813Z" }, + { url = "https://files.pythonhosted.org/packages/ea/05/6bddac9f18598ba94281e27a6943093f7d0976544b0cb5d92272c64719bd/numexpr-2.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4ba71db47ea99c659d88ee6233fa77b6dc83392f1d324e0c90ddf617ae3f421", size = 1466145, upload-time = "2025-10-13T16:15:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/24/5d/cbeb67aca0c5a76ead13df7e8bd8dd5e0d49145f90da697ba1d9f07005b0/numexpr-2.14.1-cp313-cp313-win32.whl", hash = "sha256:638dce8320f4a1483d5ca4fda69f60a70ed7e66be6e68bc23fb9f1a6b78a9e3b", size = 166996, upload-time = "2025-10-13T16:17:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/cc/23/9281bceaeb282cead95f0aa5f7f222ffc895670ea689cc1398355f6e3001/numexpr-2.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fdcd4735121658a313f878fd31136d1bfc6a5b913219e7274e9fca9f8dac3bb", size = 160189, upload-time = "2025-10-13T16:17:15.417Z" }, + { url = "https://files.pythonhosted.org/packages/f3/76/7aac965fd93a56803cbe502aee2adcad667253ae34b0badf6c5af7908b6c/numexpr-2.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:557887ad7f5d3c2a40fd7310e50597045a68e66b20a77b3f44d7bc7608523b4b", size = 163524, upload-time = "2025-10-13T16:16:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/58/65/79d592d5e63fbfab3b59a60c386853d9186a44a3fa3c87ba26bdc25b6195/numexpr-2.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:af111c8fe6fc55d15e4c7cab11920fc50740d913636d486545b080192cd0ad73", size = 152919, upload-time = "2025-10-13T16:16:44.229Z" }, + { url = "https://files.pythonhosted.org/packages/84/78/3c8335f713d4aeb99fa758d7c62f0be1482d4947ce5b508e2052bb7aeee9/numexpr-2.14.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33265294376e7e2ae4d264d75b798a915d2acf37b9dd2b9405e8b04f84d05cfc", size = 465972, upload-time = "2025-10-13T16:13:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/35/81/9ee5f69b811e8f18746c12d6f71848617684edd3161927f95eee7a305631/numexpr-2.14.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83647d846d3eeeb9a9255311236135286728b398d0d41d35dedb532dca807fe9", size = 456953, upload-time = "2025-10-13T16:15:31.186Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/9b8bc6e294d85cbb54a634e47b833e9f3276a8bdf7ce92aa808718a0212d/numexpr-2.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6e575fd3ad41ddf3355d0c7ef6bd0168619dc1779a98fe46693cad5e95d25e6e", size = 1426199, upload-time = "2025-10-13T16:13:48.231Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ce/0d4fcd31ab49319740d934fba1734d7dad13aa485532ca754e555ca16c8b/numexpr-2.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:67ea4771029ce818573b1998f5ca416bd255156feea017841b86176a938f7d19", size = 1474214, upload-time = "2025-10-13T16:15:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/b2a93cbdb3ba4e009728ad1b9ef1550e2655ea2c86958ebaf03b9615f275/numexpr-2.14.1-cp313-cp313t-win32.whl", hash = "sha256:15015d47d3d1487072d58c0e7682ef2eb608321e14099c39d52e2dd689483611", size = 167676, upload-time = "2025-10-13T16:17:17.351Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/ee3accc589ed032eea68e12172515ed96a5568534c213ad109e1f4411df1/numexpr-2.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:94c711f6d8f17dfb4606842b403699603aa591ab9f6bf23038b488ea9cfb0f09", size = 161096, upload-time = "2025-10-13T16:17:19.174Z" }, + { url = "https://files.pythonhosted.org/packages/ac/36/9db78dfbfdfa1f8bf0872993f1a334cdd8fca5a5b6567e47dcb128bcb7c2/numexpr-2.14.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ede79f7ff06629f599081de644546ce7324f1581c09b0ac174da88a470d39c21", size = 162848, upload-time = "2025-10-13T16:16:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/a5c78ae637402c5550e2e0ba175275d2515d432ec28af0cdc23c9b476e65/numexpr-2.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2eac7a5a2f70b3768c67056445d1ceb4ecd9b853c8eda9563823b551aeaa5082", size = 152270, upload-time = "2025-10-13T16:16:47.92Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ed/aabd8678077848dd9a751c5558c2057839f5a09e2a176d8dfcd0850ee00e/numexpr-2.14.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aedf38d4c0c19d3cecfe0334c3f4099fb496f54c146223d30fa930084bc8574", size = 455918, upload-time = "2025-10-13T16:13:50.338Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/3db65117f02cdefb0e5e4c440daf1c30beb45051b7f47aded25b7f4f2f34/numexpr-2.14.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439ec4d57b853792ebe5456e3160312281c3a7071ecac5532ded3278ede614de", size = 446512, upload-time = "2025-10-13T16:15:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fb/7ceb9ee55b5f67e4a3e4d73d5af4c7e37e3c9f37f54bee90361b64b17e3f/numexpr-2.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e23b87f744e04e302d82ac5e2189ae20a533566aec76a46885376e20b0645bf8", size = 1417845, upload-time = "2025-10-13T16:13:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9b5764d0eafbbb2889288f80de773791358acf6fad1a55767538d8b79599/numexpr-2.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:44f84e0e5af219dbb62a081606156420815890e041b87252fbcea5df55214c4c", size = 1466211, upload-time = "2025-10-13T16:15:48.985Z" }, + { url = "https://files.pythonhosted.org/packages/5d/21/204db708eccd71aa8bc55bcad55bc0fc6c5a4e01ad78e14ee5714a749386/numexpr-2.14.1-cp314-cp314-win32.whl", hash = "sha256:1f1a5e817c534539351aa75d26088e9e1e0ef1b3a6ab484047618a652ccc4fc3", size = 168835, upload-time = "2025-10-13T16:17:20.82Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3e/d83e9401a1c3449a124f7d4b3fb44084798e0d30f7c11e60712d9b94cf11/numexpr-2.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:587c41509bc373dfb1fe6086ba55a73147297247bedb6d588cda69169fc412f2", size = 162608, upload-time = "2025-10-13T16:17:22.228Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d6/ec947806bb57836d6379a8c8a253c2aeaa602b12fef2336bfd2462bb4ed5/numexpr-2.14.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec368819502b64f190c3f71be14a304780b5935c42aae5bf22c27cc2cbba70b5", size = 163525, upload-time = "2025-10-13T16:16:50.133Z" }, + { url = "https://files.pythonhosted.org/packages/0d/77/048f30dcf661a3d52963a88c29b52b6d5ce996d38e9313a56a922451c1e0/numexpr-2.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e87f6d203ac57239de32261c941e9748f9309cbc0da6295eabd0c438b920d3a", size = 152917, upload-time = "2025-10-13T16:16:52.055Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/956a13e628d722d649fbf2fded615134a308c082e122a48bad0e90a99ce9/numexpr-2.14.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd72d8c2a165fe45ea7650b16eb8cc1792a94a722022006bb97c86fe51fd2091", size = 466242, upload-time = "2025-10-13T16:13:55.795Z" }, + { url = "https://files.pythonhosted.org/packages/d6/dd/abe848678d82486940892f2cacf39e82eec790e8930d4d713d3f9191063b/numexpr-2.14.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70d80fcb418a54ca208e9a38e58ddc425c07f66485176b261d9a67c7f2864f73", size = 457149, upload-time = "2025-10-13T16:15:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/fd/bb/797b583b5fb9da5700a5708ca6eb4f889c94d81abb28de4d642c0f4b3258/numexpr-2.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:edea2f20c2040df8b54ee8ca8ebda63de9545b2112872466118e9df4d0ae99f3", size = 1426493, upload-time = "2025-10-13T16:13:59.244Z" }, + { url = "https://files.pythonhosted.org/packages/77/c4/0519ab028fdc35e3e7ee700def7f2b4631b175cd9e1202bd7966c1695c33/numexpr-2.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790447be6879a6c51b9545f79612d24c9ea0a41d537a84e15e6a8ddef0b6268e", size = 1474413, upload-time = "2025-10-13T16:15:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/33044878c8f4a75213cfe9c11d4c02058bb710a7a063fe14f362e8de1077/numexpr-2.14.1-cp314-cp314t-win32.whl", hash = "sha256:538961096c2300ea44240209181e31fae82759d26b51713b589332b9f2a4117e", size = 169502, upload-time = "2025-10-13T16:17:23.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/a2/5a1a2c72528b429337f49911b18c302ecd36eeab00f409147e1aa4ae4519/numexpr-2.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a40b350cd45b4446076fa11843fa32bbe07024747aeddf6d467290bf9011b392", size = 163589, upload-time = "2025-10-13T16:17:25.696Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +] + +[[package]] +name = "pandas-gbq" +version = "0.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "db-dtypes" }, + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, + { name = "google-cloud-bigquery" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "psutil" }, + { name = "pyarrow" }, + { name = "pydata-google-auth" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c7/2d2877fe71c13c6713aa1872bf8c8613044361ca00a488d6ddd5b1a74cbc/pandas_gbq-0.34.1.tar.gz", hash = "sha256:6bea5b85937251b976cf9db38151ea59abbff98771179183488d4614694bff67", size = 79211, upload-time = "2026-03-26T22:17:46.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/51/72b7c3b25ecfc6810b29ae9bffe76e26a407adb20de5b90ed984b3d483ca/pandas_gbq-0.34.1-py3-none-any.whl", hash = "sha256:b74932c6ee35dfc81582f39c792e3a68c9ef9bee8c85f25667d9d05dfadd0daf", size = 50778, upload-time = "2026-03-26T22:15:20.992Z" }, +] + +[[package]] +name = "parse" +version = "1.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/18/0bea374e5ec3c8ba15365570002187f3fef9d7265ffbc2f649529878cc80/parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3", size = 29105, upload-time = "2026-02-19T02:20:07.645Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/13/114daf766c33aec6c5a3954e7ea653f8a7ade9602c5c5a2228281698c490/parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47", size = 19693, upload-time = "2026-02-19T02:20:06.575Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pyarrow" +version = "23.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[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, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[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, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { 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, upload-time = "2025-11-04T13:40:25.248Z" }, + { 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, upload-time = "2025-11-04T13:40:27.099Z" }, + { 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, upload-time = "2025-11-04T13:40:29.806Z" }, + { 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, upload-time = "2025-11-04T13:40:33.544Z" }, + { 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, upload-time = "2025-11-04T13:40:35.479Z" }, + { 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, upload-time = "2025-11-04T13:40:37.436Z" }, + { 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, upload-time = "2025-11-04T13:40:40.289Z" }, + { 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, upload-time = "2025-11-04T13:40:42.809Z" }, + { 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, upload-time = "2025-11-04T13:40:44.752Z" }, + { 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, upload-time = "2025-11-04T13:40:46.66Z" }, + { 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, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { 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, upload-time = "2025-11-04T13:40:56.68Z" }, + { 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, upload-time = "2025-11-04T13:40:58.807Z" }, + { 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, upload-time = "2025-11-04T13:41:00.853Z" }, + { 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, upload-time = "2025-11-04T13:41:03.504Z" }, + { 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, upload-time = "2025-11-04T13:41:05.804Z" }, + { 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, upload-time = "2025-11-04T13:41:07.809Z" }, + { 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, upload-time = "2025-11-04T13:41:09.827Z" }, + { 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, upload-time = "2025-11-04T13:41:12.379Z" }, + { 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, upload-time = "2025-11-04T13:41:14.627Z" }, + { 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, upload-time = "2025-11-04T13:41:16.868Z" }, + { 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, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { 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, upload-time = "2025-11-04T13:41:28.569Z" }, + { 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, upload-time = "2025-11-04T13:41:31.055Z" }, + { 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, upload-time = "2025-11-04T13:41:33.21Z" }, + { 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, upload-time = "2025-11-04T13:41:35.508Z" }, + { 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, upload-time = "2025-11-04T13:41:37.732Z" }, + { 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, upload-time = "2025-11-04T13:41:40Z" }, + { 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, upload-time = "2025-11-04T13:41:42.323Z" }, + { 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, upload-time = "2025-11-04T13:41:45.221Z" }, + { 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, upload-time = "2025-11-04T13:41:47.474Z" }, + { 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, upload-time = "2025-11-04T13:41:49.992Z" }, + { 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, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydata-google-auth" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/0d/455cb39f0d5a914412b57c55c6b16977c61a5ac74b615eea4fb0dc54e329/pydata-google-auth-1.9.1.tar.gz", hash = "sha256:0a51ce41c601ca0bc69b8795bf58bedff74b4a6a007c9106c7cbcdec00eaced2", size = 29814, upload-time = "2025-01-23T21:04:40.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/cb/cdeaba62aa3c48f0d8834afb82b4a21463cd83df34fe01f9daa89a08ec6c/pydata_google_auth-1.9.1-py2.py3-none-any.whl", hash = "sha256:75ffce5d106e34b717b31844c1639ea505b7d9550dc23b96fb6c20d086b53fa3", size = 15552, upload-time = "2025-01-23T21:04:38.97Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyod" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "matplotlib" }, + { name = "numba" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/5c/109bbf7347b7f2b316629449cae927f9759675f86dd434d640419c032325/pyod-2.1.0.tar.gz", hash = "sha256:4f2f255b3f7f800ce542b4be97b2a1e810d2739afea98a0b3fb9588a7fdaf6f3", size = 198315, upload-time = "2026-04-06T08:04:59.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/c4/95da255d92291e0c979ebd3856901c0dc94f2e1af2e574a695f9925263d4/pyod-2.1.0-py3-none-any.whl", hash = "sha256:5ebabba5514a6df8863348ada0f33b9c8b4d0f09d04e834079739c15326cfd27", size = 238438, upload-time = "2026-04-06T08:04:57.433Z" }, +] + +[[package]] +name = "pyodbc" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/1d/9e74cbcc1d4878553eadfd59138364b38656369eb58f7e5b42fb344c0ce7/pyodbc-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24", size = 72975, upload-time = "2025-10-17T18:03:30.466Z" }, + { url = "https://files.pythonhosted.org/packages/37/c7/27d83f91b3144d3e275b5b387f0564b161ddbc4ce1b72bb3b3653e7f4f7a/pyodbc-5.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350", size = 72541, upload-time = "2025-10-17T18:03:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/2bb24e7fc95e98a7b11ea5ad1f256412de35d2e9cc339be198258c1d9a76/pyodbc-5.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d", size = 343287, upload-time = "2025-10-17T18:03:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/fa/24/88cde8b6dc07a93a92b6c15520a947db24f55db7bd8b09e85956642b7cf3/pyodbc-5.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68", size = 350094, upload-time = "2025-10-17T18:03:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/99/53c08562bc171a618fa1699297164f8885e66cde38c3b30f454730d0c488/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818", size = 1301029, upload-time = "2025-10-17T18:03:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/d8/10/68a0b5549876d4b53ba4c46eed2a7aca32d589624ed60beef5bd7382619e/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f", size = 1361420, upload-time = "2025-10-17T18:03:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/41/0f/9dfe4987283ffcb981c49a002f0339d669215eb4a3fe4ee4e14537c52852/pyodbc-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2", size = 63399, upload-time = "2025-10-17T18:03:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/56/03/15dcefe549d3888b649652af7cca36eda97c12b6196d92937ca6d11306e9/pyodbc-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e", size = 70133, upload-time = "2025-10-17T18:03:38.47Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c1/c8b128ae59a14ecc8510e9b499208e342795aecc3af4c3874805c720b8db/pyodbc-5.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353", size = 64683, upload-time = "2025-10-17T18:03:39.68Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f2/c26d82a7ce1e90b8bbb8731d3d53de73814e2f6606b9db9d978303aa8d5f/pyodbc-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797", size = 73513, upload-time = "2025-10-17T18:03:40.536Z" }, + { url = "https://files.pythonhosted.org/packages/82/d5/1ab1b7c4708cbd701990a8f7183c5bb5e0712d5e8479b919934e46dadab4/pyodbc-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780", size = 72631, upload-time = "2025-10-17T18:03:41.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/7e3831eeac2b09b31a77e6b3495491ce162035ff2903d7261b49d35aa3c2/pyodbc-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc", size = 344580, upload-time = "2025-10-17T18:03:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a6/71d26d626a3c45951620b7ff356ec920e420f0e09b0a924123682aa5e4ab/pyodbc-5.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6", size = 350224, upload-time = "2025-10-17T18:03:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/93/14/f702c5e8c2d595776266934498505f11b7f1545baf21ffec1d32c258e9d3/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05", size = 1301503, upload-time = "2025-10-17T18:03:45.013Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b2/ad92ebdd1b5c7fec36b065e586d1d34b57881e17ba5beec5c705f1031058/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e", size = 1361050, upload-time = "2025-10-17T18:03:46.298Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/dc84e232da07056cb5aaaf5f759ba4c874bc12f37569f7f1670fc71e7ae1/pyodbc-5.3.0-cp314-cp314-win32.whl", hash = "sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b", size = 65670, upload-time = "2025-10-17T18:03:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/b8/79/c48be07e8634f764662d7a279ac204f93d64172162dbf90f215e2398b0bd/pyodbc-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39", size = 72177, upload-time = "2025-10-17T18:03:57.296Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/e304574446b2263f428ce14df590ba52c2e0e0205e8d34b235b582b7d57e/pyodbc-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5", size = 66668, upload-time = "2025-10-17T18:03:58.174Z" }, + { url = "https://files.pythonhosted.org/packages/43/17/f4eabf443b838a2728773554017d08eee3aca353102934a7e3ba96fb0e31/pyodbc-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364", size = 75780, upload-time = "2025-10-17T18:03:47.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/e79e168c3d38c27d59d5d96273fd9e3c3ba55937cc944c4e60618f51de90/pyodbc-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0", size = 75503, upload-time = "2025-10-17T18:03:48.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/d1d7c125ec4a20e83fdc28e119b8321192b2bd694f432cf63e1199b2b929/pyodbc-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943", size = 398356, upload-time = "2025-10-17T18:03:49.131Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fc/f6be4b3cc3910f8c2aba37aa41671121fd6f37b402ae0fefe53a70ac7cd5/pyodbc-5.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d", size = 397291, upload-time = "2025-10-17T18:03:50.18Z" }, + { url = "https://files.pythonhosted.org/packages/03/2e/0610b1ed05a5625528d52f6cece9610e84617d35f475c89c2a52f66d13f7/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d", size = 1353900, upload-time = "2025-10-17T18:03:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f1/43497e1d37f9f71b43b2b3172e7b1bdf50851e278390c3fb6b46a3630c53/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e", size = 1406062, upload-time = "2025-10-17T18:03:52.546Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/88a1277c2f7d9ab1cec0a71e074ba24fd4a1710a43974682546da90a1343/pyodbc-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514", size = 70132, upload-time = "2025-10-17T18:03:53.715Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/ee98c62050de4aa8bafb6eb1e11b95e0b0c898bd5930137c6dc776e06a9b/pyodbc-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955", size = 79452, upload-time = "2025-10-17T18:03:54.664Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, +] + +[package.optional-dependencies] +asyncio-client = [ + { name = "aiohttp" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rissk" +version = "0.1.2" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "pyod" }, + { name = "pyyaml" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] + +[package.optional-dependencies] +gui = [ + { name = "nicegui" }, +] + +[package.metadata] +requires-dist = [ + { name = "nicegui", marker = "extra == 'gui'", specifier = ">=1.4" }, + { name = "numpy", specifier = ">=2.1.0" }, + { name = "pandas", specifier = ">=2.2.3" }, + { name = "pyod", specifier = ">=1.1.5" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "scikit-learn", specifier = ">=1.5" }, + { name = "scipy", specifier = ">=1.10" }, +] +provides-extras = ["gui"] + +[[package]] +name = "rissk-pipeline" +version = "0.1.2" +source = { editable = "rissk_kedro" } +dependencies = [ + { name = "kedro" }, + { name = "kedro-datasets", extra = ["pandas"] }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pyod" }, + { name = "pyyaml" }, + { name = "rissk" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "kedro", specifier = "==1.2.0" }, + { name = "kedro-datasets", extras = ["pandas"], specifier = ">=9.1.0" }, + { name = "numpy", specifier = ">=2.1.0" }, + { name = "pandas", specifier = ">=2.2.3" }, + { name = "pyod", specifier = ">=1.1.5" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rissk", editable = "." }, + { name = "scikit-learn", specifier = ">=1.5" }, + { name = "scipy", specifier = ">=1.10" }, +] +provides-extras = ["test"] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tables" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blosc2" }, + { name = "numexpr" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "py-cpuinfo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/a3/d213ebe7376d48055bd55a29cd9f99061afa0dcece608f94a5025d797b0a/tables-3.11.1.tar.gz", hash = "sha256:78abcf413091bc7c1e4e8c10fbbb438d1ac0b5a87436c5b972c3e8253871b6fb", size = 4790533, upload-time = "2026-03-01T11:43:36.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/bb/4a9cde6628563388db26fa86c64adb0f2475a757e72af0ec185fd520b72f/tables-3.11.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:eb30684c42a77bbecdef2b9c763c4372b0ddc9cc5bd8b2a2055f2042eee67217", size = 7045977, upload-time = "2026-03-01T11:42:48.605Z" }, + { url = "https://files.pythonhosted.org/packages/78/74/6568c8d3aabf9982ab89fe3e378afbd7aad4894bde4570991a3246169ef4/tables-3.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0367d2e3df0f10ea63ccf4279f3fe58e32ec481767320301a483e2b3cd83efc", size = 6264947, upload-time = "2026-03-01T11:42:53.192Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/ec228901fca4c996306b17f5c60a4105144df0bbd07b3a4a816f91f37b4a/tables-3.11.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56bf6fb9132ead989b7e76695d7613d6d08f071a8019038d6565ba90c66b9f3e", size = 6903733, upload-time = "2026-03-01T11:42:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/c2dc674ea70fa9a4819417289a9c0d3e4780835beeed573eb66964cfb763/tables-3.11.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e78fe190fdeb4afe430b79651bae2a4f341904eb85aa8dbafe5f1caee1c7f67", size = 7241357, upload-time = "2026-03-01T11:43:03.938Z" }, + { url = "https://files.pythonhosted.org/packages/60/b5/a59b62af4127790c618eb11c06c106706e07509a3fb9e346b2a3ffa74419/tables-3.11.1-cp311-abi3-win_amd64.whl", hash = "sha256:7fa6cb03f6fe55ae4f85e89ec5450e5c40cc4c52d8c3b60eb157a445c2219e89", size = 6526565, upload-time = "2026-03-01T11:43:08.58Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ce/561c82496e7c8c15ebf19b53b12c0ef91b322a66869db762db9711102764/tables-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a4bbd95036a4d0cc5c86c1f87fbb490b4c53cd70982f1c01b3ed6dcb3085cbb9", size = 7111409, upload-time = "2026-03-01T11:43:13.424Z" }, + { url = "https://files.pythonhosted.org/packages/84/18/bac920aee8239b572c506459607c6dd8742bc6275a43d51d2dd6ae1a1541/tables-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e3cfe79484351f7216eb8f3767bfa1217bfd271b04428f79cfa7ef6d7491919d", size = 6380142, upload-time = "2026-03-01T11:43:17.213Z" }, + { url = "https://files.pythonhosted.org/packages/59/3c/f4a694aa744d2b14d536e172c28dd70c84445f4787083a82d6d44a39e39f/tables-3.11.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a9c35f87fcb6a48c79fbc4e3ab15ca8f6053c4ce13063d6ca2ec36cbb58f40f", size = 7014135, upload-time = "2026-03-01T11:43:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/45/82/94d4320d6c0fe5bd55230eec90cd142d58cda37b7cce00a318ac2a6abd93/tables-3.11.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cf3218b76ba78d156d6ee75c19fb757d50682f6c7b4905370441afbfc9d77f3", size = 7349293, upload-time = "2026-03-01T11:43:27.569Z" }, + { url = "https://files.pythonhosted.org/packages/f7/02/a0f61a602ce2f2be8cc2e6146cc51acdaa8a1bb9b823b3863e70d3e0505d/tables-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a6f7a3b82dbf0ae0f30de635ca88bb42dd87938b0950369d0ee4289c52ae6de2", size = 6854713, upload-time = "2026-03-01T11:43:31.934Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "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, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +]