diff --git a/CLAUDE.md b/CLAUDE.md index 02cc2e8f..e59cd3b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,8 @@ src/datasure/ │ └── local.py # Local file import (csv/xlsx/xls/json/dta/parquet) ├── processing/ │ ├── prep.py # Data preparation operations (Polars) -│ └── corrections.py # Data correction application +│ ├── corrections.py # Data correction application +│ └── pii.py # PII detection (Presidio) + redaction + flags ├── replication/ # Stata/Python replication package export │ ├── package_builder.py, script_generators.py, prep_script_generator.py, │ ├── py_script_generators.py, py_prep_script_generator.py, @@ -142,6 +143,18 @@ pattern `output_view_?.py` - never commit them, and never edit them directly 5. **Reports** (generated output views): charts and tables per check 6. **Corrections / replication**: apply corrections; export a replication package that reproduces the pipeline outside DataSure +7. **PII gate** (`processing/pii.py`): column-name heuristics + Presidio + value scanning flag PII per dataset (`pii_flags_{alias}` in logs db); + per-column decisions are mask / hash (HMAC pseudonyms keyed by a + per-project salt in `pii_salt`) / code (persisted category codes in + `pii_code_map_{alias}`) / drop / keep. All four can become prep steps + (hash/code are idempotent so the export gate can safely re-apply), and + the export gate in `package_builder.py` enforces the flags regardless + (de-identified is the default export mode; the survey key column is + never redacted; salt and code maps never leave the local cache in + de-identified exports — generated scripts note hash/code steps rather + than reproducing them). spaCy models are downloaded at runtime from + the UI, never bundled ### Cache and data locations (`utils/cache_utils.py`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d66141f..5d7f5c5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -168,6 +168,18 @@ tests/ └── views/ # Tests for Streamlit page scripts ``` +### PII detection tests and the spaCy model + +PII detection (`src/datasure/processing/pii.py`) uses Microsoft Presidio, +which needs a spaCy language model for value scanning. The English model +(`en_core_web_sm`) is a **dev dependency** (installed by `uv sync` via a +direct wheel URL in `[dependency-groups]` — allowed there because groups +are not published to PyPI), so the Presidio-path tests in +`tests/processing/test_pii.py` run for real locally and in CI. Tests that +need the model skip automatically when it is absent. At runtime, end +users download models from the PII Review section of the Prepare Data +page instead. + ## Dependency Management DataSure declares dependencies in `pyproject.toml` and locks exact versions diff --git a/README.md b/README.md index d2b7828d..c5f2a4e9 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ DataSure includes 9 specialized modules for comprehensive survey data quality mo - **Local File Support**: CSV, Excel, Stata (.dta), JSON, and Parquet upload with automatic type detection - **Multi-Project Organization**: Manage multiple surveys simultaneously - **Data Preparation**: Cleaning and transformation workflows +- **PII Detection & De-identification**: Presidio-based scanning of column names and values; per-column mask, salted-hash pseudonym, category-code, drop, or keep decisions; and a de-identified (default) or with-PII replication export ### Interactive Dashboards diff --git a/ROADMAP.md b/ROADMAP.md index b93c1b52..7d34261e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,6 +44,7 @@ For feature requests and bug reports, see our [GitHub Issues](https://github.com | **Cross-Platform Support** | Windows, macOS, and Linux compatibility | Launched | | **Package Distribution** | Available via PyPI (`uv tool install datasure`) | Launched | | **Replication Package Export** | Bundle and export full analysis packages | Launched | +| **PII Detection & De-identification** | Presidio-based PII scanning, redaction prep action, de-identified exports | Launched | --- diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index caa2fc8a..26a60867 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -255,6 +255,66 @@ Delete unnecessary or problematic data: 3. Select column(s) or define row filter 4. Click "Add" +##### Redact Column + +Mask all values in a column with a redaction label (e.g. `[PERSON]`, +`*****`) while keeping the column in place — the primary tool for +removing PII without losing the dataset's structure: + +1. Click "Add data prep step" +2. Choose "Redact Column(s)" +3. Select column(s) and set the redaction label +4. Click "Add" + +#### PII Review + +Each dataset tab has a **PII Review** section that scans for columns and +values suspected to contain personally identifiable information (PII): + +- **Column-name heuristics** run instantly with no setup: multilingual + restricted-word matching (English, Spanish, French, Swahili terms for + names, addresses, phones, GPS, ages, SurveyCTO device metadata) plus a + sparsity check that flags high-cardinality free-text columns. +- **Value scanning** uses Microsoft Presidio with a spaCy language model + to detect PII *inside* values (person names, phone numbers, emails, + locations) on a sample of each text column. Models are small + (~15–40 MB) and downloaded from within the app — English is the + default; Spanish and French are available from the language selector. + +**Workflow**: + +1. (Optional) Download the language model to enable value scanning +2. Click **Scan for PII** +3. Review flagged columns: what flagged them, the detected entity type, + and sample matched values +4. Set a per-column decision: + - **mask** — replace every value with a constant label (`[PERSON]`) + - **hash** — replace values with deterministic salted pseudonyms + (`PERSON_3fa1b9c2`); the same value always gets the same token, so + group-bys, joins, and frequency analysis keep working, while the + secret salt (kept in the local cache, never exported) prevents + dictionary attacks + - **code** — replace values with readable sequential category codes + (`VILLAGE_NAME_001`), assigned in random order and persisted so + repeated exports stay consistent + - **drop** — remove the column entirely + - **keep** — leave the column untouched +5. Click **Apply decisions as prep steps** — masks, hash pseudonyms, + category codes, and drops all land in the change log like any other + prep step (replayable and removable), and the decisions are also + enforced at export time. The project salt and code maps stay in the + local cache and never leave it in de-identified exports, so the + generated replication scripts note hash/code steps as + applied-in-DataSure rather than reproducing them (the exported + datasets already carry the tokens) + +> **Warning**: De-identification is not anonymization. Even with direct +> identifiers masked or dropped, respondents may remain identifiable +> through combinations of the remaining variables (age, location, +> occupation, household composition). Deterministic pseudonyms (hash and +> code) also preserve the frequency distribution, so rare categories stay +> recognizable by their rarity. Review data before sharing. + #### Change Log All preparation steps are tracked: diff --git a/pyproject.toml b/pyproject.toml index 5966c052..b4aacd8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,10 @@ dependencies = [ "keyring>=25.6.0", "polars-readstat>=0.5.1", "pyyaml>=6.0.1", + # PII detection/redaction (spaCy comes transitively; language models are + # downloaded at runtime from the app, never bundled) + "presidio-analyzer>=2.2.360", + "presidio-anonymizer>=2.2.360", ] [project.urls] @@ -64,6 +68,10 @@ dev = [ "jupyterlab>=4.5.7", "nbconvert>=7.17.0", "mistune>=3.2.1", + # English spaCy model so tests can exercise the Presidio NER path. + # Direct URLs are allowed here because dependency groups are not + # published to PyPI; at runtime users download models from the app UI. + "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", ] [tool.uv] diff --git a/src/datasure/models/enums.py b/src/datasure/models/enums.py index a4af8e17..7b8ec9c8 100644 --- a/src/datasure/models/enums.py +++ b/src/datasure/models/enums.py @@ -15,6 +15,7 @@ class PrepActions(Enum): transform_column: str = "transform column(s)" remove_column: str = "remove column(s)" remove_row: str = "remove row(s)" + redact_column: str = "redact column(s)" class PrepMethods(Enum): diff --git a/src/datasure/processing/pii.py b/src/datasure/processing/pii.py new file mode 100644 index 00000000..3a97b6bb --- /dev/null +++ b/src/datasure/processing/pii.py @@ -0,0 +1,850 @@ +"""PII detection, review flags, and redaction for DataSure. + +Two detection passes: + +1. **Column-name heuristics** (always available, no model): strict and + substring matching against multilingual restricted-word lists (ported in + spirit from PovertyAction/PII_detection, MIT), plus a sparsity heuristic + for free-text columns. +2. **Value scan** (requires a spaCy model): Microsoft Presidio's + ``BatchAnalyzerEngine`` over a sample of values from string columns — + NER-backed entities (PERSON, LOCATION) plus pattern recognizers + (PHONE_NUMBER, EMAIL_ADDRESS, ...). + +Flags persist per dataset in ``pii_flags_{alias}`` (logs db), following the +``prep_log_{alias}`` convention. Redaction helpers apply the reviewer's +per-column decisions to DataFrames and to the correction log before export: + +- **mask** — every value replaced with a constant label (``[PERSON]``) +- **hash** — HMAC-SHA256 pseudonyms (``PERSON_3fa1b9c2``) keyed with a + per-project secret salt kept in the local cache and never exported; + deterministic, so categorical analysis (group-bys, joins, frequencies) + still works, and dictionary attacks fail without the salt +- **code** — sequential category codes (``VILLAGE_NAME_001``) assigned in + random order and persisted per project, the most readable option for + categorical analysis with no cryptanalytic surface +- **drop** — column removed +- **keep** — untouched + +Note: any deterministic pseudonym (hash or code) preserves the frequency +distribution, so rare categories remain identifiable by their rarity — +part of why de-identified output always carries the indirect-identifier +warning. + +Presidio and spaCy are imported lazily inside functions: they are heavy and +only needed when a scan or model operation actually runs. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import random +import re +import secrets +from datetime import datetime + +import polars as pl + +# Module-attribute access (not `from ... import`) so tests patching +# datasure.utils.duckdb_utils at source affect this module too. +from datasure.utils import duckdb_utils + +logger = logging.getLogger(__name__) + +# --- Languages and models ------------------------------------------------- + +PII_LANGUAGES: dict[str, str] = {"en": "English", "es": "Spanish", "fr": "French"} +PII_MODEL_OPTIONS: dict[str, str] = { + "en": "en_core_web_sm", + "es": "es_core_news_sm", + "fr": "fr_core_news_sm", +} + +# --- Detection tuning ----------------------------------------------------- + +# Presidio entities considered PII for column flagging. DATE_TIME, URL and +# NRP are excluded as too noisy for survey data. +VALUE_SCAN_ENTITIES: frozenset[str] = frozenset( + { + "PERSON", + "PHONE_NUMBER", + "EMAIL_ADDRESS", + "LOCATION", + "IP_ADDRESS", + "CREDIT_CARD", + "IBAN_CODE", + "MEDICAL_LICENSE", + } +) +VALUE_SCORE_THRESHOLD = 0.4 # per-detection minimum confidence +VALUE_HIT_THRESHOLD = 0.3 # share of sampled values with a hit → flag column +VALUE_SAMPLE_SIZE = 100 # values sampled per string column + +SPARSITY_THRESHOLD = 0.9 # unique/non-null ratio → likely free text +SPARSITY_MIN_ROWS = 20 # skip the heuristic on tiny columns + +DEFAULT_MASK = "*****" +FLAG_DECISIONS = ("undecided", "mask", "hash", "code", "drop", "keep") + +# Hex digits kept from the HMAC digest in hash pseudonyms +HASH_TOKEN_LENGTH = 8 + +# --- Restricted-word lists -------------------------------------------------- +# Ported in spirit from PovertyAction/PII_detection (MIT), restricted_words.py, +# curated for false positives and extended with French. Strict = exact +# column-name match; fuzzy = substring match. The survey KEY / caseid system +# identifiers are deliberately NOT listed: the corrections pipeline needs the +# key column, and the export gate protects it explicitly. + +_STRICT_WORDS: frozenset[str] = frozenset( + { + # SurveyCTO device/metadata identifiers + "deviceid", + "subscriberid", + "simid", + "devicephonenum", + # Direct identifiers and common survey shorthands + "name", + "gps", + "lat", + "lon", + "coord", + "age", + "dob", + "phone", + "fax", + "email", + "url", + "ip", + "enumerator", + "enum", + "address", + "birth", + # Spanish + "nombre", + "edad", + "direccion", + "telefono", + "encuestador", + # French + "nom", + "prenom", + "adresse", + "telephone", + "naissance", + # Swahili (retained from the source lists) + "jina", + "simu", + "umri", + } +) + +_FUZZY_WORDS: frozenset[str] = frozenset( + { + # Names + "first_name", + "last_name", + "firstname", + "lastname", + "fname", + "lname", + "nickname", + "resp_name", + "head_name", + "enum_name", + "_name", + # Contact + "phone", + "email", + "address", + "website", + # Location + "latitude", + "longitude", + "coordinates", + "village", + "district", + "subcounty", + "sublocation", + "parish", + "community", + "neighborhood", + "neighbourhood", + "municipio", + "panchayat", + "upazila", + "barangay", + # Demographics + "birthday", + "birth_date", + "birthyear", + "birthyy", + # Spanish + "apellido", + "direccion", + "latitud", + "longitud", + "coordenadas", + "comunidad", + "beneficiario", + "fecha_nacimiento", + "ubicacion", + # French + "prenom", + "adresse", + "quartier", + "commune", + "coordonnees", + "telephone", + "courriel", + # Swahili + "kijiji", + "wilaya", + "mkoa", + } +) + +# Mask labels per detected entity type; DEFAULT_MASK otherwise. +_ENTITY_MASKS: dict[str, str] = { + "PERSON": "[PERSON]", + "PHONE_NUMBER": "[PHONE]", + "EMAIL_ADDRESS": "[EMAIL]", + "LOCATION": "[LOCATION]", + "IP_ADDRESS": "[IP_ADDRESS]", + "CREDIT_CARD": "[CREDIT_CARD]", + "IBAN_CODE": "[IBAN]", + "MEDICAL_LICENSE": "[MEDICAL_LICENSE]", +} + +_FLAGS_SCHEMA: dict[str, pl.DataType] = { + "column": pl.String, + "source": pl.String, + "entity_type": pl.String, + "hit_rate": pl.Float64, + "sample_matches": pl.String, + "decision": pl.String, + "mask_label": pl.String, + "scanned_at": pl.String, +} + + +def empty_flags() -> pl.DataFrame: + """Return an empty PII-flags DataFrame with the canonical schema.""" + return pl.DataFrame(schema=_FLAGS_SCHEMA) + + +def mask_label_for(entity_type: str | None) -> str: + """Return the mask label for an entity type, DEFAULT_MASK if unknown.""" + if not entity_type: + return DEFAULT_MASK + return _ENTITY_MASKS.get(entity_type, DEFAULT_MASK) + + +# --- Pass 1: column-name heuristics ----------------------------------------- + + +def _name_match_source(column: str) -> str | None: + """Return the heuristic source that flags *column*, or None.""" + name = column.lower().strip() + if name in _STRICT_WORDS: + return "name_match" + if any(word in name for word in _FUZZY_WORDS): + return "fuzzy" + return None + + +def scan_column_names(df: pl.DataFrame) -> pl.DataFrame: + """Flag columns by restricted-word matching and sparsity (no model needed). + + Parameters + ---------- + df : pl.DataFrame + The dataset to scan (typically the prepped data). + + Returns + ------- + pl.DataFrame + Flags rows with source "name_match", "fuzzy", or "sparsity". + """ + now = datetime.now().isoformat(timespec="seconds") + rows: list[dict] = [] + + for column in df.columns: + source = _name_match_source(column) + if source is not None: + rows.append( + { + "column": column, + "source": source, + "entity_type": None, + "hit_rate": 1.0, + "sample_matches": "", + "decision": "undecided", + "mask_label": DEFAULT_MASK, + "scanned_at": now, + } + ) + continue + + # Sparsity: high-cardinality string columns are likely free text or + # open-ended responses that can carry identifying detail. + series = df[column] + if series.dtype == pl.String: + non_null = series.drop_nulls() + if non_null.len() >= SPARSITY_MIN_ROWS: + ratio = non_null.n_unique() / non_null.len() + if ratio >= SPARSITY_THRESHOLD: + rows.append( + { + "column": column, + "source": "sparsity", + "entity_type": None, + "hit_rate": round(ratio, 3), + "sample_matches": "", + "decision": "undecided", + "mask_label": DEFAULT_MASK, + "scanned_at": now, + } + ) + + if not rows: + return empty_flags() + return pl.DataFrame(rows, schema=_FLAGS_SCHEMA) + + +# --- Pass 2: Presidio value scan --------------------------------------------- + + +def installed_models() -> dict[str, bool]: + """Return {language code: whether its spaCy model is installed}.""" + import spacy.util + + return { + lang: spacy.util.is_package(model) for lang, model in PII_MODEL_OPTIONS.items() + } + + +def download_model(language: str) -> tuple[bool, str]: + """Download the spaCy model for *language*. + + Returns + ------- + tuple[bool, str] + (success, message). Fails gracefully on network errors. + """ + model = PII_MODEL_OPTIONS.get(language) + if model is None: + return False, f"Unsupported language: {language}" + try: + import spacy.cli + + spacy.cli.download(model) + except SystemExit as e: # spacy.cli.download exits on failure + logger.warning("spaCy model download failed for %s: %s", model, e) + return False, f"Download failed for {model}. Check your network connection." + except Exception as e: + logger.warning("spaCy model download failed for %s: %s", model, e) + return False, f"Download failed for {model}: {e}" + return True, f"Model {model} installed." + + +# Analyzer engines are expensive to build (spaCy model load); memoize per +# language for the process lifetime. A plain dict is used instead of +# st.cache_resource so this module stays streamlit-free. +_ANALYZER_CACHE: dict[str, object] = {} + + +def _get_batch_analyzer(language: str): + """Build (and cache) a Presidio BatchAnalyzerEngine for *language*.""" + if language in _ANALYZER_CACHE: + return _ANALYZER_CACHE[language] + + from presidio_analyzer import AnalyzerEngine, BatchAnalyzerEngine + from presidio_analyzer.nlp_engine import NlpEngineProvider + + configuration = { + "nlp_engine_name": "spacy", + "models": [{"lang_code": language, "model_name": PII_MODEL_OPTIONS[language]}], + } + nlp_engine = NlpEngineProvider(nlp_configuration=configuration).create_engine() + analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=[language]) + batch = BatchAnalyzerEngine(analyzer_engine=analyzer) + _ANALYZER_CACHE[language] = batch + return batch + + +def scan_values( + df: pl.DataFrame, + language: str = "en", + sample_size: int = VALUE_SAMPLE_SIZE, +) -> pl.DataFrame: + """Scan sampled values of string columns with Presidio (needs a model). + + Parameters + ---------- + df : pl.DataFrame + The dataset to scan. + language : str + Language code; its spaCy model must already be installed. + sample_size : int + Maximum number of non-null values sampled per column. + + Returns + ------- + pl.DataFrame + Flags rows with source "value_scan" for columns whose sampled values + hit PII entities at or above ``VALUE_HIT_THRESHOLD``. + """ + now = datetime.now().isoformat(timespec="seconds") + + samples: dict[str, list[str]] = {} + for column in df.columns: + if df[column].dtype != pl.String: + continue + non_null = df[column].drop_nulls() + if non_null.is_empty(): + continue + samples[column] = non_null.head(sample_size).to_list() + + if not samples: + return empty_flags() + + batch_analyzer = _get_batch_analyzer(language) + results = batch_analyzer.analyze_dict(samples, language=language) + + rows: list[dict] = [] + for result in results: + values = list(result.value) + hit_values: list[str] = [] + entity_counts: dict[str, int] = {} + for value, detections in zip(values, result.recognizer_results, strict=False): + hits = [ + d + for d in detections + if d.entity_type in VALUE_SCAN_ENTITIES + and d.score >= VALUE_SCORE_THRESHOLD + ] + if hits: + hit_values.append(str(value)) + for d in hits: + entity_counts[d.entity_type] = ( + entity_counts.get(d.entity_type, 0) + 1 + ) + + if not values: + continue + hit_rate = len(hit_values) / len(values) + if hit_rate < VALUE_HIT_THRESHOLD: + continue + + dominant = max(entity_counts, key=entity_counts.get) + sample_matches = " | ".join(v[:60] for v in hit_values[:3]) + rows.append( + { + "column": result.key, + "source": "value_scan", + "entity_type": dominant, + "hit_rate": round(hit_rate, 3), + "sample_matches": sample_matches, + "decision": "undecided", + "mask_label": mask_label_for(dominant), + "scanned_at": now, + } + ) + + if not rows: + return empty_flags() + return pl.DataFrame(rows, schema=_FLAGS_SCHEMA) + + +# --- Scan orchestration ------------------------------------------------------- + + +def run_pii_scan( + df: pl.DataFrame, + language: str = "en", + use_value_scan: bool = True, +) -> pl.DataFrame: + """Run both detection passes and combine to one flag row per column. + + When a column is flagged by both passes, the value-scan row wins for + entity/hit-rate/mask fields and the sources are joined with "+". + """ + name_flags = scan_column_names(df) + value_flags = ( + scan_values(df, language=language) if use_value_scan else empty_flags() + ) + + by_column: dict[str, dict] = { + row["column"]: row for row in name_flags.iter_rows(named=True) + } + for row in value_flags.iter_rows(named=True): + existing = by_column.get(row["column"]) + if existing is not None: + row = {**row, "source": f"{existing['source']}+{row['source']}"} + by_column[row["column"]] = row + + if not by_column: + return empty_flags() + return pl.DataFrame(list(by_column.values()), schema=_FLAGS_SCHEMA) + + +def merge_with_existing( + new_flags: pl.DataFrame, old_flags: pl.DataFrame +) -> pl.DataFrame: + """Merge a fresh scan with stored flags, preserving user decisions. + + Detection fields come from the new scan; ``decision`` (and its + ``mask_label`` when the user chose to mask) is carried over from the old + flags. Columns no longer detected but carrying a non-default decision + are retained so reviewer choices never silently disappear. + """ + if old_flags.is_empty(): + return new_flags + if new_flags.is_empty(): + return old_flags.filter(pl.col("decision") != "undecided") + + old_by_column = {row["column"]: row for row in old_flags.iter_rows(named=True)} + rows: list[dict] = [] + for row in new_flags.iter_rows(named=True): + old = old_by_column.pop(row["column"], None) + if old is not None and old["decision"] != "undecided": + row = {**row, "decision": old["decision"], "mask_label": old["mask_label"]} + rows.append(row) + + rows.extend(old for old in old_by_column.values() if old["decision"] != "undecided") + return pl.DataFrame(rows, schema=_FLAGS_SCHEMA) + + +# --- Persistence --------------------------------------------------------------- + + +def load_pii_flags(project_id: str, alias: str) -> pl.DataFrame: + """Load stored PII flags for *alias*, empty flags if none exist.""" + try: + flags = duckdb_utils.duckdb_get_table(project_id, f"pii_flags_{alias}", "logs") + except Exception: + logger.warning("PII flags for %s not found; returning empty flags", alias) + return empty_flags() + if flags.is_empty(): + return empty_flags() + return flags + + +def save_pii_flags(project_id: str, alias: str, flags: pl.DataFrame) -> None: + """Persist PII flags for *alias* to the logs db.""" + duckdb_utils.duckdb_save_table( + project_id, flags, alias=f"pii_flags_{alias}", db_name="logs" + ) + + +# --- Pseudonymization: salted hash tokens ---------------------------------------- + + +def get_or_create_pii_salt(project_id: str) -> str: + """Return the project's secret PII salt, generating it on first use. + + The salt lives in the local cache (logs db) like other project settings + and is never included in de-identified exports, so recipients cannot + dictionary-attack hash pseudonyms. It is stable for the project's + lifetime, keeping hash tokens consistent across repeated exports. + """ + try: + stored = duckdb_utils.duckdb_get_table(project_id, "pii_salt", "logs") + if not stored.is_empty(): + return stored["salt"][0] + except Exception: + logger.warning("PII salt for project %s not found; generating one", project_id) + + salt = secrets.token_hex(16) + duckdb_utils.duckdb_save_table( + project_id, pl.DataFrame({"salt": [salt]}), alias="pii_salt", db_name="logs" + ) + return salt + + +def token_prefix(column: str, entity_type: str | None) -> str: + """Return the pseudonym-token prefix for a column (e.g. ``PERSON``).""" + raw = entity_type or column + return "".join(ch if ch.isalnum() else "_" for ch in raw).upper() + + +def hash_token(value: object, salt: str, prefix: str) -> str: + """Return a deterministic salted pseudonym like ``PERSON_3fa1b9c2``.""" + digest = hmac.new(salt.encode(), str(value).encode(), hashlib.sha256).hexdigest() + return f"{prefix}_{digest[:HASH_TOKEN_LENGTH]}" + + +def hash_column_expr(column: str, salt: str, prefix: str) -> pl.Expr: + """Polars expression hashing a column's values into salted pseudonyms. + + Idempotent: values that already look like this column's tokens are + passed through unchanged, so hashing applied as a prep step is not + hashed a second time by the export gate (HMAC is not idempotent by + itself). Nulls stay null. + """ + token_pattern = re.compile( + rf"^{re.escape(prefix)}_[0-9a-f]{{{HASH_TOKEN_LENGTH}}}$" + ) + + def _tokenize(value: str) -> str: + if token_pattern.match(value): + return value + return hash_token(value, salt, prefix) + + return ( + pl.col(column) + .cast(pl.String) + .map_elements(_tokenize, return_dtype=pl.String) + .alias(column) + ) + + +def code_column_expr(column: str, mapping: dict[str, str]) -> pl.Expr: + """Polars expression recoding a column's values via a code map. + + Idempotent: values that already are codes of this mapping pass through + unchanged (so the export gate does not re-map an already-coded prep + dataset). Values missing from the map are masked — never leaked. + """ + code_values = list(set(mapping.values())) + as_string = pl.col(column).cast(pl.String) + return ( + pl.when(pl.col(column).is_null()) + .then(pl.lit(None, dtype=pl.String)) + .when(as_string.is_in(code_values)) + .then(as_string) + .otherwise(as_string.replace_strict(mapping, default=DEFAULT_MASK)) + .alias(column) + ) + + +# --- Pseudonymization: category codes --------------------------------------------- + + +def load_code_maps(project_id: str, alias: str) -> dict[str, dict[str, str]]: + """Load persisted value→code maps for *alias*: {column: {value: code}}.""" + try: + stored = duckdb_utils.duckdb_get_table( + project_id, f"pii_code_map_{alias}", "logs" + ) + except Exception: + logger.warning("PII code maps for %s not found; returning empty", alias) + return {} + if stored.is_empty(): + return {} + maps: dict[str, dict[str, str]] = {} + for row in stored.iter_rows(named=True): + maps.setdefault(row["column"], {})[row["value"]] = row["code"] + return maps + + +def save_code_maps( + project_id: str, alias: str, maps: dict[str, dict[str, str]] +) -> None: + """Persist value→code maps for *alias* to the logs db.""" + rows = [ + {"column": column, "value": value, "code": code} + for column, mapping in maps.items() + for value, code in mapping.items() + ] + if not rows: + return + duckdb_utils.duckdb_save_table( + project_id, + pl.DataFrame(rows), + alias=f"pii_code_map_{alias}", + db_name="logs", + ) + + +def build_code_maps( + dfs: list[pl.DataFrame], + flags: pl.DataFrame, + existing: dict[str, dict[str, str]] | None = None, + mask_undecided: bool = False, + protect: tuple[str, ...] = (), +) -> dict[str, dict[str, str]]: + """Assign sequential codes to every distinct value of code-decision columns. + + Distinct values are collected across all provided DataFrames (so the raw, + prepped, and corrected datasets get consistent codes), new values are + assigned in random order (no alphabetical-rank leak), and previously + assigned codes from *existing* are preserved so re-exports stay stable. + """ + maps: dict[str, dict[str, str]] = { + column: dict(mapping) for column, mapping in (existing or {}).items() + } + for row in flags.iter_rows(named=True): + column = row["column"] + if column in protect: + continue + if _effective_decision(row["decision"], mask_undecided) != "code": + continue + + values: set[str] = set() + for df in dfs: + if column in df.columns: + values.update( + df[column].drop_nulls().cast(pl.String).unique().to_list() + ) + + maps[column] = extend_code_map( + maps.get(column, {}), sorted(values), token_prefix(column, None) + ) + return maps + + +def extend_code_map( + mapping: dict[str, str], values: list[str], prefix: str +) -> dict[str, str]: + """Return *mapping* extended with codes for any new *values*. + + Existing assignments are preserved; values that already are codes of + this mapping are skipped. New values are assigned in random order. + """ + extended = dict(mapping) + new_values = sorted(set(values) - set(extended) - set(extended.values())) + random.shuffle(new_values) + next_index = len(extended) + 1 + for offset, value in enumerate(new_values): + extended[value] = f"{prefix}_{next_index + offset:03d}" + return extended + + +# --- Redaction ------------------------------------------------------------------- + + +def _effective_decision(decision: str, mask_undecided: bool) -> str: + if decision == "undecided" and mask_undecided: + return "mask" + return decision + + +def apply_pii_decisions( + df: pl.DataFrame, + flags: pl.DataFrame, + mask_undecided: bool = False, + protect: tuple[str, ...] = (), + salt: str | None = None, + code_maps: dict[str, dict[str, str]] | None = None, +) -> pl.DataFrame: + """Apply per-column PII decisions to a DataFrame. + + Parameters + ---------- + df : pl.DataFrame + The dataset to redact. + flags : pl.DataFrame + PII flags (canonical schema). + mask_undecided : bool + Treat "undecided" flags as "mask" (the export gate's conservative + default). + protect : tuple[str, ...] + Columns never redacted regardless of flags (e.g. the survey key + column, which the corrections pipeline requires). + salt : str | None + Project secret for "hash" decisions. When missing, hash columns + fall back to masking (the privacy-safe failure mode). + code_maps : dict[str, dict[str, str]] | None + Value→code maps for "code" decisions (see ``build_code_maps``). + Columns without a map fall back to masking. + + Returns + ------- + pl.DataFrame + Copy of *df* with masked/hashed/coded columns replaced (cast to + String) and dropped columns removed. + """ + if df.is_empty() or flags.is_empty(): + return df + + result = df + for row in flags.iter_rows(named=True): + column = row["column"] + if column not in result.columns or column in protect: + continue + decision = _effective_decision(row["decision"], mask_undecided) + + if decision == "hash" and salt is None: + logger.warning("No salt provided; masking %s instead of hashing", column) + decision = "mask" + if decision == "code" and not (code_maps or {}).get(column): + logger.warning("No code map for %s; masking instead of coding", column) + decision = "mask" + + if decision == "drop": + result = result.drop(column) + elif decision == "mask": + label = row["mask_label"] or DEFAULT_MASK + result = result.with_columns( + pl.when(pl.col(column).is_not_null()) + .then(pl.lit(label)) + .otherwise(pl.lit(None, dtype=pl.String)) + .alias(column) + ) + elif decision == "hash": + prefix = token_prefix(column, row["entity_type"]) + result = result.with_columns(hash_column_expr(column, salt, prefix)) + elif decision == "code": + result = result.with_columns(code_column_expr(column, code_maps[column])) + return result + + +def redact_correction_log( + corr_log: pl.DataFrame, + flags: pl.DataFrame, + mask_undecided: bool = False, + protect: tuple[str, ...] = (), + salt: str | None = None, +) -> pl.DataFrame: + """Redact correction-log values for corrections that touch redacted columns. + + The correction log's ``current_value``/``new_value`` columns carry actual + data values; when the corrected column is redacted, those values must not + leave the app (they are also embedded as literals in generated correction + scripts). Hash-decision columns get hash pseudonyms (keeping the log + analyzable); mask/drop/code columns get the mask label — code maps are + built from dataset values, so a correction's old/new values may not be + in them, and masking is the safe default. + """ + if corr_log.is_empty() or flags.is_empty() or "column" not in corr_log.columns: + return corr_log + + masked_columns: list[str] = [] + hash_prefixes: dict[str, str] = {} + for row in flags.iter_rows(named=True): + column = row["column"] + if column in protect: + continue + decision = _effective_decision(row["decision"], mask_undecided) + if decision == "hash" and salt is not None: + hash_prefixes[column] = token_prefix(column, row["entity_type"]) + elif decision in ("mask", "drop", "code", "hash"): + masked_columns.append(column) + + if not masked_columns and not hash_prefixes: + return corr_log + + def _redact_field(row: dict, field: str) -> str | None: + value = row.get(field) + if value is None: + return None + column = row.get("column") + if column in hash_prefixes: + return hash_token(value, salt, hash_prefixes[column]) + if column in masked_columns: + return DEFAULT_MASK + return value + + fields = [f for f in ("current_value", "new_value") if f in corr_log.columns] + if not fields: + return corr_log + + rows = [ + {**row, **{field: _redact_field(row, field) for field in fields}} + for row in corr_log.iter_rows(named=True) + ] + return pl.DataFrame(rows, schema=corr_log.schema) diff --git a/src/datasure/processing/prep.py b/src/datasure/processing/prep.py index 6f159c91..faa8e218 100644 --- a/src/datasure/processing/prep.py +++ b/src/datasure/processing/prep.py @@ -192,6 +192,118 @@ def execute( raise OperationError(f"Failed to remove columns: {e}") from e +class RedactColumnsOperation(PrepOperation): + """Redact all values in specified columns (PII removal). + + Three methods, selected by ``prep_args.method``: + + - ``None``/``"mask"`` — every non-null value replaced with the column's + label from ``prep_args.value`` (a list aligned with ``source_columns``; + a single string applies to all columns). + - ``"hash"`` — values replaced with deterministic salted pseudonyms + (``PERSON_3fa1b9c2``); ``prep_args.value`` carries the token prefixes. + The project salt comes from the local cache (never from prep_args, so + it cannot leak through the exported prep log). Idempotent: existing + tokens pass through on replay. + - ``"code"`` — values replaced with persisted sequential category codes + (``VILLAGE_NAME_001``). ``prep_args.additional_info`` carries the + dataset alias whose code map to load/extend. Idempotent on replay. + + All methods cast the column to String and keep nulls null. + """ + + def execute( + self, data: pl.DataFrame, prep_args: PrepActionResult + ) -> tuple[pl.DataFrame, PrepActionResult]: + """Redact columns specified in prep_args.""" + try: + columns = prep_args.source_columns + self._validate_columns_exist(data, columns) + + labels = prep_args.value + if isinstance(labels, str) or labels is None: + labels = [labels or "*****"] * len(columns) + if len(labels) != len(columns): + raise ValidationError( # noqa: TRY301 + "Number of mask labels must match number of columns" + ) + + method = (prep_args.method or "mask").lower() + if method == "hash": + results = self._apply_hash(data, columns, labels) + elif method == "code": + results = self._apply_code( + data, columns, prep_args.additional_info or "" + ) + else: + results = data.with_columns( + [ + pl.when(pl.col(col).is_not_null()) + .then(pl.lit(label)) + .otherwise(pl.lit(None, dtype=pl.String)) + .alias(col) + for col, label in zip(columns, labels, strict=True) + ] + ) + + updated_prep_args = { + "action": PrepActions.redact_column.value, + "column_names": None, + "affected_count": len(columns), + "remaining_count": results.width, + "value": labels, + "method": prep_args.method, + "source_columns": columns, + "condition": None, + "failed_count": 0, + "additional_info": prep_args.additional_info, + } + + return results, PrepActionResult(**updated_prep_args) + + except (ValidationError, OperationError): + raise + except Exception as e: + raise OperationError(f"Failed to redact columns: {e}") from e + + @staticmethod + def _apply_hash( + data: pl.DataFrame, columns: list[str], prefixes: list[str] + ) -> pl.DataFrame: + """Hash columns with the project salt (from the local cache).""" + from datasure.processing.pii import get_or_create_pii_salt, hash_column_expr + + project_id = st.session_state.st_project_id + salt = get_or_create_pii_salt(project_id) + return data.with_columns( + [ + hash_column_expr(col, salt, prefix or "HASH") + for col, prefix in zip(columns, prefixes, strict=True) + ] + ) + + @staticmethod + def _apply_code(data: pl.DataFrame, columns: list[str], alias: str) -> pl.DataFrame: + """Recode columns via the persisted per-alias code maps.""" + from datasure.processing.pii import ( + code_column_expr, + extend_code_map, + load_code_maps, + save_code_maps, + token_prefix, + ) + + project_id = st.session_state.st_project_id + maps = load_code_maps(project_id, alias) + for col in columns: + values = data[col].drop_nulls().cast(pl.String).unique().to_list() + maps[col] = extend_code_map( + maps.get(col, {}), values, token_prefix(col, None) + ) + save_code_maps(project_id, alias, maps) + return data.with_columns([code_column_expr(col, maps[col]) for col in columns]) + + class RemoveRowsOperation(PrepOperation): """Remove rows based on various conditions.""" @@ -821,6 +933,7 @@ def __init__(self): PrepActions.remove_row: RemoveRowsOperation(), PrepActions.transform_column: TransformColumnsOperation(), PrepActions.add_column: AddNewColumnOperation(), + PrepActions.redact_column: RedactColumnsOperation(), } def execute_single_action( @@ -891,6 +1004,7 @@ def _generate_action_description(prep_args: PrepActionResult) -> str: PrepActions.remove_row.value: PrepConfirmationMessages.remove_rows, PrepActions.transform_column.value: PrepConfirmationMessages.transform_columns, PrepActions.add_column.value: PrepConfirmationMessages.add_new_column, + PrepActions.redact_column.value: PrepConfirmationMessages.redact_columns, } description_func = action_description_map.get(prep_args.action) if description_func: diff --git a/src/datasure/replication/data_dict.py b/src/datasure/replication/data_dict.py index a7897a7c..9c3d2f8e 100644 --- a/src/datasure/replication/data_dict.py +++ b/src/datasure/replication/data_dict.py @@ -60,7 +60,9 @@ def _range_values(series: pl.Series, col_type: str) -> list: return [lo, hi] -def _describe_column(df: pl.DataFrame, col: str, key_col: str) -> dict: +def _describe_column( + df: pl.DataFrame, col: str, key_col: str, redacted_columns: tuple[str, ...] = () +) -> dict: series = df[col] col_type = _column_type(series.dtype) is_key = bool(key_col) and col == key_col @@ -72,6 +74,9 @@ def _describe_column(df: pl.DataFrame, col: str, key_col: str) -> dict: else: column["type"] = col_type + if col in redacted_columns: + column["description"] = "Redacted (PII)" + if is_key: column["constraints"] = ["primary_key"] @@ -99,6 +104,7 @@ def generate_data_dict( key_col: str, parquet_path: str, datasure_version: str, + redacted_columns: tuple[str, ...] = (), ) -> str: """Generate a data-dict.yaml compliant data dictionary from a DataFrame. @@ -118,6 +124,9 @@ def generate_data_dict( Path to the Parquet file, relative to the yaml file's location. datasure_version : str Version of DataSure that generated this file (used in a comment). + redacted_columns : tuple[str, ...] + Columns whose values were masked for PII; annotated with a + "Redacted (PII)" description. Returns ------- @@ -127,7 +136,7 @@ def generate_data_dict( today = date.today().isoformat() columns = ( - [_describe_column(df, col, key_col) for col in df.columns] + [_describe_column(df, col, key_col, redacted_columns) for col in df.columns] if not df.is_empty() else [] ) diff --git a/src/datasure/replication/package_builder.py b/src/datasure/replication/package_builder.py index da59de2c..5e7b2151 100644 --- a/src/datasure/replication/package_builder.py +++ b/src/datasure/replication/package_builder.py @@ -10,6 +10,16 @@ import polars as pl +from datasure.processing.pii import ( + apply_pii_decisions, + build_code_maps, + empty_flags, + get_or_create_pii_salt, + load_code_maps, + load_pii_flags, + redact_correction_log, + save_code_maps, +) from datasure.replication.codebook import generate_codebook from datasure.replication.data_dict import generate_data_dict from datasure.replication.prep_script_generator import ( @@ -21,6 +31,7 @@ from datasure.replication.py_script_generators import ( SCRIPT_EXT_PY, generate_corrections_script_py, + generate_deidentify_script_py, generate_master_script_py, ) from datasure.replication.readme import generate_readme @@ -123,6 +134,47 @@ def _select_import_script( ) +def _load_pii_flags(project_id: str, alias: str) -> pl.DataFrame: + try: + return load_pii_flags(project_id, alias) + except Exception: + logger.warning("PII flags for %s not found; returning empty flags", alias) + return empty_flags() + + +def _pii_column_lists( + pii_flags: pl.DataFrame, protect: tuple[str, ...] +) -> dict[str, list[str]]: + """Split flags into per-decision column lists. + + Returns a dict with keys "masked", "hashed", "coded", "dropped", and + "kept". "undecided" flags count as masked — the export gate's + conservative default. Protected columns (e.g. the survey key) are never + redacted. + """ + lists: dict[str, list[str]] = { + "masked": [], + "hashed": [], + "coded": [], + "dropped": [], + "kept": [], + } + bucket = { + "mask": "masked", + "undecided": "masked", + "hash": "hashed", + "code": "coded", + "drop": "dropped", + "keep": "kept", + } + for row in pii_flags.iter_rows(named=True): + if row["column"] in protect: + lists["kept"].append(row["column"]) + continue + lists[bucket.get(row["decision"], "kept")].append(row["column"]) + return lists + + def _to_parquet_bytes(df: pl.DataFrame) -> bytes: """Serialize a DataFrame to Parquet bytes. @@ -157,6 +209,7 @@ def build_replication_package( scto_form_xlsx: bytes | None = None, form_def: dict | None = None, form_id: str = "", + include_pii: bool = False, on_progress: Callable[[str], None] | None = None, ) -> bytes: """Build the Stata replication package and return it as zip bytes. @@ -172,7 +225,8 @@ def build_replication_package( alias : str The dataset alias stored in DuckDB. key_col : str - The survey key column name (used in generated scripts). + The survey key column name (used in generated scripts). Never + redacted: the corrections pipeline requires it. scto_form_xlsx : bytes | None Raw bytes of the SurveyCTO XLS form, if available. When provided the file is included in docs/ as ``{survey}_questionnaire.xlsx``. @@ -182,6 +236,13 @@ def build_replication_package( instead of the generic template. form_id : str SurveyCTO form ID, used in the generated import script header. + include_pii : bool + False (default) exports a **de-identified** package: columns flagged + in the PII review are masked or dropped (undecided flags are masked) + in every bundled dataset, the codebook, the data dictionary, and the + correction log before anything is written. True exports the data + untouched and instead bundles ``5_deidentify_data.py`` (encoding the + recorded decisions) plus the flags audit log. on_progress : Callable[[str], None] | None Optional callback invoked with a human-readable status message at each major step. Callers can pass ``st.write`` to stream progress into a @@ -227,6 +288,69 @@ def _step(msg: str) -> None: correction_log = _load_correction_log(project_id, alias) _step(f"Correction log loaded — {correction_log.height:,} entries") + # PII gate: in de-identified mode (the default) every flagged column is + # masked or dropped in the data BEFORE anything downstream is generated, + # so the CSVs, Parquets, codebook, data dictionary, correction log, and + # the value literals embedded in generated correction scripts are all + # covered. The survey key column is protected (corrections need it). + pii_flags = _load_pii_flags(project_id, alias) + protect = (key_col,) if key_col else () + pii_cols = _pii_column_lists(pii_flags, protect) + + # The salt and code maps live only in the local cache. The salt is + # embedded in 5_deidentify_data.py for with-PII exports (which carry the + # raw data anyway) so its pseudonyms stay consistent with in-app exports. + pii_salt = ( + get_or_create_pii_salt(project_id) + if (pii_cols["hashed"] or include_pii) + else None + ) + code_maps: dict[str, dict[str, str]] = {} + if pii_cols["coded"]: + code_maps = build_code_maps( + [raw_df, prepped_df, corrected_df], + pii_flags, + existing=load_code_maps(project_id, alias), + mask_undecided=True, + protect=protect, + ) + save_code_maps(project_id, alias, code_maps) + + if not include_pii and not pii_flags.is_empty(): + redact_kwargs = { + "mask_undecided": True, + "protect": protect, + "salt": pii_salt, + "code_maps": code_maps, + } + raw_df = apply_pii_decisions(raw_df, pii_flags, **redact_kwargs) + prepped_df = apply_pii_decisions(prepped_df, pii_flags, **redact_kwargs) + corrected_df = apply_pii_decisions(corrected_df, pii_flags, **redact_kwargs) + correction_log = redact_correction_log( + correction_log, + pii_flags, + mask_undecided=True, + protect=protect, + salt=pii_salt, + ) + _step( + "De-identification applied — " + f"{len(pii_cols['masked'])} column(s) masked, " + f"{len(pii_cols['hashed'])} hashed, " + f"{len(pii_cols['coded'])} recoded, " + f"{len(pii_cols['dropped'])} dropped" + + ( + f", {len(pii_cols['kept'])} flagged column(s) kept" + if pii_cols["kept"] + else "" + ) + ) + elif include_pii: + _step( + "Export includes PII — data left untouched; " + "`5_deidentify_data.py` will be bundled" + ) + n_by_action = _action_summary(correction_log) # Generate scripts @@ -290,6 +414,20 @@ def _step(msg: str) -> None: ) _step("Python replication scripts generated (`uv run`-ready)") + deidentify_script_py = ( + generate_deidentify_script_py( + pii_flags=pii_flags, + project_name=project_name, + survey_name=survey_name, + datasure_version=datasure_version, + salt=pii_salt, + ) + if include_pii + else None + ) + if deidentify_script_py is not None: + _step("`5_deidentify_data.py` generated") + readme = generate_readme( project_name=project_name, survey_name=survey_name, @@ -301,6 +439,12 @@ def _step(msg: str) -> None: corrected_rows=corrected_df.height if not corrected_df.is_empty() else 0, n_corrections_by_action=n_by_action, include_scto_form=scto_form_xlsx is not None, + include_pii=include_pii, + pii_masked_columns=pii_cols["masked"], + pii_hashed_columns=pii_cols["hashed"], + pii_coded_columns=pii_cols["coded"], + pii_dropped_columns=pii_cols["dropped"], + pii_kept_columns=pii_cols["kept"], ) _step("README generated") @@ -319,6 +463,11 @@ def _step(msg: str) -> None: key_col=key_col, parquet_path=corrected_parquet_path, datasure_version=datasure_version, + redacted_columns=( + tuple(pii_cols["masked"] + pii_cols["hashed"] + pii_cols["coded"]) + if not include_pii + else () + ), ) _step("data-dict.yaml generated") @@ -383,6 +532,11 @@ def _step(msg: str) -> None: zf.writestr( f"{root}/2_scripts/4_corrections.{SCRIPT_EXT_PY}", corrections_script_py ) + if deidentify_script_py is not None: + zf.writestr( + f"{root}/2_scripts/5_deidentify_data.{SCRIPT_EXT_PY}", + deidentify_script_py, + ) # 3_data/ zf.writestr(f"{root}/3_data/1_raw/{safe_survey}_raw.csv", raw_csv) @@ -408,6 +562,10 @@ def _step(msg: str) -> None: zf.writestr(f"{root}/4_output/2_figures/.gitkeep", "") zf.writestr(f"{root}/4_output/3_logs/correction_log.csv", correction_log_csv) zf.writestr(f"{root}/4_output/3_logs/prep_log.csv", prep_log_csv) + # The flags audit log carries sample matched values (actual data), so + # it is bundled only when the export already includes PII. + if include_pii and not pii_flags.is_empty(): + zf.writestr(f"{root}/4_output/3_logs/pii_flags.csv", pii_flags.write_csv()) logger.info("Zip assembled: %s", root) _step("Zip file assembled") diff --git a/src/datasure/replication/prep_script_generator.py b/src/datasure/replication/prep_script_generator.py index 039bab79..712a4ed8 100644 --- a/src/datasure/replication/prep_script_generator.py +++ b/src/datasure/replication/prep_script_generator.py @@ -27,6 +27,7 @@ _ACT_REMOVE_ROW = "remove row(s)" _ACT_TRANSFORM = "transform column(s)" _ACT_ADD_COL = "add new column" +_ACT_REDACT_COL = "redact column(s)" _METH_BY_INDEX = "by row index" @@ -108,6 +109,37 @@ def _stata_remove_columns(args: dict, _desc: str) -> list[str]: return [f"drop {_col_list(cols)}"] +def _stata_redact_columns(args: dict, _desc: str) -> list[str]: + """Mask every value in the given columns with its redaction label.""" + cols = _cols(args) + method = (args.get("method") or "mask").lower() + + if method in ("hash", "code"): + # Salted HMAC pseudonyms / persisted category codes cannot be + # reproduced in Stata (no salt or code map is ever exported). The + # bundled raw dataset already carries the tokens in de-identified + # exports, so downstream steps still line up. + return [ + f"{_C} NOTE: '{method}' redaction of {_col_list(cols)} was applied " + "in DataSure and cannot be reproduced here.", + f"{_C} The exported datasets already contain the pseudonym tokens.", + ] + + labels = args.get("value") or [] + if isinstance(labels, str): + labels = [labels] * len(cols) + if len(labels) != len(cols): + labels = ["*****"] * len(cols) + + lines: list[str] = [] + for col, label in zip(cols, labels, strict=True): + lines += [ + f"tostring {col}, replace force", + f'replace {col} = "{label}" if {col} != ""', + ] + return lines + + def _drop_by_index(values: list) -> list[str]: idx_vals = [str(int(v)) for v in values if str(v).strip() not in (",", "")] if not idx_vals: @@ -265,6 +297,7 @@ def _stata_add_column(args: dict, _desc: str) -> list[str]: _ACT_REMOVE_ROW: _stata_remove_rows, _ACT_TRANSFORM: _stata_transform_column, _ACT_ADD_COL: _stata_add_column, + _ACT_REDACT_COL: _stata_redact_columns, } # --------------------------------------------------------------------------- diff --git a/src/datasure/replication/py_prep_script_generator.py b/src/datasure/replication/py_prep_script_generator.py index 95ecb100..0e120d8b 100644 --- a/src/datasure/replication/py_prep_script_generator.py +++ b/src/datasure/replication/py_prep_script_generator.py @@ -16,6 +16,7 @@ from datasure.replication.prep_script_generator import ( _ACT_ADD_COL, + _ACT_REDACT_COL, _ACT_REMOVE_COL, _ACT_REMOVE_ROW, _ACT_TRANSFORM, @@ -162,6 +163,39 @@ def _py_remove_columns(args: dict, _desc: str) -> list[str]: return [f"df = df.drop({cols!r})"] +def _py_redact_columns(args: dict, _desc: str) -> list[str]: + """Mask every value in the given columns with its redaction label.""" + cols = _cols(args) + method = (args.get("method") or "mask").lower() + + if method in ("hash", "code"): + # The project salt / code maps live only in DataSure's local cache + # and are never exported, so this step cannot be re-derived here. + # De-identified exports already carry the tokens in the bundled raw + # dataset (the transformation is inherited on replay); with-PII + # exports include 5_deidentify_data.py for consistent pseudonyms. + return [ + f"# NOTE: {method!r} redaction of {cols!r} was applied in DataSure", + "# and cannot be reproduced here; exported datasets already carry", + "# the pseudonym tokens.", + ] + + labels = args.get("value") or [] + if isinstance(labels, str): + labels = [labels] * len(cols) + if len(labels) != len(cols): + labels = ["*****"] * len(cols) + + lines: list[str] = [] + for col, label in zip(cols, labels, strict=True): + lines.append( + f"df = df.with_columns(pl.when(pl.col({col!r}).is_not_null())" + f".then(pl.lit({label!r}))" + f".otherwise(pl.lit(None, dtype=pl.String)).alias({col!r}))" + ) + return lines + + def _py_remove_rows(args: dict, _desc: str) -> list[str]: method = args.get("method", "") cols = _cols(args) @@ -470,6 +504,8 @@ def generate_prepare_data_script_py( lines += _py_transform_column(args, description) elif action == _ACT_ADD_COL: lines += _py_add_column(args, description, seed=survey_name) + elif action == _ACT_REDACT_COL: + lines += _py_redact_columns(args, description) else: lines.append(f"# NOTE: unknown action {action!r}") lines.append("") diff --git a/src/datasure/replication/py_script_generators.py b/src/datasure/replication/py_script_generators.py index f73a374c..3c6ea1a8 100644 --- a/src/datasure/replication/py_script_generators.py +++ b/src/datasure/replication/py_script_generators.py @@ -182,6 +182,190 @@ def generate_corrections_script_py( return "\n".join(lines) + "\n" +def generate_deidentify_script_py( + pii_flags: pl.DataFrame, + project_name: str, + survey_name: str, + datasure_version: str, + salt: str | None = None, +) -> str: + """Generate a Python script that de-identifies the bundled datasets. + + Included in packages exported *with* PII: it encodes the PII decisions + recorded in DataSure (mask/hash/code/drop per column; undecided treated + as mask) so a recipient can produce de-identified copies of every + bundled Parquet dataset with ``uv run 5_deidentify_data.py``. + + Parameters + ---------- + pii_flags : pl.DataFrame + PII flags table (processing/pii.py canonical schema). + project_name : str + Human-readable project name. + survey_name : str + Human-readable survey name. + datasure_version : str + Version of DataSure that generated this package. + salt : str | None + The project's PII salt, embedded so hash pseudonyms match in-app + de-identified exports. Only ever passed for with-PII packages, + which carry the raw data anyway; keep the package (and this + script) confidential. + + Returns + ------- + str + Python script content as a string. + """ + header = _py_header( + "De-identification Script (Python)", + project_name, + survey_name, + datasure_version, + ["polars"], + ) + safe_survey = _safe_survey(survey_name) + + def _prefix(column: str, entity_type: str | None) -> str: + raw = entity_type or column + return "".join(ch if ch.isalnum() else "_" for ch in raw).upper() + + decisions: list[tuple[str, str, str]] = [] + for row in pii_flags.iter_rows(named=True): + decision = row["decision"] + if decision == "undecided": + decision = "mask" # conservative default + if decision == "hash" and salt is None: + decision = "mask" # no salt available — privacy-safe fallback + if decision in ("mask", "drop"): + decisions.append((row["column"], decision, row["mask_label"] or "*****")) + elif decision in ("hash", "code"): + decisions.append( + (row["column"], decision, _prefix(row["column"], row["entity_type"])) + ) + + decision_lines = ( + ["DECISIONS = ["] + [f" {d!r}," for d in decisions] + ["]"] + if decisions + else ["DECISIONS = [] # no PII flags were recorded in DataSure"] + ) + + has_hash = any(action == "hash" for _, action, _ in decisions) + + lines = [ + header, + "import hashlib", + "import hmac", + "from pathlib import Path", + "", + "import polars as pl", + "", + "PKG_ROOT = Path(__file__).resolve().parents[1]", + "DATASETS = [", + f' PKG_ROOT / "3_data" / "1_raw" / "{safe_survey}_raw.parquet",', + f' PKG_ROOT / "3_data" / "2_intermediate" / "{safe_survey}_prepped.parquet",', + f' PKG_ROOT / "3_data" / "3_final" / "{safe_survey}_corrected.parquet",', + "]", + "", + "# (column, action, label) decisions recorded in DataSure's PII", + '# review. "mask" replaces every non-null value with the label;', + '# "hash" replaces values with deterministic salted pseudonyms', + '# (label is the token prefix); "code" assigns sequential category', + '# codes; "drop" removes the column.', + *decision_lines, + "", + *( + [ + "# Project PII salt — embedded because this package already", + "# contains the raw data; it keeps hash pseudonyms consistent", + "# with DataSure's own de-identified exports. Do not share it", + "# separately from the package.", + f"SALT = {salt!r}", + "", + "", + "def hash_token(value, prefix):", + " digest = hmac.new(", + " SALT.encode(), str(value).encode(), hashlib.sha256", + " ).hexdigest()", + ' return f"{prefix}_{digest[:8]}"', + "", + ] + if has_hash + else [] + ), + "", + "# Category codes are assigned from the sorted distinct values across", + "# all bundled datasets (consistent within this run; note in-app", + "# exports use a persisted random-order map, so tokens differ).", + "code_maps: dict = {}", + 'for column, action, _label in [d for d in DECISIONS if d[1] == "code"]:', + " values: set = set()", + " for path in DATASETS:", + " if not path.exists():", + " continue", + " frame = pl.read_parquet(path)", + " if column in frame.columns:", + " values.update(", + " frame[column].drop_nulls().cast(pl.String).unique().to_list()", + " )", + " prefix = _label", + " code_maps[column] = {", + ' value: f"{prefix}_{i:03d}"', + " for i, value in enumerate(sorted(values), start=1)", + " }", + "", + "for path in DATASETS:", + " if not path.exists():", + " continue", + " df = pl.read_parquet(path)", + " for column, action, label in DECISIONS:", + " if column not in df.columns:", + " continue", + ' if action == "drop":', + " df = df.drop(column)", + ' elif action == "hash":', + " df = df.with_columns(", + " pl.col(column)", + " .cast(pl.String)", + " .map_elements(", + " lambda v, p=label: hash_token(v, p),", + " return_dtype=pl.String,", + " )", + " .alias(column)", + " )", + ' elif action == "code":', + " df = df.with_columns(", + " pl.when(pl.col(column).is_not_null())", + " .then(", + " pl.col(column)", + " .cast(pl.String)", + ' .replace_strict(code_maps[column], default="*****")', + " )", + " .otherwise(pl.lit(None, dtype=pl.String))", + " .alias(column)", + " )", + " else:", + " df = df.with_columns(", + " pl.when(pl.col(column).is_not_null())", + " .then(pl.lit(label))", + " .otherwise(pl.lit(None, dtype=pl.String))", + " .alias(column)", + " )", + ' out = path.with_name(path.stem + "_deidentified.parquet")', + " df.write_parquet(out)", + ' print(f"Wrote {out} — {df.height:,} rows")', + "", + 'print("")', + 'print("WARNING: De-identification is not anonymization. Even with")', + 'print("direct identifiers masked or dropped, subjects may remain")', + 'print("identifiable through combinations of remaining variables")', + 'print("(e.g. age, location, occupation). Deterministic pseudonyms")', + 'print("(hash/code) also preserve frequencies, so rare categories")', + 'print("stay recognizable by rarity. Review before sharing.")', + ] + return "\n".join(lines) + "\n" + + def generate_master_script_py( project_name: str, survey_name: str, diff --git a/src/datasure/replication/readme.py b/src/datasure/replication/readme.py index 470a1816..3d2dd9b9 100644 --- a/src/datasure/replication/readme.py +++ b/src/datasure/replication/readme.py @@ -16,6 +16,12 @@ def generate_readme( corrected_rows: int, n_corrections_by_action: dict[str, int], include_scto_form: bool = False, + include_pii: bool = True, + pii_masked_columns: list[str] | None = None, + pii_hashed_columns: list[str] | None = None, + pii_coded_columns: list[str] | None = None, + pii_dropped_columns: list[str] | None = None, + pii_kept_columns: list[str] | None = None, ) -> str: """Generate a Markdown README for the replication package. @@ -41,6 +47,21 @@ def generate_readme( Count of corrections keyed by action type. include_scto_form : bool Whether the SurveyCTO XLS questionnaire was included in the package. + include_pii : bool + Whether the package was exported with PII (True) or de-identified + (False). + pii_masked_columns : list[str] | None + Columns masked in a de-identified export (or slated for masking by + ``5_deidentify_data.py`` in a with-PII export). + pii_hashed_columns : list[str] | None + Columns replaced with salted hash pseudonyms (deterministic tokens + that preserve categorical structure). + pii_coded_columns : list[str] | None + Columns recoded with sequential category codes. + pii_dropped_columns : list[str] | None + Columns dropped in a de-identified export (or slated for dropping). + pii_kept_columns : list[str] | None + Columns flagged in the PII review but kept by reviewer decision. Returns ------- @@ -51,6 +72,71 @@ def generate_readme( safe_survey = survey_name.lower().replace(" ", "_") safe_project = project_name.lower().replace(" ", "_") + pii_masked_columns = pii_masked_columns or [] + pii_hashed_columns = pii_hashed_columns or [] + pii_coded_columns = pii_coded_columns or [] + pii_dropped_columns = pii_dropped_columns or [] + pii_kept_columns = pii_kept_columns or [] + + def _fmt_cols(columns: list[str]) -> str: + return ", ".join(f"`{c}`" for c in columns) if columns else "none" + + indirect_warning = [ + "> **Warning — indirect identifiers.** De-identification is not", + "> anonymization: even with direct identifiers masked or dropped,", + "> subjects may remain identifiable through combinations of the", + "> remaining variables (e.g. age, location, occupation, household", + "> composition). Review the data before sharing it further.", + ] + if include_pii: + pii_section = [ + "## PII & De-identification", + "", + "**This package was exported WITH personally identifiable", + "information (PII).** Store it only in encrypted,", + "access-controlled locations, in line with your organization's", + "data-security policy.", + "", + "The PII decisions recorded in DataSure are encoded in", + "`2_scripts/5_deidentify_data.py`. Run it to produce", + "de-identified copies of every bundled dataset", + "(`*_deidentified.parquet`):", + "", + "```", + "uv run 5_deidentify_data.py", + "```", + "", + f"- Columns to be masked: {_fmt_cols(pii_masked_columns)}", + f"- Columns to be hashed (salted pseudonyms): {_fmt_cols(pii_hashed_columns)}", + f"- Columns to be recoded (category codes): {_fmt_cols(pii_coded_columns)}", + f"- Columns to be dropped: {_fmt_cols(pii_dropped_columns)}", + f"- Flagged but kept by reviewer decision: {_fmt_cols(pii_kept_columns)}", + "", + "The full flag audit trail is in `4_output/3_logs/pii_flags.csv`.", + "", + *indirect_warning, + "", + ] + else: + pii_section = [ + "## PII & De-identification", + "", + "**This package was exported de-identified.** Columns flagged in", + "DataSure's PII review were masked or dropped in every bundled", + "dataset, the codebook, the data dictionary, and the correction", + "log before export.", + "", + f"- Masked columns: {_fmt_cols(pii_masked_columns)}", + "- Hashed columns (salted pseudonyms — deterministic, so", + f" group-bys/joins/frequencies still work): {_fmt_cols(pii_hashed_columns)}", + f"- Recoded columns (category codes): {_fmt_cols(pii_coded_columns)}", + f"- Dropped columns: {_fmt_cols(pii_dropped_columns)}", + f"- Flagged but kept by reviewer decision: {_fmt_cols(pii_kept_columns)}", + "", + *indirect_warning, + "", + ] + if n_corrections_by_action: action_rows = "\n".join( f"| {action:<28} | {count:>5} |" @@ -83,6 +169,7 @@ def generate_readme( "data dictionary are also included, along with Parquet copies of the", "raw/prepped/corrected datasets so the package is usable without Stata.", "", + *pii_section, "## Folder Structure", "", "```text", @@ -98,7 +185,14 @@ def generate_readme( "│ ├── 1_install_packages.do", "│ ├── 2_import_data.do # Stata only — imports raw CSV as DTA", "│ ├── 3_prepare_data.do / .py", - "│ └── 4_corrections.do / .py", + *( + [ + "│ ├── 4_corrections.do / .py", + "│ └── 5_deidentify_data.py # De-identify bundled datasets", + ] + if include_pii + else ["│ └── 4_corrections.do / .py"] + ), "├── 3_data/", f"│ ├── 1_raw/ # {safe_survey}_raw.csv/.parquet (pre-generated);", "│ │ # .dta (generated by the do-files)", @@ -181,6 +275,14 @@ def generate_readme( f"| {'2_scripts/3_prepare_data.py':<45} | {'Data preparation steps (Python)':<40} |", f"| {'2_scripts/4_corrections.do':<45} | {'All data corrections (Stata)':<40} |", f"| {'2_scripts/4_corrections.py':<45} | {'All data corrections (Python)':<40} |", + *( + [ + f"| {'2_scripts/5_deidentify_data.py':<45} | {'De-identifies bundled datasets':<40} |", + f"| {'4_output/3_logs/pii_flags.csv':<45} | {'PII review flag audit trail':<40} |", + ] + if include_pii + else [] + ), f"| {'3_data/2_intermediate/' + safe_survey + '_prepped.dta':<45} | {'Dataset after prep steps (generated)':<40} |", f"| {'3_data/2_intermediate/' + safe_survey + '_prepped.parquet':<45} | {'Same, pre-generated as Parquet':<40} |", f"| {'3_data/3_final/' + safe_survey + '_corrected.dta':<45} | {'Final corrected dataset (generated)':<40} |", diff --git a/src/datasure/utils/prep_utils.py b/src/datasure/utils/prep_utils.py index 640cbde8..49b5c379 100644 --- a/src/datasure/utils/prep_utils.py +++ b/src/datasure/utils/prep_utils.py @@ -35,6 +35,7 @@ class PrepDescriptions: PrepActions.add_column.value: "Create a new column using various calculation methods or data sources", PrepActions.remove_column.value: "Delete selected columns from your dataset to reduce size or remove unnecessary data", PrepActions.remove_row.value: "Delete specific rows based on index position or conditional criteria", + PrepActions.redact_column.value: "Mask all values in selected columns with a redaction label (e.g. [PERSON]) to remove PII", } # Add Column Methods @@ -257,6 +258,24 @@ def remove_columns(cls, result: PrepActionResult) -> str: f"your dataset. {result.remaining_count} {remaining_text} remaining." ) + @classmethod + def redact_columns(cls, result: PrepActionResult) -> str: + """Generate message for redacting columns.""" + column_count = ( + len(result.source_columns) if isinstance(result.source_columns, list) else 1 + ) + column_text = cls._pluralize(column_count, "column") + column_display = cls._format_column_names(result.source_columns) + method = (result.method or "mask").lower() + how = { + "hash": "replaced with salted hash pseudonyms", + "code": "replaced with category codes", + }.get(method, "masked with a redaction label") + return ( + f"✓ {column_count} {column_text} redacted. All values in " + f"{column_display} were {how}." + ) + @classmethod def remove_rows(cls, result: PrepActionResult) -> str: """Generate message for removing rows.""" @@ -511,6 +530,7 @@ def _resolve_remove_row(cls, result: PrepActionResult) -> str: PrepActions.add_column.value: "_resolve_add_column", PrepActions.remove_column.value: "remove_columns", PrepActions.remove_row.value: "_resolve_remove_row", + PrepActions.redact_column.value: "redact_columns", } @classmethod diff --git a/src/datasure/views/prep_view.py b/src/datasure/views/prep_view.py index 64b441ae..fabb1298 100644 --- a/src/datasure/views/prep_view.py +++ b/src/datasure/views/prep_view.py @@ -1,3 +1,5 @@ +import logging + import pandas as pd import polars as pl import streamlit as st @@ -15,6 +17,7 @@ PrepOperations, PrepRowConditions, ) +from datasure.processing import pii from datasure.processing.prep import prep_apply_action from datasure.utils.dataframe_utils import ColumnByType, get_df_columns from datasure.utils.duckdb_utils import ( @@ -45,6 +48,8 @@ section_header, ) +logger = logging.getLogger(__name__) + # === PAGE GUARDS === # # Get project id @@ -209,6 +214,7 @@ def _is_prep_form_incomplete(action: str, prep_args: dict) -> bool: PrepActions.transform_column.value: _is_transform_column_incomplete, PrepActions.remove_column.value: lambda args: not args.get("source_columns"), PrepActions.remove_row.value: _is_remove_row_incomplete, + PrepActions.redact_column.value: lambda args: not args.get("source_columns"), } validator = validators.get(action) @@ -779,6 +785,40 @@ def remove_column_handler(self) -> dict | None: "additional_info": None, } + def redact_column_handler(self) -> dict | None: + """Handle redact column UI and logic (PII masking).""" + dp_prep_redact_cols = st.multiselect( + label="Select columns to redact", + options=self.all_cols, + help=( + "All values in the selected columns are replaced with the " + "redaction label. Use the PII Review section above to see " + "which columns are suspected to contain PII." + ), + key=f"st_sb_redact_cols{self.step_index}", + ) + dp_prep_redact_label = st.text_input( + label="Redaction label", + value="*****", + help="The label written in place of every value, e.g. [PERSON]", + key=f"st_sb_redact_label{self.step_index}", + ) + + return { + "action": PrepActions.redact_column.value, + "column_names": None, + "affected_count": len(dp_prep_redact_cols) if dp_prep_redact_cols else 0, + "remaining_count": self.prep_data.shape[1], + "value": [dp_prep_redact_label or "*****"] * len(dp_prep_redact_cols) + if dp_prep_redact_cols + else [], + "method": None, + "source_columns": dp_prep_redact_cols if dp_prep_redact_cols else [], + "condition": None, + "failed_count": None, + "additional_info": None, + } + def remove_rows_handler(self) -> dict | None: """Handle remove rows UI and logic. @@ -905,6 +945,9 @@ def prep_add_step(prep_data: pl.DataFrame | pd.DataFrame, step_index: int): elif dp_prep_action == PrepActions.remove_row.value: prep_args = prep_handler.remove_rows_handler() + elif dp_prep_action == PrepActions.redact_column.value: + prep_args = prep_handler.redact_column_handler() + if prep_args is None: st.warning("Please complete the form to add a new preparation step.") return @@ -987,6 +1030,251 @@ def _remove_prep_step(action_index: str, log=prep_log, alias=label): ) +# --- PII Review ---# + + +def _pii_model_controls(section_index: int) -> tuple[str, bool]: + """Render language/model controls; return (language, model_installed).""" + models = pii.installed_models() + lang_col, status_col = st.columns((0.3, 0.7)) + + with lang_col: + language = ( + st.selectbox( + label="Detection language", + options=list(pii.PII_LANGUAGES), + format_func=lambda code: pii.PII_LANGUAGES[code], + key=f"st_pii_lang_{section_index}", + help="Language of the survey responses, used for value scanning", + ) + or "en" + ) + + model_name = pii.PII_MODEL_OPTIONS[language] + model_ready = models.get(language, False) + + with status_col: + if model_ready: + st.caption( + f":material/check_circle: NER model **{model_name}** installed — " + "column names and values will both be scanned." + ) + else: + st.caption( + f"NER model **{model_name}** is not installed — only column-name " + "heuristics will run. Download the model to enable value scanning." + ) + if st.button( + ":material/download: Download model", + key=f"st_pii_download_{section_index}", + ): + with st.spinner(f"Downloading {model_name}..."): + success, message = pii.download_model(language) + if success: + st.success(message) + st.rerun() + else: + st.error(message) + + return language, model_ready + + +def _apply_pii_decisions_as_prep_steps( + flags: pl.DataFrame, prep_columns: list[str], alias: str +) -> int: + """Convert mask/hash/code/drop decisions into prep actions. + + Returns the number of prep steps applied. + """ + mask_cols: list[str] = [] + mask_labels: list[str] = [] + hash_cols: list[str] = [] + hash_prefixes: list[str] = [] + code_cols: list[str] = [] + drop_cols: list[str] = [] + for row in flags.iter_rows(named=True): + if row["column"] not in prep_columns: + continue + if row["decision"] == "mask": + mask_cols.append(row["column"]) + mask_labels.append(row["mask_label"] or pii.DEFAULT_MASK) + elif row["decision"] == "hash": + hash_cols.append(row["column"]) + hash_prefixes.append(pii.token_prefix(row["column"], row["entity_type"])) + elif row["decision"] == "code": + code_cols.append(row["column"]) + elif row["decision"] == "drop": + drop_cols.append(row["column"]) + + steps = 0 + if mask_cols: + prep_apply_action( + project_id, + alias, + PrepActionResult( + action=PrepActions.redact_column.value, + source_columns=mask_cols, + value=mask_labels, + ), + ) + steps += 1 + if hash_cols: + prep_apply_action( + project_id, + alias, + PrepActionResult( + action=PrepActions.redact_column.value, + source_columns=hash_cols, + value=hash_prefixes, + method="hash", + ), + ) + steps += 1 + if code_cols: + prep_apply_action( + project_id, + alias, + PrepActionResult( + action=PrepActions.redact_column.value, + source_columns=code_cols, + method="code", + additional_info=alias, + ), + ) + steps += 1 + if drop_cols: + prep_apply_action( + project_id, + alias, + PrepActionResult( + action=PrepActions.remove_column.value, + source_columns=drop_cols, + ), + ) + steps += 1 + return steps + + +def pii_review_section(prep_data: pl.DataFrame, alias: str, section_index: int) -> None: + """Render the PII Review section for one dataset tab.""" + with st.container(border=True): + section_header("PII Review", icon=":material/shield:") + st.caption( + "Scan this dataset for columns and values suspected to contain " + "personally identifiable information (PII), then decide per column: " + "**mask** (replace values with a label), **hash** (salted " + "pseudonyms — deterministic tokens that keep categorical analysis " + "working), **code** (readable category codes like VILLAGE_001), " + "**drop** (remove the column), or **keep**. All decisions can be " + "applied as prep steps and are also enforced at export time." + ) + + language, model_ready = _pii_model_controls(section_index) + + if st.button( + ":material/search: Scan for PII", + key=f"st_pii_scan_{section_index}", + type="secondary", + ): + with st.spinner("Scanning for PII..."): + try: + new_flags = pii.run_pii_scan( + prep_data, language=language, use_value_scan=model_ready + ) + merged = pii.merge_with_existing( + new_flags, pii.load_pii_flags(project_id, alias) + ) + pii.save_pii_flags(project_id, alias, merged) + st.success( + f"Scan complete — {merged.height} column(s) flagged." + if merged.height + else "Scan complete — no columns flagged." + ) + except Exception as e: + # UI boundary: a failed scan must not crash the page. + logger.exception("PII scan failed for alias %s", alias) + st.error(f"PII scan failed: {e}") + + flags = pii.load_pii_flags(project_id, alias) + if flags.is_empty(): + st.info( + "No PII flags yet. Click **Scan for PII** to check column names" + + (" and values." if model_ready else ".") + ) + return + + edited = st.data_editor( + flags, + column_config={ + "column": st.column_config.TextColumn("Column"), + "source": st.column_config.TextColumn( + "Flagged by", help="Detection pass that flagged this column" + ), + "entity_type": st.column_config.TextColumn("Entity"), + "hit_rate": st.column_config.NumberColumn("Hit rate", format="%.2f"), + "sample_matches": st.column_config.TextColumn("Sample flagged values"), + "decision": st.column_config.SelectboxColumn( + "Decision", options=list(pii.FLAG_DECISIONS), required=True + ), + "mask_label": st.column_config.TextColumn( + "Mask label", help="Label written in place of masked values" + ), + "scanned_at": None, + }, + disabled=["column", "source", "entity_type", "hit_rate", "sample_matches"], + hide_index=True, + width="stretch", + key=f"st_pii_editor_{section_index}", + ) + + save_col, apply_col = st.columns(2) + with save_col: + if st.button( + "Save decisions", + key=f"st_pii_save_{section_index}", + width="stretch", + ): + pii.save_pii_flags(project_id, alias, pl.DataFrame(edited)) + st.success("PII decisions saved.") + with apply_col: + if st.button( + "Apply decisions as prep steps", + key=f"st_pii_apply_{section_index}", + width="stretch", + type="primary", + help=( + "Masks, hash pseudonyms, category codes, and column drops " + "are added to the change log below, so they are replayable " + "and removable like any prep step." + ), + ): + try: + edited_flags = pl.DataFrame(edited) + pii.save_pii_flags(project_id, alias, edited_flags) + steps = _apply_pii_decisions_as_prep_steps( + edited_flags, prep_data.columns, alias + ) + if steps: + st.success(f"{steps} preparation step(s) added.") + st.rerun() + else: + st.info("No mask, hash, code, or drop decisions to apply.") + except Exception as e: + # UI boundary: surface and log, never crash the page. + logger.exception( + "Applying PII decisions failed for alias %s", alias + ) + st.error(f"Applying PII decisions failed: {e}") + + st.warning( + "De-identification is not anonymization: even with direct " + "identifiers masked or dropped, subjects may remain identifiable " + "through combinations of remaining variables (e.g. age, location, " + "occupation). Review before sharing any export.", + icon=":material/warning:", + ) + + # === PAGE LAYOUT === # # -- DATA PREP PAGE --# @@ -1067,6 +1355,8 @@ def _remove_prep_step(action_index: str, log=prep_log, alias=label): with pt2: prep_remove_step() + pii_review_section(prep_data, label, section_index=i) + with st.container(border=True): section_header("Change Log") diff --git a/src/datasure/views/replication_view.py b/src/datasure/views/replication_view.py index ec77dc19..ef4b0b1e 100644 --- a/src/datasure/views/replication_view.py +++ b/src/datasure/views/replication_view.py @@ -9,6 +9,7 @@ import polars as pl import streamlit as st +from datasure.processing import pii from datasure.replication.package_builder import build_replication_package from datasure.utils.cache_utils import get_cache_path from datasure.utils.config_utils import ConfigurationService @@ -78,23 +79,33 @@ def _resolve_page_config( return alias, key_col -def _zip_filename(project_name: str, page_name: str) -> str: +def _zip_filename(project_name: str, page_name: str, deidentified: bool = False) -> str: """Return the download filename for the replication package zip. - Example: ``replication_ors_zinc_community_hfc.zip`` + Example: ``replication_ors_zinc_community_hfc.zip`` (or with a + ``_deidentified`` suffix for de-identified exports). """ safe_p = project_name.lower().replace(" ", "_") safe_pg = page_name.lower().replace(" ", "_") - return f"replication_{safe_p}_{safe_pg}.zip" + suffix = "_deidentified" if deidentified else "" + return f"replication_{safe_p}_{safe_pg}{suffix}.zip" -def _package_tree(safe_project: str, safe_page: str, is_scto: bool) -> str: +def _package_tree( + safe_project: str, safe_page: str, is_scto: bool, include_pii: bool = False +) -> str: """Return the zip contents as a plain-text directory tree.""" surveys_line = ( f"│ ├── 1_surveys/{safe_page}_questionnaire.xlsx\n" if is_scto else "│ ├── 1_surveys/\n" ) + scripts_tail = ( + "│ ├── 4_corrections.do / .py\n" + "│ └── 5_deidentify_data.py (de-identify bundled datasets)\n" + if include_pii + else "│ └── 4_corrections.do / .py\n" + ) return ( f"replication_{safe_project}_{safe_page}/\n" f"├── README.md\n" @@ -110,7 +121,7 @@ def _package_tree(safe_project: str, safe_page: str, is_scto: bool) -> str: f"│ ├── 1_install_packages.do\n" f"│ ├── 2_import_data.do (Stata only)\n" f"│ ├── 3_prepare_data.do / .py\n" - f"│ └── 4_corrections.do / .py\n" + f"{scripts_tail}" f"├── 3_data/\n" f"│ ├── 1_raw/{safe_page}_raw.csv, {safe_page}_raw.parquet\n" f"│ ├── 2_intermediate/{safe_page}_prepped.parquet\n" @@ -125,7 +136,11 @@ def _package_tree(safe_project: str, safe_page: str, is_scto: bool) -> str: f" │ ├── 0_main.log\n" f" │ └── ...per-script logs\n" f" ├── correction_log.csv\n" - f" └── prep_log.csv" + + ( + " ├── prep_log.csv\n └── pii_flags.csv" + if include_pii + else " └── prep_log.csv" + ) ) @@ -242,10 +257,59 @@ def _render_config_details(page_configs: pl.DataFrame) -> None: ) -def _render_package_preview(safe_project: str, safe_page: str, is_scto: bool) -> None: +def _render_package_preview( + safe_project: str, safe_page: str, is_scto: bool, include_pii: bool = False +) -> None: """Render the collapsible zip-contents preview.""" with st.expander("What's inside the zip?", expanded=False): - st.code(_package_tree(safe_project, safe_page, is_scto), language="text") + st.code( + _package_tree(safe_project, safe_page, is_scto, include_pii), + language="text", + ) + + +def _render_pii_flags_summary(project_id: str, alias: str, include_pii: bool) -> None: + """Show what the export will do with each PII-flagged column.""" + flags = pii.load_pii_flags(project_id, alias) + if flags.is_empty(): + st.info( + "No PII scan has been run for this dataset. Nothing will be " + "redacted — run **Scan for PII** on the Prepare Data page to " + "flag columns first.", + icon=":material/shield:", + ) + return + + if include_pii: + action_expr = pl.lit("exported as-is (PII included)") + else: + action_expr = ( + pl.when(pl.col("decision").is_in(["mask", "undecided"])) + .then(pl.lit("masked")) + .when(pl.col("decision") == "hash") + .then(pl.lit("hashed (salted pseudonyms)")) + .when(pl.col("decision") == "code") + .then(pl.lit("recoded (category codes)")) + .when(pl.col("decision") == "drop") + .then(pl.lit("dropped")) + .otherwise(pl.lit("kept (reviewer decision)")) + ) + summary = flags.select( + pl.col("column"), + pl.col("source").alias("flagged by"), + pl.col("decision"), + action_expr.alias("export action"), + ) + st.dataframe(summary, hide_index=True, width="stretch") + + kept = flags.filter(pl.col("decision") == "keep") + if not include_pii and not kept.is_empty(): + st.warning( + f"{kept.height} flagged column(s) will be exported unredacted " + "because the reviewer chose **keep**: " + + ", ".join(f"`{c}`" for c in kept["column"].to_list()), + icon=":material/warning:", + ) # ── Page ────────────────────────────────────────────────────────────────────── @@ -295,22 +359,13 @@ def _render_package_preview(safe_project: str, safe_page: str, is_scto: bool) -> """ ) -st.error( - "**This package contains personally identifiable information (PII).** " - "The zip file includes raw survey data with respondent PII. " - "You must download it only to an **encrypted, access-controlled storage location** " - "in compliance with IPA data security and confidentiality policies. " - "Do not store this package on unencrypted drives, shared folders, or cloud services " - "that are not approved for confidential data.", - icon=":material/lock:", -) st.divider() # ── Page selector ───────────────────────────────────────────────────────────── section_header("Configure export") -page_name_col, _ = st.columns(2) +page_name_col, export_mode_col = st.columns(2) with page_name_col: selected_page: str | None = st.selectbox( "Page Name", @@ -321,15 +376,52 @@ def _render_package_preview(safe_project: str, safe_page: str, is_scto: bool) -> "The associated dataset will be included as raw data in the package." ), ) +with export_mode_col: + export_mode = st.radio( + "Export mode", + options=["De-identified (recommended)", "Include PII"], + help=( + "De-identified: columns flagged in the PII review are masked or " + "dropped in every exported file. Include PII: data is exported " + "as-is, with a de-identification script bundled." + ), + key="_replication_export_mode", + ) +include_pii = export_mode == "Include PII" + +if include_pii: + st.error( + "**This package will contain personally identifiable information " + "(PII).** The zip file includes raw survey data with respondent PII. " + "You must download it only to an **encrypted, access-controlled " + "storage location** in compliance with IPA data security and " + "confidentiality policies. Do not store this package on unencrypted " + "drives, shared folders, or cloud services that are not approved for " + "confidential data. The recorded PII decisions are bundled as " + "`2_scripts/5_deidentify_data.py` so recipients can de-identify " + "downstream.", + icon=":material/lock:", + ) +else: + st.warning( + "**De-identification is not anonymization.** Columns flagged in the " + "PII review will be masked or dropped, but subjects may remain " + "identifiable through combinations of the remaining variables " + "(e.g. age, location, occupation, household composition). Review " + "the flagged-column summary below and the exported data before " + "sharing.", + icon=":material/warning:", + ) alias: str = "" key_col: str = "" -# Clear any previously built zip when the page selection changes. -if st.session_state.get("_replication_page_sel") != selected_page: +# Clear any previously built zip when the page selection or mode changes. +if st.session_state.get("_replication_page_sel") != (selected_page, include_pii): st.session_state.pop("_replication_zip", None) st.session_state.pop("_replication_filename", None) - st.session_state["_replication_page_sel"] = selected_page + st.session_state.pop("_replication_zip_include_pii", None) + st.session_state["_replication_page_sel"] = (selected_page, include_pii) if selected_page: resolved = _resolve_page_config(configs, selected_page) @@ -341,6 +433,7 @@ def _render_package_preview(safe_project: str, safe_page: str, is_scto: bool) -> else: alias, key_col = resolved _render_config_details(configs.filter(pl.col("page_name") == selected_page)) + _render_pii_flags_summary(project_id, alias, include_pii) else: st.info("Select a page to see its details here.") @@ -375,24 +468,39 @@ def _render_package_preview(safe_project: str, safe_page: str, is_scto: bool) -> scto_form_xlsx=scto_xlsx, form_def=scto_form_def, form_id=scto_form_id, + include_pii=include_pii, on_progress=_on_progress, ) build_status.update(label="Package ready!", state="complete", expanded=False) st.session_state["_replication_zip"] = zip_bytes + st.session_state["_replication_zip_include_pii"] = include_pii st.session_state["_replication_filename"] = _zip_filename( - project_name, selected_page + project_name, selected_page, deidentified=not include_pii ) # ── Download ────────────────────────────────────────────────────────────────── if "_replication_zip" in st.session_state: - st.success("Package ready — confirm the PII notice above, then download.") - pii_confirmed = st.checkbox( - "I confirm I am downloading this package to an encrypted, " - "access-controlled storage location.", - key="_replication_pii_confirmed", - ) + built_with_pii = st.session_state.get("_replication_zip_include_pii", True) + if built_with_pii: + st.success("Package ready — confirm the PII notice above, then download.") + pii_confirmed = st.checkbox( + "I confirm I am downloading this package to an encrypted, " + "access-controlled storage location.", + key="_replication_pii_confirmed", + ) + else: + st.success( + "De-identified package ready — acknowledge the indirect-identifier " + "notice, then download." + ) + pii_confirmed = st.checkbox( + "I understand that de-identification does not guarantee anonymity: " + "subjects may remain indirectly identifiable through combinations " + "of the remaining variables.", + key="_replication_pii_confirmed", + ) st.download_button( label="Download replication package (.zip)", data=st.session_state["_replication_zip"], @@ -423,4 +531,5 @@ def _render_package_preview(safe_project: str, safe_page: str, is_scto: bool) -> safe_project, selected_page.lower().replace(" ", "_"), is_scto, + include_pii, ) diff --git a/tests/models/test_enums.py b/tests/models/test_enums.py index 1a12ba8a..4a129feb 100644 --- a/tests/models/test_enums.py +++ b/tests/models/test_enums.py @@ -33,8 +33,12 @@ class TestPrepActions: """Tests for the PrepActions enum.""" def test_member_count(self): - """Verify PrepActions has exactly 4 members.""" - assert len(PrepActions) == 4 + """Verify PrepActions has exactly 5 members.""" + assert len(PrepActions) == 5 + + def test_redact_column_value(self): + """Verify redact_column has the expected string value.""" + assert PrepActions.redact_column.value == "redact column(s)" def test_add_column_value(self): """Verify add_column has the expected string value.""" diff --git a/tests/processing/test_pii.py b/tests/processing/test_pii.py new file mode 100644 index 00000000..b02d6a97 --- /dev/null +++ b/tests/processing/test_pii.py @@ -0,0 +1,511 @@ +"""Tests for the PII detection and redaction module.""" + +from __future__ import annotations + +from unittest.mock import patch + +import polars as pl +import pytest + +from datasure.processing.pii import ( + DEFAULT_MASK, + PII_MODEL_OPTIONS, + apply_pii_decisions, + build_code_maps, + empty_flags, + get_or_create_pii_salt, + hash_token, + installed_models, + load_code_maps, + load_pii_flags, + mask_label_for, + merge_with_existing, + redact_correction_log, + run_pii_scan, + save_code_maps, + save_pii_flags, + scan_column_names, + scan_values, +) + + +def _en_model_installed() -> bool: + import spacy.util + + return spacy.util.is_package(PII_MODEL_OPTIONS["en"]) + + +requires_en_model = pytest.mark.skipif( + not _en_model_installed(), + reason="spaCy model en_core_web_sm not installed (see CONTRIBUTING.md)", +) + + +@pytest.fixture(autouse=True) +def mock_database_functions(monkeypatch): + """Override the autouse fixture from conftest. + + Disables database mocking for these tests (pure-Polars unit tests). + """ + pass + + +def _flags(rows: list[dict]) -> pl.DataFrame: + base = { + "column": "", + "source": "name_match", + "entity_type": None, + "hit_rate": 1.0, + "sample_matches": "", + "decision": "undecided", + "mask_label": DEFAULT_MASK, + "scanned_at": "2026-01-01T00:00:00", + } + return pl.DataFrame([{**base, **row} for row in rows], schema=empty_flags().schema) + + +# --------------------------------------------------------------------------- +# Pass 1: column-name heuristics +# --------------------------------------------------------------------------- + + +class TestScanColumnNames: + def test_strict_match(self): + df = pl.DataFrame({"age": [30, 40]}) + flags = scan_column_names(df) + assert flags["column"].to_list() == ["age"] + assert flags["source"].to_list() == ["name_match"] + + def test_fuzzy_substring_match(self): + df = pl.DataFrame({"enum_name": ["a"], "household_latitude": [1.0]}) + flags = scan_column_names(df) + assert set(flags["column"].to_list()) == {"enum_name", "household_latitude"} + assert set(flags["source"].to_list()) == {"fuzzy"} + + def test_clean_columns_not_flagged(self): + df = pl.DataFrame({"consent": ["yes", "no"], "outcome": [1, 2]}) + assert scan_column_names(df).is_empty() + + def test_sparsity_flags_high_cardinality_strings(self): + values = [f"unique text {i}" for i in range(30)] + df = pl.DataFrame({"notes": values}) + flags = scan_column_names(df) + assert flags["column"].to_list() == ["notes"] + assert flags["source"].to_list() == ["sparsity"] + + def test_sparsity_skips_small_columns(self): + df = pl.DataFrame({"notes": ["a", "b", "c"]}) + assert scan_column_names(df).is_empty() + + def test_sparsity_skips_low_cardinality(self): + df = pl.DataFrame({"category": ["a", "b"] * 20}) + assert scan_column_names(df).is_empty() + + def test_sparsity_skips_numeric_columns(self): + df = pl.DataFrame({"measurement": list(range(100))}) + assert scan_column_names(df).is_empty() + + def test_multilingual_matches(self): + df = pl.DataFrame({"nombre": ["x"], "adresse": ["y"], "jina": ["z"]}) + flags = scan_column_names(df) + assert set(flags["column"].to_list()) == {"nombre", "adresse", "jina"} + + +# --------------------------------------------------------------------------- +# Pass 2: Presidio value scan (needs spaCy model) +# --------------------------------------------------------------------------- + + +@requires_en_model +class TestScanValues: + def test_person_names_flagged(self): + df = pl.DataFrame({"contact": ["John Smith", "Mary Johnson", "Robert Brown"]}) + flags = scan_values(df, language="en") + assert "contact" in flags["column"].to_list() + row = flags.filter(pl.col("column") == "contact").row(0, named=True) + assert row["entity_type"] == "PERSON" + assert row["mask_label"] == "[PERSON]" + assert row["sample_matches"] != "" + + def test_emails_flagged(self): + df = pl.DataFrame( + {"reach": ["a@example.org", "b@example.org", "c@example.org"]} + ) + flags = scan_values(df, language="en") + row = flags.filter(pl.col("column") == "reach").row(0, named=True) + assert row["entity_type"] == "EMAIL_ADDRESS" + + def test_clean_values_not_flagged(self): + df = pl.DataFrame({"answer": ["yes", "no", "maybe", "yes", "no"]}) + flags = scan_values(df, language="en") + assert "answer" not in flags["column"].to_list() + + def test_numeric_columns_skipped(self): + df = pl.DataFrame({"amount": [1.5, 2.5, 3.5]}) + assert scan_values(df, language="en").is_empty() + + +@requires_en_model +class TestRunPiiScan: + def test_combines_passes_and_joins_sources(self): + df = pl.DataFrame({"enum_name": ["John Smith", "Mary Johnson", "Robert Brown"]}) + flags = run_pii_scan(df, language="en", use_value_scan=True) + row = flags.filter(pl.col("column") == "enum_name").row(0, named=True) + assert row["source"] == "fuzzy+value_scan" + assert row["entity_type"] == "PERSON" + + +class TestRunPiiScanNoModel: + def test_name_scan_only(self): + df = pl.DataFrame({"age": [30, 40]}) + flags = run_pii_scan(df, use_value_scan=False) + assert flags["column"].to_list() == ["age"] + + +# --------------------------------------------------------------------------- +# Merge with existing flags +# --------------------------------------------------------------------------- + + +class TestMergeWithExisting: + def test_no_old_flags_returns_new(self): + new = _flags([{"column": "age"}]) + assert merge_with_existing(new, empty_flags()).equals(new) + + def test_decision_preserved_on_rescan(self): + new = _flags([{"column": "age", "hit_rate": 0.5}]) + old = _flags([{"column": "age", "decision": "keep"}]) + merged = merge_with_existing(new, old) + row = merged.row(0, named=True) + assert row["decision"] == "keep" + assert row["hit_rate"] == 0.5 + + def test_undecided_stale_flags_dropped(self): + new = _flags([{"column": "age"}]) + old = _flags([{"column": "old_col", "decision": "undecided"}]) + merged = merge_with_existing(new, old) + assert merged["column"].to_list() == ["age"] + + def test_decided_stale_flags_retained(self): + new = _flags([{"column": "age"}]) + old = _flags([{"column": "old_col", "decision": "mask"}]) + merged = merge_with_existing(new, old) + assert set(merged["column"].to_list()) == {"age", "old_col"} + + +# --------------------------------------------------------------------------- +# Redaction +# --------------------------------------------------------------------------- + + +class TestApplyPiiDecisions: + def test_mask_replaces_non_null_values(self): + df = pl.DataFrame({"name": ["Alice", None, "Bob"], "age": [1, 2, 3]}) + flags = _flags( + [{"column": "name", "decision": "mask", "mask_label": "[PERSON]"}] + ) + result = apply_pii_decisions(df, flags) + assert result["name"].to_list() == ["[PERSON]", None, "[PERSON]"] + assert result["age"].to_list() == [1, 2, 3] + + def test_mask_casts_numeric_to_string(self): + df = pl.DataFrame({"latitude": [26.08, 25.91]}) + flags = _flags([{"column": "latitude", "decision": "mask"}]) + result = apply_pii_decisions(df, flags) + assert result["latitude"].dtype == pl.String + assert result["latitude"].to_list() == [DEFAULT_MASK, DEFAULT_MASK] + + def test_drop_removes_column(self): + df = pl.DataFrame({"name": ["a"], "age": [1]}) + flags = _flags([{"column": "name", "decision": "drop"}]) + result = apply_pii_decisions(df, flags) + assert "name" not in result.columns + + def test_keep_leaves_column_untouched(self): + df = pl.DataFrame({"name": ["Alice"]}) + flags = _flags([{"column": "name", "decision": "keep"}]) + result = apply_pii_decisions(df, flags) + assert result["name"].to_list() == ["Alice"] + + def test_undecided_untouched_by_default(self): + df = pl.DataFrame({"name": ["Alice"]}) + flags = _flags([{"column": "name", "decision": "undecided"}]) + result = apply_pii_decisions(df, flags) + assert result["name"].to_list() == ["Alice"] + + def test_undecided_masked_when_mask_undecided(self): + df = pl.DataFrame({"name": ["Alice"]}) + flags = _flags([{"column": "name", "decision": "undecided"}]) + result = apply_pii_decisions(df, flags, mask_undecided=True) + assert result["name"].to_list() == [DEFAULT_MASK] + + def test_protected_columns_never_redacted(self): + df = pl.DataFrame({"key": ["k1"], "name": ["Alice"]}) + flags = _flags( + [ + {"column": "key", "decision": "mask"}, + {"column": "name", "decision": "mask"}, + ] + ) + result = apply_pii_decisions(df, flags, protect=("key",)) + assert result["key"].to_list() == ["k1"] + assert result["name"].to_list() == [DEFAULT_MASK] + + def test_missing_columns_ignored(self): + df = pl.DataFrame({"age": [1]}) + flags = _flags([{"column": "ghost", "decision": "mask"}]) + assert apply_pii_decisions(df, flags).equals(df) + + def test_empty_flags_no_change(self): + df = pl.DataFrame({"name": ["Alice"]}) + assert apply_pii_decisions(df, empty_flags()).equals(df) + + +class TestHashToken: + def test_deterministic(self): + assert hash_token("Alice", "salt", "PERSON") == hash_token( + "Alice", "salt", "PERSON" + ) + + def test_distinct_values_distinct_tokens(self): + assert hash_token("Alice", "salt", "PERSON") != hash_token( + "Bob", "salt", "PERSON" + ) + + def test_salt_changes_tokens(self): + assert hash_token("Alice", "salt-a", "PERSON") != hash_token( + "Alice", "salt-b", "PERSON" + ) + + def test_prefix_format(self): + token = hash_token("Alice", "salt", "PERSON") + assert token.startswith("PERSON_") + assert len(token) == len("PERSON_") + 8 + + +class TestPiiSalt: + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + def test_creates_and_persists_salt(self, mock_get, mock_save): + mock_get.return_value = pl.DataFrame() + salt = get_or_create_pii_salt("proj") + assert len(salt) == 32 # token_hex(16) + assert mock_save.call_args.kwargs["alias"] == "pii_salt" + + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + def test_returns_existing_salt(self, mock_get, mock_save): + mock_get.return_value = pl.DataFrame({"salt": ["existing-salt"]}) + assert get_or_create_pii_salt("proj") == "existing-salt" + mock_save.assert_not_called() + + +class TestCodeMaps: + def test_build_covers_all_distinct_values(self): + df = pl.DataFrame({"village": ["a", "b", "a", None]}) + flags = _flags([{"column": "village", "decision": "code"}]) + maps = build_code_maps([df], flags) + assert set(maps["village"]) == {"a", "b"} + assert all(code.startswith("VILLAGE_") for code in maps["village"].values()) + assert len(set(maps["village"].values())) == 2 + + def test_values_collected_across_dataframes(self): + df1 = pl.DataFrame({"village": ["a"]}) + df2 = pl.DataFrame({"village": ["b"]}) + flags = _flags([{"column": "village", "decision": "code"}]) + maps = build_code_maps([df1, df2], flags) + assert set(maps["village"]) == {"a", "b"} + + def test_existing_codes_preserved_and_extended(self): + df = pl.DataFrame({"village": ["a", "b", "c"]}) + flags = _flags([{"column": "village", "decision": "code"}]) + existing = {"village": {"a": "VILLAGE_001"}} + maps = build_code_maps([df], flags, existing=existing) + assert maps["village"]["a"] == "VILLAGE_001" + assert set(maps["village"]) == {"a", "b", "c"} + + def test_non_code_decisions_ignored(self): + df = pl.DataFrame({"village": ["a"]}) + flags = _flags([{"column": "village", "decision": "mask"}]) + assert build_code_maps([df], flags) == {} + + def test_existing_code_tokens_not_reenumerated(self): + # A prepped dataset already coded as a prep step must not have its + # tokens treated as new data values when the export gate builds maps. + df = pl.DataFrame({"village": ["a", "VILLAGE_001"]}) + flags = _flags([{"column": "village", "decision": "code"}]) + existing = {"village": {"a": "VILLAGE_001"}} + maps = build_code_maps([df], flags, existing=existing) + assert maps["village"] == {"a": "VILLAGE_001"} + + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + def test_save_uses_code_map_table(self, mock_save): + save_code_maps("proj", "baseline", {"village": {"a": "VILLAGE_001"}}) + assert mock_save.call_args.kwargs["alias"] == "pii_code_map_baseline" + + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + def test_load_round_trip(self, mock_get): + mock_get.return_value = pl.DataFrame( + {"column": ["village"], "value": ["a"], "code": ["VILLAGE_001"]} + ) + assert load_code_maps("proj", "baseline") == {"village": {"a": "VILLAGE_001"}} + + +class TestApplyPseudonymDecisions: + def test_hash_is_deterministic_and_null_safe(self): + df = pl.DataFrame({"name": ["Alice", "Bob", "Alice", None]}) + flags = _flags( + [{"column": "name", "decision": "hash", "entity_type": "PERSON"}] + ) + result = apply_pii_decisions(df, flags, salt="s") + values = result["name"].to_list() + assert values[0] == values[2] != values[1] + assert values[3] is None + assert values[0].startswith("PERSON_") + + def test_hash_numeric_column_cast_to_string(self): + df = pl.DataFrame({"phone": [5551234, 5555678]}) + flags = _flags([{"column": "phone", "decision": "hash"}]) + result = apply_pii_decisions(df, flags, salt="s") + assert result["phone"].dtype == pl.String + assert result["phone"][0].startswith("PHONE_") + + def test_hash_without_salt_falls_back_to_mask(self): + df = pl.DataFrame({"name": ["Alice"]}) + flags = _flags([{"column": "name", "decision": "hash"}]) + result = apply_pii_decisions(df, flags, salt=None) + assert result["name"].to_list() == [DEFAULT_MASK] + + def test_code_uses_mapping(self): + df = pl.DataFrame({"village": ["a", "b", None]}) + flags = _flags([{"column": "village", "decision": "code"}]) + maps = {"village": {"a": "VILLAGE_001", "b": "VILLAGE_002"}} + result = apply_pii_decisions(df, flags, code_maps=maps) + assert result["village"].to_list() == ["VILLAGE_001", "VILLAGE_002", None] + + def test_code_unmapped_value_masked(self): + df = pl.DataFrame({"village": ["a", "surprise"]}) + flags = _flags([{"column": "village", "decision": "code"}]) + maps = {"village": {"a": "VILLAGE_001"}} + result = apply_pii_decisions(df, flags, code_maps=maps) + assert result["village"].to_list() == ["VILLAGE_001", DEFAULT_MASK] + + def test_code_without_map_falls_back_to_mask(self): + df = pl.DataFrame({"village": ["a"]}) + flags = _flags([{"column": "village", "decision": "code"}]) + result = apply_pii_decisions(df, flags, code_maps=None) + assert result["village"].to_list() == [DEFAULT_MASK] + + def test_hash_idempotent_on_already_hashed_data(self): + df = pl.DataFrame({"name": ["Alice", "Bob"]}) + flags = _flags( + [{"column": "name", "decision": "hash", "entity_type": "PERSON"}] + ) + once = apply_pii_decisions(df, flags, salt="s") + twice = apply_pii_decisions(once, flags, salt="s") + assert twice["name"].to_list() == once["name"].to_list() + + def test_code_idempotent_on_already_coded_data(self): + df = pl.DataFrame({"village": ["a", "b"]}) + flags = _flags([{"column": "village", "decision": "code"}]) + maps = {"village": {"a": "VILLAGE_001", "b": "VILLAGE_002"}} + once = apply_pii_decisions(df, flags, code_maps=maps) + twice = apply_pii_decisions(once, flags, code_maps=maps) + assert twice["village"].to_list() == once["village"].to_list() + + +class TestRedactCorrectionLog: + def _corr_log(self) -> pl.DataFrame: + return pl.DataFrame( + { + "column": ["name", "consent"], + "current_value": ["Alice", "no"], + "new_value": ["Alicia", "yes"], + } + ) + + def test_masks_values_for_redacted_columns(self): + flags = _flags([{"column": "name", "decision": "mask"}]) + result = redact_correction_log(self._corr_log(), flags) + assert result["current_value"].to_list() == [DEFAULT_MASK, "no"] + assert result["new_value"].to_list() == [DEFAULT_MASK, "yes"] + + def test_dropped_columns_also_masked(self): + flags = _flags([{"column": "name", "decision": "drop"}]) + result = redact_correction_log(self._corr_log(), flags) + assert result["current_value"].to_list() == [DEFAULT_MASK, "no"] + + def test_kept_columns_untouched(self): + flags = _flags([{"column": "name", "decision": "keep"}]) + result = redact_correction_log(self._corr_log(), flags) + assert result["current_value"].to_list() == ["Alice", "no"] + + def test_protected_columns_untouched(self): + flags = _flags([{"column": "name", "decision": "mask"}]) + result = redact_correction_log(self._corr_log(), flags, protect=("name",)) + assert result["current_value"].to_list() == ["Alice", "no"] + + def test_empty_log_passthrough(self): + flags = _flags([{"column": "name", "decision": "mask"}]) + empty = pl.DataFrame() + assert redact_correction_log(empty, flags).is_empty() + + def test_hash_columns_get_pseudonyms(self): + flags = _flags( + [{"column": "name", "decision": "hash", "entity_type": "PERSON"}] + ) + result = redact_correction_log(self._corr_log(), flags, salt="s") + assert result["current_value"][0].startswith("PERSON_") + assert result["current_value"][0] == hash_token("Alice", "s", "PERSON") + assert result["current_value"][1] == "no" + + def test_hash_without_salt_masks(self): + flags = _flags([{"column": "name", "decision": "hash"}]) + result = redact_correction_log(self._corr_log(), flags, salt=None) + assert result["current_value"].to_list() == [DEFAULT_MASK, "no"] + + def test_code_columns_masked(self): + flags = _flags([{"column": "name", "decision": "code"}]) + result = redact_correction_log(self._corr_log(), flags) + assert result["current_value"].to_list() == [DEFAULT_MASK, "no"] + + +# --------------------------------------------------------------------------- +# Persistence and helpers +# --------------------------------------------------------------------------- + + +class TestPersistence: + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + def test_save_uses_pii_flags_table(self, mock_save): + flags = _flags([{"column": "age"}]) + save_pii_flags("proj", "baseline", flags) + assert mock_save.call_args.kwargs["alias"] == "pii_flags_baseline" + assert mock_save.call_args.kwargs["db_name"] == "logs" + + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + def test_load_returns_stored_flags(self, mock_get): + stored = _flags([{"column": "age"}]) + mock_get.return_value = stored + assert load_pii_flags("proj", "baseline").equals(stored) + + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + def test_load_missing_returns_empty(self, mock_get): + mock_get.side_effect = RuntimeError("no table") + assert load_pii_flags("proj", "baseline").is_empty() + + +class TestHelpers: + def test_mask_label_known_entity(self): + assert mask_label_for("PERSON") == "[PERSON]" + + def test_mask_label_unknown_entity(self): + assert mask_label_for("SOMETHING_ELSE") == DEFAULT_MASK + + def test_mask_label_none(self): + assert mask_label_for(None) == DEFAULT_MASK + + def test_installed_models_shape(self): + models = installed_models() + assert set(models) == {"en", "es", "fr"} + assert all(isinstance(v, bool) for v in models.values()) diff --git a/tests/processing/test_prep.py b/tests/processing/test_prep.py index 8b828af1..109cda6b 100644 --- a/tests/processing/test_prep.py +++ b/tests/processing/test_prep.py @@ -16,6 +16,7 @@ PrepAction, PrepError, PrepProcessor, + RedactColumnsOperation, RemoveColumnsOperation, RemoveRowsOperation, TransformColumnsOperation, @@ -84,6 +85,7 @@ def test_from_args_all_types(self): ("remove row(s)", PrepActions.remove_row), ("transform column(s)", PrepActions.transform_column), ("add new column", PrepActions.add_column), + ("redact column(s)", PrepActions.redact_column), ] for action_str, expected in cases: action = PrepAction.from_args(PrepActionResult(action=action_str)) @@ -162,6 +164,130 @@ def test_parse_value_list_no_brackets(self): # === REMOVE COLUMNS OPERATION TESTS === # +class TestRedactColumnsOperation: + """Test RedactColumnsOperation (PII masking).""" + + def test_redact_single_column(self): + op = RedactColumnsOperation() + data = pl.DataFrame({"name": ["Alice", "Bob"], "age": [1, 2]}) + prep_args = PrepActionResult( + action="redact column(s)", source_columns=["name"], value=["[PERSON]"] + ) + result, args = op.execute(data, prep_args) + assert result["name"].to_list() == ["[PERSON]", "[PERSON]"] + assert result["age"].to_list() == [1, 2] + assert args.affected_count == 1 + + def test_nulls_stay_null(self): + op = RedactColumnsOperation() + data = pl.DataFrame({"name": ["Alice", None]}) + prep_args = PrepActionResult( + action="redact column(s)", source_columns=["name"], value=["*****"] + ) + result, _ = op.execute(data, prep_args) + assert result["name"].to_list() == ["*****", None] + + def test_numeric_column_cast_to_string(self): + op = RedactColumnsOperation() + data = pl.DataFrame({"latitude": [26.08, 25.91]}) + prep_args = PrepActionResult( + action="redact column(s)", source_columns=["latitude"], value=["*****"] + ) + result, _ = op.execute(data, prep_args) + assert result["latitude"].dtype == pl.String + + def test_single_string_label_applies_to_all_columns(self): + op = RedactColumnsOperation() + data = pl.DataFrame({"a": ["x"], "b": ["y"]}) + prep_args = PrepActionResult( + action="redact column(s)", source_columns=["a", "b"], value="[REDACTED]" + ) + result, args = op.execute(data, prep_args) + assert result["a"].to_list() == ["[REDACTED]"] + assert result["b"].to_list() == ["[REDACTED]"] + assert args.value == ["[REDACTED]", "[REDACTED]"] + + def test_missing_column_raises(self): + op = RedactColumnsOperation() + data = pl.DataFrame({"a": ["x"]}) + prep_args = PrepActionResult( + action="redact column(s)", source_columns=["ghost"], value=["*"] + ) + with pytest.raises(OperationError, match="Columns not found"): + op.execute(data, prep_args) + + def test_mismatched_label_count_raises(self): + op = RedactColumnsOperation() + data = pl.DataFrame({"a": ["x"], "b": ["y"]}) + prep_args = PrepActionResult( + action="redact column(s)", source_columns=["a", "b"], value=["*"] + ) + with pytest.raises(ValidationError, match="mask labels"): + op.execute(data, prep_args) + + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + @patch("datasure.processing.prep.st") + def test_hash_method(self, mock_st, mock_get, mock_save): + mock_st.session_state.st_project_id = "proj" + mock_get.return_value = pl.DataFrame() # no stored salt → generated + op = RedactColumnsOperation() + data = pl.DataFrame({"name": ["Alice", "Bob", "Alice", None]}) + prep_args = PrepActionResult( + action="redact column(s)", + source_columns=["name"], + value=["PERSON"], + method="hash", + ) + result, args = op.execute(data, prep_args) + values = result["name"].to_list() + assert values[0] == values[2] != values[1] + assert values[0].startswith("PERSON_") + assert values[3] is None + assert args.method == "hash" + + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + @patch("datasure.processing.prep.st") + def test_hash_method_idempotent_on_replay(self, mock_st, mock_get, mock_save): + mock_st.session_state.st_project_id = "proj" + mock_get.return_value = pl.DataFrame({"salt": ["stable-salt"]}) + op = RedactColumnsOperation() + data = pl.DataFrame({"name": ["Alice", "Bob"]}) + prep_args = PrepActionResult( + action="redact column(s)", + source_columns=["name"], + value=["PERSON"], + method="hash", + ) + once, _ = op.execute(data, prep_args) + twice, _ = op.execute(once, prep_args) + assert twice["name"].to_list() == once["name"].to_list() + + @patch("datasure.utils.duckdb_utils.duckdb_save_table") + @patch("datasure.utils.duckdb_utils.duckdb_get_table") + @patch("datasure.processing.prep.st") + def test_code_method(self, mock_st, mock_get, mock_save): + mock_st.session_state.st_project_id = "proj" + mock_get.return_value = pl.DataFrame() # no stored map → built fresh + op = RedactColumnsOperation() + data = pl.DataFrame({"village": ["a", "b", "a", None]}) + prep_args = PrepActionResult( + action="redact column(s)", + source_columns=["village"], + method="code", + additional_info="baseline", + ) + result, _args = op.execute(data, prep_args) + values = result["village"].to_list() + assert values[0] == values[2] != values[1] + assert values[0].startswith("VILLAGE_") + assert values[3] is None + # the extended map is persisted for replay/export stability + saved_aliases = [c.kwargs.get("alias") for c in mock_save.call_args_list] + assert "pii_code_map_baseline" in saved_aliases + + class TestRemoveColumnsOperation: """Test RemoveColumnsOperation.""" diff --git a/tests/replication/test_package_builder.py b/tests/replication/test_package_builder.py index 468d675c..aecd84ef 100644 --- a/tests/replication/test_package_builder.py +++ b/tests/replication/test_package_builder.py @@ -351,3 +351,245 @@ def test_no_raw_parquet_written(self, zip_bytes): names = zf.namelist() assert not any("_raw.parquet" in n for n in names) assert any("_raw.csv" in n for n in names) + + +# --------------------------------------------------------------------------- +# PII export gate +# --------------------------------------------------------------------------- + +_PII_RAW = pl.DataFrame( + { + "key": ["k1", "k2"], + "enum_name": ["Kailash Khosla", "Arjun Patel"], + "household_latitude": [26.08, 25.91], + "age": [34, 51], + } +) +_PII_FLAGS = pl.DataFrame( + { + "column": ["enum_name", "household_latitude", "age"], + "source": ["fuzzy", "fuzzy", "name_match"], + "entity_type": ["PERSON", None, None], + "hit_rate": [1.0, 1.0, 1.0], + "sample_matches": ["Kailash Khosla", "", ""], + "decision": ["mask", "drop", "keep"], + "mask_label": ["[PERSON]", "*****", "*****"], + "scanned_at": ["2026-01-01T00:00:00"] * 3, + } +) +_PII_CORR_LOG = pl.DataFrame( + { + "date": ["2026-01-01"], + "KEY": ["k1"], + "ID": [None], + "action": ["modify value"], + "column": ["enum_name"], + "current_value": ["Kailash Khosla"], + "new_value": ["K. Khosla"], + "reason": ["typo"], + } +) + + +def _pii_duckdb_get(project_id, table, db_name, **kwargs): + name = str(table) + if "pii_flags" in name: + return _PII_FLAGS + if "corr_log" in name: + return _PII_CORR_LOG + if "prep_log" in name or name == "pii_salt" or "pii_code_map" in name: + return pl.DataFrame() + return _PII_RAW + + +def _build_pii_package(include_pii: bool) -> bytes: + with ( + patch( + "datasure.replication.package_builder.duckdb_get_table", + side_effect=_pii_duckdb_get, + ), + patch( + "datasure.utils.duckdb_utils.duckdb_get_table", + side_effect=_pii_duckdb_get, + ), + patch("datasure.utils.duckdb_utils.duckdb_save_table"), + ): + return build_replication_package( + project_id="test-proj", + project_name="Test Project", + survey_name="Baseline Survey", + alias="baseline", + key_col="key", + include_pii=include_pii, + ) + + +class TestBuildReplicationPackageDeidentified: + @pytest.fixture() + def zip_file(self): + return zipfile.ZipFile(BytesIO(_build_pii_package(include_pii=False))) + + def _read(self, zip_file, suffix: str) -> str: + name = next(n for n in zip_file.namelist() if n.endswith(suffix)) + return zip_file.read(name).decode() + + def test_masked_column_in_raw_csv(self, zip_file): + raw_csv = self._read(zip_file, "_raw.csv") + assert "Kailash" not in raw_csv + assert "[PERSON]" in raw_csv + + def test_dropped_column_absent(self, zip_file): + raw_csv = self._read(zip_file, "_raw.csv") + assert "household_latitude" not in raw_csv + + def test_kept_column_intact(self, zip_file): + raw_csv = self._read(zip_file, "_raw.csv") + assert "age" in raw_csv + assert "34" in raw_csv + + def test_correction_log_redacted(self, zip_file): + corr = self._read(zip_file, "correction_log.csv") + assert "Kailash" not in corr + + def test_codebook_samples_redacted(self, zip_file): + codebook = self._read(zip_file, "codebook.csv") + assert "Kailash" not in codebook + + def test_data_dict_annotated_and_redacted(self, zip_file): + dd = self._read(zip_file, "data-dict.yaml") + assert "Redacted (PII)" in dd + assert "Kailash" not in dd + + def test_no_deidentify_script_or_flags_csv(self, zip_file): + names = zip_file.namelist() + assert not any("5_deidentify" in n for n in names) + assert not any("pii_flags.csv" in n for n in names) + + def test_readme_states_deidentified(self, zip_file): + readme = self._read(zip_file, "README.md") + assert "exported de-identified" in readme + assert "indirect identifiers" in readme + assert "`enum_name`" in readme + + +class TestBuildReplicationPackageWithPii: + @pytest.fixture() + def zip_file(self): + return zipfile.ZipFile(BytesIO(_build_pii_package(include_pii=True))) + + def _read(self, zip_file, suffix: str) -> str: + name = next(n for n in zip_file.namelist() if n.endswith(suffix)) + return zip_file.read(name).decode() + + def test_data_left_untouched(self, zip_file): + raw_csv = self._read(zip_file, "_raw.csv") + assert "Kailash" in raw_csv + assert "household_latitude" in raw_csv + + def test_deidentify_script_bundled_and_parses(self, zip_file): + import ast + + script = self._read(zip_file, "5_deidentify_data.py") + ast.parse(script) + assert "('enum_name', 'mask', '[PERSON]')" in script + assert "('household_latitude', 'drop', '*****')" in script + + def test_flags_audit_log_bundled(self, zip_file): + flags_csv = self._read(zip_file, "pii_flags.csv") + assert "enum_name" in flags_csv + + def test_readme_states_with_pii(self, zip_file): + readme = self._read(zip_file, "README.md") + assert "WITH personally identifiable" in readme + assert "5_deidentify_data.py" in readme + + +class TestBuildReplicationPackagePseudonyms: + """Hash and code decisions produce deterministic pseudonyms in exports.""" + + _RAW = pl.DataFrame( + { + "key": ["k1", "k2", "k3"], + "enum_name": ["Kailash Khosla", "Arjun Patel", "Kailash Khosla"], + "village_name": ["Rampur", "Sitapur", "Rampur"], + } + ) + _FLAGS = pl.DataFrame( + { + "column": ["enum_name", "village_name"], + "source": ["fuzzy"] * 2, + "entity_type": ["PERSON", None], + "hit_rate": [1.0] * 2, + "sample_matches": [""] * 2, + "decision": ["hash", "code"], + "mask_label": ["[PERSON]", "*****"], + "scanned_at": ["2026-01-01T00:00:00"] * 2, + } + ) + + @pytest.fixture() + def zip_file(self): + state: dict = {} + + def _get(project_id, table, db_name, **kwargs): + name = str(table) + if "pii_flags" in name: + return self._FLAGS + if name == "pii_salt": + return state.get("salt", pl.DataFrame()) + if "pii_code_map" in name: + return state.get("codes", pl.DataFrame()) + if "prep_log" in name or "corr_log" in name: + return pl.DataFrame() + return self._RAW + + def _save(project_id, data, alias="", db_name="", **kwargs): + if alias == "pii_salt": + state["salt"] = data + if "pii_code_map" in alias: + state["codes"] = data + + with ( + patch( + "datasure.replication.package_builder.duckdb_get_table", + side_effect=_get, + ), + patch("datasure.utils.duckdb_utils.duckdb_get_table", side_effect=_get), + patch("datasure.utils.duckdb_utils.duckdb_save_table", side_effect=_save), + ): + blob = build_replication_package( + project_id="test-proj", + project_name="Test Project", + survey_name="Baseline Survey", + alias="baseline", + key_col="key", + include_pii=False, + ) + return zipfile.ZipFile(BytesIO(blob)) + + def _read(self, zip_file, suffix: str) -> str: + name = next(n for n in zip_file.namelist() if n.endswith(suffix)) + return zip_file.read(name).decode() + + def test_hash_tokens_deterministic_in_raw_csv(self, zip_file): + raw_csv = self._read(zip_file, "_raw.csv") + assert "Kailash" not in raw_csv + person_tokens = [ + cell + for line in raw_csv.splitlines()[1:] + for cell in line.split(",") + if cell.startswith("PERSON_") + ] + assert len(person_tokens) == 3 + # k1 and k3 share the same name → identical pseudonyms + assert person_tokens[0] == person_tokens[2] != person_tokens[1] + + def test_code_tokens_in_raw_csv(self, zip_file): + raw_csv = self._read(zip_file, "_raw.csv") + assert "Rampur" not in raw_csv + assert "VILLAGE_NAME_" in raw_csv + + def test_readme_lists_pseudonym_columns(self, zip_file): + readme = self._read(zip_file, "README.md") + assert "Hashed columns" in readme + assert "Recoded columns" in readme diff --git a/tests/replication/test_prep_script_generator.py b/tests/replication/test_prep_script_generator.py index 70b22171..f2807c49 100644 --- a/tests/replication/test_prep_script_generator.py +++ b/tests/replication/test_prep_script_generator.py @@ -10,6 +10,7 @@ _fmt_val, _is_numeric_val, _stata_add_column, + _stata_redact_columns, _stata_remove_columns, _stata_remove_rows, _stata_transform_column, @@ -110,6 +111,43 @@ def test_multiple_columns(self): assert lines == ["drop a b"] +class TestStataRedactColumns: + def test_single_column(self): + lines = _stata_redact_columns( + {"source_columns": ["name"], "value": ["[PERSON]"]}, "" + ) + assert lines == [ + "tostring name, replace force", + 'replace name = "[PERSON]" if name != ""', + ] + + def test_single_string_label_applies_to_all(self): + lines = _stata_redact_columns( + {"source_columns": ["a", "b"], "value": "*****"}, "" + ) + assert 'replace a = "*****" if a != ""' in lines + assert 'replace b = "*****" if b != ""' in lines + + def test_mismatched_labels_fall_back_to_default(self): + lines = _stata_redact_columns( + {"source_columns": ["a", "b"], "value": ["only-one"]}, "" + ) + assert 'replace a = "*****" if a != ""' in lines + + def test_hash_method_emits_note(self): + lines = _stata_redact_columns( + {"source_columns": ["name"], "value": ["PERSON"], "method": "hash"}, "" + ) + assert all(line.startswith("*") for line in lines) + assert "cannot be reproduced" in lines[0] + + def test_code_method_emits_note(self): + lines = _stata_redact_columns( + {"source_columns": ["village"], "method": "code"}, "" + ) + assert all(line.startswith("*") for line in lines) + + class TestStataRemoveRows: def test_by_index_single(self): lines = _stata_remove_rows({"method": "by row index", "value": [5]}, "") diff --git a/tests/replication/test_py_prep_script_generator.py b/tests/replication/test_py_prep_script_generator.py index 595781f9..63a8175d 100644 --- a/tests/replication/test_py_prep_script_generator.py +++ b/tests/replication/test_py_prep_script_generator.py @@ -9,6 +9,7 @@ from datasure.replication.py_prep_script_generator import ( _py_add_column, + _py_redact_columns, _py_remove_columns, _py_remove_rows, _py_transform_column, @@ -26,6 +27,47 @@ def test_drops_listed_columns(self): assert lines == ["df = df.drop(['a', 'b'])"] +# --------------------------------------------------------------------------- +# _py_redact_columns +# --------------------------------------------------------------------------- + + +class TestPyRedactColumns: + def test_single_column(self): + lines = _py_redact_columns( + {"source_columns": ["name"], "value": ["[PERSON]"]}, "" + ) + assert len(lines) == 1 + assert "pl.when(pl.col('name').is_not_null())" in lines[0] + assert "pl.lit('[PERSON]')" in lines[0] + + def test_single_string_label_applies_to_all(self): + lines = _py_redact_columns({"source_columns": ["a", "b"], "value": "*"}, "") + assert len(lines) == 2 + assert all("pl.lit('*')" in line for line in lines) + + def test_generated_code_parses(self): + lines = _py_redact_columns( + {"source_columns": ["name"], "value": ["[PERSON]"]}, "" + ) + ast.parse("\n".join(lines)) + + def test_hash_method_emits_note(self): + lines = _py_redact_columns( + {"source_columns": ["name"], "value": ["PERSON"], "method": "hash"}, "" + ) + assert all(line.startswith("#") for line in lines) + assert "cannot be reproduced" in "\n".join(lines) + ast.parse("\n".join(lines)) + + def test_code_method_emits_note(self): + lines = _py_redact_columns( + {"source_columns": ["village"], "method": "code"}, "" + ) + assert all(line.startswith("#") for line in lines) + ast.parse("\n".join(lines)) + + # --------------------------------------------------------------------------- # _py_remove_rows # --------------------------------------------------------------------------- diff --git a/tests/replication/test_py_script_generators.py b/tests/replication/test_py_script_generators.py index 923c5f6f..b83e826b 100644 --- a/tests/replication/test_py_script_generators.py +++ b/tests/replication/test_py_script_generators.py @@ -7,15 +7,31 @@ import polars as pl import pytest +from datasure.processing.pii import empty_flags from datasure.replication.py_script_generators import ( SCRIPT_EXT_PY, _emit_py, _py_lit, generate_corrections_script_py, + generate_deidentify_script_py, generate_master_script_py, ) +def _pii_flags(rows: list[dict]) -> pl.DataFrame: + base = { + "column": "", + "source": "name_match", + "entity_type": None, + "hit_rate": 1.0, + "sample_matches": "", + "decision": "undecided", + "mask_label": "*****", + "scanned_at": "2026-01-01T00:00:00", + } + return pl.DataFrame([{**base, **row} for row in rows], schema=empty_flags().schema) + + def test_script_ext(): assert SCRIPT_EXT_PY == "py" @@ -112,6 +128,97 @@ def test_writes_corrected_parquet(self): assert "_corrected.parquet" in script +# --------------------------------------------------------------------------- +# generate_deidentify_script_py +# --------------------------------------------------------------------------- + + +class TestGenerateDeidentifyScriptPy: + def _script(self, flags): + return generate_deidentify_script_py( + flags, "Test Project", "Baseline Survey", "1.0.0" + ) + + def test_parses_as_valid_python(self): + flags = _pii_flags( + [{"column": "name", "decision": "mask", "mask_label": "[PERSON]"}] + ) + ast.parse(self._script(flags)) + + def test_decisions_embedded(self): + flags = _pii_flags( + [ + {"column": "name", "decision": "mask", "mask_label": "[PERSON]"}, + {"column": "latitude", "decision": "drop"}, + ] + ) + script = self._script(flags) + assert "('name', 'mask', '[PERSON]')" in script + assert "('latitude', 'drop', '*****')" in script + + def test_undecided_treated_as_mask(self): + flags = _pii_flags([{"column": "notes", "decision": "undecided"}]) + script = self._script(flags) + assert "('notes', 'mask', '*****')" in script + + def test_kept_columns_excluded(self): + flags = _pii_flags([{"column": "age", "decision": "keep"}]) + script = self._script(flags) + assert "'age'" not in script + + def test_empty_flags_yields_empty_decisions(self): + script = self._script(empty_flags()) + ast.parse(script) + assert "DECISIONS = []" in script + + def test_targets_all_bundled_datasets(self): + script = self._script(empty_flags()) + for name in ( + "baseline_survey_raw.parquet", + "baseline_survey_prepped.parquet", + "baseline_survey_corrected.parquet", + ): + assert name in script + + def test_prints_indirect_identifier_warning(self): + script = self._script(empty_flags()) + assert "not anonymization" in script + + def test_hash_decision_embeds_salt_and_helper(self): + flags = _pii_flags( + [{"column": "name", "decision": "hash", "entity_type": "PERSON"}] + ) + script = generate_deidentify_script_py( + flags, "P", "Baseline Survey", "1.0.0", salt="secret-salt" + ) + ast.parse(script) + assert "SALT = 'secret-salt'" in script + assert "def hash_token(" in script + assert "('name', 'hash', 'PERSON')" in script + + def test_hash_without_salt_falls_back_to_mask(self): + flags = _pii_flags( + [ + { + "column": "name", + "decision": "hash", + "mask_label": "[PERSON]", + } + ] + ) + script = generate_deidentify_script_py(flags, "P", "S", "1.0.0", salt=None) + ast.parse(script) + assert "('name', 'mask', '[PERSON]')" in script + assert "SALT =" not in script + + def test_code_decision_embeds_prefix(self): + flags = _pii_flags([{"column": "village_name", "decision": "code"}]) + script = self._script(flags) + ast.parse(script) + assert "('village_name', 'code', 'VILLAGE_NAME')" in script + assert "code_maps" in script + + # --------------------------------------------------------------------------- # generate_master_script_py # --------------------------------------------------------------------------- diff --git a/tests/replication/test_readme.py b/tests/replication/test_readme.py index 04693431..3d4f88a5 100644 --- a/tests/replication/test_readme.py +++ b/tests/replication/test_readme.py @@ -128,3 +128,54 @@ def test_is_markdown_with_h1_title(self, readme): def test_folder_tree_is_fenced(self, readme): assert "```text" in readme assert readme.count("```") >= 2 + + +class TestReadmePiiSection: + def _readme(self, include_pii: bool) -> str: + return generate_readme( + project_name="P", + survey_name="S", + datasure_version="1.0", + correction_count=0, + prep_count=0, + raw_rows=10, + prepped_rows=10, + corrected_rows=10, + n_corrections_by_action={}, + include_pii=include_pii, + pii_masked_columns=["enum_name"], + pii_dropped_columns=["latitude"], + pii_kept_columns=["age"], + ) + + def test_with_pii_mentions_deidentify_script(self): + readme = self._readme(include_pii=True) + assert "WITH personally identifiable" in readme + assert "5_deidentify_data.py" in readme + assert "pii_flags.csv" in readme + + def test_deidentified_lists_redactions(self): + readme = self._readme(include_pii=False) + assert "exported de-identified" in readme + assert "`enum_name`" in readme + assert "`latitude`" in readme + assert "`age`" in readme + assert "5_deidentify_data.py" not in readme + + def test_indirect_identifier_warning_in_both_modes(self): + for include_pii in (True, False): + assert "indirect identifiers" in self._readme(include_pii) + + def test_default_is_with_pii_for_backward_compat(self): + readme = generate_readme( + project_name="P", + survey_name="S", + datasure_version="1.0", + correction_count=0, + prep_count=0, + raw_rows=0, + prepped_rows=0, + corrected_rows=0, + n_corrections_by_action={}, + ) + assert "PII & De-identification" in readme diff --git a/tests/views/test_prep_view.py b/tests/views/test_prep_view.py index 82cbb4bd..894b70df 100644 --- a/tests/views/test_prep_view.py +++ b/tests/views/test_prep_view.py @@ -1616,11 +1616,16 @@ def test_page_layout_with_aliases(self): mock_tab.__exit__ = MagicMock(return_value=False) _st.tabs = MagicMock(return_value=[mock_tab]) - # Mock columns with context manager support + # Mock columns with context manager support (spec-aware: return as + # many columns as requested) mock_col = MagicMock() mock_col.__enter__ = MagicMock(return_value=mock_col) mock_col.__exit__ = MagicMock(return_value=False) - _st.columns = MagicMock(return_value=[mock_col, mock_col, mock_col]) + _st.columns = MagicMock( + side_effect=lambda spec, **kw: ( + [mock_col] * (spec if isinstance(spec, int) else len(spec)) + ) + ) # Mock container context manager mock_container = MagicMock() @@ -1639,6 +1644,13 @@ def test_page_layout_with_aliases(self): _st.selectbox = MagicMock(return_value=None) _st.multiselect = MagicMock(return_value=[]) + def _get_table(project_id, alias, db_name, **kwargs): + if "pii_flags" in str(alias): + return pl.DataFrame() + if "prep_log" in str(alias): + return prep_log_df + return sample_df + with ( patch( "datasure.utils.duckdb_utils.duckdb_get_aliases", @@ -1646,12 +1658,7 @@ def test_page_layout_with_aliases(self): ), patch( "datasure.utils.duckdb_utils.duckdb_get_table", - side_effect=[ - prep_log_df, # prep_log for tab - sample_df, # prep_data for tab - prep_log_df, # prep_log re-fetch in change log - prep_log_df, # prep_log in prep_remove_step - ], + side_effect=_get_table, ), patch("datasure.utils.duckdb_utils.duckdb_save_table"), patch("datasure.utils.navigations_utils.page_navigation"), @@ -1701,7 +1708,11 @@ def test_page_layout_with_empty_prep_data(self): mock_col = MagicMock() mock_col.__enter__ = MagicMock(return_value=mock_col) mock_col.__exit__ = MagicMock(return_value=False) - _st.columns = MagicMock(return_value=[mock_col, mock_col, mock_col]) + _st.columns = MagicMock( + side_effect=lambda spec, **kw: ( + [mock_col] * (spec if isinstance(spec, int) else len(spec)) + ) + ) mock_container = MagicMock() mock_container.__enter__ = MagicMock(return_value=mock_container) @@ -1718,6 +1729,15 @@ def test_page_layout_with_empty_prep_data(self): _st.selectbox = MagicMock(return_value=None) _st.multiselect = MagicMock(return_value=[]) + def _get_table(project_id, alias, db_name, **kwargs): + if "pii_flags" in str(alias): + return pl.DataFrame() + if "prep_log" in str(alias): + return empty_log + if db_name == "raw": + return raw_df + return empty_df # prep data (empty → raw fallback) + with ( patch( "datasure.utils.duckdb_utils.duckdb_get_aliases", @@ -1725,13 +1745,7 @@ def test_page_layout_with_empty_prep_data(self): ), patch( "datasure.utils.duckdb_utils.duckdb_get_table", - side_effect=[ - empty_log, # prep_log - empty_df, # prep_data (empty) - raw_df, # raw data fallback - empty_log, # prep_log in prep_remove_step - empty_log, # prep_log re-fetch in change log - ], + side_effect=_get_table, ), patch("datasure.utils.duckdb_utils.duckdb_save_table") as mock_save, patch("datasure.utils.navigations_utils.page_navigation"), diff --git a/tests/views/test_replication_view.py b/tests/views/test_replication_view.py index dc00ae00..5ded2c58 100644 --- a/tests/views/test_replication_view.py +++ b/tests/views/test_replication_view.py @@ -179,6 +179,25 @@ def test_import_script_is_stata_only(self): assert "2_import_data.do" in tree assert "2_import_data.do / .py" not in tree + def test_deidentified_tree_has_no_deidentify_script(self): + tree = _package_tree("p", "s", is_scto=False, include_pii=False) + assert "5_deidentify_data.py" not in tree + assert "pii_flags.csv" not in tree + + def test_with_pii_tree_lists_deidentify_script(self): + tree = _package_tree("p", "s", is_scto=False, include_pii=True) + assert "5_deidentify_data.py" in tree + assert "pii_flags.csv" in tree + + +class TestZipFilenamePii: + def test_deidentified_suffix(self): + result = _zip_filename("Proj", "Page", deidentified=True) + assert result == "replication_proj_page_deidentified.zip" + + def test_default_no_suffix(self): + assert _zip_filename("Proj", "Page") == "replication_proj_page.zip" + # --------------------------------------------------------------------------- # _resolve_page_config diff --git a/uv.lock b/uv.lock index 2a9d0a08..927a4f8a 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/78/b556548d92b9e29ae68a86e7b416888820900809e189e39caf308c7d44a3/altair-6.2.1-py3-none-any.whl", hash = "sha256:bf2fee3733c3a31a588e45b857a2495a88d506970deb87f74e1613f0247446b1", size = 797498, upload-time = "2026-06-05T16:23:34.799Z" }, ] +[[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" @@ -206,6 +215,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "blis" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d0/d8cc8c9a4488a787e7fa430f6055e5bd1ddb22c340a751d9e901b82e2efe/blis-1.3.3.tar.gz", hash = "sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd", size = 2644873, upload-time = "2025-11-17T12:28:30.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/0a/a4c8736bc497d386b0ffc76d321f478c03f1a4725e52092f93b38beb3786/blis-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e10c8d3e892b1dbdff365b9d00e08291876fc336915bf1a5e9f188ed087e1a91", size = 6925522, upload-time = "2025-11-17T12:27:29.199Z" }, + { url = "https://files.pythonhosted.org/packages/83/5a/3437009282f23684ecd3963a8b034f9307cdd2bf4484972e5a6b096bf9ac/blis-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66e6249564f1db22e8af1e0513ff64134041fa7e03c8dd73df74db3f4d8415a7", size = 1232787, upload-time = "2025-11-17T12:27:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0e/82221910d16259ce3017c1442c468a3f206a4143a96fbba9f5b5b81d62e8/blis-1.3.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7260da065958b4e5475f62f44895ef9d673b0f47dcf61b672b22b7dae1a18505", size = 2844596, upload-time = "2025-11-17T12:27:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/ab547f1a5c23e20bca16fbcf04021c32aac3f969be737ea4980509a7ca90/blis-1.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9327a6ca67de8ae76fe071e8584cc7f3b2e8bfadece4961d40f2826e1cda2df", size = 11377746, upload-time = "2025-11-17T12:27:35.342Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/7733820aa62da32526287a63cd85c103b2b323b186c8ee43b7772ff7017c/blis-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c4ae70629cf302035d268858a10ca4eb6242a01b2dc8d64422f8e6dcb8a8ee74", size = 3041954, upload-time = "2025-11-17T12:27:37.479Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/e39d67fd3296b649772780ca6aab081412838ecb54e0b0c6432d01626a50/blis-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45866a9027d43b93e8b59980a23c5d7358b6536fc04606286e39fdcfce1101c2", size = 14251222, upload-time = "2025-11-17T12:27:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/ea/44/b749f8777b020b420bceaaf60f66432fc30cc904ca5b69640ec9cbef11ed/blis-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:27f82b8633030f8d095d2b412dffa7eb6dbc8ee43813139909a20012e54422ea", size = 6171233, upload-time = "2025-11-17T12:27:41.921Z" }, + { url = "https://files.pythonhosted.org/packages/16/d1/429cf0cf693d4c7dc2efed969bd474e315aab636e4a95f66c4ed7264912d/blis-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a1c74e100665f8e918ebdbae2794576adf1f691680b5cdb8b29578432f623ef", size = 6929663, upload-time = "2025-11-17T12:27:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/11/69/363c8df8d98b3cc97be19aad6aabb2c9c53f372490d79316bdee92d476e7/blis-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f6c595185176ce021316263e1a1d636a3425b6c48366c1fd712d08d0b71849a", size = 1230939, upload-time = "2025-11-17T12:27:46.19Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/fbf65d906d823d839076c5150a6f8eb5ecbc5f9135e0b6510609bda1e6b7/blis-1.3.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d734b19fba0be7944f272dfa7b443b37c61f9476d9ab054a9ac53555ceadd2e0", size = 2818835, upload-time = "2025-11-17T12:27:48.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/58deaa3ad856dd3cc96493e40ffd2ed043d18d4d304f85a65cde1ccbf644/blis-1.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ef6d6e2b599a3a2788eb6d9b443533961265aa4ec49d574ed4bb846e548dcdb", size = 11366550, upload-time = "2025-11-17T12:27:49.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/82/816a7adfe1f7acc8151f01ec86ef64467a3c833932d8f19f8e06613b8a4e/blis-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8c888438ae99c500422d50698e3028b65caa8ebb44e24204d87fda2df64058f7", size = 3023686, upload-time = "2025-11-17T12:27:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/0e93b865f648b5519360846669a35f28ee8f4e1d93d054f6850d8afbabde/blis-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8177879fd3590b5eecdd377f9deafb5dc8af6d684f065bd01553302fb3fcf9a7", size = 14250939, upload-time = "2025-11-17T12:27:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/20/07/fb43edc2ff0a6a367e4a94fc39eb3b85aa1e55e24cc857af2db145ce9f0d/blis-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:f20f7ad69aaffd1ce14fe77de557b6df9b61e0c9e582f75a843715d836b5c8af", size = 6192759, upload-time = "2025-11-17T12:27:56.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f7/d26e62d9be3d70473a63e0a5d30bae49c2fe138bebac224adddcdef8a7ce/blis-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1e647341f958421a86b028a2efe16ce19c67dba2a05f79e8f7e80b1ff45328aa", size = 6928322, upload-time = "2025-11-17T12:27:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/750d12da388f714958eb2f2fd177652323bbe7ec528365c37129edd6eb84/blis-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d563160f874abb78a57e346f07312c5323f7ad67b6370052b6b17087ef234a8e", size = 1229635, upload-time = "2025-11-17T12:28:00.118Z" }, + { url = "https://files.pythonhosted.org/packages/e8/36/eac4199c5b200a5f3e93cad197da8d26d909f218eb444c4f552647c95240/blis-1.3.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30b8a5b90cb6cb81d1ada9ae05aa55fb8e70d9a0ae9db40d2401bb9c1c8f14c4", size = 2815650, upload-time = "2025-11-17T12:28:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/bf/51/472e7b36a6bedb5242a9757e7486f702c3619eff76e256735d0c8b1679c6/blis-1.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9f5c53b277f6ac5b3ca30bc12ebab7ea16c8f8c36b14428abb56924213dc127", size = 11359008, upload-time = "2025-11-17T12:28:04.589Z" }, + { url = "https://files.pythonhosted.org/packages/84/da/d0dfb6d6e6321ae44df0321384c32c322bd07b15740d7422727a1a49fc5d/blis-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6297e7616c158b305c9a8a4e47ca5fc9b0785194dd96c903b1a1591a7ca21ddf", size = 3011959, upload-time = "2025-11-17T12:28:06.862Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2b0b5e556fa0364ed671051ea078a6d6d7b979b1cfef78d64ad3ca5f0c7f/blis-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f966ca74f89f8a33e568b9a1d71992fc9a0d29a423e047f0a212643e21b5458", size = 14232456, upload-time = "2025-11-17T12:28:08.779Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/4cdc81a47bf862c0b06d91f1bc6782064e8b69ac9b5d4ff51d97e4ff03da/blis-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97", size = 6192624, upload-time = "2025-11-17T12:28:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8a/80f7c68fbc24a76fc9c18522c46d6d69329c320abb18e26a707a5d874083/blis-1.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415", size = 6934081, upload-time = "2025-11-17T12:28:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/e5/52/d1aa3a51a7fc299b0c89dcaa971922714f50b1202769eebbdaadd1b5cff7/blis-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b", size = 1231486, upload-time = "2025-11-17T12:28:18.008Z" }, + { url = "https://files.pythonhosted.org/packages/99/4f/badc7bd7f74861b26c10123bba7b9d16f99cd9535ad0128780360713820f/blis-1.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0", size = 2814944, upload-time = "2025-11-17T12:28:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/72/a6/f62a3bd814ca19ec7e29ac889fd354adea1217df3183e10217de51e2eb8b/blis-1.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74", size = 11345825, upload-time = "2025-11-17T12:28:21.354Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/671af79ee42bc4c968cae35c091ac89e8721c795bfa4639100670dc59139/blis-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990", size = 3008771, upload-time = "2025-11-17T12:28:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/7cd7f8490da7c98ee01557f2105885cc597217b0e7fd2eeb9e22cdd4ef23/blis-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb", size = 14219213, upload-time = "2025-11-17T12:28:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/0a/de/acae8e9f9a1f4bb393d41c8265898b0f29772e38eac14e9f69d191e2c006/blis-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf", size = 6324695, upload-time = "2025-11-17T12:28:28.401Z" }, +] + [[package]] name = "cachetools" version = "7.1.4" @@ -215,6 +264,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, ] +[[package]] +name = "catalogue" +version = "2.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/244d58127e1cdf04cf2dc7d9566f0d24ef01d5ce21811bab088ecc62b5ea/catalogue-2.0.10.tar.gz", hash = "sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15", size = 19561, upload-time = "2023-09-25T06:29:24.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/96/d32b941a501ab566a16358d68b6eb4e4acc373fab3c3c4d7d9e649f7b4bb/catalogue-2.0.10-py3-none-any.whl", hash = "sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f", size = 17325, upload-time = "2023-09-25T06:29:23.337Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -432,6 +490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] +[[package]] +name = "cloudpathlib" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/19/58bc6b5d7d0f81c7209b05445af477e147c486552f96665a5912211839b9/cloudpathlib-0.24.0.tar.gz", hash = "sha256:c521a984e77b47e656fe78e20a7e3e260e0ab45fc69e33ac01094227c979e34a", size = 53600, upload-time = "2026-04-30T00:54:43.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/5b/ba933f896d9b0b07608d575a8501e2b4e32166b60d84c430a4a7285ebe64/cloudpathlib-0.24.0-py3-none-any.whl", hash = "sha256:b1c51e2d2ec7dc4fed6538991f4aea849d6cf11a7e6b9069f86e461aa1f9b5b4", size = 63214, upload-time = "2026-04-30T00:54:42.06Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -450,6 +517,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "confection" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/65/efd0fe8a936fc8ca2978cb7b82581fb20d901c6039e746a808f746b7647b/confection-1.3.3.tar.gz", hash = "sha256:f0f6810d567ff73993fe74d218ca5e1ffb6a44fb03f391257fc5d033546cbfaa", size = 54895, upload-time = "2026-03-24T18:45:24.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e4/d66708bdf0d92fb4d49b22cdff4b10cec38aca5dcd7e81d909bb55c65cd7/confection-1.3.3-py3-none-any.whl", hash = "sha256:b9fef9ee84b237ef4611ec3eb5797b70e13063e6310ad9f15536373f5e313c82", size = 35902, upload-time = "2026-03-24T18:45:22.664Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -624,50 +700,61 @@ toml = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -679,6 +766,62 @@ 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 = "cymem" +version = "2.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/2f0fbb32535c3731b7c2974c569fb9325e0a38ed5565a08e1139a3b71e82/cymem-2.0.13.tar.gz", hash = "sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588", size = 12320, upload-time = "2025-11-14T14:58:36.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/64/1db41f7576a6b69f70367e3c15e968fd775ba7419e12059c9966ceb826f8/cymem-2.0.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:673183466b0ff2e060d97ec5116711d44200b8f7be524323e080d215ee2d44a5", size = 43587, upload-time = "2025-11-14T14:57:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/81/13/57f936fc08551323aab3f92ff6b7f4d4b89d5b4e495c870a67cb8d279757/cymem-2.0.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bee2791b3f6fc034ce41268851462bf662ff87e8947e35fb6dd0115b4644a61f", size = 43139, upload-time = "2025-11-14T14:57:23.363Z" }, + { url = "https://files.pythonhosted.org/packages/32/a6/9345754be51e0479aa387b7b6cffc289d0fd3201aaeb8dade4623abd1e02/cymem-2.0.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f3aee3adf16272bca81c5826eed55ba3c938add6d8c9e273f01c6b829ecfde22", size = 245063, upload-time = "2025-11-14T14:57:24.839Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/6bc654101526fa86e82bf6b05d99b2cd47c30a333cfe8622c26c0592beb2/cymem-2.0.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30c4e75a3a1d809e89106b0b21803eb78e839881aa1f5b9bd27b454bc73afde3", size = 244496, upload-time = "2025-11-14T14:57:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fb/853b7b021e701a1f41687f3704d5f469aeb2a4f898c3fbb8076806885955/cymem-2.0.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec99efa03cf8ec11c8906aa4d4cc0c47df393bc9095c9dd64b89b9b43e220b04", size = 243287, upload-time = "2025-11-14T14:57:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2b/0e4664cafc581de2896d75000651fd2ce7094d33263f466185c28ffc96e4/cymem-2.0.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c90a6ecba994a15b17a3f45d7ec74d34081df2f73bd1b090e2adc0317e4e01b6", size = 248287, upload-time = "2025-11-14T14:57:29.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/f94c6950edbfc2aafb81194fc40b6cacc8e994e9359d3cb4328c5705b9b5/cymem-2.0.13-cp311-cp311-win_amd64.whl", hash = "sha256:ce821e6ba59148ed17c4567113b8683a6a0be9c9ac86f14e969919121efb61a5", size = 40116, upload-time = "2025-11-14T14:57:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/00/df/2455eff6ac0381ff165db6883b311f7016e222e3dd62185517f8e8187ed0/cymem-2.0.13-cp311-cp311-win_arm64.whl", hash = "sha256:0dca715e708e545fd1d97693542378a00394b20a37779c1ae2c8bdbb43acef79", size = 36349, upload-time = "2025-11-14T14:57:31.573Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/478a2911ab5028cb710b4900d64aceba6f4f882fcb13fd8d40a456a1b6dc/cymem-2.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8afbc5162a0fe14b6463e1c4e45248a1b2fe2cbcecc8a5b9e511117080da0eb", size = 43745, upload-time = "2025-11-14T14:57:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/f9/71/f0f8adee945524774b16af326bd314a14a478ed369a728a22834e6785a18/cymem-2.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9251d889348fe79a75e9b3e4d1b5fa651fca8a64500820685d73a3acc21b6a8", size = 42927, upload-time = "2025-11-14T14:57:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/159780fe162ff715d62b809246e5fc20901cef87ca28b67d255a8d741861/cymem-2.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:742fc19764467a49ed22e56a4d2134c262d73a6c635409584ae3bf9afa092c33", size = 258346, upload-time = "2025-11-14T14:57:34.917Z" }, + { url = "https://files.pythonhosted.org/packages/eb/12/678d16f7aa1996f947bf17b8cfb917ea9c9674ef5e2bd3690c04123d5680/cymem-2.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f190a92fe46197ee64d32560eb121c2809bb843341733227f51538ce77b3410d", size = 260843, upload-time = "2025-11-14T14:57:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/0dd8c167c08cd85e70d274b7235cfe1e31b3cebc99221178eaf4bbb95c6f/cymem-2.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d670329ee8dbbbf241b7c08069fe3f1d3a1a3e2d69c7d05ea008a7010d826298", size = 254607, upload-time = "2025-11-14T14:57:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/d6514a412a1160aa65db539836b3d47f9b59f6675f294ec34ae32f867c82/cymem-2.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a84ba3178d9128b9ffb52ce81ebab456e9fe959125b51109f5b73ebdfc6b60d6", size = 262421, upload-time = "2025-11-14T14:57:39.265Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fe/3ee37d02ca4040f2fb22d34eb415198f955862b5dd47eee01df4c8f5454c/cymem-2.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:2ff1c41fd59b789579fdace78aa587c5fc091991fa59458c382b116fc36e30dc", size = 40176, upload-time = "2025-11-14T14:57:40.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/fb/1b681635bfd5f2274d0caa8f934b58435db6c091b97f5593738065ddb786/cymem-2.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:6bbd701338df7bf408648191dff52472a9b334f71bcd31a21a41d83821050f67", size = 35959, upload-time = "2025-11-14T14:57:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/95a4d1e3bebfdfa7829252369357cf9a764f67569328cd9221f21e2c952e/cymem-2.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:891fd9030293a8b652dc7fb9fdc79a910a6c76fc679cd775e6741b819ffea476", size = 43478, upload-time = "2025-11-14T14:57:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/8fc929cc29ae466b7b4efc23ece99cbd3ea34992ccff319089c624d667fd/cymem-2.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89c4889bd16513ce1644ccfe1e7c473ba7ca150f0621e66feac3a571bde09e7e", size = 42695, upload-time = "2025-11-14T14:57:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b3/deeb01354ebaf384438083ffe0310209ef903db3e7ba5a8f584b06d28387/cymem-2.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45dcaba0f48bef9cc3d8b0b92058640244a95a9f12542210b51318da97c2cf28", size = 250573, upload-time = "2025-11-14T14:57:44.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/36/bc980b9a14409f3356309c45a8d88d58797d02002a9d794dd6c84e809d3a/cymem-2.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e96848faaafccc0abd631f1c5fb194eac0caee4f5a8777fdbb3e349d3a21741c", size = 254572, upload-time = "2025-11-14T14:57:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dd/a12522952624685bd0f8968e26d2ed6d059c967413ce6eb52292f538f1b0/cymem-2.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e02d3e2c3bfeb21185d5a4a70790d9df40629a87d8d7617dc22b4e864f665fa3", size = 248060, upload-time = "2025-11-14T14:57:47.605Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/5dc933ddfeb2dfea747a0b935cb965b9a7580b324d96fc5f5a1b5ff8df29/cymem-2.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fece5229fd5ecdcd7a0738affb8c59890e13073ae5626544e13825f26c019d3c", size = 254601, upload-time = "2025-11-14T14:57:48.861Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/d23b06166864fa94e13a98e5922986ce774832936473578febce64448d75/cymem-2.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:38aefeb269597c1a0c2ddf1567dd8605489b661fa0369c6406c1acd433b4c7ba", size = 40103, upload-time = "2025-11-14T14:57:50.396Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9e/c7b21271ab88a21760f3afdec84d2bc09ffa9e6c8d774ad9d4f1afab0416/cymem-2.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:717270dcfd8c8096b479c42708b151002ff98e434a7b6f1f916387a6c791e2ad", size = 36016, upload-time = "2025-11-14T14:57:51.611Z" }, + { url = "https://files.pythonhosted.org/packages/7f/28/d3b03427edc04ae04910edf1c24b993881c3ba93a9729a42bcbb816a1808/cymem-2.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7e1a863a7f144ffb345397813701509cfc74fc9ed360a4d92799805b4b865dd1", size = 46429, upload-time = "2025-11-14T14:57:52.582Z" }, + { url = "https://files.pythonhosted.org/packages/35/a9/7ed53e481f47ebfb922b0b42e980cec83e98ccb2137dc597ea156642440c/cymem-2.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c16cb80efc017b054f78998c6b4b013cef509c7b3d802707ce1f85a1d68361bf", size = 46205, upload-time = "2025-11-14T14:57:53.64Z" }, + { url = "https://files.pythonhosted.org/packages/61/39/a3d6ad073cf7f0fbbb8bbf09698c3c8fac11be3f791d710239a4e8dd3438/cymem-2.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d78a27c88b26c89bd1ece247d1d5939dba05a1dae6305aad8fd8056b17ddb51", size = 296083, upload-time = "2025-11-14T14:57:55.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/20697c8bc19f624a595833e566f37d7bcb9167b0ce69de896eba7cfc9c2d/cymem-2.0.13-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d36710760f817194dacb09d9fc45cb6a5062ed75e85f0ef7ad7aeeb13d80cc3", size = 286159, upload-time = "2025-11-14T14:57:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/82/d4/9326e3422d1c2d2b4a8fb859bdcce80138f6ab721ddafa4cba328a505c71/cymem-2.0.13-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c8f30971cadd5dcf73bcfbbc5849b1f1e1f40db8cd846c4aa7d3b5e035c7b583", size = 288186, upload-time = "2025-11-14T14:57:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/68da7dd749b72884dc22e898562f335002d70306069d496376e5ff3b6153/cymem-2.0.13-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9d441d0e45798ec1fd330373bf7ffa6b795f229275f64016b6a193e6e2a51522", size = 290353, upload-time = "2025-11-14T14:58:00.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/dbf2ad6ecd19b99b3aab6203b1a06608bbd04a09c522d836b854f2f30f73/cymem-2.0.13-cp313-cp313t-win_amd64.whl", hash = "sha256:d1c950eebb9f0f15e3ef3591313482a5a611d16fc12d545e2018cd607f40f472", size = 44764, upload-time = "2025-11-14T14:58:01.793Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/35701c13e1fc7b0895198c8b20068c569a841e0daf8e0b14d1dc0816b28f/cymem-2.0.13-cp313-cp313t-win_arm64.whl", hash = "sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f", size = 38964, upload-time = "2025-11-14T14:58:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2e/f0e1596010a9a57fa9ebd124a678c07c5b2092283781ae51e79edcf5cb98/cymem-2.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1", size = 43812, upload-time = "2025-11-14T14:58:04.227Z" }, + { url = "https://files.pythonhosted.org/packages/bc/45/8ccc21df08fcbfa6aa3efeb7efc11a1c81c90e7476e255768bb9c29ba02a/cymem-2.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b", size = 42951, upload-time = "2025-11-14T14:58:05.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/8c/fe16531631f051d3d1226fa42e2d76fd2c8d5cfa893ec93baee90c7a9d90/cymem-2.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f", size = 249878, upload-time = "2025-11-14T14:58:06.95Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/39d67b80ffb260457c05fcc545de37d82e9e2dbafc93dd6b64f17e09b933/cymem-2.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef", size = 252571, upload-time = "2025-11-14T14:58:08.232Z" }, + { url = "https://files.pythonhosted.org/packages/53/0e/76f6531f74dfdfe7107899cce93ab063bb7ee086ccd3910522b31f623c08/cymem-2.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705", size = 248555, upload-time = "2025-11-14T14:58:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/eee56757db81f0aefc2615267677ae145aff74228f529838425057003c0d/cymem-2.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e", size = 254177, upload-time = "2025-11-14T14:58:10.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/a4b58ec9e53c836dce07ef39837a64a599f4a21a134fc7ca57a3a8f9a4b5/cymem-2.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed", size = 40853, upload-time = "2025-11-14T14:58:12.116Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/9931d1f83e5aeba175440af0b28f0c2e6f71274a5a7b688bc3e907669388/cymem-2.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1", size = 36970, upload-time = "2025-11-14T14:58:13.114Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/af447c2184dec6dec973be14614df8ccb4d16d1c74e0784ab4f02538433c/cymem-2.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042", size = 46804, upload-time = "2025-11-14T14:58:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/e10f33a8d4fc17f9b933d451038218437f9326c2abb15a3e7f58ce2a06ec/cymem-2.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612", size = 46254, upload-time = "2025-11-14T14:58:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/5efeb2d2ea6ebad2745301ad33a4fa9a8f9a33b66623ee4d9185683007a6/cymem-2.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431", size = 296061, upload-time = "2025-11-14T14:58:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/2a3f65842cc8443c2c0650cf23d525be06c8761ab212e0a095a88627be1b/cymem-2.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6", size = 285784, upload-time = "2025-11-14T14:58:17.412Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/dd5f9729398f0108c2e71d942253d0d484d299d08b02e474d7cfc43ed0b0/cymem-2.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e", size = 288062, upload-time = "2025-11-14T14:58:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/5a/01/ffe51729a8f961a437920560659073e47f575d4627445216c1177ecd4a41/cymem-2.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc", size = 290465, upload-time = "2025-11-14T14:58:21.815Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/c9e7d68607f71ef978c81e334ab2898b426944c71950212b1467186f69f9/cymem-2.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1", size = 46665, upload-time = "2025-11-14T14:58:23.512Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/150e406a2db5535533aa3c946de58f0371f2e412e23f050c704588023e6e/cymem-2.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f", size = 39715, upload-time = "2025-11-14T14:58:24.773Z" }, +] + [[package]] name = "datasure" version = "1.0.0.post1" @@ -695,6 +838,8 @@ dependencies = [ { name = "plotly" }, { name = "polars" }, { name = "polars-readstat" }, + { name = "presidio-analyzer" }, + { name = "presidio-anonymizer" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -708,6 +853,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "en-core-web-sm" }, { name = "jupyter" }, { name = "jupyterlab" }, { name = "mistune" }, @@ -730,6 +876,8 @@ requires-dist = [ { name = "plotly", specifier = ">=6.2.0" }, { name = "polars", specifier = ">=1.30.0" }, { name = "polars-readstat", specifier = ">=0.5.1" }, + { name = "presidio-analyzer", specifier = ">=2.2.360" }, + { name = "presidio-anonymizer", specifier = ">=2.2.360" }, { name = "pyarrow", specifier = ">=23.0.1" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pyyaml", specifier = ">=6.0.1" }, @@ -742,6 +890,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "en-core-web-sm", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" }, { name = "jupyter", specifier = ">=1.1.1" }, { name = "jupyterlab", specifier = ">=4.5.7" }, { name = "mistune", specifier = ">=3.2.1" }, @@ -846,6 +995,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/82/4d52f3f9f9703a226b26b80bdae3f6905aeefe5221bf1815fc93ff02ca25/duckdb-1.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:0f8722346024e5d9f02b58bf7b0491a629f97fdc8a04a10e432940f471ee387a", size = 14449863, upload-time = "2026-06-17T10:48:50.18Z" }, ] +[[package]] +name = "en-core-web-sm" +version = "3.8.0" +source = { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" } +wheels = [ + { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", hash = "sha256:1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -873,6 +1030,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, ] +[[package]] +name = "filelock" +version = "3.30.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/01/9256cbe36eb2a84501920adad7d0e86738ab0ad60e64f4f5c38d7dd94dc1/filelock-3.30.3.tar.gz", hash = "sha256:6575420cd497ed15fc43a7ac46c48f6b6371c733fc40cf5a4e216871397bc1e2", size = 177706, upload-time = "2026-07-17T15:17:45.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/20/b0d6da41a08b30faef4f20302f5d826a9d39073750cd9100b463eb840cf4/filelock-3.30.3-py3-none-any.whl", hash = "sha256:06e1c6d5e12277022172b7f8d090affe0e8f940f1a37ca45f548855ee51a6b86", size = 94544, upload-time = "2026-07-17T15:17:44.129Z" }, +] + [[package]] name = "fonttools" version = "4.63.0" @@ -1664,6 +1830,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1815,6 +1993,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] +[[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 = "mistune" version = "3.3.1" @@ -1833,6 +2020,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "murmurhash" +version = "1.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/2e/88c147931ea9725d634840d538622e94122bceaf346233349b7b5c62964b/murmurhash-1.0.15.tar.gz", hash = "sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01", size = 13291, upload-time = "2025-11-14T09:51:15.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/ca/77d3e69924a8eb4508bb4f0ad34e46adbeedeb93616a71080e61e53dad71/murmurhash-1.0.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f32307fb9347680bb4fe1cbef6362fb39bd994f1b59abd8c09ca174e44199081", size = 27397, upload-time = "2025-11-14T09:50:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/a936f577d35b245d47b310f29e5e9f09fcac776c8c992f1ab51a9fb0cee2/murmurhash-1.0.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539d8405885d1d19c005f3a2313b47e8e54b0ee89915eb8dfbb430b194328e6c", size = 27692, upload-time = "2025-11-14T09:50:04.144Z" }, + { url = "https://files.pythonhosted.org/packages/4d/64/5f8cfd1fd9cbeb43fcff96672f5bd9e7e1598d1c970f808ecd915490dc20/murmurhash-1.0.15-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4cd739a00f5a4602201b74568ddabae46ec304719d9be752fd8f534a9464b5e", size = 128396, upload-time = "2025-11-14T09:50:05.268Z" }, + { url = "https://files.pythonhosted.org/packages/ac/10/d9ce29d559a75db0d8a3f13ea12c7f541ec9de2afca38dc70418b890eedb/murmurhash-1.0.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44d211bcc3ec203c47dac06f48ee871093fcbdffa6652a6cc5ea7180306680a8", size = 128687, upload-time = "2025-11-14T09:50:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/48/cd/dc97ab7e68cdfa1537a56e36dbc846c5a66701cc39ecee2d4399fe61996c/murmurhash-1.0.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f9bf47101354fb1dc4b2e313192566f04ba295c28a37e2f71c692759acc1ba3c", size = 128198, upload-time = "2025-11-14T09:50:08.062Z" }, + { url = "https://files.pythonhosted.org/packages/53/73/32f2aaa22c1e4afae337106baf0c938abf36a6cc879cfee83a00461bbbf7/murmurhash-1.0.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c69b4d3bcd6233782a78907fe10b9b7a796bdc5d28060cf097d067bec280a5d", size = 127214, upload-time = "2025-11-14T09:50:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/82/ed/812103a7f353eba2d83655b08205e13a38c93b4db0692f94756e1eb44516/murmurhash-1.0.15-cp311-cp311-win_amd64.whl", hash = "sha256:e43a69496342ce530bdd670264cb7c8f45490b296e4764c837ce577e3c7ebd53", size = 25241, upload-time = "2025-11-14T09:50:10.373Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5f/2c511bdd28f7c24da37a00116ffd0432b65669d098f0d0260c66ac0ffdc2/murmurhash-1.0.15-cp311-cp311-win_arm64.whl", hash = "sha256:f3e99a6ee36ef5372df5f138e3d9c801420776d3641a34a49e5c2555f44edba7", size = 23216, upload-time = "2025-11-14T09:50:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/b6/46/be8522d3456fdccf1b8b049c6d82e7a3c1114c4fc2cfe14b04cba4b3e701/murmurhash-1.0.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d37e3ae44746bca80b1a917c2ea625cf216913564ed43f69d2888e5df97db0cb", size = 27884, upload-time = "2025-11-14T09:50:13.133Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/630449bf4f6178d7daf948ce46ad00b25d279065fc30abd8d706be3d87e0/murmurhash-1.0.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0861cb11039409eaf46878456b7d985ef17b6b484103a6fc367b2ecec846891d", size = 27855, upload-time = "2025-11-14T09:50:14.859Z" }, + { url = "https://files.pythonhosted.org/packages/ff/30/ea8f601a9bf44db99468696efd59eb9cff1157cd55cb586d67116697583f/murmurhash-1.0.15-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5a301decfaccfec70fe55cb01dde2a012c3014a874542eaa7cc73477bb749616", size = 134088, upload-time = "2025-11-14T09:50:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/c9/de/c40ce8c0877d406691e735b8d6e9c815f36a82b499d358313db5dbe219d7/murmurhash-1.0.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32c6fde7bd7e9407003370a07b5f4addacabe1556ad3dc2cac246b7a2bba3400", size = 133978, upload-time = "2025-11-14T09:50:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/bd49963ecd84ebab2fe66595e2d1ed41d5e8b5153af5dc930f0bd827007c/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d8b43a7011540dc3c7ce66f2134df9732e2bc3bbb4a35f6458bc755e48bde26", size = 132956, upload-time = "2025-11-14T09:50:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/2530769c545074417c862583f05f4245644599f1e9ff619b3dfe2969aafc/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43bf4541892ecd95963fcd307bf1c575fc0fee1682f41c93007adee71ca2bb40", size = 134184, upload-time = "2025-11-14T09:50:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/b249b042f5afe34d14ada2dc4afc777e883c15863296756179652e081c44/murmurhash-1.0.15-cp312-cp312-win_amd64.whl", hash = "sha256:f4ac15a2089dc42e6eb0966622d42d2521590a12c92480aafecf34c085302cca", size = 25647, upload-time = "2025-11-14T09:50:21.049Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/028179259aebc18fd4ba5cae2601d1d47517427a537ab44336446431a215/murmurhash-1.0.15-cp312-cp312-win_arm64.whl", hash = "sha256:4a70ca4ae19e600d9be3da64d00710e79dde388a4d162f22078d64844d0ebdda", size = 23338, upload-time = "2025-11-14T09:50:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/ba300b5f04dae0409202d6285668b8a9d3ade43a846abee3ef611cb388d5/murmurhash-1.0.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe50dc70e52786759358fd1471e309b94dddfffb9320d9dfea233c7684c894ba", size = 27861, upload-time = "2025-11-14T09:50:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/29c19d268e6f4ea1ed2a462c901eed1ed35b454e2cbc57da592fad663ac6/murmurhash-1.0.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1349a7c23f6092e7998ddc5bd28546cc31a595afc61e9fdb3afc423feec3d7ad", size = 27840, upload-time = "2025-11-14T09:50:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/e2/63/58e2de2b5232cd294c64092688c422196e74f9fa8b3958bdf02d33df24b9/murmurhash-1.0.15-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ba6d05de2613535b5a9227d4ad8ef40a540465f64660d4a8800634ae10e04f", size = 133080, upload-time = "2025-11-14T09:50:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/d13e2e9f8ba1ced06840921a50f7cece0a475453284158a3018b72679761/murmurhash-1.0.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa1b70b3cc2801ab44179c65827bbd12009c68b34e9d9ce7125b6a0bd35af63c", size = 132648, upload-time = "2025-11-14T09:50:27.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/47994f1813fa205c84977b0ff51ae6709f8539af052c7491a5f863d82bdc/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:213d710fb6f4ef3bc11abbfad0fa94a75ffb675b7dc158c123471e5de869f9af", size = 131502, upload-time = "2025-11-14T09:50:29.339Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/90c1fd00b4aeb704fb5e84cd666b33ffd7f245155048071ffbb51d2bb57d/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b65a5c4e7f5d71f7ccac2d2b60bdf7092d7976270878cfec59d5a66a533db823", size = 132736, upload-time = "2025-11-14T09:50:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/da73462dbfa77f6433b128d2120ba7ba300f8c06dc4f4e022c38d240a5f5/murmurhash-1.0.15-cp313-cp313-win_amd64.whl", hash = "sha256:9aba94c5d841e1904cd110e94ceb7f49cfb60a874bbfb27e0373622998fb7c7c", size = 25682, upload-time = "2025-11-14T09:50:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/bb/83/032729ef14971b938fbef41ee125fc8800020ee229bd35178b6ede8ee934/murmurhash-1.0.15-cp313-cp313-win_arm64.whl", hash = "sha256:263807eca40d08c7b702413e45cca75ecb5883aa337237dc5addb660f1483378", size = 23370, upload-time = "2025-11-14T09:50:33.264Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/7547d9205e9bd2f8e5dfd0b682cc9277594f98909f228eb359489baec1df/murmurhash-1.0.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:694fd42a74b7ce257169d14c24aa616aa6cd4ccf8abe50eca0557e08da99d055", size = 29955, upload-time = "2025-11-14T09:50:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c7/3afd5de7a5b3ae07fe2d3a3271b327ee1489c58ba2b2f2159bd31a25edb9/murmurhash-1.0.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a2ea4546ba426390beff3cd10db8f0152fdc9072c4f2583ec7d8aa9f3e4ac070", size = 30108, upload-time = "2025-11-14T09:50:35.53Z" }, + { url = "https://files.pythonhosted.org/packages/02/69/d6637ee67d78ebb2538c00411f28ea5c154886bbe1db16c49435a8a4ab16/murmurhash-1.0.15-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:34e5a91139c40b10f98d0b297907f5d5267b4b1b2e5dd2eb74a021824f751b98", size = 164054, upload-time = "2025-11-14T09:50:36.591Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4c/89e590165b4c7da6bf941441212a721a270195332d3aacfdfdf527d466ca/murmurhash-1.0.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35606868a5961cf42e79314ca0bddf5a400ce377b14d83192057928d6252ec", size = 168153, upload-time = "2025-11-14T09:50:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/95c42df0c21d2e413b9fcd17317a7587351daeb264dc29c6aec1fdbd26f8/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:43cc6ac3b91ca0f7a5ae9c063ba4d6c26972c97fd7c25280ecc666413e4c5535", size = 164345, upload-time = "2025-11-14T09:50:39.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/22/9d02c880a88b83bb3ce7d6a38fb727373ab78d82e5f3d8d9fc5612219f90/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:847d712136cb462f0e4bd6229ee2d9eb996d8854eb8312dff3d20c8f5181fda5", size = 161990, upload-time = "2025-11-14T09:50:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/750232524e0dc262e8dcede6536dafc766faadd9a52f1d23746b02948ad8/murmurhash-1.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:2680851af6901dbe66cc4aa7ef8e263de47e6e1b425ae324caa571bdf18f8d58", size = 28812, upload-time = "2025-11-14T09:50:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/4ad9d215ef6ade89f27a72dc4e86b98ef1a43534cc3e6a6900a362a0bf0a/murmurhash-1.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:189a8de4d657b5da9efd66601b0636330b08262b3a55431f2379097c986995d0", size = 25398, upload-time = "2025-11-14T09:50:43.023Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/726df275edf07688146966e15eaaa23168100b933a2e1a29b37eb56c6db8/murmurhash-1.0.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b", size = 28029, upload-time = "2025-11-14T09:50:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/24ecf9061bc2b20933df8aba47c73e904274ea8811c8300cab92f6f82372/murmurhash-1.0.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68", size = 27912, upload-time = "2025-11-14T09:50:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/ba/26/fff3caba25aa3c0622114e03c69fb66c839b22335b04d7cce91a3a126d44/murmurhash-1.0.15-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e", size = 131847, upload-time = "2025-11-14T09:50:46.819Z" }, + { url = "https://files.pythonhosted.org/packages/df/e4/0f2b9fc533467a27afb4e906c33f32d5f637477de87dd94690e0c44335a6/murmurhash-1.0.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724", size = 132267, upload-time = "2025-11-14T09:50:48.298Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/9d1c107989728ec46e25773d503aa54070b32822a18cfa7f9d5f41bc17a5/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba", size = 131894, upload-time = "2025-11-14T09:50:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/0d/81/dcf27c71445c0e993b10e33169a098ca60ee702c5c58fcbde205fa6332a6/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322", size = 132054, upload-time = "2025-11-14T09:50:50.747Z" }, + { url = "https://files.pythonhosted.org/packages/bc/32/e874a14b2d2246bd2d16f80f49fad393a3865d4ee7d66d2cae939a67a29a/murmurhash-1.0.15-cp314-cp314-win_amd64.whl", hash = "sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22", size = 26579, upload-time = "2025-11-14T09:50:52.278Z" }, + { url = "https://files.pythonhosted.org/packages/af/8e/4fca051ed8ae4d23a15aaf0a82b18cb368e8cf84f1e3b474d5749ec46069/murmurhash-1.0.15-cp314-cp314-win_arm64.whl", hash = "sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4", size = 24341, upload-time = "2025-11-14T09:50:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/c72c2a4edd86aac829337ab9f83cf04cdb15e5d503e4c9a3a243f30a261c/murmurhash-1.0.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b", size = 30146, upload-time = "2025-11-14T09:50:54.705Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d7/72b47ebc86436cd0aa1fd4c6e8779521ec389397ac11389990278d0f7a47/murmurhash-1.0.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf", size = 30141, upload-time = "2025-11-14T09:50:55.829Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/6d2f09135079c34dc2d26e961c52742d558b320c61503f273eab6ba743d9/murmurhash-1.0.15-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0", size = 163898, upload-time = "2025-11-14T09:50:56.946Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e2/9c1b462e33f9cb2d632056f07c90b502fc20bd7da50a15d0557343bd2fed/murmurhash-1.0.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e", size = 168040, upload-time = "2025-11-14T09:50:58.234Z" }, + { url = "https://files.pythonhosted.org/packages/e8/73/8694db1408fcdfa73589f7df6c445437ea146986fa1e393ec60d26d6e30c/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749", size = 164239, upload-time = "2025-11-14T09:50:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f9/8e360bdfc3c44e267e7e046f0e0b9922766da92da26959a6963f597e6bb5/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc", size = 161811, upload-time = "2025-11-14T09:51:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/f9/31/97649680595b1096803d877ababb9a67c07f4378f177ec885eea28b9db6d/murmurhash-1.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc", size = 29817, upload-time = "2025-11-14T09:51:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/4fce8755f25d77324401886c00017c556be7ca3039575b94037aff905385/murmurhash-1.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d", size = 26219, upload-time = "2025-11-14T09:51:03.563Z" }, +] + [[package]] name = "narwhals" version = "2.22.1" @@ -2196,6 +2439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "phonenumbers" +version = "9.0.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/c3/e154829a50679c38ae28ec9c4f151f2c425db5e70fd445266e76f6d6cd65/phonenumbers-9.0.34.tar.gz", hash = "sha256:00751c75d1166485ca80ce02ec15b6a61a2628e9b313381579330bc70c934075", size = 2306776, upload-time = "2026-07-03T06:30:37.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/0a/3a7980f3b071dde9a297d85cf6b18ba4bc2e5024e1c453b3da8a1dc14268/phonenumbers-9.0.34-py2.py3-none-any.whl", hash = "sha256:1221bf8e65bd2c02770226488af806d4636814bc997104d3a1f7de6ed6410bd2", size = 2595344, upload-time = "2026-07-03T06:30:33.913Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -2357,6 +2609,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c5/2fb8592d691bd114de25d9c84300b23541dca7060eac11d7b4bed0327786/polars_runtime_32-1.42.1-cp310-abi3-win_arm64.whl", hash = "sha256:7051226e6b42ffc395a7a9190377cd28649fbfb991b8f85c6271f4e1cfb736fb", size = 46718300, upload-time = "2026-06-30T04:56:59.855Z" }, ] +[[package]] +name = "preshed" +version = "3.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cymem" }, + { name = "murmurhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/75/fe6b7bbd0dea530a001b0e24c331b21a0be2786e402abf3c57f5dce43d4b/preshed-3.0.13.tar.gz", hash = "sha256:d75f718bbfd97e992f7827e0fa7faf6a91bdd9c922d5baa4b50d62731396cb89", size = 18338, upload-time = "2026-03-23T08:57:31.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/d1/7bc39738388b38ff48cecbb326a9b2bb3f422bb32097be92e010f3162395/preshed-3.0.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5268c0e6fa96f50cdf87f516c2d4b32563c12706ee768e75c00e8d0098acd545", size = 136718, upload-time = "2026-03-23T08:56:23.889Z" }, + { url = "https://files.pythonhosted.org/packages/f6/65/de465b6801740140c2b5d2db6c312ca7937dcfd0442f1ae7d50dee529544/preshed-3.0.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df642547a1a94079978a0ea8f4593ab4b8d3bd43f767bef0ef64d9a214f8c4c9", size = 137261, upload-time = "2026-03-23T08:56:25.303Z" }, + { url = "https://files.pythonhosted.org/packages/89/83/478ee078746a4a413c841542caebd2ea74b659475b8bf5f2e3724b6fe655/preshed-3.0.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09397592d333a77f88454e72b7f1f941b2afaf040b392b9e74898dbc4648cdf5", size = 821010, upload-time = "2026-03-23T08:56:26.455Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2e/1ac761e973966893cd3a0ad3256360365276e2d1e779e351448981a1156a/preshed-3.0.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8e6fe0620ed0f96a246d46447055c447e071cd8222731a045c235e8a758c918", size = 823096, upload-time = "2026-03-23T08:56:28.126Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/7824cfd85dd7fe547888de20228ebd87d9acd3708206d30b82211e382d23/preshed-3.0.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:502f93f49a22788203f02d3067d4ea077a0cca3864de6a792eae12e7ce589e14", size = 1812148, upload-time = "2026-03-23T08:56:29.755Z" }, + { url = "https://files.pythonhosted.org/packages/34/48/32160a24705d56179de6af838c10a0c735c955dae5f9e4bb344750b79bc2/preshed-3.0.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:acd4d89abeca3678c5d8c89b3cd351314465bc67c7fa053d2644f8513e543386", size = 1881154, upload-time = "2026-03-23T08:56:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/ed/22/0344b50f8b1ad9e3aac08099c47e1aba91c81602fd117d2673f6606ecae6/preshed-3.0.13-cp311-cp311-win_amd64.whl", hash = "sha256:de87fbabb0f37c3c92d4dd9b94fc82ab73cdab4247cdfbd57ab3926caa983919", size = 122219, upload-time = "2026-03-23T08:56:32.74Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/812eeaa568510f396e27edab01100ca71418f032fd7098b107f12e572361/preshed-3.0.13-cp311-cp311-win_arm64.whl", hash = "sha256:5e2753779832e411e93eb727f3d409c0a6b7408e5ce4dd868076d8ece48c7693", size = 109308, upload-time = "2026-03-23T08:56:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/ccff23c44c04088c248539005fcda78b9014512a34d170c5360f02ad908b/preshed-3.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5d14eea14bd01291388928991d7df7d60b9fd19ae970e55006eb4d29b0c1e8eb", size = 138497, upload-time = "2026-03-23T08:56:35.321Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/cad5a8145881a771e6c0d002f2e585fc19b962f120860b54d32af5baa342/preshed-3.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f05b08ce92399c0655b5e0eb5a1cc1f9e295703ed3aabdfaf6538dfa8ae23d57", size = 138010, upload-time = "2026-03-23T08:56:36.399Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a2/c5fed4fb3e946699259d11e4036a3cfdd8c89b3e542e3077d46781642425/preshed-3.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:62cf7f3113132891d6bba70ff547ad81c6fe50a31930bbbb8499f1d47cd122b7", size = 861498, upload-time = "2026-03-23T08:56:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/51/94/8c9bc48a6ea4903f53a1a0031ce8e35687526949f25821762ef21493c007/preshed-3.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b8de3f58043070a354477995acdd98626ce43e4193c708ebd0f694e467f5155", size = 868988, upload-time = "2026-03-23T08:56:39.324Z" }, + { url = "https://files.pythonhosted.org/packages/b6/df/ecd2f40055ff52527ca117ffbfafb888c1a3079b59fbabe03c5b8f9b7240/preshed-3.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:183b339956a9e1d7a4a00038a3b9587a734db9e8bd915939a49791bd1b372156", size = 1847382, upload-time = "2026-03-23T08:56:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/88/bdb244e40284ded3632a9f88c23bc80230bd7b2ae4a8b7f2cc91adead7a8/preshed-3.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e77bed56aded7cbe5d28d6bd2178bc5b13eda0e0e464dab205fb578fa915000", size = 1919236, upload-time = "2026-03-23T08:56:42.616Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/c91ea56342e6c364fc69b444a1ac5432327857199c44032c9cc9dc4c3a23/preshed-3.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:04d8f13f2986e5d11af5ac51f55ce3106c70c41b483d20ea392e6180bdd0f870", size = 122938, upload-time = "2026-03-23T08:56:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0b/6a99d99619fd83b14c696e2489caed7070647488d4d3ac0b723d35db2de0/preshed-3.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:19318dc1cd8cac6663c6c830bf7e0002d2de853769fb03e056774e97c21bedfd", size = 109194, upload-time = "2026-03-23T08:56:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/401158195d6dc7f6aef0b354d74d0e95c9da124499448c2b3dbb95b71204/preshed-3.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0d0c14187dc0078d8a63bf190ec045a4d13e7748b6caeb557a7d575e411410b", size = 137289, upload-time = "2026-03-23T08:56:46.516Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/e20e64573988528785447a6893b2e7ab287ecfd85b3888e978b28812fd20/preshed-3.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7770987c2e57497cd26124a9be5f652b5b3ccd0def89859ab0da8bca6144a3de", size = 136847, upload-time = "2026-03-23T08:56:47.572Z" }, + { url = "https://files.pythonhosted.org/packages/b9/72/18168f881359c4482d312f8dc196371bdd61c1583a52b34390da4c88bbea/preshed-3.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a7bc48220de579be6bdb0a8715482cf36e2a625a6fd5ad26c9f43485a4a23b5", size = 831478, upload-time = "2026-03-23T08:56:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3a/3543476091087102775568cea9885dde3453569e9aeee365809108de572f/preshed-3.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5c8462472f790c16708306aef3a102a762bd19dfe3d2f8ee08bd5e12f51b835", size = 839913, upload-time = "2026-03-23T08:56:49.937Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/b13f01329decc44ef53cfb6b4601ba85382dcb2a4ec78d9250f03a418066/preshed-3.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c046736239cc8d72670749b79b526e4111839a2fc461a58545d212797649129c", size = 1816452, upload-time = "2026-03-23T08:56:51.233Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/f1a996c6832234efd4d543041b582418d41ac480ee55c557ec9e65344637/preshed-3.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c333f18e9a81c8a6de0603fd8781e17115324b117c445ca91abdf7bfb1abe49", size = 1888978, upload-time = "2026-03-23T08:56:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b9/96fb71499049885ce19545903fdd38877bbc2be0da47e37c04d01f3e9f66/preshed-3.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:461327f8dd36520dcf1fd55a671e0c3c2c97a2d95e22fc85faa31173f4785dda", size = 122134, upload-time = "2026-03-23T08:56:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a7/32a4903019d936a2316fdd330bedddac287ac26326107d24fb76a1fbc60a/preshed-3.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:35d6c5acb3ee3b12b87a551913063f0cec784055c2af16e028c19fe875f079d0", size = 108497, upload-time = "2026-03-23T08:56:55.816Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b5/993886c98f5caaa6f07a648cac97a7c62a3093091cad65e1e43a1bd41cc4/preshed-3.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2f1efae396cadab5f3890a2fd43d2ee65373ef9096ccbb805e51e8d8bcc563b", size = 137882, upload-time = "2026-03-23T08:56:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/c6/86/b7fd137cbf140afd6c45e895946068a15f5b55642916de0075e6eb18581c/preshed-3.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d6acc1f5031a535a55a6f7148e2f274554a8343a16309c700cebea0fe7aee8c", size = 138233, upload-time = "2026-03-23T08:56:58.318Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/21a7e79625614134273dfed32bca5bb4c2ec1313e33fbd12d41657536f1f/preshed-3.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da9d931e7660dcdd757e5870269f0c159126d682ed73ed313971d199eb0f334", size = 834835, upload-time = "2026-03-23T08:56:59.48Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/2dbd299516461831ae90e0d5b0637137bf28520c4e6dd0b01d6f1886659a/preshed-3.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4ae5cfe075bb7a07982e382bca44f41ddf041f4d24cbd358e8cccfc049259b8", size = 834928, upload-time = "2026-03-23T08:57:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d3/af654eba4f6587c4ee02c5043e62c194b0a1c4431ffef0c67b9518f6b61c/preshed-3.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7557963d0125a3a7bcdb2eb6948f3e45da31b5a7f066b55320de3dea22d7557f", size = 1820368, upload-time = "2026-03-23T08:57:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/ebcb2b9e8cb881e40b55b0bf450f8a6b187e2ef3ae0c685cce81d2d85026/preshed-3.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c4bc60dc994864095d784b7e4d77dba3e64188d169ac88722b699d175561fddb", size = 1888251, upload-time = "2026-03-23T08:57:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/c6c012779edcaa6e2cd092c554e98dc53e77f41205b07208655ba77e2327/preshed-3.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:208dcebbe294bf1881ce33fb015d56ab2a7587aece85a09147727174207892e4", size = 125211, upload-time = "2026-03-23T08:57:05.83Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/390ef87d732ef64e673ef6bf9e5d898453986e979efa50fb3a400e2c0766/preshed-3.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:cf8e1a7a1823b2a7765121446c630140ac6e8650c07a6efbf375e168d1fef4f7", size = 111942, upload-time = "2026-03-23T08:57:06.996Z" }, + { url = "https://files.pythonhosted.org/packages/80/3a/a9dde3167bcecb27ae82ce4567b5ab1aa3989113ae6814c092ce223cc4ef/preshed-3.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ca43ecbc3783eda4d6ab3416ae2ecd9ef23dca5f53995843f69f7457bcd0677", size = 144997, upload-time = "2026-03-23T08:57:08.064Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/22d9355b50b6a13b407dcad0a81df83fb1d5602092d1f05834674dde8fda/preshed-3.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c8596e41a258ff213553a441e0bb3eb388fd8158e84a7bf3aae6d8ede2c166d3", size = 147294, upload-time = "2026-03-23T08:57:09.411Z" }, + { url = "https://files.pythonhosted.org/packages/70/42/a225ee83fdb306d2a503f21a627953b820f4e079c90c8a84338957cb8ff5/preshed-3.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f8856ca3d88e9b250630d70abb4f260d8933151ddfb413024784b25b009868e", size = 952110, upload-time = "2026-03-23T08:57:10.592Z" }, + { url = "https://files.pythonhosted.org/packages/40/ba/09a9dfe3d22d7e745483fd5d7f2a82cd4d39c161f7d2daa0faa4bd6402be/preshed-3.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e5b2865aecbd2e1e10e5d19bb8bfad765863c1307c6c3e51f2a08bd64122409", size = 932217, upload-time = "2026-03-23T08:57:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/e10e2e05133e7fcbd7c40536af1148c82dd24357b8f5726e2c7bc51cfd53/preshed-3.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09f96b477c987755b3c945df214ea1c1c80bfb350e9f34e78da89585535b77e8", size = 1896542, upload-time = "2026-03-23T08:57:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/37/aa/51e5b4109a4cdfae28c3613eeeb10764a3794ebef8de93ffbb109465bea3/preshed-3.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:670db59a52e1823b5f088c764df474e65b686592d4093adbeef14581c95ee2cb", size = 1959473, upload-time = "2026-03-23T08:57:15.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6a/1d966f367a14c703dde629d150d996c1b727d442f620300b21c9ec1a24d1/preshed-3.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:b03e21b0bf95eb56e23973f32cabb930e94f352228652f81c0955dbd6967d904", size = 146229, upload-time = "2026-03-23T08:57:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/22/80/368139067603e590a000122355f9c8576c8ebed4fb0b8849feaa2698489d/preshed-3.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc", size = 119339, upload-time = "2026-03-23T08:57:18.882Z" }, +] + +[[package]] +name = "presidio-analyzer" +version = "2.2.362" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "phonenumbers" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "spacy" }, + { name = "tldextract" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/8a/d6cf4bddbb11d9c78f5c9342bbbcde4f90fe4e3c7de114a836ec4ed8a6cf/presidio_analyzer-2.2.362-py3-none-any.whl", hash = "sha256:4c36438924b1fcb4df92ea5cf2d8dc57508808e116b10923c983b8732aa07d90", size = 201084, upload-time = "2026-03-15T12:40:43.801Z" }, +] + +[[package]] +name = "presidio-anonymizer" +version = "2.2.363" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/2b/8ce5b595ae0cee8cade7cf2d1620c63a2fe27726e5169732e604d60adfae/presidio_anonymizer-2.2.363.tar.gz", hash = "sha256:27b63f3de85507ecc732a29a63e01ac424a810aefe5b90f05ba9b558320d37ab", size = 25254, upload-time = "2026-06-28T16:09:14.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/60/03eb1ae29a8ee93a7227147fd9cce9e17c7088102b6dcf6ec5545e77847c/presidio_anonymizer-2.2.363-py3-none-any.whl", hash = "sha256:d689fa5b837541eeea5a772b79058a972185d560a232b83965d9384bdd0c62ad", size = 36890, upload-time = "2026-06-28T16:09:12.986Z" }, +] + [[package]] name = "prometheus-client" version = "0.25.0" @@ -2991,6 +3323,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "requests-file" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -3024,6 +3368,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "rpds-py" version = "2026.5.1" @@ -3399,6 +3756,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, ] +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -3408,6 +3783,18 @@ 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 = "smart-open" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/53/9c513747547fd595d5c143259129ea8b9c3ea2f6b7bb9dcea2b1966ded3c/smart_open-8.0.1.tar.gz", hash = "sha256:18b1c4496003c6902be17c15f032b5c319f307c89c6ae9e6b028b508bed8b2cf", size = 61882, upload-time = "2026-07-15T13:56:10.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/96/325b8c507ccecc50421fecc0345a502ee6e4a44785af3c4e6ecbadad624a/smart_open-8.0.1-py3-none-any.whl", hash = "sha256:3e97f90e92a952cb57863dfe132082c400a52eeeb27c067692fb51dbcc5b0089", size = 73504, upload-time = "2026-07-15T13:56:09.033Z" }, +] + [[package]] name = "smmap" version = "5.0.3" @@ -3426,6 +3813,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] +[[package]] +name = "spacy" +version = "3.8.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "jinja2" }, + { name = "murmurhash" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "spacy-legacy" }, + { name = "spacy-loggers" }, + { name = "srsly" }, + { name = "thinc" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "wasabi" }, + { name = "weasel" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/8c/0ccf32d9a6b4fd8737bba33d599ddb98934399c1d523f825a4beb4bd1495/spacy-3.8.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c55cb123c3edfba8c252ce6ae27ffb3d7f60a53ba5e108c3534421586c5fdda", size = 6617470, upload-time = "2026-03-29T10:40:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/e1/61/7f7d38e71daac7f91ffd362fb15645b6f9a68ad231e0ed6ff5c1dc6f6930/spacy-3.8.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab6c1ace316338dac334fc93c849994bbd717f9ebf59d2bc4158e978b2f542ee", size = 6441524, upload-time = "2026-03-29T10:40:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/18385aa5aeb9bcb299e8074da162b24e5c8bea5aa4d1dfa3dbafb35e9d1f/spacy-3.8.14-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7bece0450cd8ab841cfa8527fcc0ce18c4454f28e3b9fca42a450803a067355b", size = 32050591, upload-time = "2026-03-29T10:40:29.704Z" }, + { url = "https://files.pythonhosted.org/packages/fa/67/5c4a65ed2cedc598ad000a2b9f45afc76bb8d17a592cc01082dffa8bbc50/spacy-3.8.14-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc5e5f2ed121d57d819d247bb59253dc320a58acbd237b85f86c2aa38cab6bd1", size = 32296467, upload-time = "2026-03-29T10:40:32.557Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/28c118879791b3a7ffa81796d22203daac428e6f75572f1b8da1539e1ac6/spacy-3.8.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c4e5bc5cdefe39ea139985776a2e8eae05e7ff2bf51ca1bd65247dc45feeb8e", size = 32288404, upload-time = "2026-03-29T10:40:35.583Z" }, + { url = "https://files.pythonhosted.org/packages/72/1c/32aefcea2468782fcdb994f2f96cac93dc74f6589ce01047db42d9a299a2/spacy-3.8.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c228f4c9ae618173334c17adb748d66b574b6594bc3575233e15cd5ad1cb26b", size = 33113476, upload-time = "2026-03-29T10:40:38.577Z" }, + { url = "https://files.pythonhosted.org/packages/86/32/fc00532eabeace451175dd9b152ddd636e8f6a42248b5d90141f98be2af5/spacy-3.8.14-cp311-cp311-win_amd64.whl", hash = "sha256:6f51d1ce8b1ba30123f6bef6e795c4bc5466608e6e8a015dc828bd21d399aa9c", size = 15359704, upload-time = "2026-03-29T10:40:41.25Z" }, + { url = "https://files.pythonhosted.org/packages/de/31/89ff6722ec91f328dc717932849c6f57249c8a9d429d8670a6c8f70e576b/spacy-3.8.14-cp311-cp311-win_arm64.whl", hash = "sha256:c0c6c9d8771cc3708e309b07310d330fc8443a6bca34f4ff20b0f22751d8faf9", size = 14717168, upload-time = "2026-03-29T10:40:43.916Z" }, + { url = "https://files.pythonhosted.org/packages/0c/78/e4f2ae19a791cae756cd0e801204953eaec4e9ab75a60ad39f671dbb8d5a/spacy-3.8.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:726f02c60a2c6b0029167370d22d51731172a053d29c7e2ea6190db6de3ab483", size = 6218335, upload-time = "2026-03-29T10:40:46.298Z" }, + { url = "https://files.pythonhosted.org/packages/06/df/178bbab47fa209c8baf2f1e609cbddc6b18a985200be1ceee22bd5b89beb/spacy-3.8.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e3ebe50b93f2d40e8ec3451255528bb622ccb12be39fd140bb87668ce8d1075b", size = 6033860, upload-time = "2026-03-29T10:40:47.861Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e8/048d83b73b28686307bd9a60878a58de7b7b21b562ca4de8b5bd558031e9/spacy-3.8.14-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:daeb64b048f12c059997281aed53eb8776d26416dd313cf17ad6f63124b2b564", size = 32725099, upload-time = "2026-03-29T10:40:50.194Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/1799af5f4ccc8eb7500e4a20ca301488134429dba08cda5be68ce6ab2992/spacy-3.8.14-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d45715a24446f23b98ec3f09409a1d4111983d1d64613250ee38c3270e21853", size = 33205838, upload-time = "2026-03-29T10:40:53.029Z" }, + { url = "https://files.pythonhosted.org/packages/78/07/81ab9acd0ec64bfdd7339acfc4cf35f5fb74bbbb0b2be7e64d717c416bac/spacy-3.8.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1069a8be34940809f8462eb69f09a3f0ce59bf8b9cb82475f2a8e3580f50ece0", size = 32090380, upload-time = "2026-03-29T10:40:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/74/a5/b081b5bd3cedb2634c23eb470b5e24c65c894c57646567f47627291c2b3f/spacy-3.8.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2dfa77aec7fdebac0455d8afd4ce1d92d6f868b03d507ed1976179a63db7b374", size = 32991946, upload-time = "2026-03-29T10:40:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/4371413a6dfc1fa837282a365498165f828c2f3fe018dfb35336acc869e0/spacy-3.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:9def18c76a4472b326cb91a195623c9ca38a2b86999ad2df9e00b49ba8c63734", size = 14226946, upload-time = "2026-03-29T10:41:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/12ac876017da6c1e6b72afcc3c8b309996227fd3aa15382cd3311aee21b8/spacy-3.8.14-cp312-cp312-win_arm64.whl", hash = "sha256:d6257133357e4801c9c5d011925af5439b0a015aacf3c16528aa0009982431c7", size = 13628765, upload-time = "2026-03-29T10:41:03.806Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e5/822bbdfa459fee863ef2e9879a34b0ae5db7cd1e3eb76d32c766f19222e9/spacy-3.8.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b4f60fa8b9641a5e93e7a96db0cdd106d05d61756bf1d0ddcd1705ad347909a", size = 6202114, upload-time = "2026-03-29T10:41:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/7e/de/0e512154113e1f341567f2b9341835775e4180c180221e60faedaebb2f65/spacy-3.8.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0860c57220c633ccb20468bcd64bfb0d28908990c371a8857951d093a148dc8e", size = 6015458, upload-time = "2026-03-29T10:41:07.79Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4f/29c7e56afc7db07348a9e0efe0243b5eef465d5dc3d56433f164378c3fa6/spacy-3.8.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c24620b7dba879c69cebc51ef3b1107d4d4e44a1e0d4baa439372887d00c3fd9", size = 32510659, upload-time = "2026-03-29T10:41:09.88Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ce/cae678f664d5467016819253f5d6e52f8e68a12d8e799b651d73ec2a9a4b/spacy-3.8.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9699c1248d115d5825987c287a6f6acd66386ef3ebee7994ee67ba093e932c59", size = 32841057, upload-time = "2026-03-29T10:41:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/04/d4/419868afd449bdd367df005932537eea66c71e97c899ba278f3124933f3c/spacy-3.8.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:042d799e342fdb6bb5b02a4213a95acc9116c40ed3c849bb0a8296fbe648ec22", size = 31763252, upload-time = "2026-03-29T10:41:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/df5c1fee45f200b749ba72eeb536fbb2c545fc56230324954263b2f3be00/spacy-3.8.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b2264294097336e86832e8663f1ab3a7215621184863c96c082ab17ee11937", size = 32717872, upload-time = "2026-03-29T10:41:18.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/f1882ec2f5cc9c4e73cf2132997a03c397d7ceeb5ee7f7bb878b51a16365/spacy-3.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:4b6d4f20e291a7c70e37de2f246622b44a0ce82efaa710c9801c6bd599e75177", size = 14220335, upload-time = "2026-03-29T10:41:20.89Z" }, +] + +[[package]] +name = "spacy-legacy" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/79/91f9d7cc8db5642acad830dcc4b49ba65a7790152832c4eceb305e46d681/spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774", size = 23806, upload-time = "2023-01-23T09:04:15.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/12e842c70ff8828e34e543a2c7176dac4da006ca6901c9e8b43efab8bc6b/spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f", size = 29971, upload-time = "2023-01-23T09:04:13.45Z" }, +] + +[[package]] +name = "spacy-loggers" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/3d/926db774c9c98acf66cb4ed7faf6c377746f3e00b84b700d0868b95d0712/spacy-loggers-1.0.5.tar.gz", hash = "sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24", size = 20811, upload-time = "2023-09-11T12:26:52.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" }, +] + [[package]] name = "sqlfluff" version = "4.2.2" @@ -3448,6 +3905,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ac/227298acc7230cdd56feb108b0f3a42e47f2e0d58bee50e35cd5facab7de/sqlfluff-4.2.2-py3-none-any.whl", hash = "sha256:62dbfbcd33728351043d216767ec017754a24cd8fb624e8321f61a79988b4fa7", size = 1005710, upload-time = "2026-06-04T15:36:51.661Z" }, ] +[[package]] +name = "srsly" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/db/f794f219a6c788b881252d2536a8c4a97d2bdaadc690391e1cb53d123d71/srsly-2.5.3.tar.gz", hash = "sha256:08f98dbecbff3a31466c4ae7c833131f59d3655a0ad8ac749e6e2c149e2b0680", size = 490881, upload-time = "2026-03-23T11:56:59.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/36/5d7bb412d52e9cca787f9bfe838b596367189b254e50bf90f234a97184bf/srsly-2.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:785a09216ac31570fb301ddb9f61ee73d1f18f8b9561f712dce0b8ac8628bc88", size = 656760, upload-time = "2026-03-23T11:55:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/d6/dc/124f008cd2be3e887e972cbdeb17c5aee0f42093eca02c7cfd63bb5daf19/srsly-2.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0017c7d2a0cd9a4f1bdc00d946b45edcf90bb0e271e8f084c1ce542bf6708c32", size = 657503, upload-time = "2026-03-23T11:55:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/2c97244ebab125d55f1bfb7bb94e9572b3e819410dffd6a040eca1112350/srsly-2.5.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66ebae2c70305987341519ec1a720072a3cb3e4b1d52ac0e9e841f4d02658d3d", size = 1139161, upload-time = "2026-03-23T11:55:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ea/ecd396188f7591d80b89665f7af9e3ae02e42683daef57033ad7993ad3f9/srsly-2.5.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca4a068f6e14d84113a02fcb875c6b50a6285a12938c0e7a157eb3a63c50a86", size = 1142438, upload-time = "2026-03-23T11:55:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/9c/65/143e2e143c53d498ad0956f69d0e09189aa7a6e0ee6017758c285ba1ab2d/srsly-2.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e283fa2a8f7350fb9fb70ecdee28d59d39c92f4c7f1cc90a44d6b86db3b3a8b3", size = 1101783, upload-time = "2026-03-23T11:55:53.906Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/1392a5593de0cd3d08c2d6c071b877c84358a37f63172c4e9cb71706842d/srsly-2.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ffc97e22730ea97b00f7c303ccc60b1305e786afadb2a4a46578dafa4d29da0", size = 1115876, upload-time = "2026-03-23T11:55:55.624Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a5/6193aa4c08e488821538fcbce2282449e228fd2183ed67d118bb5ccd8b54/srsly-2.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f09b551f6c3e334652831ac68c770ee4284741ce0a3895bf1ccf2a1178d66cdd", size = 651733, upload-time = "2026-03-23T11:55:56.964Z" }, + { url = "https://files.pythonhosted.org/packages/66/a8/a73181743b6d237026615ca75c3fb3e4780736f1390550a7350d0c7f1149/srsly-2.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:21cf09e417d3e4f3fbf7dd337fd6d948c97abd01896b9b4cb80e81cd9778a73a", size = 639124, upload-time = "2026-03-23T11:55:58.532Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/e9f7fcec4cc92ad8bad6316c4241638b8cf7380382d4489d94ec6c436452/srsly-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:71e51c046ccbeefb86524c6b1e17574f579c6ac4dc8ea4a09437d3e8f88342d3", size = 658379, upload-time = "2026-03-23T11:55:59.85Z" }, + { url = "https://files.pythonhosted.org/packages/21/e4/fea4512e9785f58509b2cf67d993323848e583161b5fcfdc7dd9d7c1f3df/srsly-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f73c0db911552e94fe2016e1759d261d2f47926f68826664cada3723c87006a", size = 658513, upload-time = "2026-03-23T11:56:01.239Z" }, + { url = "https://files.pythonhosted.org/packages/20/b1/53591681b6ff2699a4f97b2d5552ba196eaa6a979b0873605f4c04b5f7ee/srsly-2.5.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c1ac27ae5f4bb9163c7d2c45fc8ec173aac3d92e32086d9472b326c5c6e570e", size = 1172265, upload-time = "2026-03-23T11:56:02.589Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c9/741e29f534919a944a16da4184924b1d3404c4bf60716ab2b91be771d1e3/srsly-2.5.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99026bcd9cbd3211cc36517400b04ca0fc5d3e412b14daf84ee6e65f67d9a2d8", size = 1180873, upload-time = "2026-03-23T11:56:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/89/57/5554f786eccf78b2750d6ac63be126e1b67badec2cb409dd611cf6f8c52b/srsly-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:07d682679e639eb46ff7e6da4a92714f4d5ffe351d088ee66f221e9b1f8865bb", size = 1120437, upload-time = "2026-03-23T11:56:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/9b4f73b1be3692f86d72ccc131c8e50f26f824d5c8830a59390bcc5b60ef/srsly-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e0542d85d6b55cf2934050d6ffcb1cd76c768dcf9572e7467002cf087bb366d", size = 1137376, upload-time = "2026-03-23T11:56:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/5a/de/89ca640ca1953c4612279ce515d0af35658df3c06cdb324329bc91b4a7e1/srsly-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:598f1e494c18cacb978299d77125415a586417081959f8ec3f068b32d97f8933", size = 652459, upload-time = "2026-03-23T11:56:07.994Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/7ab6d49e36d9cc72ee15746cabd116eb6f338be8a06c1882968ee9d6c7d7/srsly-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:4b1b721cd3ad1a9b2343519aadc786a4d09d5c0666962d49852eb12d6ec3fe26", size = 638411, upload-time = "2026-03-23T11:56:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5c/12901e3794f4158abc6da750725aad6c2afddb1e4227b300fe7c71f66957/srsly-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e67b6bbacbfadea5e100266d2797f2d4cec9883ea4dc84a5537673850036a8d8", size = 656750, upload-time = "2026-03-23T11:56:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/181c26370995f96f56f1b64b801e3ca1e0d703fc36506ae28606d62369fb/srsly-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:348c231b4477d8fe86603131d0f166d2feac9c372704dfc4398be71cc5b6fb07", size = 656746, upload-time = "2026-03-23T11:56:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/35876c78889f8ffe11ed3521644e666c3aef20ea31527b70f47456cf35c2/srsly-2.5.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b0938c2978c91ae1ef9c1f2ba35abb86330e198fb23469e356eba311e02233ee", size = 1155762, upload-time = "2026-03-23T11:56:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/3e/da/40b71ca9906c8eb8f8feb6ac11d33dad458c85a56e1de764b96d402168a0/srsly-2.5.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f6a837954429ecbe6dcdd27390d2fb4c7d01a3f99c9ffcf9ce66b2a6dd1b738", size = 1161092, upload-time = "2026-03-23T11:56:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/c0dd30cc8b93ce8137ff4766f743c882440ce49195fffc5d50eaeef311a6/srsly-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3576c125c486ce2958c2047e8858fe3cfc9ea877adfa05203b0986f9badee355", size = 1109984, upload-time = "2026-03-23T11:56:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/f3/34354f183d8faafc631585571224b54d1b4b67e796972c36519c074ca355/srsly-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fb59c42922e095d1ea36085c55bc16e2adb06a7bfe57b24d381e0194ae699f2", size = 1128409, upload-time = "2026-03-23T11:56:18.761Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d9/5531f8a19492060b4e76e4ab06aca6f096fb5128fe18cc813d1772daf653/srsly-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:111805927f05f5db440aeeacb85ce43da0b19ce7b2a09567a9ef8d30f3cc4d83", size = 650820, upload-time = "2026-03-23T11:56:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/62fb7a971eca29e12f03fb9ddacb058548c14d33e5b5675ff0f85839cc7b/srsly-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:0f106b0a700ab56e4a7c431b0f1444009ab6cb332edc7bbf6811c2a43f4722cb", size = 637278, upload-time = "2026-03-23T11:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5b/e4ef43c2a381711230af98d4c94a5323df48d6a7899ee652e05bf889290e/srsly-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:39c13d552a9f9674a12cdcdc66b0c2f02f3430d0cd04c5f9cf598824c2bd3d65", size = 661294, upload-time = "2026-03-23T11:56:23.29Z" }, + { url = "https://files.pythonhosted.org/packages/92/2d/ebce7f3717e52cd0a01f4ec570f388f3b7098526794fcf1ad734e0b8f852/srsly-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14c930767cc169611a2dc14e23bc7638cfb616d6f79029700ade033607343540", size = 660952, upload-time = "2026-03-23T11:56:24.908Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/a8f3e9b214be2624c8e8a78d38ca7b1d4e26b92d57018412e4bfc4abe89a/srsly-2.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f2d464f0d0237e32fb53f0ec6f05418652c550e772b50e9918e83a1577cba4d", size = 1154554, upload-time = "2026-03-23T11:56:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/2a89dc3180a51e633a87a079ca064225f4aaf46c7b2a5fc720e28f261d98/srsly-2.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d18933248a5bb0ad56a1bae6003a9a7f37daac2ecb0c5bcbfaaf081b317e1c84", size = 1155746, upload-time = "2026-03-23T11:56:28.102Z" }, + { url = "https://files.pythonhosted.org/packages/b8/36/72e5ce3153927ca404b6f5bf5280e6ff3399c11557df472b153945468e0a/srsly-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7ea5412ea229e571ac9738cbe14f845cc06c8e4e956afb5f42061ccd087ef31f", size = 1112374, upload-time = "2026-03-23T11:56:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/0895de109c28eca0d41a811ab7c076d4e4a505e8466f06bae22f5180a1dd/srsly-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8d3988970b4cf7d03bdd5b5169302ff84562dd2e1e0f84aeb34df3e5b5dc19bf", size = 1127732, upload-time = "2026-03-23T11:56:31.458Z" }, + { url = "https://files.pythonhosted.org/packages/c7/79/a37fa7759797fbdfe0a2e029ab13e78b1e81e191220d2bb8ff57d869aefb/srsly-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:6a02d7dcc16126c8fae1c1c09b2072798a1dc482ab5f9c52b12c7114dac47325", size = 656467, upload-time = "2026-03-23T11:56:33.14Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/0dae019b3b90ad9037f91de4c390555cdaac9460a93ad62b02b03babdff5/srsly-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:1c9129c4abe31903ff7996904a51afdd5428060de6c3d12af49a4da5e8df2821", size = 643040, upload-time = "2026-03-23T11:56:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/3a/44/72dd5285b2e05435d98b0797f101d91d9b345d491ddc1fdb9bd09e27ccb8/srsly-2.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:29d5d01ba4c2e9c01f936e5e6d5babc4a47b38c9cbd6e1ec23f6d5a49df32605", size = 666200, upload-time = "2026-03-23T11:56:35.753Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ad/002c71b87fc3f648c9bf0ec47de0c3822bf2c95c8896a589dd03e7fd3977/srsly-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c8df4039426d99f0148b5743542842ab96b82daded0b342555e15a639927757", size = 667409, upload-time = "2026-03-23T11:56:37.172Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/2cea3d5e80aeecfc4ece9e7e1783e7792cc3bad7ab85ab585882e1db4e38/srsly-2.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:06a43d63bde2e8cccadb953d7fff70b18196ca286b65dd2ad16006d65f3f8166", size = 1265941, upload-time = "2026-03-23T11:56:38.825Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/8a4d7e86dd0370a2e5af251b646000197bb5b7e0f9aa360c71bbfb253d0d/srsly-2.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:808cfafc047f0dec507a34c8fa8e4cda5722737fd33577df73452f52f7aca644", size = 1250693, upload-time = "2026-03-23T11:56:40.449Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/340129de5ea7b237271b12f8a6962cfa7eb0c5a3056794626d348c5ae7c7/srsly-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71d4cbe2b2a1335c76ed0acae2dc862163787d8b01a705e1949796907ed94ccd", size = 1242408, upload-time = "2026-03-23T11:56:41.8Z" }, + { url = "https://files.pythonhosted.org/packages/01/cb/d7fee7ab27c6aa2e3f865fb7b50ba18c81a4c763bba12bdf53df246441bc/srsly-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f69083d33cb329cfc74317da937fb3270c0f40fabc1b4488702d8074b4a3e", size = 1242749, upload-time = "2026-03-23T11:56:43.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d1/9bad3a0f2fa7b72f4e0cf1d267b00513092d20ef538c47f72823ae4f7656/srsly-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8ac016ffaeac35bc010992b71bf8afdd39d458f201c8138d84cf78778a936e6c", size = 673783, upload-time = "2026-03-23T11:56:44.875Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -3543,6 +4051,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "thinc" +version = "8.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blis" }, + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "murmurhash" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "setuptools" }, + { name = "srsly" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/46/76df95f2c327f9a9cef30c1523bf285627897097163584dcf5f77b2ebce2/thinc-8.3.13.tar.gz", hash = "sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9", size = 194640, upload-time = "2026-03-23T07:22:36.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/72/ca06842a007e8c794e8c59462f242cdfd6167d7cc9d0155ad004b194b015/thinc-8.3.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4565102638038a01a2193c7f5d41ccbd6233fbdcb1f1b184322a06add4f51f18", size = 844359, upload-time = "2026-03-23T07:21:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/e6aef092f478d263f72eb3933b55a6f37ba97c6a0ea0a61d13fbf9bf0c19/thinc-8.3.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:859fbd9d9b16af5278da23589b4afbe2ab6b0dd615df4d3229b7c4e67cd3107e", size = 812089, upload-time = "2026-03-23T07:21:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/9ce0424d456cd3580cc3a855b23a7ff86b81d5299fceb496a2f56f06c1c0/thinc-8.3.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a518d5c761a0f2341e530e867de133dc3ed814558365b2a68ec53b89c482a43f", size = 4101388, upload-time = "2026-03-23T07:21:46.135Z" }, + { url = "https://files.pythonhosted.org/packages/ad/51/ec91c0434bd9a1096ab874bbd6dc110c5089d7fc513137e6af59bd051eec/thinc-8.3.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81337dfbee37f58f36c0c70f9a819dce1b32cdc13d959181e10de079621f6ac6", size = 4131972, upload-time = "2026-03-23T07:21:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/ff/67/e30dea753c90cff5cb9e5feb34948fdb89a6774b84d849585b49e16a730e/thinc-8.3.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fbc0ee16edd260c6a4a9e365ff36d0a682c9e7ca6d7b985682659ef2e3e73826", size = 5101283, upload-time = "2026-03-23T07:21:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/b7544eddababa16e548b26a96fff29eeb307ce938df5fa4af9371fe8ed5d/thinc-8.3.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0355c37e40d1a9fc2a1b8e9c2e294d8586f6baa97bcac6b9002f2dddb4b82ae9", size = 5264488, upload-time = "2026-03-23T07:21:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a9/49391a40d703efc0f7a451310373261835f71fd3e6e2e8cfc08ee02f78ad/thinc-8.3.13-cp311-cp311-win_amd64.whl", hash = "sha256:0a0fa13dcfe4b319c3a396432c1dbff30d3de37dbbdee559e76600ee2b9486df", size = 1795058, upload-time = "2026-03-23T07:21:53.424Z" }, + { url = "https://files.pythonhosted.org/packages/c1/31/fd5348d44beda12a3ee415cbba9ed4fd0b17ce65db1d473c38a29a8d6153/thinc-8.3.13-cp311-cp311-win_arm64.whl", hash = "sha256:cd8a2b714c061969eee65802965167a6ada1fe708d82fe176d98dcb95ebe182a", size = 1721215, upload-time = "2026-03-23T07:21:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/3e/af/f7c1ebfe92eb5d27d7f2f3da67a11e2eb57bc30ab1553279af6dc65b65a8/thinc-8.3.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d", size = 821097, upload-time = "2026-03-23T07:21:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/69d7338575d98df85d0b54c0f5fc277dba72587fe9ab846ecdd12a998bcb/thinc-8.3.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411", size = 791932, upload-time = "2026-03-23T07:21:58.38Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a5/21d010c81e81e1589e5ccb4950e521804d13726e541e87f644c51815673b/thinc-8.3.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49", size = 3854219, upload-time = "2026-03-23T07:21:59.819Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ff/6914bf370bd1d604d89e6dfb46b97d10cd9b00d42ff8c036283e92314a8c/thinc-8.3.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f", size = 3903307, upload-time = "2026-03-23T07:22:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/5572b47fa155fb3388c071515b74024fa17a6efd1df9406da378f0aa84ef/thinc-8.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258", size = 4836882, upload-time = "2026-03-23T07:22:03.565Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a8d77c7bac089697c6df302cc3c936a1ab36a4720deae889e6f1dbcbd0eb/thinc-8.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59", size = 5033398, upload-time = "2026-03-23T07:22:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/5651bb1f904d04220fc7670035ada921bf0638e2cff6444d67c12887a968/thinc-8.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb", size = 1721248, upload-time = "2026-03-23T07:22:06.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/8d/683703de021ffbe46833d722b70f49ffbbca8e5bd6876256977555d92d7d/thinc-8.3.13-cp312-cp312-win_arm64.whl", hash = "sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525", size = 1645777, upload-time = "2026-03-23T07:22:08.104Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/7b46942176df459d1804a9e77b0976f7c56f3abf3ec7485d0e5f836a0382/thinc-8.3.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8", size = 817337, upload-time = "2026-03-23T07:22:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/a7/79/53085a72cd8f4fc4e6e313d05ea5aa98e870684f4a0fb318a9875fc0a964/thinc-8.3.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec", size = 788120, upload-time = "2026-03-23T07:22:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3e/d61b462b16da95ac6885f95bb395e672040ee594833e571a6edcffd234f5/thinc-8.3.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22", size = 3844666, upload-time = "2026-03-23T07:22:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/898cc654bb123734c71ec5a425c02ca34439517d01ce1c95a6563295580e/thinc-8.3.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f", size = 3890658, upload-time = "2026-03-23T07:22:14.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/56/1abdbf0a4ad628e8a05d6516fe0745969649d805367a3dccad8ee872981b/thinc-8.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3", size = 4819933, upload-time = "2026-03-23T07:22:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/f1/22/b84dbdc6be5055bbdb2a7352e2c393f67e8593c137f1b83c82bf1e062b6e/thinc-8.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499", size = 5018099, upload-time = "2026-03-23T07:22:18.356Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/763cd7ba949334c9d2cddc92dadb68b344cb9546dc01b8d4a733dcaa16c1/thinc-8.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f", size = 1720309, upload-time = "2026-03-23T07:22:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/f5/15/a11f7bb3cbc97dfecf32a90552f5a8f8a5c99316a99c6c17bdabf5baf256/thinc-8.3.13-cp313-cp313-win_arm64.whl", hash = "sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521", size = 1644606, upload-time = "2026-03-23T07:22:21.339Z" }, + { url = "https://files.pythonhosted.org/packages/80/40/f4937d113912c6d669ffe982356ab29dcb6c7fe3be926a15981dbbb6a91c/thinc-8.3.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865", size = 817024, upload-time = "2026-03-23T07:22:23.005Z" }, + { url = "https://files.pythonhosted.org/packages/d2/00/4d4ed1a11ba2920b85a03a0683b16d97dc5beb2e78078dbf0e13e43bcea7/thinc-8.3.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e", size = 792096, upload-time = "2026-03-23T07:22:24.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/dc33d6932be8721af2ef76b4a3a6e8020648630eabae61fb916d2a861d1d/thinc-8.3.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b", size = 3842215, upload-time = "2026-03-23T07:22:25.836Z" }, + { url = "https://files.pythonhosted.org/packages/af/bc/a6d37d8dadc2c5b524f51192413481160c42c9dd6105e8d5551531623225/thinc-8.3.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb", size = 3849253, upload-time = "2026-03-23T07:22:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/ce9c7067f1dfe5985875927de9cf7a79f9dae3e69487fd650dfba558029d/thinc-8.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe", size = 4831163, upload-time = "2026-03-23T07:22:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a8/f57819347fc4d8bef2204d15fcbb9d7dff2d6cdd5f83d5ed91456ddacc55/thinc-8.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990", size = 4986051, upload-time = "2026-03-23T07:22:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/a82214bb7c7c1e2d92b69e1a7654be90cfab180082c6108e45a98af2422c/thinc-8.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc", size = 1740382, upload-time = "2026-03-23T07:22:32.869Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ef/1648fda54e9689058335ff54f650a7a314db2a42e21af1b83949b2dc748e/thinc-8.3.13-cp314-cp314-win_arm64.whl", hash = "sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f", size = 1667687, upload-time = "2026-03-23T07:22:34.967Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -3564,6 +4127,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] +[[package]] +name = "tldextract" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "idna" }, + { name = "requests" }, + { name = "requests-file" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/7b/644fbbb49564a6cb124a8582013315a41148dba2f72209bba14a84242bf0/tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb", size = 126105, upload-time = "2025-12-28T23:58:05.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/42/0e49d6d0aac449ca71952ec5bae764af009754fcb2e76a5cc097543747b3/tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9", size = 105886, upload-time = "2025-12-28T23:58:04.071Z" }, +] + [[package]] name = "toml" version = "0.10.2" @@ -3665,6 +4243,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -3726,6 +4319,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] +[[package]] +name = "wasabi" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/f9/054e6e2f1071e963b5e746b48d1e3727470b2a490834d18ad92364929db3/wasabi-1.1.3.tar.gz", hash = "sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878", size = 30391, upload-time = "2024-05-31T16:56:18.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/7c/34330a89da55610daa5f245ddce5aab81244321101614751e7537f125133/wasabi-1.1.3-py3-none-any.whl", hash = "sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c", size = 27880, upload-time = "2024-05-31T16:56:16.699Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -3753,6 +4358,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, ] +[[package]] +name = "weasel" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpathlib" }, + { name = "confection" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "smart-open" }, + { name = "srsly" }, + { name = "typer" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/e5/e272bb9a045105a1fdf4b798d8086f5932a178f4d738f17a74f5c9e0ae9a/weasel-1.0.0.tar.gz", hash = "sha256:7b129b44c90cc543b760532974ca1e4eb30dad2aa2026f57bdce66354ae610fc", size = 38682, upload-time = "2026-03-20T08:10:25.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/07/57ebf7a6798b016c064bd0ca81b4c6a99daa4dc377b898bc7b41eb6b5af0/weasel-1.0.0-py3-none-any.whl", hash = "sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc", size = 50713, upload-time = "2026-03-20T08:10:23.637Z" }, +] + [[package]] name = "webcolors" version = "25.10.0" @@ -3848,6 +4473,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] + [[package]] name = "zipp" version = "4.1.0"