diff --git a/.github/workflows/converter-lightdash-ci.yml b/.github/workflows/converter-lightdash-ci.yml new file mode 100644 index 00000000..802e78e7 --- /dev/null +++ b/.github/workflows/converter-lightdash-ci.yml @@ -0,0 +1,63 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +name: Converters Lightdash CI + +on: + push: + branches: [ "main" ] + paths: + - 'converters/lightdash/**' + - '.github/**' + pull_request: + branches: [ "main" ] + paths: + - 'converters/lightdash/**' + - '.github/**' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Sync dependencies + working-directory: converters/lightdash + run: | + uv sync + + - name: Unit Tests + working-directory: converters/lightdash + run: | + uv run pytest diff --git a/converters/lightdash/README.md b/converters/lightdash/README.md new file mode 100644 index 00000000..fac5fd95 --- /dev/null +++ b/converters/lightdash/README.md @@ -0,0 +1,94 @@ + + +# Apache Ossie <> Lightdash converter + +Bidirectional converter between Ossie documents and +[Lightdash](https://github.com/lightdash/lightdash) semantic definitions. +Lightdash reads its semantic layer from dbt `schema.yml` files: dimensions and +metrics are declared per column (and per model) under `meta`. This converter +translates between that shape and Ossie. + +- **Export** (`osi_to_lightdash`): Ossie document → a dbt `schema.yml`-shaped + dictionary with Lightdash `meta` blocks, ready to merge into a dbt project. +- **Import** (`lightdash_to_osi`): a Lightdash-flavoured `schema.yml` → an + Ossie document, as a migration path for teams with an existing installed + base of Lightdash metrics. + +``` +ossie-lightdash export semantic_model.yaml schema.yml +ossie-lightdash import schema.yml semantic_model.json --database analytics_db --schema marts +``` + +## Mapping + +| Ossie | Lightdash (dbt meta) | +| ----- | -------------------- | +| `dataset` | dbt model (`name` = table part of `source`) | +| `dataset.source` | assembled on import from `--database` / `--schema` / model name | +| `field` (no `dimension`) | plain column entry | +| `field` with `dimension` | `columns[].meta.dimension` (`is_time` ↔ `type: date/timestamp`; an empty `dimension: {}` marks a categorical dimension) | +| `field.label` / `.description` | `meta.dimension.label` / column `description` | +| `field.expression` (≠ column name) | `meta.dimension.sql` (`dataset.col` ↔ `${TABLE}.col`) | +| `metric` with single-aggregation expression (`SUM(ds.col)`, `COUNT(DISTINCT ds.col)`, ...) | column-level `meta.metrics.` with a typed metric (`sum`, `count_distinct`, ...) | +| `metric` with any other single-dataset expression | model-level `meta.metrics.` with `type: number` + `sql` | +| `relationship` | `meta.joins` (`sql_on` built from / parsed into column pairs) | +| Lightdash presentation attributes (`label`, `format`, `round`, `compact`, `group_label`, `hidden`, `percentile`, ...) | `custom_extensions` with `vendor_name: "lightdash"`; on export the extension data is overlaid onto the generated definition (structural keys — `sql`/`label` on dimensions, `sql`/`description` on metrics — are protected and cannot be overridden) | + +Expressions are written under the `ANSI_SQL` dialect. Warehouse-specific +dialects (e.g. `BIGQUERY`) can be added once the surrounding tooling resolves +them. + +## Recommended source shape for dbt-native flows + +If the Ossie documents are also consumed by dbt's native OSI parsing, prefer +importing **without** `--database` (i.e. `schema.table` sources): the database +is usually environment-dependent in dbt projects, and a database-less source +keeps one document valid across environments (see +[dbt-core#15649](https://github.com/dbt-labs/dbt-core/issues/15649)). +Omitting `--schema` as well is reported as a `SOURCE_UNQUALIFIED` issue. + +## Known limitations + +- **Cross-dataset metrics are dropped on export** (with a + `CROSS_DATASET_METRIC_DROPPED` issue): a Lightdash model metric cannot + reference other tables. +- **Percentile metrics** keep `type` / `percentile` in the `lightdash` + extension (Ossie expressions cannot express them faithfully) and re-export + as model-level metrics. +- **`primary_key` / `unique_keys` are not exported** — Lightdash has no + corresponding concept — and consequently cannot be reconstructed on import. +- **`dataset.name` is not preserved when it differs from the source table + name**: the dbt model is named after the table part of `source`, and the + import direction derives dataset names from model names. References inside + expressions and relationships are rewritten consistently, but a + name-stable round-trip is not guaranteed. +- **Relationships with mismatched `from_columns` / `to_columns` lengths are + skipped on export** with a `RELATIONSHIP_COLUMNS_MISMATCHED` issue. +- **`ai_context` is not carried** into Lightdash meta. +- **Model-level Lightdash meta beyond `metrics` and `joins`** (`label`, + `group_details`, `sql_filter`, `order_fields_by`, column + `additional_dimensions`, ...) is not carried yet. +- **Standalone Lightdash YAML projects** (Lightdash without dbt) are not + supported yet; the converter targets the dbt-meta flavour. +- Custom extensions from other vendors are ignored on export (reported as + `FOREIGN_EXTENSION_IGNORED`); they remain untouched in the Ossie document. +- Documents are emitted at the current in-repo spec version. Note that + dbt-core 1.12's native OSI parsing accepts spec versions `0.1.0` / `0.1.1` + only. diff --git a/converters/lightdash/pyproject.toml b/converters/lightdash/pyproject.toml new file mode 100644 index 00000000..0d7a2090 --- /dev/null +++ b/converters/lightdash/pyproject.toml @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[project] +name = "apache-ossie-lightdash" +version = "0.1.0.dev0" +description = "Lightdash (dbt meta semantic definitions) <> Apache Ossie converter" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] +requires-python = ">=3.11" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Lightdash" +] +dependencies = [ + "apache-ossie>=0.2.0.dev0", + "PyYAML>=6.0", +] + +[project.scripts] +ossie-lightdash = "ossie_lightdash.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_lightdash"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] + +[tool.uv.sources] +# apache-ossie is not yet published to PyPI; resolve it from the in-repo +# package for now. Remove this block once apache-ossie published to PyPI. +apache-ossie = { path = "../../python", editable = true} diff --git a/converters/lightdash/src/ossie_lightdash/__init__.py b/converters/lightdash/src/ossie_lightdash/__init__.py new file mode 100644 index 00000000..afeea775 --- /dev/null +++ b/converters/lightdash/src/ossie_lightdash/__init__.py @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie_lightdash.converter_issues import ( + ConverterIssue, + ConverterIssueType, + ConverterResult, +) +from ossie_lightdash.lightdash_to_osi import LightdashToOSIConverter +from ossie_lightdash.osi_to_lightdash import OSIToLightdashConverter + +__all__ = [ + "ConverterIssue", + "ConverterIssueType", + "ConverterResult", + "LightdashToOSIConverter", + "OSIToLightdashConverter", +] diff --git a/converters/lightdash/src/ossie_lightdash/cli.py b/converters/lightdash/src/ossie_lightdash/cli.py new file mode 100644 index 00000000..b42bd3c3 --- /dev/null +++ b/converters/lightdash/src/ossie_lightdash/cli.py @@ -0,0 +1,97 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Command line interface for the Ossie <> Lightdash converter.""" + +import argparse +import json +import sys +from pathlib import Path + +import yaml + +from ossie import OSIDocument +from ossie_lightdash.lightdash_to_osi import LightdashToOSIConverter +from ossie_lightdash.osi_to_lightdash import OSIToLightdashConverter + + +def _read_document(path: Path) -> OSIDocument: + text = path.read_text(encoding="utf-8") + if path.suffix == ".json": + return OSIDocument.model_validate_json(text) + return OSIDocument.model_validate(yaml.safe_load(text)) + + +def _print_issues(issues) -> None: + for issue in issues: + print(f"[{issue.issue_type.value}] {issue.element_name}", file=sys.stderr) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="ossie-lightdash") + subparsers = parser.add_subparsers(dest="command", required=True) + + export_parser = subparsers.add_parser( + "export", help="Ossie document (.json/.yaml) -> Lightdash dbt schema.yml" + ) + export_parser.add_argument("input", type=Path) + export_parser.add_argument("output", type=Path) + + import_parser = subparsers.add_parser( + "import", help="Lightdash dbt schema.yml -> Ossie document (.json/.yaml)" + ) + import_parser.add_argument("input", type=Path) + import_parser.add_argument("output", type=Path) + import_parser.add_argument("--database", default=None) + import_parser.add_argument("--schema", default=None) + import_parser.add_argument( + "--semantic-model-name", default="lightdash_semantic_model" + ) + + args = parser.parse_args() + + if args.command == "export": + result = OSIToLightdashConverter().convert(_read_document(args.input)) + args.output.write_text( + yaml.safe_dump(result.output, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + else: + schema_yml = yaml.safe_load(args.input.read_text(encoding="utf-8")) + result = LightdashToOSIConverter().convert( + schema_yml, + database=args.database, + schema=args.schema, + semantic_model_name=args.semantic_model_name, + ) + document = result.output.model_dump(by_alias=True, exclude_none=True) + if args.output.suffix == ".json": + args.output.write_text( + json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8" + ) + else: + args.output.write_text( + yaml.safe_dump(document, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + _print_issues(result.issues) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/converters/lightdash/src/ossie_lightdash/converter_issues.py b/converters/lightdash/src/ossie_lightdash/converter_issues.py new file mode 100644 index 00000000..97a5c210 --- /dev/null +++ b/converters/lightdash/src/ossie_lightdash/converter_issues.py @@ -0,0 +1,63 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass +from enum import Enum +from typing import Generic, List, TypeVar + + +class ConverterIssueType(Enum): + """Identifies the kind of information loss that occurred during conversion.""" + + # Import: the dataset source could not be qualified with a schema/database. + SOURCE_UNQUALIFIED = "SOURCE_UNQUALIFIED" + # Import: a join's sql_on could not be parsed into column pairs. + JOIN_SQL_UNPARSED = "JOIN_SQL_UNPARSED" + # Export: a metric references more than one dataset, which a Lightdash + # model metric cannot express. + CROSS_DATASET_METRIC_DROPPED = "CROSS_DATASET_METRIC_DROPPED" + # Export: a relationship's from_columns/to_columns differ in length, so a + # correct sql_on cannot be built. + RELATIONSHIP_COLUMNS_MISMATCHED = "RELATIONSHIP_COLUMNS_MISMATCHED" + # Export: a `lightdash` extension whose data is not valid JSON cannot be + # applied; its presentation attributes are lost. + EXTENSION_DATA_INVALID = "EXTENSION_DATA_INVALID" + # Import: a model-level metric without `sql` has no expressible OSI + # expression and is skipped. + METRIC_SQL_MISSING = "METRIC_SQL_MISSING" + # Export: a custom extension from another vendor cannot be carried into + # Lightdash meta (it remains in the OSI document itself). + FOREIGN_EXTENSION_IGNORED = "FOREIGN_EXTENSION_IGNORED" + + +@dataclass(frozen=True) +class ConverterIssue: + """Records a single instance of information loss during conversion.""" + + issue_type: ConverterIssueType + element_name: str + + +T = TypeVar("T") + + +@dataclass(frozen=True) +class ConverterResult(Generic[T]): + """Return value of a converter's convert() method, pairing the output with any conversion issues.""" + + output: T + issues: List[ConverterIssue] diff --git a/converters/lightdash/src/ossie_lightdash/expression_utils.py b/converters/lightdash/src/ossie_lightdash/expression_utils.py new file mode 100644 index 00000000..275abe0e --- /dev/null +++ b/converters/lightdash/src/ossie_lightdash/expression_utils.py @@ -0,0 +1,125 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Small expression helpers shared by both conversion directions. + +Lightdash SQL snippets reference columns as ``${TABLE}.column`` (and joined +tables as ``${other_table.column}``); OSI expressions reference them as +``dataset.column``. These helpers translate between the two spellings and +recognise the single-aggregation shapes that map onto Lightdash's typed +metrics. +""" + +import re +from typing import Optional, Tuple + +# Aggregations that translate to a typed Lightdash metric. Anything else is +# exported as a `number` metric with raw SQL. +_AGG_TO_LIGHTDASH_TYPE = { + "SUM": "sum", + "MIN": "min", + "MAX": "max", + "AVG": "average", + "AVERAGE": "average", + "MEDIAN": "median", + "COUNT": "count", +} + +_LIGHTDASH_TYPE_TO_AGG = { + "sum": "SUM", + "min": "MIN", + "max": "MAX", + "average": "AVG", + "median": "MEDIAN", + "count": "COUNT", +} + +_SIMPLE_AGG_RE = re.compile( + r"^\s*(?P[A-Za-z_]+)\s*\(\s*(?PDISTINCT\s+)?(?P[A-Za-z_][\w.]*)\s*\)\s*$", + re.IGNORECASE, +) + +def parse_simple_aggregation(expression: str) -> Optional[Tuple[str, str]]: + """Parse ``AGG(qualifier.column)`` into a (lightdash_type, column_ref) pair. + + Returns None when the expression is anything more complex than a single + aggregation over a single column reference. + """ + match = _SIMPLE_AGG_RE.match(expression) + if not match: + return None + func = match.group("func").upper() + inner = match.group("inner") + if match.group("distinct"): + if func != "COUNT": + return None + return ("count_distinct", inner) + lightdash_type = _AGG_TO_LIGHTDASH_TYPE.get(func) + if lightdash_type is None: + return None + return (lightdash_type, inner) + + +def build_aggregation(lightdash_type: str, dataset: str, column: str) -> Optional[str]: + """Build the OSI expression for a typed Lightdash metric, if it has one.""" + if lightdash_type == "count_distinct": + return f"COUNT(DISTINCT {dataset}.{column})" + agg = _LIGHTDASH_TYPE_TO_AGG.get(lightdash_type) + if agg is None: + return None + return f"{agg}({dataset}.{column})" + + +def strip_qualifier(column_ref: str) -> str: + """Return the bare column name of a possibly ``qualifier.column`` reference.""" + return column_ref.rsplit(".", 1)[-1] + + +def qualifier_of(column_ref: str) -> Optional[str]: + """Return the qualifier of a ``qualifier.column`` reference, if present.""" + if "." in column_ref: + return column_ref.rsplit(".", 1)[0] + return None + + +def osi_sql_to_lightdash(expression: str, dataset: str) -> str: + """Rewrite ``dataset.column`` references into Lightdash's ``${TABLE}.column``.""" + return re.sub( + rf"\b{re.escape(dataset)}\.(\w+)", + r"${TABLE}.\1", + expression, + ) + + +def lightdash_sql_to_osi(sql: str, dataset: str) -> str: + """Rewrite Lightdash column references into OSI ``dataset.column`` references. + + ``${TABLE}.column`` refers to the current model; ``${other_table.column}`` + refers to a joined model and becomes a cross-dataset reference. + """ + rewritten = sql.replace("${TABLE}.", f"{dataset}.") + return re.sub(r"\$\{(\w+)\.(\w+)\}", r"\1.\2", rewritten) + + + +def referenced_datasets(expression: str, dataset_names: set) -> set: + """Return which of the given dataset names an OSI expression references.""" + found = set() + for match in re.finditer(r"([A-Za-z_]\w*)\.\w+", expression): + if match.group(1) in dataset_names: + found.add(match.group(1)) + return found diff --git a/converters/lightdash/src/ossie_lightdash/lightdash_to_osi.py b/converters/lightdash/src/ossie_lightdash/lightdash_to_osi.py new file mode 100644 index 00000000..7fd415bc --- /dev/null +++ b/converters/lightdash/src/ossie_lightdash/lightdash_to_osi.py @@ -0,0 +1,328 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert Lightdash semantic definitions into an OSI document. + +The input is a dbt ``schema.yml``-shaped dictionary whose ``meta`` blocks +carry Lightdash dimensions, metrics and joins. Structural information becomes +first-class OSI vocabulary (datasets, fields, metrics, relationships); +Lightdash presentation attributes without OSI vocabulary (``format``, +``round``, ``group_label``, ``hidden``, ...) are preserved in +``custom_extensions`` entries with ``vendor_name: "lightdash"`` so that the +export direction can reproduce them exactly. +""" + +import json +import re +from typing import Any, Dict, List, Optional, Tuple + +from ossie import ( + OSICustomExtension, + OSIDataset, + OSIDialect, + OSIDialectExpression, + OSIDimension, + OSIDocument, + OSIExpression, + OSIField, + OSIMetric, + OSIRelationship, + OSISemanticModel, +) + +from ossie_lightdash.converter_issues import ( + ConverterIssue, + ConverterIssueType, + ConverterResult, +) +from ossie_lightdash.expression_utils import ( + build_aggregation, + lightdash_sql_to_osi, +) + +LIGHTDASH_VENDOR_NAME = "lightdash" + +# Keys that are structurally encoded in OSI vocabulary and therefore must NOT +# be duplicated into the extension (a stale copy would win on export). +# ``type`` stays in the extension only for metric types whose semantics OSI +# expressions cannot express faithfully (currently ``percentile``). +_STRUCTURAL_METRIC_KEYS = {"sql", "description"} +_STRUCTURAL_DIMENSION_KEYS = {"label", "sql"} + +_TIME_DIMENSION_TYPES = {"date", "timestamp"} + +_JOIN_PAIR_RE = re.compile( + r"\$\{(\w+)\.(\w+)\}\s*=\s*\$\{(\w+)\.(\w+)\}", +) + + +def _ansi(expression: str) -> OSIExpression: + return OSIExpression( + dialects=[ + OSIDialectExpression(dialect=OSIDialect.ANSI_SQL, expression=expression) + ] + ) + + +def _type_needs_extension(lightdash_type: str) -> bool: + """True for metric types an OSI expression cannot encode faithfully. + + ``number`` is fully described by its SQL and typed aggregations are + recovered by parsing the expression, so only the remaining types + (currently ``percentile``) must survive inside the extension. + """ + if lightdash_type == "number": + return False + return build_aggregation(lightdash_type, "_", "_") is None + + +def _lightdash_extension(data: Dict[str, Any]) -> List[OSICustomExtension]: + if not data: + return [] + return [ + OSICustomExtension( + vendor_name=LIGHTDASH_VENDOR_NAME, + data=json.dumps(data, ensure_ascii=False, sort_keys=True), + ) + ] + + +class LightdashToOSIConverter: + """Converts a Lightdash-flavoured dbt schema.yml dict into an OSIDocument.""" + + def convert( + self, + schema_yml: Dict[str, Any], + *, + database: Optional[str] = None, + schema: Optional[str] = None, + semantic_model_name: str = "lightdash_semantic_model", + ) -> ConverterResult[OSIDocument]: + issues: List[ConverterIssue] = [] + datasets: List[OSIDataset] = [] + metrics: List[OSIMetric] = [] + relationships: List[OSIRelationship] = [] + + for model in schema_yml.get("models") or []: + dataset, model_metrics, model_relationships = self._convert_model( + model, database=database, schema=schema, issues=issues + ) + datasets.append(dataset) + metrics.extend(model_metrics) + relationships.extend(model_relationships) + + document = OSIDocument( + version="0.2.0.dev0", + semantic_model=[ + OSISemanticModel( + name=semantic_model_name, + datasets=datasets, + metrics=metrics or None, + relationships=relationships or None, + ) + ], + ) + return ConverterResult(output=document, issues=issues) + + def _convert_model( + self, + model: Dict[str, Any], + *, + database: Optional[str], + schema: Optional[str], + issues: List[ConverterIssue], + ) -> Tuple[OSIDataset, List[OSIMetric], List[OSIRelationship]]: + name = model["name"] + source = ".".join(part for part in [database, schema, name] if part) + if schema is None: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.SOURCE_UNQUALIFIED, + element_name=name, + ) + ) + + fields: List[OSIField] = [] + metrics: List[OSIMetric] = [] + for column in model.get("columns") or []: + field, column_metrics = self._convert_column(column, dataset_name=name) + fields.append(field) + metrics.extend(column_metrics) + + model_meta = model.get("meta") or {} + for metric_name, definition in (model_meta.get("metrics") or {}).items(): + if not definition.get("sql"): + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.METRIC_SQL_MISSING, + element_name=metric_name, + ) + ) + continue + metrics.append( + self._convert_sql_metric(metric_name, definition, dataset_name=name) + ) + + relationships = self._convert_joins( + model_meta.get("joins") or [], from_model=name, issues=issues + ) + + dataset = OSIDataset( + name=name, + source=source, + description=model.get("description"), + fields=fields or None, + ) + return dataset, metrics, relationships + + def _convert_column( + self, column: Dict[str, Any], *, dataset_name: str + ) -> Tuple[OSIField, List[OSIMetric]]: + column_name = column["name"] + meta = column.get("meta") or {} + dimension_meta = meta.get("dimension") + + expression = column_name + dimension: Optional[OSIDimension] = None + label: Optional[str] = None + extension_data: Dict[str, Any] = {} + if dimension_meta is not None: + label = dimension_meta.get("label") + if dimension_meta.get("sql"): + expression = lightdash_sql_to_osi(dimension_meta["sql"], dataset_name) + dimension = OSIDimension( + is_time=dimension_meta.get("type") in _TIME_DIMENSION_TYPES + ) + extension_data = { + key: value + for key, value in dimension_meta.items() + if key not in _STRUCTURAL_DIMENSION_KEYS + } + + field = OSIField( + name=column_name, + expression=_ansi(expression), + dimension=dimension, + label=label, + description=column.get("description"), + custom_extensions=_lightdash_extension(extension_data) or None, + ) + + metrics = [ + self._convert_column_metric( + metric_name, definition, dataset_name=dataset_name, column=column_name + ) + for metric_name, definition in (meta.get("metrics") or {}).items() + ] + return field, metrics + + def _convert_column_metric( + self, + metric_name: str, + definition: Dict[str, Any], + *, + dataset_name: str, + column: str, + ) -> OSIMetric: + lightdash_type = definition.get("type", "number") + expression = build_aggregation(lightdash_type, dataset_name, column) + if expression is None: + sql = definition.get("sql") + if sql: + expression = lightdash_sql_to_osi(sql, dataset_name) + else: + expression = f"{dataset_name}.{column}" + + return self._build_metric( + metric_name, + definition, + expression=expression, + keep_type_in_extension=_type_needs_extension(lightdash_type), + ) + + def _convert_sql_metric( + self, metric_name: str, definition: Dict[str, Any], *, dataset_name: str + ) -> OSIMetric: + expression = lightdash_sql_to_osi(definition["sql"], dataset_name) + return self._build_metric( + metric_name, + definition, + expression=expression, + keep_type_in_extension=_type_needs_extension(definition.get("type", "number")), + ) + + @staticmethod + def _build_metric( + metric_name: str, + definition: Dict[str, Any], + *, + expression: str, + keep_type_in_extension: bool, + ) -> OSIMetric: + excluded = set(_STRUCTURAL_METRIC_KEYS) + if not keep_type_in_extension: + excluded.add("type") + extension_data = { + key: value for key, value in definition.items() if key not in excluded + } + return OSIMetric( + name=metric_name, + expression=_ansi(expression), + description=definition.get("description"), + custom_extensions=_lightdash_extension(extension_data) or None, + ) + + @staticmethod + def _convert_joins( + joins: List[Dict[str, Any]], + *, + from_model: str, + issues: List[ConverterIssue], + ) -> List[OSIRelationship]: + relationships: List[OSIRelationship] = [] + for join in joins: + to_model = join.get("join") + pairs = _JOIN_PAIR_RE.findall(join.get("sql_on") or "") + from_columns: List[str] = [] + to_columns: List[str] = [] + for left_table, left_column, right_table, right_column in pairs: + if left_table == from_model and right_table == to_model: + from_columns.append(left_column) + to_columns.append(right_column) + elif left_table == to_model and right_table == from_model: + from_columns.append(right_column) + to_columns.append(left_column) + if not to_model or not from_columns: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.JOIN_SQL_UNPARSED, + element_name=f"{from_model} -> {to_model or ''}", + ) + ) + continue + relationships.append( + OSIRelationship.model_validate( + { + "name": f"{from_model}_to_{to_model}", + "from": from_model, + "to": to_model, + "from_columns": from_columns, + "to_columns": to_columns, + } + ) + ) + return relationships diff --git a/converters/lightdash/src/ossie_lightdash/osi_to_lightdash.py b/converters/lightdash/src/ossie_lightdash/osi_to_lightdash.py new file mode 100644 index 00000000..e9860175 --- /dev/null +++ b/converters/lightdash/src/ossie_lightdash/osi_to_lightdash.py @@ -0,0 +1,260 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert an OSI document into Lightdash semantic definitions. + +The output is a dbt ``schema.yml``-shaped dictionary whose ``meta`` blocks +carry Lightdash dimensions, metrics and joins, ready to be merged into a dbt +project that Lightdash reads. Lightdash-specific presentation attributes that +have no OSI vocabulary round-trip through ``custom_extensions`` entries with +``vendor_name: "lightdash"``; their keys are overlaid onto the generated +definitions and win for presentation attributes, while structural keys +(``sql``/``label`` on dimensions, ``sql``/``description`` on metrics) are +protected so they can never override the OSI-derived definition. +""" + +import json +from typing import Any, Dict, List, Optional + +from ossie import OSIDataset, OSIDialect, OSIDocument, OSIMetric, OSISemanticModel + +from ossie_lightdash.converter_issues import ( + ConverterIssue, + ConverterIssueType, + ConverterResult, +) +from ossie_lightdash.expression_utils import ( + osi_sql_to_lightdash, + parse_simple_aggregation, + qualifier_of, + referenced_datasets, + strip_qualifier, +) + +LIGHTDASH_VENDOR_NAME = "lightdash" + +# Structural keys are owned by OSI vocabulary (the import direction never puts +# them into the extension); dropping them here keeps a hand-authored extension +# from overriding the OSI-derived definition. ``type`` stays overridable on +# metrics: it is the documented channel for types OSI expressions cannot +# express (e.g. percentile). +_PROTECTED_DIMENSION_KEYS = {"sql", "label"} +_PROTECTED_METRIC_KEYS = {"sql", "description"} + + +def _pick_expression(osi_expression: Any, dialect: OSIDialect) -> str: + """Return the expression for the preferred dialect (fallback: first available).""" + for dialect_expression in osi_expression.dialects: + if dialect_expression.dialect is dialect: + return dialect_expression.expression + return osi_expression.dialects[0].expression if osi_expression.dialects else "" + + +def _lightdash_extension_data(element: Any, issues: List[ConverterIssue]) -> Dict[str, Any]: + """Return the ``lightdash`` vendor extension data of an OSI element, if any.""" + data: Dict[str, Any] = {} + for extension in element.custom_extensions or []: + if extension.vendor_name == LIGHTDASH_VENDOR_NAME: + try: + data.update(json.loads(extension.data)) + except (TypeError, ValueError): + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.EXTENSION_DATA_INVALID, + element_name=getattr(element, "name", ""), + ) + ) + else: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.FOREIGN_EXTENSION_IGNORED, + element_name=getattr(element, "name", ""), + ) + ) + return data + + +def _model_name_for(dataset: OSIDataset) -> str: + """A Lightdash table is addressed by its dbt model name = the source's table part.""" + return dataset.source.rsplit(".", 1)[-1] + + +class OSIToLightdashConverter: + """Converts an OSIDocument into a Lightdash-flavoured dbt schema.yml dict.""" + + def __init__(self, dialect: OSIDialect = OSIDialect.ANSI_SQL) -> None: + self._dialect = dialect + + def convert(self, document: OSIDocument) -> ConverterResult[Dict[str, Any]]: + issues: List[ConverterIssue] = [] + models: List[Dict[str, Any]] = [] + for semantic_model in document.semantic_model: + models.extend(self._convert_semantic_model(semantic_model, issues)) + return ConverterResult(output={"version": 2, "models": models}, issues=issues) + + def _convert_semantic_model( + self, semantic_model: OSISemanticModel, issues: List[ConverterIssue] + ) -> List[Dict[str, Any]]: + datasets = semantic_model.datasets or [] + dataset_names = {dataset.name for dataset in datasets} + model_name_by_dataset = { + dataset.name: _model_name_for(dataset) for dataset in datasets + } + + models_by_dataset: Dict[str, Dict[str, Any]] = {} + columns_by_dataset: Dict[str, Dict[str, Dict[str, Any]]] = {} + for dataset in datasets: + model, columns = self._convert_dataset(dataset, issues) + models_by_dataset[dataset.name] = model + columns_by_dataset[dataset.name] = columns + + for metric in semantic_model.metrics or []: + self._convert_metric( + metric, + dataset_names, + models_by_dataset, + columns_by_dataset, + issues, + ) + + for relationship in semantic_model.relationships or []: + from_model = models_by_dataset.get(relationship.from_dataset) + to_model_name = model_name_by_dataset.get(relationship.to) + from_model_name = model_name_by_dataset.get(relationship.from_dataset) + if from_model is None or to_model_name is None: + continue + if len(relationship.from_columns) != len(relationship.to_columns): + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.RELATIONSHIP_COLUMNS_MISMATCHED, + element_name=relationship.name, + ) + ) + continue + sql_on = " AND ".join( + f"${{{from_model_name}.{from_column}}} = ${{{to_model_name}.{to_column}}}" + for from_column, to_column in zip( + relationship.from_columns, relationship.to_columns + ) + ) + joins = from_model.setdefault("meta", {}).setdefault("joins", []) + joins.append({"join": to_model_name, "sql_on": sql_on}) + + return [models_by_dataset[dataset.name] for dataset in datasets] + + def _convert_dataset( + self, dataset: OSIDataset, issues: List[ConverterIssue] + ) -> tuple: + columns_by_name: Dict[str, Dict[str, Any]] = {} + for field in dataset.fields or []: + column: Dict[str, Any] = {"name": field.name} + if field.description: + column["description"] = field.description + + dimension: Dict[str, Any] = {} + if field.label: + dimension["label"] = field.label + if field.dimension is not None and field.dimension.is_time: + dimension["type"] = "date" + expression = _pick_expression(field.expression, self._dialect) + if expression and expression != field.name: + dimension["sql"] = osi_sql_to_lightdash(expression, dataset.name) + dimension.update( + { + key: value + for key, value in _lightdash_extension_data(field, issues).items() + if key not in _PROTECTED_DIMENSION_KEYS + } + ) + # An empty dict still marks dimension-ness: a field OSI declares as a + # categorical dimension must not degrade to a plain column on export, + # or the import direction could not reconstruct it. + if dimension or field.dimension is not None: + column["meta"] = {"dimension": dimension} + columns_by_name[field.name] = column + + model: Dict[str, Any] = {"name": _model_name_for(dataset)} + if dataset.description: + model["description"] = dataset.description + model["columns"] = list(columns_by_name.values()) + return model, columns_by_name + + def _convert_metric( + self, + metric: OSIMetric, + dataset_names: set, + models_by_dataset: Dict[str, Dict[str, Any]], + columns_by_dataset: Dict[str, Dict[str, Dict[str, Any]]], + issues: List[ConverterIssue], + ) -> None: + expression = _pick_expression(metric.expression, self._dialect) + extension_data = _lightdash_extension_data(metric, issues) + + target_dataset = self._resolve_target_dataset(expression, dataset_names) + if target_dataset is None: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.CROSS_DATASET_METRIC_DROPPED, + element_name=metric.name, + ) + ) + return + + definition: Dict[str, Any] = {} + if metric.description: + definition["description"] = metric.description + + target_column: Optional[str] = None + parsed = parse_simple_aggregation(expression) + if parsed is not None: + lightdash_type, column_ref = parsed + qualifier = qualifier_of(column_ref) + if qualifier in (None, target_dataset): + target_column = strip_qualifier(column_ref) + definition["type"] = lightdash_type + if target_column is None or target_column not in columns_by_dataset[target_dataset]: + definition["type"] = extension_data.get("type", "number") + definition["sql"] = osi_sql_to_lightdash(expression, target_dataset) + target_column = None + + definition.update( + { + key: value + for key, value in extension_data.items() + if key not in _PROTECTED_METRIC_KEYS + } + ) + + if target_column is not None: + column = columns_by_dataset[target_dataset][target_column] + metrics = ( + column.setdefault("meta", {}).setdefault("metrics", {}) + ) + metrics[metric.name] = definition + else: + model = models_by_dataset[target_dataset] + metrics = model.setdefault("meta", {}).setdefault("metrics", {}) + metrics[metric.name] = definition + + @staticmethod + def _resolve_target_dataset(expression: str, dataset_names: set) -> Optional[str]: + referenced = referenced_datasets(expression, dataset_names) + if len(referenced) == 1: + return next(iter(referenced)) + if len(referenced) == 0 and len(dataset_names) == 1: + return next(iter(dataset_names)) + return None diff --git a/converters/lightdash/tests/__init__.py b/converters/lightdash/tests/__init__.py new file mode 100644 index 00000000..13a83393 --- /dev/null +++ b/converters/lightdash/tests/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/converters/lightdash/tests/test_lightdash_to_osi.py b/converters/lightdash/tests/test_lightdash_to_osi.py new file mode 100644 index 00000000..b8d95522 --- /dev/null +++ b/converters/lightdash/tests/test_lightdash_to_osi.py @@ -0,0 +1,255 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json + +from ossie_lightdash import ConverterIssueType, LightdashToOSIConverter + +SCHEMA_YML = { + "version": 2, + "models": [ + { + "name": "orders", + "description": "One row per order", + "meta": { + "joins": [ + { + "join": "customers", + "sql_on": "${orders.customer_id} = ${customers.customer_id}", + } + ], + "metrics": { + "conversion_rate": { + "type": "number", + "label": "Conversion rate", + "format": "percent", + "round": 1, + "sql": "SUM(${TABLE}.completed_count) / NULLIF(SUM(${TABLE}.total_count), 0)", + } + }, + }, + "columns": [ + { + "name": "order_date", + "description": "Date the order was placed", + "meta": {"dimension": {"label": "Order date", "type": "date"}}, + }, + { + "name": "status", + "meta": {"dimension": {"label": "Status", "type": "string"}}, + }, + { + "name": "amount", + "description": "Order amount", + "meta": { + "metrics": { + "total_amount": { + "type": "sum", + "label": "Total amount", + "format": "usd", + }, + "median_amount": {"type": "median"}, + "p90_amount": {"type": "percentile", "percentile": 90}, + } + }, + }, + {"name": "completed_count"}, + {"name": "total_count"}, + { + "name": "customer_id", + "meta": { + "metrics": { + "unique_customers": {"type": "count_distinct"}, + } + }, + }, + ], + }, + { + "name": "customers", + "columns": [{"name": "customer_id"}], + }, + ], +} + + +def _metric(document, name): + return next(m for m in document.semantic_model[0].metrics if m.name == name) + + +def _lightdash_data(element): + for extension in element.custom_extensions or []: + if extension.vendor_name == "lightdash": + return json.loads(extension.data) + return {} + + +class TestLightdashToOSI: + def test_dataset_source_is_qualified(self): + result = LightdashToOSIConverter().convert( + SCHEMA_YML, database="analytics_db", schema="marts" + ) + dataset = result.output.semantic_model[0].datasets[0] + assert dataset.source == "analytics_db.marts.orders" + assert not any( + issue.issue_type is ConverterIssueType.SOURCE_UNQUALIFIED + for issue in result.issues + ) + + def test_missing_schema_is_reported(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML) + dataset = result.output.semantic_model[0].datasets[0] + assert dataset.source == "orders" + assert any( + issue.issue_type is ConverterIssueType.SOURCE_UNQUALIFIED + for issue in result.issues + ) + + def test_time_dimension(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML, schema="marts") + field = result.output.semantic_model[0].datasets[0].fields[0] + assert field.name == "order_date" + assert field.label == "Order date" + assert field.dimension is not None and field.dimension.is_time + assert field.description == "Date the order was placed" + + def test_typed_metric_becomes_aggregation_expression(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML, schema="marts") + metric = _metric(result.output, "total_amount") + assert metric.expression.dialects[0].expression == "SUM(orders.amount)" + assert _lightdash_data(metric) == {"label": "Total amount", "format": "usd"} + + def test_count_distinct_metric(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML, schema="marts") + metric = _metric(result.output, "unique_customers") + assert ( + metric.expression.dialects[0].expression + == "COUNT(DISTINCT orders.customer_id)" + ) + + def test_percentile_metric_keeps_type_in_extension(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML, schema="marts") + metric = _metric(result.output, "p90_amount") + assert _lightdash_data(metric) == {"type": "percentile", "percentile": 90} + + def test_sql_metric_expression_is_rewritten(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML, schema="marts") + metric = _metric(result.output, "conversion_rate") + assert ( + metric.expression.dialects[0].expression + == "SUM(orders.completed_count) / NULLIF(SUM(orders.total_count), 0)" + ) + assert _lightdash_data(metric) == { + "label": "Conversion rate", + "format": "percent", + "round": 1, + } + + def test_join_becomes_relationship(self): + result = LightdashToOSIConverter().convert(SCHEMA_YML, schema="marts") + relationship = result.output.semantic_model[0].relationships[0] + assert relationship.from_dataset == "orders" + assert relationship.to == "customers" + assert relationship.from_columns == ["customer_id"] + assert relationship.to_columns == ["customer_id"] + + def test_percentile_with_sql_keeps_type_in_extension(self): + schema_yml = { + "models": [ + { + "name": "orders", + "meta": { + "metrics": { + "p90_custom": { + "type": "percentile", + "percentile": 90, + "sql": "${TABLE}.amount - ${TABLE}.discount", + } + } + }, + "columns": [], + } + ] + } + result = LightdashToOSIConverter().convert(schema_yml, schema="marts") + metric = _metric(result.output, "p90_custom") + assert ( + metric.expression.dialects[0].expression + == "orders.amount - orders.discount" + ) + assert _lightdash_data(metric) == {"type": "percentile", "percentile": 90} + + def test_joined_table_references_become_cross_dataset(self): + schema_yml = { + "models": [ + { + "name": "orders", + "meta": { + "metrics": { + "orders_per_customer": { + "type": "number", + "sql": "COUNT(${TABLE}.order_id) / COUNT(DISTINCT ${customers.customer_id})", + } + } + }, + "columns": [], + } + ] + } + result = LightdashToOSIConverter().convert(schema_yml, schema="marts") + metric = _metric(result.output, "orders_per_customer") + assert ( + metric.expression.dialects[0].expression + == "COUNT(orders.order_id) / COUNT(DISTINCT customers.customer_id)" + ) + + def test_model_metric_without_sql_is_skipped(self): + schema_yml = { + "models": [ + { + "name": "orders", + "meta": {"metrics": {"broken_metric": {"type": "number"}}}, + "columns": [], + } + ] + } + result = LightdashToOSIConverter().convert(schema_yml, schema="marts") + assert result.output.semantic_model[0].metrics is None + assert any( + issue.issue_type is ConverterIssueType.METRIC_SQL_MISSING + and issue.element_name == "broken_metric" + for issue in result.issues + ) + + def test_unparseable_join_is_reported(self): + schema_yml = { + "models": [ + { + "name": "orders", + "meta": { + "joins": [{"join": "customers", "sql_on": "1 = 1"}], + }, + "columns": [], + } + ] + } + result = LightdashToOSIConverter().convert(schema_yml, schema="marts") + assert result.output.semantic_model[0].relationships is None + assert any( + issue.issue_type is ConverterIssueType.JOIN_SQL_UNPARSED + for issue in result.issues + ) diff --git a/converters/lightdash/tests/test_osi_to_lightdash.py b/converters/lightdash/tests/test_osi_to_lightdash.py new file mode 100644 index 00000000..1f48fb06 --- /dev/null +++ b/converters/lightdash/tests/test_osi_to_lightdash.py @@ -0,0 +1,262 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json + +from ossie import ( + OSICustomExtension, + OSIDataset, + OSIDialect, + OSIDialectExpression, + OSIDimension, + OSIDocument, + OSIExpression, + OSIField, + OSIMetric, + OSIRelationship, + OSISemanticModel, +) + +from ossie_lightdash import ConverterIssueType, OSIToLightdashConverter + + +def _ansi(expression: str) -> OSIExpression: + return OSIExpression( + dialects=[ + OSIDialectExpression(dialect=OSIDialect.ANSI_SQL, expression=expression) + ] + ) + + +def _document() -> OSIDocument: + orders = OSIDataset( + name="orders", + source="analytics_db.marts.orders", + description="One row per order", + fields=[ + OSIField( + name="order_date", + expression=_ansi("order_date"), + dimension=OSIDimension(is_time=True), + label="Order date", + ), + OSIField( + name="status", + expression=_ansi("status"), + dimension=OSIDimension(is_time=False), + ), + OSIField(name="amount", expression=_ansi("amount")), + OSIField(name="customer_id", expression=_ansi("customer_id")), + ], + ) + customers = OSIDataset( + name="customers", + source="analytics_db.marts.customers", + fields=[OSIField(name="customer_id", expression=_ansi("customer_id"))], + ) + metrics = [ + OSIMetric( + name="total_amount", + expression=_ansi("SUM(orders.amount)"), + description="Sum of order amounts", + custom_extensions=[ + OSICustomExtension( + vendor_name="lightdash", + data=json.dumps({"label": "Total amount", "format": "usd"}), + ) + ], + ), + OSIMetric( + name="conversion_rate", + expression=_ansi( + "SUM(orders.completed_count) / NULLIF(SUM(orders.total_count), 0)" + ), + custom_extensions=[ + OSICustomExtension( + vendor_name="lightdash", + data=json.dumps({"format": "percent", "round": 1}), + ) + ], + ), + OSIMetric( + name="cross_dataset", + expression=_ansi("SUM(orders.amount) / COUNT(customers.customer_id)"), + ), + OSIMetric( + name="foreign_vendor_metric", + expression=_ansi("SUM(orders.amount)"), + custom_extensions=[ + OSICustomExtension(vendor_name="somebi", data='{"x": 1}') + ], + ), + ] + relationships = [ + OSIRelationship.model_validate( + { + "name": "orders_to_customers", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["customer_id"], + } + ) + ] + return OSIDocument( + version="0.2.0.dev0", + semantic_model=[ + OSISemanticModel( + name="sales", + datasets=[orders, customers], + metrics=metrics, + relationships=relationships, + ) + ], + ) + + +def _model(output, name): + return next(m for m in output["models"] if m["name"] == name) + + +def _column(model, name): + return next(c for c in model["columns"] if c["name"] == name) + + +class TestOSIToLightdash: + def test_time_dimension_exports_date_type(self): + result = OSIToLightdashConverter().convert(_document()) + column = _column(_model(result.output, "orders"), "order_date") + assert column["meta"]["dimension"] == {"label": "Order date", "type": "date"} + + def test_categorical_dimension_keeps_dimension_marker(self): + result = OSIToLightdashConverter().convert(_document()) + column = _column(_model(result.output, "orders"), "status") + assert column["meta"]["dimension"] == {} + + def test_plain_field_has_no_dimension_meta(self): + result = OSIToLightdashConverter().convert(_document()) + column = _column(_model(result.output, "orders"), "amount") + assert "dimension" not in column.get("meta", {}) + + def test_simple_aggregation_becomes_column_metric(self): + result = OSIToLightdashConverter().convert(_document()) + column = _column(_model(result.output, "orders"), "amount") + metric = column["meta"]["metrics"]["total_amount"] + assert metric["type"] == "sum" + assert metric["label"] == "Total amount" + assert metric["format"] == "usd" + assert metric["description"] == "Sum of order amounts" + assert "sql" not in metric + + def test_complex_expression_becomes_model_metric(self): + result = OSIToLightdashConverter().convert(_document()) + metric = _model(result.output, "orders")["meta"]["metrics"]["conversion_rate"] + assert metric["type"] == "number" + assert ( + metric["sql"] + == "SUM(${TABLE}.completed_count) / NULLIF(SUM(${TABLE}.total_count), 0)" + ) + assert metric["format"] == "percent" + assert metric["round"] == 1 + + def test_cross_dataset_metric_is_dropped_with_issue(self): + result = OSIToLightdashConverter().convert(_document()) + assert any( + issue.issue_type is ConverterIssueType.CROSS_DATASET_METRIC_DROPPED + and issue.element_name == "cross_dataset" + for issue in result.issues + ) + + def test_foreign_extension_is_reported(self): + result = OSIToLightdashConverter().convert(_document()) + assert any( + issue.issue_type is ConverterIssueType.FOREIGN_EXTENSION_IGNORED + and issue.element_name == "foreign_vendor_metric" + for issue in result.issues + ) + + def test_extension_cannot_override_structural_keys(self): + document = _document() + tampered = document.model_copy(deep=True) + metric = tampered.semantic_model[0].metrics[0].model_copy( + update={ + "custom_extensions": [ + OSICustomExtension( + vendor_name="lightdash", + data=json.dumps( + {"label": "Total amount", "sql": "1 + 1", "description": "stale"} + ), + ) + ] + } + ) + tampered.semantic_model[0].metrics[0] = metric + result = OSIToLightdashConverter().convert(tampered) + column = _column(_model(result.output, "orders"), "amount") + exported = column["meta"]["metrics"]["total_amount"] + assert exported["label"] == "Total amount" + assert "sql" not in exported + assert exported["description"] == "Sum of order amounts" + + def test_mismatched_relationship_columns_are_skipped(self): + document = _document() + tampered = document.model_copy(deep=True) + relationship = OSIRelationship.model_validate( + { + "name": "broken", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id", "order_id"], + "to_columns": ["customer_id"], + } + ) + tampered.semantic_model[0].relationships[0] = relationship + result = OSIToLightdashConverter().convert(tampered) + assert "joins" not in _model(result.output, "orders").get("meta", {}) + assert any( + issue.issue_type is ConverterIssueType.RELATIONSHIP_COLUMNS_MISMATCHED + and issue.element_name == "broken" + for issue in result.issues + ) + + def test_invalid_extension_json_is_reported(self): + document = _document() + tampered = document.model_copy(deep=True) + metric = tampered.semantic_model[0].metrics[0].model_copy( + update={ + "custom_extensions": [ + OSICustomExtension(vendor_name="lightdash", data="{not json") + ] + } + ) + tampered.semantic_model[0].metrics[0] = metric + result = OSIToLightdashConverter().convert(tampered) + assert any( + issue.issue_type is ConverterIssueType.EXTENSION_DATA_INVALID + and issue.element_name == "total_amount" + for issue in result.issues + ) + + def test_relationship_becomes_join(self): + result = OSIToLightdashConverter().convert(_document()) + joins = _model(result.output, "orders")["meta"]["joins"] + assert joins == [ + { + "join": "customers", + "sql_on": "${orders.customer_id} = ${customers.customer_id}", + } + ] diff --git a/converters/lightdash/tests/test_tpcds_roundtrip.py b/converters/lightdash/tests/test_tpcds_roundtrip.py new file mode 100644 index 00000000..8538aaa0 --- /dev/null +++ b/converters/lightdash/tests/test_tpcds_roundtrip.py @@ -0,0 +1,121 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Round-trip the in-repo TPC-DS example through the Lightdash converter. + +Ossie -> Lightdash schema.yml -> Ossie must preserve the structural core: +datasets, fields (and their dimension-ness), single-dataset metrics and +relationships. Cross-dataset metrics are the documented exception: Lightdash +model metrics cannot reference other tables, so the export direction drops +them with a CROSS_DATASET_METRIC_DROPPED issue. +""" + +from pathlib import Path + +import yaml + +from ossie import OSIDocument + +from ossie_lightdash import ( + ConverterIssueType, + LightdashToOSIConverter, + OSIToLightdashConverter, +) + +TPCDS_PATH = Path(__file__).parent / ".." / ".." / ".." / "examples" / "tpcds_semantic_model.yaml" + + +def _load_tpcds() -> OSIDocument: + return OSIDocument.model_validate(yaml.safe_load(TPCDS_PATH.read_text())) + + +def _roundtrip(): + original = _load_tpcds() + exported = OSIToLightdashConverter().convert(original) + reimported = LightdashToOSIConverter().convert( + exported.output, + database="tpcds", + schema="public", + semantic_model_name=original.semantic_model[0].name, + ) + return original, exported, reimported + + +class TestTpcdsRoundtrip: + def test_datasets_and_sources_are_preserved(self): + original, _, reimported = _roundtrip() + original_sources = { + dataset.name: dataset.source + for dataset in original.semantic_model[0].datasets + } + roundtripped_sources = { + dataset.name: dataset.source + for dataset in reimported.output.semantic_model[0].datasets + } + assert roundtripped_sources == original_sources + + def test_fields_and_dimension_flags_are_preserved(self): + original, _, reimported = _roundtrip() + for original_dataset, roundtripped_dataset in zip( + original.semantic_model[0].datasets, + reimported.output.semantic_model[0].datasets, + ): + original_fields = { + field.name: (field.dimension is not None and field.dimension.is_time, + field.dimension is not None) + for field in original_dataset.fields or [] + } + roundtripped_fields = { + field.name: (field.dimension is not None and field.dimension.is_time, + field.dimension is not None) + for field in roundtripped_dataset.fields or [] + } + assert roundtripped_fields == original_fields + + def test_single_dataset_metrics_survive_with_expressions(self): + original, exported, reimported = _roundtrip() + dropped = { + issue.element_name + for issue in exported.issues + if issue.issue_type is ConverterIssueType.CROSS_DATASET_METRIC_DROPPED + } + original_metrics = { + metric.name: metric.expression.dialects[0].expression + for metric in original.semantic_model[0].metrics or [] + if metric.name not in dropped + } + roundtripped_metrics = { + metric.name: metric.expression.dialects[0].expression + for metric in reimported.output.semantic_model[0].metrics or [] + } + assert set(roundtripped_metrics) == set(original_metrics) + for name, expression in original_metrics.items(): + assert roundtripped_metrics[name].replace(" ", "") == expression.replace( + " ", "" + ), name + + def test_relationships_are_preserved(self): + original, _, reimported = _roundtrip() + original_edges = { + (r.from_dataset, r.to, tuple(r.from_columns), tuple(r.to_columns)) + for r in original.semantic_model[0].relationships or [] + } + roundtripped_edges = { + (r.from_dataset, r.to, tuple(r.from_columns), tuple(r.to_columns)) + for r in reimported.output.semantic_model[0].relationships or [] + } + assert roundtripped_edges == original_edges diff --git a/converters/lightdash/uv.lock b/converters/lightdash/uv.lock new file mode 100644 index 00000000..682505de --- /dev/null +++ b/converters/lightdash/uv.lock @@ -0,0 +1,304 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "apache-ossie" +version = "0.2.0.dev0" +source = { editable = "../../python" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "apache-ossie-lightdash" +version = "0.1.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "apache-ossie" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "apache-ossie", editable = "../../python" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]