diff --git a/.github/workflows/converter-hologres-ci.yml b/.github/workflows/converter-hologres-ci.yml new file mode 100644 index 00000000..9272e809 --- /dev/null +++ b/.github/workflows/converter-hologres-ci.yml @@ -0,0 +1,75 @@ +# +# 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 Hologres CI + +# This converter's tests read three files from outside its own directory rather than +# copying them: the canonical TPC-DS example, the shared validator, and the schema the +# validator checks against. They are listed here so a change to any of them still runs +# these tests. +on: + push: + branches: [ "main" ] + paths: + - 'converters/hologres/**' + - '.github/workflows/converter-hologres-ci.yml' + - 'examples/tpcds_semantic_model.yaml' + - 'validation/validate.py' + - 'core-spec/osi-schema.json' + pull_request: + branches: [ "main" ] + paths: + - 'converters/hologres/**' + - '.github/workflows/converter-hologres-ci.yml' + - 'examples/tpcds_semantic_model.yaml' + - 'validation/validate.py' + - 'core-spec/osi-schema.json' + +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}" + + # Deliberately not `--group live`: the live tests need a Hologres instance and + # skip themselves without one. + - name: Sync dependencies + working-directory: converters/hologres + run: | + uv sync + + - name: Unit Tests + working-directory: converters/hologres + run: | + uv run pytest diff --git a/ROADMAP.md b/ROADMAP.md index 72d564c4..e53113a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -435,6 +435,7 @@ Broad ecosystem adoption depends on practical tools that let teams validate thei - [Salesforce Converter](converters/salesforce/) — Ossie ↔ Salesforce converter - [Apache Polaris Converter](converters/polaris/) — Ossie → Apache Polaris converter - [OrionBelt Converter](converters/orionbelt/) — bidirectional Ossie ↔ OrionBelt OBML converter +- [Hologres Converter](converters/hologres/) — bidirectional Ossie ↔ Alibaba Cloud Hologres Semantic View converter (exports `CREATE SEMANTIC VIEW` DDL, imports the published `model_yaml`) **Related Issues:** diff --git a/converters/README.md b/converters/README.md index 5c9a4d54..3c9c7ebd 100644 --- a/converters/README.md +++ b/converters/README.md @@ -76,6 +76,7 @@ The Ossie specification currently defines extensions for the following vendors: | `OMNI` | Omni semantic model | | `WISDOM` | WisdomAI domain | | `NVIDIA_GSF` | NVIDIA Generative Semantic Fabric standalone YAML | +| `HOLOGRES` | Alibaba Cloud Hologres Semantic View | Each vendor may define custom extensions (via the `custom_extensions` field in the Ossie spec) to carry vendor-specific metadata that does not have an equivalent in the core specification. diff --git a/converters/hologres/README.md b/converters/hologres/README.md new file mode 100644 index 00000000..84064b5a --- /dev/null +++ b/converters/hologres/README.md @@ -0,0 +1,233 @@ + + +# Apache Ossie Hologres Converter + +Converts between Apache Ossie semantic models and [Alibaba Cloud Hologres](https://www.alibabacloud.com/product/hologres) +Semantic Views, available in Hologres V5.0.0 and later. + +A Hologres Semantic View declares tables, their relationships, business dimensions and +metrics as a queryable database object. Queries name dimensions and `AGG(metric)` instead +of repeating joins and aggregations, and the engine aggregates each metric within its own +join subtree so a one-to-many join cannot inflate a total. + +The two directions are deliberately asymmetric, because Hologres publishes and consumes +its Semantic View definitions in different formats: + +- **Export (Ossie -> Hologres)** produces `CREATE SEMANTIC VIEW` **SQL DDL text**. + Hologres has no YAML import function, so the DDL is the only way to create a + Semantic View. +- **Import (Hologres -> Ossie)** consumes the **`model_yaml`** that Hologres publishes + for every Semantic View in the `hologres.hg_semantic_view_properties` system table. + +## Installation + +```bash +uv sync +``` + +## Usage + +### Command line + +Export an Apache Ossie model to DDL and run it: + +```bash +ossie-hologres export -i model.yaml -o view.sql +psql -h -p 80 -U -d -f view.sql +``` + +Import an existing Semantic View back into Apache Ossie. Hologres publishes the +structured model for every Semantic View in a system table: + +```bash +psql -h -p 80 -U -d -At -c \ + "SELECT property_value FROM hologres.hg_semantic_view_properties + WHERE schema_name = current_schema() + AND view_name = 'sales_sv' AND property_key = 'model_yaml';" > model_yaml.yaml + +ossie-hologres import -i model_yaml.yaml -o model.yaml +``` + +Export options: + +| Option | Purpose | +|--------|---------| +| `--schema` | Schema for the view, and a default for datasets whose `source` has none. Never overrides a schema already written into a `source`. | +| `--database` | Assert the database the dataset sources belong to. | +| `--drop-if-exists` | Prefix a `DROP SEMANTIC VIEW IF EXISTS`. Hologres has no `CREATE OR REPLACE` or `ALTER`, so this is how a definition is changed. | +| `--metric-owner METRIC=DATASET` | Name the table a metric belongs to, for metrics whose expression has no qualified column to infer it from (`COUNT(*)`). Repeatable. | +| `--skip-unsupported-metrics` | Warn about and skip metrics with no Semantic View form instead of failing. | + +### Python API + +```python +from ossie_hologres import convert_ossie_to_semantic_view, convert_semantic_view_to_ossie + +ddl = convert_ossie_to_semantic_view(ossie_yaml, schema="public") +ossie_yaml = convert_semantic_view_to_ossie(model_yaml) +``` + +## Mapping + +| Apache Ossie | Hologres Semantic View | +|--------------|------------------------| +| `semantic_model.name` | `view_name` | +| `semantic_model.description` | view-level `COMMENT` | +| `dataset.name` | `TABLES` alias | +| `dataset.source` | the `[schema.]table` in `TABLES` | +| `dataset.primary_key` | `PRIMARY KEY (...)` | +| `dataset.fields[]` | `DIMENSIONS . AS ` | +| `field.description` | dimension `COMMENT` | +| `metrics[]` | `METRICS . AS ` | +| `metric.description` | metric `COMMENT` | +| `relationships[]` | `RELATIONSHIPS AS (cols) REFERENCES (cols)` | + +Three mappings are worth explaining. + +**Dataset names are table aliases.** On import the Hologres alias (`o`, `c`) becomes the +Ossie dataset name, because the alias is what every dimension, metric and relationship +references. That also makes Hologres' own `sum(o.amount)` already correct as an Ossie +dataset-qualified metric expression. + +**Relationship direction carries cardinality.** Hologres records +`relationship_type: many_to_one` and nothing else. Ossie already encodes the same fact in +the direction of a relationship -- `from` is the many side holding the foreign key, `to` +is the one side holding the primary key -- so the type is derived rather than stored. +Getting the direction backwards makes Hologres treat a join as one-to-many and aggregate +a metric more than once, so it matters. + +**Metric ownership.** Ossie metrics are model-level; Hologres namespaces each metric under +the table it aggregates. The owner is inferred from the metric expression's column +references. `COUNT(*)` references none, so it needs an explicit owner via a +`custom_extensions` entry or `--metric-owner`; the converter refuses to guess, because +picking the wrong owner silently changes the number under a fan-out join. + +**Expressions are read and written as `ANSI_SQL` only.** This converter introduces no +dialect token of its own, so it needs no change to the core spec. Hologres is +PostgreSQL-compatible, but that alone does not justify one: the portable spelling is +nearly always available and sqlglot normalizes to it, so `x::text` becomes +`CAST(x AS TEXT)` in both directions. + +For the PostgreSQL-only syntax that does remain -- `j -> 'k'`, `s ~ 'pattern'`, the +1-based `arr[1]` -- the `ANSI_SQL` label is not strictly accurate. It is still the better +trade. A converter that looks for an `ANSI_SQL` expression and finds none *drops the +field* (see `converters/databricks`), so over-labelling loses data silently, whereas an +optimistic label at worst surfaces as a SQL error on the target engine. Deciding this per +expression was tried and abandoned: sqlglot's default dialect is not ANSI SQL, so any such +test mislabels in both directions -- passing `ILIKE` as portable while flagging the +standard `SUBSTRING`, `EXTRACT` and `DATE_TRUNC` as vendor-specific. + +## Requirements + +Hologres V5.0.0 or later, with `hg_enable_semantic_view_query` on (the default). Confirm +with: + +```sql +SELECT hg_version(); +``` + +## Limitations + +These come from Hologres itself, not the converter. The converter reports each one with +the offending field named rather than emitting DDL the server will reject. + +### Definitions + +| Constraint | Effect | +|------------|--------| +| A definition expression must be row-level over a **single** table | A dimension or metric spanning two datasets is rejected | +| Aggregates are limited to `count` / `sum` / `avg` / `min` / `max` | `stddev`, `percentile_cont` and friends are rejected | +| No derived, ratio or filtered metrics | `SUM(a) / COUNT(*)` is rejected; compute it in the query layer | +| A `REFERENCES` target must be the target table's `PRIMARY KEY` | A relationship whose `to_columns` are not the target's key is rejected. A matching `unique_keys` entry is promoted with a warning | +| No window functions, subqueries, `VOLATILE` or set-returning functions | Structurally detectable cases are rejected here; the rest are rejected by Hologres at `CREATE` time | +| A top-level operator in a definition must be parenthesised | Handled automatically: `a \|\| b` is emitted as `(a \|\| b)` | +| Dimension and metric names are referenced bare in queries | Names must be unique across the whole view. Ossie only requires field names to be unique per dataset, so a collision across datasets is rejected rather than silently renamed | +| A Semantic View cannot span databases | Datasets naming different databases are rejected | +| No `CREATE OR REPLACE` or `ALTER SEMANTIC VIEW` | Use `--drop-if-exists` to recreate | + +### Information not preserved on export + +Hologres offers exactly one annotation slot: `COMMENT`, on the view, each dimension and +each metric. `description` maps there, and a string-form `ai_context` is folded in. +Everything else is reported as a warning rather than dropped silently: +`unique_keys` (unless promoted), `datatype`, `dimension.is_time`, `label`, object-form +`ai_context` (synonyms, instructions, examples), `dataset.description` (the `TABLES` +clause takes no comment), `relationship.ai_context`, and non-`HOLOGRES` vendor +`custom_extensions`. + +### Round-trip fidelity + +A full loop is lossless for everything Hologres stores: + +``` +Ossie -> DDL -> Hologres -> model_yaml -> Ossie +``` + +Two caveats. Expressions come back **normalized, not byte-identical**: sqlglot upper-cases +function names and rewrites `x::text` as `CAST(x AS TEXT)`, and Hologres re-renders +operator expressions with its own parenthesisation and explicit casts. And a `source` is +always rebuilt as a full three-part `database.schema.table` from Hologres' `base_table`, +so a two-part or unqualified `source` gains the missing parts. + +## Development + +```bash +uv sync +uv run pytest +``` + +### Live tests + +A `CREATE SEMANTIC VIEW` statement can only really be validated by a Hologres server, so +the suite includes end-to-end tests that create a view, query it, and read the model back. +They are skipped unless the connection environment variables are set, which keeps CI and a +plain `uv run pytest` hermetic: + +```bash +export HOLOGRES_HOST= +export HOLOGRES_PORT=80 +export HOLOGRES_USER='BASIC$account' # single quotes: the $ is literal +export HOLOGRES_PASSWORD='' +export HOLOGRES_DB= + +uv sync --group live +uv run pytest -m live -v +``` + +The tests create everything inside an `ossie_hologres_it` schema and drop it afterwards. +Credentials are read only from the environment; none are stored in this repository. + +The `live` dependency group holds the PostgreSQL driver and is excluded from +`default-groups`, so CI never installs a database driver it cannot use. + +### Fixtures + +The `tests/fixtures` pairs are not hand-written. Each `*_semantic_view.sql` was executed +against a real Hologres 5.0.0 instance and the resulting view queried, and each +`*_model_yaml.yaml` is that instance's own readback of the corresponding DDL. A live test +re-checks the readback against the committed fixture, so if Hologres changes the shape it +emits, the suite says so instead of quietly testing a stale format. + +## Future effort + +- Import from `ddl_text` as an alternative to `model_yaml`, for views created before + `model_yaml` was populated. +- Revisit derived and ratio metrics if Hologres gains support for them. +- Map `ai_context` synonyms if Hologres gains an AI annotation surface. diff --git a/converters/hologres/pyproject.toml b/converters/hologres/pyproject.toml new file mode 100644 index 00000000..05e43fe9 --- /dev/null +++ b/converters/hologres/pyproject.toml @@ -0,0 +1,74 @@ +# 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", + "jsonschema>=4.26.0", +] +# Only needed to run the env-gated live tests against a real Hologres instance. +# Deliberately excluded from `default-groups` so CI never installs a database +# driver it cannot use. +live = [ + "psycopg[binary]>=3.2", +] + +[project] +name = "apache-ossie-hologres" +version = "0.2.0.dev0" +description = "Alibaba Cloud Hologres Semantic View <> 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", + "Open Semantic Interchange", + "Hologres", + "semantic view", +] +dependencies = [ + "PyYAML>=6.0", + "sqlglot>=30.12.0", +] + +[project.scripts] +ossie-hologres = "ossie_hologres.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_hologres"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "live: requires a reachable Hologres instance (skipped unless HOLOGRES_* env vars are set)", +] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] diff --git a/converters/hologres/src/ossie_hologres/__init__.py b/converters/hologres/src/ossie_hologres/__init__.py new file mode 100644 index 00000000..6195b61c --- /dev/null +++ b/converters/hologres/src/ossie_hologres/__init__.py @@ -0,0 +1,38 @@ +# 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. + +"""Bidirectional converter between Apache Ossie semantic models and Alibaba Cloud +Hologres Semantic Views (Hologres V5.0.0+). Pure offline string-in / string-out +transforms. + +Export produces `CREATE SEMANTIC VIEW` DDL text rather than YAML, because Hologres has +no YAML import function -- the DDL is the only way to (re)create a Semantic View. +Import consumes the `model_yaml` that Hologres publishes in +`hologres.hg_semantic_view_properties`. + + from ossie_hologres import convert_ossie_to_semantic_view, convert_semantic_view_to_ossie +""" + +from ._common import ConversionError +from .ossie_to_semantic_view import convert_ossie_to_semantic_view +from .semantic_view_to_ossie import convert_semantic_view_to_ossie + +__all__ = [ + "ConversionError", + "convert_ossie_to_semantic_view", + "convert_semantic_view_to_ossie", +] diff --git a/converters/hologres/src/ossie_hologres/_common.py b/converters/hologres/src/ossie_hologres/_common.py new file mode 100644 index 00000000..38b0a3e2 --- /dev/null +++ b/converters/hologres/src/ossie_hologres/_common.py @@ -0,0 +1,507 @@ +# 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. + +"""Shared helpers for the Apache Ossie <-> Hologres Semantic View converters. + +Both directions are pure offline transforms. Cross-cutting concerns live here: +version constants, the dialect preference order, the `custom_extensions` stash +protocol, SQL identifier/literal quoting, and the sqlglot expression layer that +qualifies and unqualifies column references. +""" + +import json +import re + +import sqlglot +import yaml +from sqlglot import exp + +# Apache Ossie semantic model spec version this converter targets (see core-spec). +# +# NOTE: this is an exact-match check. Like the Databricks spoke, this converter +# intentionally has no `apache-ossie` package dependency, so nothing updates this +# automatically -- it MUST be bumped in lockstep with the `version` in `core-spec/` +# whenever the spec version moves, or the converter will reject otherwise-valid +# Apache Ossie files. +OSSIE_VERSION = "0.2.0.dev0" + +# Vendor id used for the `custom_extensions` stash. This is a vendor name, not a +# dialect: the Ossie Vendor field is free-form, so it needs no spec change. +VENDOR = "HOLOGRES" + +# The only expression dialect this converter reads or writes. +# +# Hologres is PostgreSQL-compatible, but that does not justify a vendor dialect token: +# the portable spellings are almost always available (`CAST(x AS TEXT)` rather than +# `x::text`), and sqlglot normalizes to them. For the genuinely PostgreSQL-only syntax +# that remains, tagging it as a vendor dialect would be worse than labelling it +# ANSI_SQL, because a converter looking for an ANSI_SQL expression and finding none +# drops the field entirely. So, as the NVIDIA GSF converter puts it, anything else +# stays ANSI_SQL rather than being labelled inaccurately. +DIALECT_ANSI = "ANSI_SQL" + +# Hologres is PostgreSQL wire- and dialect-compatible, so sqlglot parses and +# generates its expressions with the postgres dialect. +SQLGLOT_DIALECT = "postgres" + +# Bump when the shape of a stashed `data` blob changes. +STASH_VERSION = 1 + +# Metric-level stash key naming the owning table alias. Only needed for metrics +# whose expression has no column reference (`count(*)`), where the owner cannot be +# recovered from the expression itself. +STASH_OWNER = "owner" + +# Model-level stash key recording the schema the Semantic View itself lives in. +# `CREATE SEMANTIC VIEW public.sales_sv` has no Ossie home -- `semantic_model.name` +# holds only the bare view name. +STASH_VIEW_SCHEMA = "view_schema" + +# The only aggregate functions Hologres METRICS accept, keyed by sqlglot node type. +# Membership is tested by exact type, not `isinstance(node, exp.AggFunc)`: `stddev` +# and `percentile_cont` are also AggFuncs but Hologres rejects them. +METRIC_AGGREGATES = { + exp.Count: "count", + exp.Sum: "sum", + exp.Avg: "avg", + exp.Min: "min", + exp.Max: "max", +} + +# Expression shapes Hologres forbids in a DIMENSIONS or METRICS definition, which +# must be a row-level expression over a single physical alias. Order matters: a +# windowed aggregate and an aggregate FILTER clause both *contain* an AggFunc, so the +# more specific shapes are checked first to produce the more accurate message. +_NON_ROW_LEVEL = { + exp.Window: "a window function", + exp.Filter: "an aggregate FILTER clause", + exp.Select: "a subquery", + exp.Subquery: "a subquery", + exp.AggFunc: "an aggregate function", +} + +# A bare SQL identifier that needs no quoting, e.g. `svacc_orders`. Uppercase is +# excluded on purpose: PostgreSQL folds unquoted identifiers to lower case, so an +# identifier that is not already lower case must be quoted to survive. +_BARE_IDENTIFIER_RE = re.compile(r"^[a-z_][a-z0-9_]*$") + +# PostgreSQL keywords that cannot appear as a bare table or column name. sqlglot's +# generator does not quote these (it emits `select` for the identifier `select`, +# which is a syntax error), so the DDL writer consults this set itself. Covers the +# "reserved" and "reserved (cannot be function or type name)" categories of the +# PostgreSQL keyword table. +_RESERVED_WORDS = frozenset( + """ + all analyse analyze and any array as asc asymmetric both case cast check + collate column constraint create current_catalog current_date current_role + current_time current_timestamp current_user default deferrable desc distinct + do else end except false fetch for foreign from grant group having in initially + intersect into lateral leading limit localtime localtimestamp not null offset + on only or order placing primary references returning select session_user some + symmetric table then to trailing true union unique user using variadic when + where window with authorization binary collation concurrently cross + current_schema freeze full ilike inner is isnull join left like natural notnull + outer overlaps right similar tablesample verbose + """.split() +) + + +class ConversionError(Exception): + """Raised when an input cannot be converted.""" + + +def require(obj, key, what): + """Return `obj[key]`, or raise a clean ConversionError if it is missing/empty -- so + malformed input surfaces as an error message rather than a raw KeyError traceback. + + Presence is tested by key (not truthiness), so a legitimately falsy value such as + `0` or `False` is returned; a missing key, a null, or an empty/whitespace string is + rejected. + """ + if not isinstance(obj, dict) or key not in obj or obj[key] is None: + raise ConversionError(f"{what} is missing required '{key}'") + value = obj[key] + if isinstance(value, str) and not value.strip(): + raise ConversionError(f"{what} has an empty '{key}'") + return value + + +def require_str(obj, key, what): + """Like require(), but also enforce the value is a string -- so a non-string scalar + (e.g. a YAML number used as a name or expression) raises a clean ConversionError + instead of crashing later in a string operation.""" + value = require(obj, key, what) + if not isinstance(value, str): + raise ConversionError(f"{what}: '{key}' must be a string, got {type(value).__name__}") + return value + + +# YAML 1.1 (PyYAML's default) treats bare on/off/yes/no/y/n as booleans. Hologres emits +# its `model_yaml` with YAML 1.2 semantics, so a dimension literally named `no` or a +# description of `on` would silently become a Python bool and be written into Ossie as +# `true`/`false`. The Loader below uses 1.2 semantics; the Dumper additionally +# force-quotes those tokens on output so the YAML it emits round-trips the same way +# through a YAML 1.1 reader (e.g. stock yaml.safe_load). +class _Yaml12Loader(yaml.SafeLoader): + """SafeLoader with YAML 1.2 boolean semantics.""" + + +class _Yaml12Dumper(yaml.SafeDumper): + """SafeDumper with YAML 1.2 boolean semantics.""" + + +_YAML12_BOOL = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$") +for _cls in (_Yaml12Loader, _Yaml12Dumper): + # Drop the YAML 1.1 bool resolver (yes/no/on/off/y/n) and re-add a 1.2 one. + _cls.yaml_implicit_resolvers = { + ch: [(tag, rx) for (tag, rx) in resolvers if tag != "tag:yaml.org,2002:bool"] + for ch, resolvers in _cls.yaml_implicit_resolvers.items() + } + _cls.add_implicit_resolver("tag:yaml.org,2002:bool", _YAML12_BOOL, list("tTfF")) + + +_YAML11_BOOL_STRS = frozenset( + variant + for word in ("y", "n", "yes", "no", "on", "off", "true", "false") + for variant in (word, word.capitalize(), word.upper()) +) + + +def _represent_str(dumper, data): + style = "'" if data in _YAML11_BOOL_STRS else None + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style) + + +_Yaml12Dumper.add_representer(str, _represent_str) + + +def load_yaml(text): + """Parse YAML with 1.2 boolean semantics. A syntax error is surfaced as a + ConversionError so callers (and the CLI) get a clean message, not a traceback.""" + try: + return yaml.load(text, Loader=_Yaml12Loader) + except yaml.YAMLError as e: + raise ConversionError(f"Invalid YAML: {e}") from e + + +def dump_yaml(obj): + """Serialize to YAML with 1.2 boolean semantics, preserving key insertion order.""" + return yaml.dump( + obj, + Dumper=_Yaml12Dumper, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + ) + + +def is_simple_identifier(text): + """True if `text` is a bare lower-case SQL identifier needing no quoting.""" + return isinstance(text, str) and bool(_BARE_IDENTIFIER_RE.match(text.strip())) + + +def needs_quoting(name): + """True if `name` must be double-quoted to be a valid SQL identifier. + + Anything not already a bare lower-case identifier needs quoting because PostgreSQL + folds unquoted identifiers to lower case, and reserved keywords need it because they + are a syntax error when left bare. + """ + return not is_simple_identifier(name) or name.strip() in _RESERVED_WORDS + + +def quote_identifier(name, what): + """Render `name` as a SQL identifier, double-quoting it only when necessary.""" + if not isinstance(name, str) or not name.strip(): + raise ConversionError(f"{what}: identifier must be a non-empty string") + name = name.strip() + if "\x00" in name: + raise ConversionError(f"{what}: identifier contains a NUL byte") + if not needs_quoting(name): + return name + escaped = name.replace('"', '""') + return f'"{escaped}"' + + +def quote_literal(text, what): + """Render `text` as a SQL string literal for a COMMENT clause. + + Single quotes are doubled. No backslash escaping is applied: PostgreSQL (and + Hologres) default to `standard_conforming_strings = on`, where a backslash in a + plain literal is an ordinary character. + """ + if not isinstance(text, str): + raise ConversionError(f"{what}: comment must be a string, got {type(text).__name__}") + if "\x00" in text: + raise ConversionError(f"{what}: comment contains a NUL byte") + escaped = text.replace("'", "''") + return f"'{escaped}'" + + +def read_stash(obj): + """Return the HOLOGRES stash dict on an Apache Ossie object, or {} if absent. + + The `_v` version marker is stripped from the returned dict. + """ + for ext in (obj or {}).get("custom_extensions") or []: + if ext.get("vendor_name") == VENDOR: + try: + data = json.loads(ext.get("data") or "{}") + except json.JSONDecodeError as e: + raise ConversionError( + f"HOLOGRES custom_extensions data is not valid JSON: {e}" + ) from e + if not isinstance(data, dict): + raise ConversionError("HOLOGRES custom_extensions data must be a JSON object") + data.pop("_v", None) + return data + return {} + + +def write_stash(obj, data): + """Attach a HOLOGRES `custom_extensions` entry holding `data` (a dict). + + No-op when `data` is empty, so hand-authored Apache Ossie stays clean. Merges into + an existing HOLOGRES entry if one is already present. + """ + if not data: + return + payload = {"_v": STASH_VERSION} + payload.update(data) + blob = json.dumps(payload) + exts = obj.setdefault("custom_extensions", []) + for ext in exts: + if ext.get("vendor_name") == VENDOR: + ext["data"] = blob + return + exts.append({"vendor_name": VENDOR, "data": blob}) + + +def foreign_vendor_extensions(obj): + """Return non-HOLOGRES custom_extensions (dropped on export, with a warning).""" + return [ + ext + for ext in (obj or {}).get("custom_extensions") or [] + if ext.get("vendor_name") != VENDOR + ] + + +def pick_expression(ossie_expression): + """Choose the SQL string for an Apache Ossie expression. + + Returns None if there is no ANSI_SQL dialect, so the caller can raise with the name + of the offending field or metric. + """ + dialects = { + d.get("dialect"): d.get("expression") + for d in (ossie_expression or {}).get("dialects") or [] + } + expr = dialects.get(DIALECT_ANSI) + if expr is not None and not isinstance(expr, str): + raise ConversionError(f"expression must be a string, got {type(expr).__name__}") + return expr + + +def ossie_expression(text, dialect): + """Build an Apache Ossie `expression` block holding a single dialect.""" + return {"dialects": [{"dialect": dialect, "expression": text}]} + + +def synonyms_of(ai_context): + """Extract the synonyms list from an Apache Ossie ai_context (object form only).""" + if isinstance(ai_context, dict): + return list(ai_context.get("synonyms") or []) + return [] + + +def merge_description(description, ai_context): + """Fold a string-form ai_context into a description. + + The Apache Ossie schema allows ai_context to be either a string or an object. A + string has no Semantic View home of its own, so it is appended to the description + (which maps to COMMENT). Object-form ai_context has no COMMENT equivalent at all and + is reported as dropped by the caller. + """ + if isinstance(ai_context, str) and ai_context.strip(): + return f"{description}\n{ai_context}" if description else ai_context + return description + + +def parse_expression(text, what): + """Parse a SQL expression with the postgres dialect, or raise ConversionError. + + Never let an unparseable expression through: the export path writes DDL straight to + a database, so an expression we cannot understand must not be emitted verbatim. + """ + if not isinstance(text, str) or not text.strip(): + raise ConversionError(f"{what}: expression must be a non-empty string") + try: + node = sqlglot.parse_one(text, dialect=SQLGLOT_DIALECT) + except Exception as e: # sqlglot raises ParseError/TokenError, both non-public + raise ConversionError(f"{what}: cannot parse expression {text!r}: {e}") from e + if node is None: + raise ConversionError(f"{what}: cannot parse expression {text!r}") + return node + + +def render_expression(node): + """Serialize a sqlglot node back to Hologres-compatible SQL.""" + return node.sql(dialect=SQLGLOT_DIALECT) + + +# Top-level expression forms the CREATE SEMANTIC VIEW grammar accepts unparenthesised. +# Verified against Hologres 5.0.0: a bare operator at the top level of a definition is a +# syntax error there -- `a || b`, `a + 1` and `a::text` are all rejected while `(a || b)`, +# `(a + 1)` and `cast(a as text)` are accepted -- even though the same operators are fine +# inside a function call's argument list. +_BARE_DEFINITION_FORMS = ( + exp.Column, + exp.Literal, + exp.Boolean, + exp.Null, + exp.Func, + exp.Case, + exp.Paren, +) + + +def render_definition(node): + """Render an expression for a DIMENSIONS or METRICS definition clause. + + Adds the parentheses the Hologres DDL grammar requires around a top-level operator, + and leaves everything else alone so ordinary definitions stay readable. + """ + sql = render_expression(node) + if isinstance(node, _BARE_DEFINITION_FORMS): + return sql + return f"({sql})" + + +def normalize_expression(text, what="expression"): + """Round-trip an expression through sqlglot to get its canonical form. + + Conversion is normalization-stable, not byte-stable: sqlglot upper-cases function + names and rewrites `x::text` as `CAST(x AS TEXT)`. Comparisons in tests go through + this so they assert semantic equality rather than incidental formatting. + """ + return render_expression(parse_expression(text, what)) + + +def strip_parens(node): + """Unwrap redundant outer parentheses, so `(sum(x))` is treated as `sum(x)`.""" + while isinstance(node, exp.Paren): + node = node.this + return node + + +def column_refs(node): + """Return the (qualifier, column) pairs referenced by an expression. + + The qualifier is `""` for an unqualified column, matching sqlglot's `Column.table`. + """ + return [(col.table, col.name) for col in node.find_all(exp.Column)] + + +def assert_row_level(node, what): + """Reject expression shapes Hologres forbids in a definition. + + Hologres definitions are row-scope ASTs over a single physical alias. Volatile and + set-returning functions are also forbidden but are not structurally detectable here; + Hologres rejects those at CREATE SEMANTIC VIEW time. + """ + for kind, description in _NON_ROW_LEVEL.items(): + if isinstance(node, kind) or next(node.find_all(kind), None) is not None: + raise ConversionError( + f"{what}: Hologres definitions must be row-level expressions over a " + f"single table, but this contains {description}" + ) + + +def _apply_quoting(identifier): + """Force `quoted` on a sqlglot identifier that cannot be emitted bare. + + sqlglot's generator does not quote reserved words, so without this a column named + `user` on an alias named `order` renders as the invalid `order.user`. + """ + if identifier is not None and not identifier.args.get("quoted") and needs_quoting(identifier.name): + identifier.set("quoted", True) + + +def qualify_columns(node, alias, known_aliases, what): + """Qualify every column in `node` with `alias`, in place. + + Ossie field expressions are conventionally unqualified bare columns while Hologres + requires `alias.column`, so unqualified columns get `alias`. A column already + qualified with a *different* dataset is a cross-table reference, which Hologres + rejects, so it is an error here rather than invalid DDL later. + """ + for col in node.find_all(exp.Column): + qualifier = col.table + if not qualifier: + col.set("table", exp.to_identifier(alias, quoted=needs_quoting(alias))) + elif qualifier != alias: + if qualifier in known_aliases: + raise ConversionError( + f"{what}: references table '{qualifier}' but belongs to '{alias}'; " + f"Hologres definitions cannot span tables" + ) + raise ConversionError( + f"{what}: references unknown table '{qualifier}' " + f"(known tables: {', '.join(sorted(known_aliases))})" + ) + else: + _apply_quoting(col.args.get("table")) + _apply_quoting(col.this) + return node + + +def unqualify_columns(node, alias): + """Drop the `alias.` qualifier from every column in `node` that carries it, in place. + + The inverse of qualify_columns, so an imported Ossie field expression reads as a + plain column name the way hand-authored Ossie models do. + """ + for col in node.find_all(exp.Column): + if col.table == alias: + col.set("table", None) + return node + + +def metric_aggregate(node, what): + """Validate a metric expression and return (aggregate_name, aggregate_node). + + Hologres METRICS accept exactly one whitelisted aggregate applied to a row-level + expression over one table. Anything else -- a ratio, a sum of two aggregates, a + CASE around an aggregate, or a non-whitelisted aggregate -- has no Semantic View + form and is rejected here. + """ + root = strip_parens(node) + agg = METRIC_AGGREGATES.get(type(root)) + if agg is None: + raise ConversionError( + f"{what}: Hologres METRICS must be exactly one of " + f"count/sum/avg/min/max over a single table, but the expression is " + f"{render_expression(node)!r}. Derived and ratio metrics such as " + f"'SUM(a) / COUNT(*)' have no Semantic View form -- compute them in the " + f"query layer instead." + ) + inner = root.this + # `count(*)` has a Star argument and `count(DISTINCT x)` a Distinct wrapper; neither + # is a row-level expression to check, but their operands are. + if not isinstance(inner, exp.Star): + assert_row_level(inner, what) + return agg, root diff --git a/converters/hologres/src/ossie_hologres/cli.py b/converters/hologres/src/ossie_hologres/cli.py new file mode 100644 index 00000000..86e3ad9b --- /dev/null +++ b/converters/hologres/src/ossie_hologres/cli.py @@ -0,0 +1,130 @@ +# 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 Apache Ossie <-> Hologres Semantic View converter. + + ossie-hologres export -i model.yaml [-o view.sql] [--schema public] [--drop-if-exists] + ossie-hologres import -i model_yaml.yaml [-o model.yaml] [--name my_model] + +`export` converts an Apache Ossie semantic model into `CREATE SEMANTIC VIEW` DDL, ready +to execute against Hologres V5.0.0 or later. `import` converts the `model_yaml` that +Hologres publishes in `hologres.hg_semantic_view_properties` back into Apache Ossie. + +With no `-o`, the result is written to stdout. Conversions that drop information emit +warnings to stderr. +""" + +import argparse +import sys + +from ._common import ConversionError +from .ossie_to_semantic_view import convert_ossie_to_semantic_view +from .semantic_view_to_ossie import convert_semantic_view_to_ossie + + +def _metric_owner(value): + """Parse a `--metric-owner metric=dataset` pair.""" + name, sep, dataset = value.partition("=") + if not sep or not name.strip() or not dataset.strip(): + raise argparse.ArgumentTypeError( + f"expected 'metric=dataset', got {value!r}" + ) + return name.strip(), dataset.strip() + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="ossie-hologres", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command") + sub.required = True # set as attribute (the add_subparsers kwarg is 3.7+) + + exp = sub.add_parser("export", help="Apache Ossie semantic model -> Hologres DDL") + exp.add_argument("-i", "--input", required=True, help="Apache Ossie YAML file") + exp.add_argument("-o", "--output", help="output .sql file (default: stdout)") + exp.add_argument( + "-s", + "--schema", + help="schema for the view and for datasets whose source has none; " + "never overrides a schema written into a dataset source", + ) + exp.add_argument( + "-d", + "--database", + help="assert the database the dataset sources belong to", + ) + exp.add_argument( + "--drop-if-exists", + action="store_true", + help="prefix a DROP SEMANTIC VIEW IF EXISTS; Hologres has no CREATE OR REPLACE " + "or ALTER, so this is how a definition is changed", + ) + exp.add_argument( + "--metric-owner", + action="append", + type=_metric_owner, + metavar="METRIC=DATASET", + default=[], + help="name the table a metric belongs to, for metrics whose expression has no " + "qualified column to infer it from (such as COUNT(*)); repeatable", + ) + exp.add_argument( + "--skip-unsupported-metrics", + action="store_true", + help="warn about and skip metrics with no Semantic View form (derived and ratio " + "metrics) instead of failing", + ) + + imp = sub.add_parser("import", help="Hologres Semantic View model_yaml -> Apache Ossie") + imp.add_argument("-i", "--input", required=True, help="Hologres model_yaml file") + imp.add_argument("-o", "--output", help="output Apache Ossie YAML (default: stdout)") + imp.add_argument("--name", help="Apache Ossie model name (default: the view name)") + return parser + + +def main(argv=None): + args = _build_parser().parse_args(argv) + try: + with open(args.input, encoding="utf-8") as fh: + text = fh.read() + if args.command == "export": + out = convert_ossie_to_semantic_view( + text, + schema=args.schema, + database=args.database, + drop_if_exists=args.drop_if_exists, + metric_owners=dict(args.metric_owner), + skip_unsupported_metrics=args.skip_unsupported_metrics, + ) + else: + out = convert_semantic_view_to_ossie(text, model_name=args.name) + except (ConversionError, OSError, UnicodeError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + if args.output: + with open(args.output, "w", encoding="utf-8") as fh: + fh.write(out) + else: + sys.stdout.write(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py b/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py new file mode 100644 index 00000000..b1e174af --- /dev/null +++ b/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py @@ -0,0 +1,528 @@ +# 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. + +"""Export an Apache Ossie semantic model as Hologres `CREATE SEMANTIC VIEW` DDL. + +Hologres has no YAML import function, so DDL text is the only way to (re)create a +Semantic View. The DDL this module emits is meant to be executed as-is. + +The conversion is deliberately fail-closed. Hologres enforces real semantic +constraints -- single-table definitions, a five-function aggregate whitelist, and +`REFERENCES` targets that must be the referenced table's primary key -- and emitting +DDL that is already known to violate them, only to have the server reject it, is +strictly worse than raising here with the offending field named. +""" + +import warnings + +from ._common import ( + OSSIE_VERSION, + STASH_OWNER, + STASH_VIEW_SCHEMA, + ConversionError, + assert_row_level, + column_refs, + foreign_vendor_extensions, + load_yaml, + merge_description, + metric_aggregate, + pick_expression, + parse_expression, + qualify_columns, + quote_identifier, + quote_literal, + read_stash, + render_definition, + require_str, +) + + +def _warn(scope, msg): + warnings.warn(f"[{scope}] {msg}") + + +def convert_ossie_to_semantic_view( + ossie_yaml_str, + *, + schema=None, + database=None, + drop_if_exists=False, + metric_owners=None, + skip_unsupported_metrics=False, +): + """Parse Apache Ossie YAML and return Hologres `CREATE SEMANTIC VIEW` DDL (string). + + `schema` qualifies the view itself and supplies a default schema for datasets whose + `source` carries none; it never overrides a schema written into a `source`. + `database` asserts the database the model's sources belong to. + `drop_if_exists` prefixes a `DROP SEMANTIC VIEW IF EXISTS`, which is how a + definition is changed -- Hologres has no `CREATE OR REPLACE` or `ALTER`. + `metric_owners` maps a metric name to its owning dataset, for metrics whose + expression has no column reference to infer the owner from (`count(*)`). + `skip_unsupported_metrics` downgrades an unconvertible metric from an error to a + warning, so a model that is mostly expressible still converts. + """ + root = load_yaml(ossie_yaml_str) + if not isinstance(root, dict): + raise ConversionError("Invalid Apache Ossie YAML: expected a mapping at the root") + + version = str(root.get("version", "")) + if version != OSSIE_VERSION: + raise ConversionError( + f"Unsupported Apache Ossie version '{version}'. Supported: {OSSIE_VERSION}" + ) + + models = root.get("semantic_model") + if not isinstance(models, list) or not models: + raise ConversionError("'semantic_model' must be a non-empty list") + if len(models) > 1: + _warn("model", "multiple semantic models found; converting only the first") + + return _convert_model( + models[0], + schema=schema, + database=database, + drop_if_exists=drop_if_exists, + metric_owners=metric_owners or {}, + skip_unsupported_metrics=skip_unsupported_metrics, + ) + + +def _convert_model( + model, *, schema, database, drop_if_exists, metric_owners, skip_unsupported_metrics +): + name = require_str(model, "name", "semantic model") + dataset_list = model.get("datasets") or [] + if not dataset_list: + raise ConversionError(f"Model '{name}' has no datasets") + + datasets = {} + for entry in dataset_list: + ds_name = require_str(entry, "name", f"Model '{name}': dataset") + if ds_name in datasets: + raise ConversionError(f"Model '{name}': duplicate dataset name '{ds_name}'") + datasets[ds_name] = entry + aliases = set(datasets) + + relationships = model.get("relationships") or [] + sources = _resolve_sources(datasets, schema=schema, database=database) + primary_keys = _resolve_primary_keys(name, datasets, relationships) + + tables = [ + _render_table(alias, sources[alias], primary_keys.get(alias)) + for alias in datasets + ] + rels = [ + _render_relationship(rel, datasets, primary_keys, aliases) + for rel in relationships + ] + dimensions = _render_dimensions(datasets, aliases) + metrics = _render_metrics( + model, aliases, metric_owners, skip_unsupported_metrics + ) + + _warn_dropped(model, datasets, relationships, primary_keys) + + view_schema = schema or read_stash(model).get(STASH_VIEW_SCHEMA) + view_ref = _render_table_ref(view_schema, name, f"semantic model '{name}'") + + statements = [] + if drop_if_exists: + statements.append(f"DROP SEMANTIC VIEW IF EXISTS {view_ref};") + + clauses = [f"CREATE SEMANTIC VIEW {view_ref}", _clause("TABLES", tables)] + if rels: + clauses.append(_clause("RELATIONSHIPS", rels)) + if dimensions: + clauses.append(_clause("DIMENSIONS", dimensions)) + if metrics: + clauses.append(_clause("METRICS", metrics)) + + comment = merge_description(model.get("description"), model.get("ai_context")) + if comment: + clauses.append(f" COMMENT = {quote_literal(comment, f'model {name!r}')}") + + statements.append("\n".join(clauses) + ";") + return "\n".join(statements) + "\n" + + +def _clause(keyword, items): + body = ",\n".join(f" {item}" for item in items) + return f" {keyword} (\n{body}\n )" + + +# --- tables and sources ----------------------------------------------------------- + + +def _parse_source(source, what): + """Split an Ossie `source` into (database, schema, table). + + Accepts `database.schema.table`, `schema.table`, or `table`. The missing leading + parts come back as None rather than being guessed at. + """ + if not isinstance(source, str) or not source.strip(): + raise ConversionError(f"{what}: missing or empty 'source'") + parts = [p.strip() for p in source.strip().split(".")] + if any(not p or any(ch.isspace() for ch in p) for p in parts): + raise ConversionError( + f"{what}: source '{source}' has an empty or whitespace-containing part" + ) + if len(parts) == 3: + return parts[0], parts[1], parts[2] + if len(parts) == 2: + return None, parts[0], parts[1] + if len(parts) == 1: + return None, None, parts[0] + raise ConversionError( + f"{what}: source '{source}' must be 'database.schema.table', " + f"'schema.table', or 'table'" + ) + + +def _resolve_sources(datasets, *, schema, database): + """Map each dataset to its (schema, table) pair, checking they share one database. + + A Semantic View cannot reach across databases, so a model whose datasets name + different ones has no Hologres form. The database component itself is not emitted: + the DDL addresses tables as `[schema.]table` within the connected database. + """ + resolved = {} + databases = {} + for alias, dataset in datasets.items(): + what = f"dataset '{alias}'" + db, ds_schema, table = _parse_source(dataset.get("source"), what) + if db is not None: + databases[alias] = db + resolved[alias] = (ds_schema or schema, table) + + distinct = set(databases.values()) + if len(distinct) > 1: + detail = ", ".join(f"{a} -> {d}" for a, d in sorted(databases.items())) + raise ConversionError( + f"A Semantic View cannot span multiple databases, but the datasets name " + f"{len(distinct)}: {detail}" + ) + if database is not None and distinct and distinct != {database}: + raise ConversionError( + f"Requested database '{database}' does not match the database named by the " + f"dataset sources ('{next(iter(distinct))}')" + ) + return resolved + + +def _render_table_ref(schema, table, what): + quoted = quote_identifier(table, what) + if schema: + return f"{quote_identifier(schema, what)}.{quoted}" + return quoted + + +def _render_table(alias, source, primary_key): + what = f"dataset '{alias}'" + ref = _render_table_ref(source[0], source[1], what) + rendered = f"{quote_identifier(alias, what)} AS {ref}" + if primary_key: + cols = ", ".join(quote_identifier(c, what) for c in primary_key) + rendered += f" PRIMARY KEY ({cols})" + return rendered + + +# --- primary keys and relationships ----------------------------------------------- + + +def _resolve_primary_keys(model_name, datasets, relationships): + """Determine the PRIMARY KEY to declare for each dataset. + + Hologres requires a `REFERENCES` target to be the referenced table's declared + primary key, and the design relies on that key to de-duplicate metric owners that a + join fans out. A dataset that is never referenced may omit its key entirely. + """ + referenced = {} + for rel in relationships: + rel_name = rel.get("name", "") + to = require_str(rel, "to", f"relationship '{rel_name}'") + if to not in datasets: + raise ConversionError( + f"Relationship '{rel_name}' references unknown dataset '{to}'" + ) + referenced.setdefault(to, []).append(rel) + + keys = {} + for alias, dataset in datasets.items(): + pk = dataset.get("primary_key") or None + if pk: + keys[alias] = list(pk) + continue + if alias not in referenced: + continue + + # No primary_key, but something references it. A unique_keys entry matching the + # incoming foreign key is an equivalent guarantee, so promote it rather than + # refusing a model that does carry the needed uniqueness. + wanted = {tuple(sorted(r.get("to_columns") or [])) for r in referenced[alias]} + promoted = next( + ( + list(uk) + for uk in dataset.get("unique_keys") or [] + if tuple(sorted(uk)) in wanted + ), + None, + ) + if promoted is None: + rel_names = ", ".join(r.get("name", "") for r in referenced[alias]) + raise ConversionError( + f"Model '{model_name}': dataset '{alias}' is referenced by relationship(s) " + f"{rel_names} but declares no 'primary_key'. Hologres requires a PRIMARY KEY " + f"on the referenced side; correct metric aggregation over fan-out joins " + f"depends on it." + ) + _warn( + f"dataset '{alias}'", + f"no primary_key; promoting unique_keys entry {promoted} to PRIMARY KEY " + f"because a relationship references those columns", + ) + keys[alias] = promoted + return keys + + +def _render_relationship(rel, datasets, primary_keys, aliases): + rel_name = require_str(rel, "name", "relationship") + what = f"relationship '{rel_name}'" + from_ds = require_str(rel, "from", what) + to_ds = require_str(rel, "to", what) + for side, ds in (("from", from_ds), ("to", to_ds)): + if ds not in aliases: + raise ConversionError(f"{what}: '{side}' names unknown dataset '{ds}'") + + from_cols = list(rel.get("from_columns") or []) + to_cols = list(rel.get("to_columns") or []) + if not from_cols or not to_cols: + raise ConversionError(f"{what}: 'from_columns' and 'to_columns' must be non-empty") + if len(from_cols) != len(to_cols): + raise ConversionError( + f"{what}: 'from_columns' ({len(from_cols)}) and 'to_columns' " + f"({len(to_cols)}) must have the same number of columns" + ) + + pk = primary_keys.get(to_ds) or [] + if set(to_cols) != set(pk): + raise ConversionError( + f"{what}: Hologres requires the REFERENCES target to be the primary key of " + f"'{to_ds}', but to_columns={to_cols} and its primary key is {pk}" + ) + + # Hologres pairs the two column lists positionally, so order to_columns as the + # primary key is declared and move from_columns with it. + order = [to_cols.index(col) for col in pk] + from_cols = [from_cols[i] for i in order] + to_cols = list(pk) + + from_list = ", ".join(quote_identifier(c, what) for c in from_cols) + to_list = ", ".join(quote_identifier(c, what) for c in to_cols) + return ( + f"{quote_identifier(rel_name, what)} AS " + f"{quote_identifier(from_ds, what)}({from_list}) REFERENCES " + f"{quote_identifier(to_ds, what)}({to_list})" + ) + + +# --- dimensions and metrics ------------------------------------------------------- + + +def _render_dimensions(datasets, aliases): + """Render every dataset field as a DIMENSIONS entry. + + Semantic View queries reference dimensions by bare name (`GROUP BY city_dim`), so a + name must be unique across the whole view. Ossie only requires field names to be + unique within a dataset, so a collision is possible and is an error: a dimension + name is the user-facing query API, and silently renaming it would break queries + written against the model. + """ + rendered = [] + owner_of = {} + for alias, dataset in datasets.items(): + for field in dataset.get("fields") or []: + what = f"dataset '{alias}' field '{field.get('name', '')}'" + field_name = require_str(field, "name", what) + if field_name in owner_of: + raise ConversionError( + f"Dimension name '{field_name}' is defined by both dataset " + f"'{owner_of[field_name]}' and dataset '{alias}'. Semantic View " + f"queries reference dimensions by bare name, so names must be " + f"unique across the whole view." + ) + owner_of[field_name] = alias + + expr_text = pick_expression(field.get("expression")) + if expr_text is None: + raise ConversionError( + f"{what}: no ANSI_SQL expression dialect available" + ) + node = parse_expression(expr_text, what) + assert_row_level(node, what) + qualify_columns(node, alias, aliases, what) + + entry = ( + f"{quote_identifier(alias, what)}.{quote_identifier(field_name, what)} " + f"AS {render_definition(node)}" + ) + comment = merge_description(field.get("description"), field.get("ai_context")) + if comment: + entry += f" COMMENT = {quote_literal(comment, what)}" + rendered.append(entry) + return rendered + + +def _metric_owner(metric_name, node, aliases, override, what): + """Determine which table a metric belongs to. + + Hologres namespaces a metric under an owning alias, and for anything other than + `count(*)` that alias must be the table the aggregate reads. So the expression's own + column references are authoritative; an override only fills in the cases where the + expression cannot say. + """ + qualifiers = {table for table, _ in column_refs(node) if table} + unknown = qualifiers - aliases + if unknown: + raise ConversionError( + f"{what}: references unknown table(s) {', '.join(sorted(unknown))} " + f"(known tables: {', '.join(sorted(aliases))})" + ) + if len(qualifiers) > 1: + raise ConversionError( + f"{what}: reads {len(qualifiers)} tables ({', '.join(sorted(qualifiers))}); " + f"Hologres metrics must aggregate over a single table" + ) + + inferred = next(iter(qualifiers), None) + if inferred and override and override != inferred: + raise ConversionError( + f"{what}: declared owner '{override}' contradicts the expression, which " + f"reads table '{inferred}'" + ) + owner = inferred or override + if owner is None: + raise ConversionError( + f"{what}: cannot tell which table this metric belongs to, because the " + f"expression references no qualified column. Name the owning dataset in a " + f"HOLOGRES custom_extensions entry ({{\"{STASH_OWNER}\": \"\"}}) " + f"or pass --metric-owner {metric_name}=." + ) + if owner not in aliases: + raise ConversionError( + f"{what}: declared owner '{owner}' is not a dataset in this model " + f"(known tables: {', '.join(sorted(aliases))})" + ) + return owner + + +def _render_metrics(model, aliases, metric_owners, skip_unsupported): + rendered = [] + for metric in model.get("metrics") or []: + metric_name = require_str(metric, "name", "metric") + what = f"metric '{metric_name}'" + try: + expr_text = pick_expression(metric.get("expression")) + if expr_text is None: + raise ConversionError( + f"{what}: no ANSI_SQL expression dialect available" + ) + node = parse_expression(expr_text, what) + metric_aggregate(node, what) + override = metric_owners.get(metric_name) or read_stash(metric).get(STASH_OWNER) + owner = _metric_owner(metric_name, node, aliases, override, what) + qualify_columns(node, owner, aliases, what) + except ConversionError as e: + if not skip_unsupported: + raise + _warn(what, f"skipped: {e}") + continue + + entry = ( + f"{quote_identifier(owner, what)}.{quote_identifier(metric_name, what)} " + f"AS {render_definition(node)}" + ) + comment = merge_description(metric.get("description"), metric.get("ai_context")) + if comment: + entry += f" COMMENT = {quote_literal(comment, what)}" + rendered.append(entry) + return rendered + + +# --- fidelity reporting ----------------------------------------------------------- + + +def _warn_dropped(model, datasets, relationships, primary_keys): + """Report Ossie metadata that a Semantic View has nowhere to keep. + + Hologres offers exactly one annotation slot -- COMMENT, on the view, each dimension + and each metric. Everything else is named individually here rather than dropped + silently, so a round trip's losses are visible. + """ + if foreign_vendor_extensions(model): + _warn("model", "foreign-vendor custom_extensions dropped") + if isinstance(model.get("ai_context"), dict): + _warn( + "model", + "model-level ai_context (object) dropped; Hologres has no synonyms or " + "instructions surface, only COMMENT", + ) + + for alias, dataset in datasets.items(): + scope = f"dataset '{alias}'" + if dataset.get("description"): + _warn(scope, "dataset description dropped (Semantic View TABLES take no COMMENT)") + if isinstance(dataset.get("ai_context"), dict): + _warn(scope, "dataset-level ai_context (object) dropped") + if foreign_vendor_extensions(dataset): + _warn(scope, "foreign-vendor custom_extensions dropped") + extra_keys = [ + uk + for uk in dataset.get("unique_keys") or [] + if list(uk) != primary_keys.get(alias) + ] + if extra_keys: + _warn(scope, f"unique_keys {extra_keys} dropped (only PRIMARY KEY is supported)") + + for field in dataset.get("fields") or []: + fscope = f"{scope} field '{field.get('name', '')}'" + if field.get("datatype"): + _warn(fscope, "datatype dropped (Semantic View dimensions are untyped)") + if field.get("label"): + _warn(fscope, "label dropped") + if (field.get("dimension") or {}).get("is_time") is not None: + _warn(fscope, "dimension.is_time has no Semantic View counterpart; dropped") + if isinstance(field.get("ai_context"), dict): + _warn(fscope, "field-level ai_context (object) dropped") + if foreign_vendor_extensions(field): + _warn(fscope, "foreign-vendor custom_extensions dropped") + + for metric in model.get("metrics") or []: + mscope = f"metric '{metric.get('name', '')}'" + if metric.get("datatype"): + _warn(mscope, "datatype dropped (Semantic View metrics are untyped)") + if isinstance(metric.get("ai_context"), dict): + _warn(mscope, "metric-level ai_context (object) dropped") + if foreign_vendor_extensions(metric): + _warn(mscope, "foreign-vendor custom_extensions dropped") + + for rel in relationships: + rscope = f"relationship '{rel.get('name', '')}'" + if rel.get("ai_context"): + _warn(rscope, "relationship ai_context dropped") + if foreign_vendor_extensions(rel): + _warn(rscope, "foreign-vendor custom_extensions dropped") diff --git a/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py new file mode 100644 index 00000000..f38ed414 --- /dev/null +++ b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py @@ -0,0 +1,225 @@ +# 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 a Hologres Semantic View into an Apache Ossie semantic model. + +The input is the `model_yaml` that Hologres publishes for every Semantic View in the +`hologres.hg_semantic_view_properties` system table: + + SELECT property_value + FROM hologres.hg_semantic_view_properties + WHERE schema_name = current_schema() + AND view_name = 'sales_sv' + AND property_key = 'model_yaml'; + +`model_yaml` is preferred over the `ddl_text` that sits beside it: it is already +structured, so importing it needs no SQL statement parser. + +A Hologres table alias becomes the Ossie dataset name. The alias is what the view's +dimensions, metrics and relationships all reference, so using it keeps those references +valid without rewriting, and it makes Hologres' `sum(o.amount)` already correct as an +Ossie dataset-qualified metric expression. +""" + +from ._common import ( + DIALECT_ANSI, + OSSIE_VERSION, + STASH_OWNER, + ConversionError, + column_refs, + dump_yaml, + load_yaml, + ossie_expression, + parse_expression, + render_expression, + require_str, + strip_parens, + unqualify_columns, + write_stash, +) + +# The only relationship type Hologres records. Ossie encodes the same thing in the +# direction of a relationship -- `from` is the many side, `to` is the one side -- so the +# type needs no home of its own and is not stashed. +_MANY_TO_ONE = "many_to_one" + + +def convert_semantic_view_to_ossie(model_yaml_str, *, model_name=None): + """Parse a Hologres Semantic View `model_yaml` and return Apache Ossie YAML (string). + + `model_name` overrides the Ossie semantic model name, which otherwise comes from the + view name. + """ + view = load_yaml(model_yaml_str) + if not isinstance(view, dict): + raise ConversionError( + "Invalid Hologres model_yaml: expected a mapping at the root" + ) + + name = model_name or require_str(view, "name", "semantic view") + tables = view.get("tables") + if not isinstance(tables, list) or not tables: + raise ConversionError(f"Semantic view '{name}': 'tables' must be a non-empty list") + + datasets = [] + metrics = [] + aliases = set() + for table in tables: + alias = require_str(table, "name", f"semantic view '{name}': table") + if alias in aliases: + raise ConversionError(f"Semantic view '{name}': duplicate table alias '{alias}'") + aliases.add(alias) + datasets.append(_convert_table(table, alias, name)) + metrics.extend(_convert_metrics(table, alias, name)) + + model = {"name": name} + description = view.get("description") + if description: + model["description"] = description + model["datasets"] = datasets + + relationships = _convert_relationships(view, aliases, name) + if relationships: + model["relationships"] = relationships + if metrics: + model["metrics"] = metrics + + return dump_yaml({"version": OSSIE_VERSION, "semantic_model": [model]}) + + +def _convert_table(table, alias, view_name): + what = f"semantic view '{view_name}': table '{alias}'" + dataset = {"name": alias, "source": _convert_source(table, what)} + + primary_key = (table.get("primary_key") or {}).get("columns") + if primary_key: + dataset["primary_key"] = list(primary_key) + + fields = [ + _convert_dimension(dim, alias, what) for dim in table.get("dimensions") or [] + ] + if fields: + dataset["fields"] = fields + return dataset + + +def _convert_source(table, what): + """Rebuild a three-part Ossie `source` from the table's `base_table` block.""" + base = table.get("base_table") + if not isinstance(base, dict): + raise ConversionError(f"{what} is missing required 'base_table'") + table_name = require_str(base, "table", f"{what} base_table") + parts = [base.get("database"), base.get("schema"), table_name] + return ".".join(str(p) for p in parts if p) + + +def _convert_dimension(dim, alias, table_what): + name = require_str(dim, "name", f"{table_what}: dimension") + what = f"{table_what}: dimension '{name}'" + expr_text = require_str(dim, "expr", what) + + # Hologres always writes dimension expressions alias-qualified (`o.region`). Ossie + # field expressions are conventionally bare column names, so drop the owning alias. + # The outer parentheses the export direction adds to satisfy the DDL grammar come + # back in model_yaml, so drop those too rather than accumulating them. + node = unqualify_columns(strip_parens(parse_expression(expr_text, what)), alias) + + field = {"name": name, "expression": _expression_for(node)} + description = dim.get("description") + if description: + field["description"] = description + return field + + +def _convert_metrics(table, alias, view_name): + """Lift a table's metrics to the model level, where Ossie keeps them. + + Hologres namespaces a metric under its owning alias while Ossie metric names are + model-global. That is not a narrowing: a Semantic View query references a metric by + bare name, so the names are already unique across the view. + """ + metrics = [] + for entry in table.get("metrics") or []: + name = require_str(entry, "name", f"semantic view '{view_name}': metric") + what = f"semantic view '{view_name}': metric '{name}'" + expr_text = require_str(entry, "expr", what) + node = parse_expression(expr_text, what) + metric = {"name": name, "expression": _expression_for(node)} + + description = entry.get("description") + if description: + metric["description"] = description + + # `count(*)` names no column, so the owning table cannot be recovered from the + # expression on the way back out. Record it, and only then -- an owner that the + # expression already implies would just be noise. + if not column_refs(node): + write_stash(metric, {STASH_OWNER: alias}) + metrics.append(metric) + return metrics + + +def _expression_for(node): + """Wrap a rendered expression in an Apache Ossie expression block. + + Everything is labelled ANSI_SQL. Hologres is PostgreSQL-compatible and the portable + spelling is nearly always available -- `CAST(x AS TEXT)` rather than `x::text`, which + is what sqlglot normalizes to anyway -- so a vendor dialect label would buy very + little. For the PostgreSQL-only syntax that does remain, such as `j -> 'k'`, a vendor + label would actively hurt: a converter looking for an ANSI_SQL expression and finding + none drops the field, whereas an inaccurate ANSI_SQL label at worst surfaces as a SQL + error on the target engine. Deciding this per expression was also tried and abandoned; + sqlglot's default dialect is not ANSI SQL, so any such test mislabels both ways. + """ + return ossie_expression(render_expression(node), DIALECT_ANSI) + + +def _convert_relationships(view, aliases, view_name): + relationships = [] + for rel in view.get("relationships") or []: + name = require_str(rel, "name", f"semantic view '{view_name}': relationship") + what = f"semantic view '{view_name}': relationship '{name}'" + + rel_type = rel.get("relationship_type", _MANY_TO_ONE) + if rel_type != _MANY_TO_ONE: + raise ConversionError( + f"{what}: unsupported relationship_type '{rel_type}'; Hologres Semantic " + f"Views define only '{_MANY_TO_ONE}'" + ) + + # Hologres' left/right is Ossie's from/to: left is the many side that holds the + # foreign key, right is the one side holding the primary key. + from_ds = require_str(rel, "left_table", what) + to_ds = require_str(rel, "right_table", what) + for label, ds in (("left_table", from_ds), ("right_table", to_ds)): + if ds not in aliases: + raise ConversionError(f"{what}: {label} '{ds}' is not a table in this view") + + pairs = rel.get("relationship_columns") + if not isinstance(pairs, list) or not pairs: + raise ConversionError(f"{what}: 'relationship_columns' must be a non-empty list") + + relationships.append( + { + "name": name, + "from": from_ds, + "to": to_ds, + "from_columns": [require_str(p, "left_column", what) for p in pairs], + "to_columns": [require_str(p, "right_column", what) for p in pairs], + } + ) + return relationships diff --git a/converters/hologres/tests/_util.py b/converters/hologres/tests/_util.py new file mode 100644 index 00000000..bbdee0b3 --- /dev/null +++ b/converters/hologres/tests/_util.py @@ -0,0 +1,37 @@ +# 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. + +"""Shared test helpers.""" + +import pathlib + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" + +# The canonical example models live at the repository root, not inside this converter. +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +EXAMPLES = REPO_ROOT / "examples" +VALIDATOR = REPO_ROOT / "validation" / "validate.py" +SCHEMA = REPO_ROOT / "core-spec" / "osi-schema.json" + + +def read_fixture(name): + return (FIXTURES / name).read_text(encoding="utf-8") + + +def ossie_doc(model): + """Wrap a single semantic model in a minimal valid Apache Ossie document.""" + return {"version": "0.2.0.dev0", "semantic_model": [model]} diff --git a/converters/hologres/tests/conftest.py b/converters/hologres/tests/conftest.py new file mode 100644 index 00000000..0bcde537 --- /dev/null +++ b/converters/hologres/tests/conftest.py @@ -0,0 +1,23 @@ +# 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 pathlib +import sys + +# Make the converter modules in ../src importable from the tests. +_SRC = pathlib.Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(_SRC)) diff --git a/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml b/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml new file mode 100644 index 00000000..dd39c814 --- /dev/null +++ b/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml @@ -0,0 +1,24 @@ +name: svacc_order_sv +description: Single-table order analysis +tables: +- name: o + base_table: + database: retail + schema: public + table: svacc_orders + primary_key: + columns: + - order_id + dimensions: + - name: region_dim + expr: o.region + - name: status_dim + expr: o.status + description: "The order's current status" + metrics: + - name: total_revenue + expr: sum(o.amount) + description: Total revenue + - name: order_count + expr: "count(*)" + diff --git a/converters/hologres/tests/fixtures/fixtureA_ossie.yaml b/converters/hologres/tests/fixtures/fixtureA_ossie.yaml new file mode 100644 index 00000000..db8aaf3f --- /dev/null +++ b/converters/hologres/tests/fixtures/fixtureA_ossie.yaml @@ -0,0 +1,35 @@ +version: 0.2.0.dev0 +semantic_model: + - name: svacc_order_sv + description: Single-table order analysis + datasets: + - name: o + source: retail.public.svacc_orders + primary_key: [order_id] + fields: + - name: region_dim + expression: + dialects: + - dialect: ANSI_SQL + expression: region + - name: status_dim + expression: + dialects: + - dialect: ANSI_SQL + expression: status + description: The order's current status + metrics: + - name: total_revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(o.amount) + description: Total revenue + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: COUNT(*) + custom_extensions: + - vendor_name: HOLOGRES + data: '{"_v": 1, "owner": "o"}' diff --git a/converters/hologres/tests/fixtures/fixtureA_semantic_view.sql b/converters/hologres/tests/fixtures/fixtureA_semantic_view.sql new file mode 100644 index 00000000..4850ae12 --- /dev/null +++ b/converters/hologres/tests/fixtures/fixtureA_semantic_view.sql @@ -0,0 +1,13 @@ +CREATE SEMANTIC VIEW svacc_order_sv + TABLES ( + o AS public.svacc_orders PRIMARY KEY (order_id) + ) + DIMENSIONS ( + o.region_dim AS o.region, + o.status_dim AS o.status COMMENT = 'The order''s current status' + ) + METRICS ( + o.total_revenue AS SUM(o.amount) COMMENT = 'Total revenue', + o.order_count AS COUNT(*) + ) + COMMENT = 'Single-table order analysis'; diff --git a/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml b/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml new file mode 100644 index 00000000..bf471a4b --- /dev/null +++ b/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml @@ -0,0 +1,65 @@ +name: svacc_sales_sv +description: 销售分析语义视图 +tables: +- name: o + base_table: + database: retail + schema: public + table: svacc_orders + primary_key: + columns: + - order_id + dimensions: + - name: region_dim + expr: o.region + - name: status_dim + expr: o.status + metrics: + - name: total + expr: sum(o.amount) + - name: order_count + expr: "count(*)" +- name: c + base_table: + database: retail + schema: public + table: svacc_customers + primary_key: + columns: + - customer_id + dimensions: + - name: city_dim + expr: c.city + description: 客户城市 + metrics: + - name: credit + expr: sum(c.credit_limit) + - name: avg_credit + expr: avg(c.credit_limit) +- name: i + base_table: + database: retail + schema: public + table: svacc_order_items + primary_key: + columns: + - item_id + metrics: + - name: item_qty + expr: sum(i.quantity) +relationships: +- name: rel_oc + left_table: o + right_table: c + relationship_columns: + - left_column: customer_id + right_column: customer_id + relationship_type: many_to_one +- name: rel_io + left_table: i + right_table: o + relationship_columns: + - left_column: order_id + right_column: order_id + relationship_type: many_to_one + diff --git a/converters/hologres/tests/fixtures/fixtureB_ossie.yaml b/converters/hologres/tests/fixtures/fixtureB_ossie.yaml new file mode 100644 index 00000000..c5d9ec45 --- /dev/null +++ b/converters/hologres/tests/fixtures/fixtureB_ossie.yaml @@ -0,0 +1,72 @@ +version: 0.2.0.dev0 +semantic_model: + - name: svacc_sales_sv + description: 销售分析语义视图 + datasets: + - name: o + source: retail.public.svacc_orders + primary_key: [order_id] + fields: + - name: region_dim + expression: + dialects: + - dialect: ANSI_SQL + expression: region + - name: status_dim + expression: + dialects: + - dialect: ANSI_SQL + expression: status + - name: c + source: retail.public.svacc_customers + primary_key: [customer_id] + fields: + - name: city_dim + expression: + dialects: + - dialect: ANSI_SQL + expression: city + description: 客户城市 + - name: i + source: retail.public.svacc_order_items + primary_key: [item_id] + relationships: + - name: rel_oc + from: o + to: c + from_columns: [customer_id] + to_columns: [customer_id] + - name: rel_io + from: i + to: o + from_columns: [order_id] + to_columns: [order_id] + metrics: + - name: total + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(o.amount) + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: COUNT(*) + custom_extensions: + - vendor_name: HOLOGRES + data: '{"_v": 1, "owner": "o"}' + - name: credit + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(c.credit_limit) + - name: avg_credit + expression: + dialects: + - dialect: ANSI_SQL + expression: AVG(c.credit_limit) + - name: item_qty + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(i.quantity) diff --git a/converters/hologres/tests/fixtures/fixtureB_semantic_view.sql b/converters/hologres/tests/fixtures/fixtureB_semantic_view.sql new file mode 100644 index 00000000..51aaaace --- /dev/null +++ b/converters/hologres/tests/fixtures/fixtureB_semantic_view.sql @@ -0,0 +1,23 @@ +CREATE SEMANTIC VIEW svacc_sales_sv + TABLES ( + o AS public.svacc_orders PRIMARY KEY (order_id), + c AS public.svacc_customers PRIMARY KEY (customer_id), + i AS public.svacc_order_items PRIMARY KEY (item_id) + ) + RELATIONSHIPS ( + rel_oc AS o(customer_id) REFERENCES c(customer_id), + rel_io AS i(order_id) REFERENCES o(order_id) + ) + DIMENSIONS ( + o.region_dim AS o.region, + o.status_dim AS o.status, + c.city_dim AS c.city COMMENT = '客户城市' + ) + METRICS ( + o.total AS SUM(o.amount), + o.order_count AS COUNT(*), + c.credit AS SUM(c.credit_limit), + c.avg_credit AS AVG(c.credit_limit), + i.item_qty AS SUM(i.quantity) + ) + COMMENT = '销售分析语义视图'; diff --git a/converters/hologres/tests/fixtures/tpcds_semantic_view.sql b/converters/hologres/tests/fixtures/tpcds_semantic_view.sql new file mode 100644 index 00000000..04a7e749 --- /dev/null +++ b/converters/hologres/tests/fixtures/tpcds_semantic_view.sql @@ -0,0 +1,53 @@ +CREATE SEMANTIC VIEW tpcds_retail_model + TABLES ( + store_sales AS public.store_sales PRIMARY KEY (ss_item_sk, ss_ticket_number), + date_dim AS public.date_dim PRIMARY KEY (d_date_sk), + customer AS public.customer PRIMARY KEY (c_customer_sk), + item AS public.item PRIMARY KEY (i_item_sk), + store AS public.store PRIMARY KEY (s_store_sk) + ) + RELATIONSHIPS ( + store_sales_to_date AS store_sales(ss_sold_date_sk) REFERENCES date_dim(d_date_sk), + store_sales_to_customer AS store_sales(ss_customer_sk) REFERENCES customer(c_customer_sk), + store_sales_to_item AS store_sales(ss_item_sk) REFERENCES item(i_item_sk), + store_sales_to_store AS store_sales(ss_store_sk) REFERENCES store(s_store_sk) + ) + DIMENSIONS ( + store_sales.ss_sold_date_sk AS store_sales.ss_sold_date_sk COMMENT = 'Foreign key to date dimension', + store_sales.ss_item_sk AS store_sales.ss_item_sk COMMENT = 'Foreign key to item dimension', + store_sales.ss_customer_sk AS store_sales.ss_customer_sk COMMENT = 'Foreign key to customer dimension', + store_sales.ss_store_sk AS store_sales.ss_store_sk COMMENT = 'Foreign key to store dimension', + store_sales.ss_quantity AS store_sales.ss_quantity COMMENT = 'Quantity of items sold', + store_sales.ss_sales_price AS store_sales.ss_sales_price COMMENT = 'Sales price per unit', + store_sales.ss_ext_sales_price AS store_sales.ss_ext_sales_price COMMENT = 'Extended sales price (quantity * price)', + store_sales.ss_net_profit AS store_sales.ss_net_profit COMMENT = 'Net profit from the sale', + date_dim.d_date_sk AS date_dim.d_date_sk COMMENT = 'Surrogate key for date', + date_dim.d_date AS date_dim.d_date COMMENT = 'Actual date value', + date_dim.d_year AS date_dim.d_year COMMENT = 'Year', + date_dim.d_quarter_name AS date_dim.d_quarter_name COMMENT = 'Quarter name (e.g., 2024Q1)', + date_dim.d_month_name AS date_dim.d_month_name COMMENT = 'Month name', + customer.c_customer_sk AS customer.c_customer_sk COMMENT = 'Surrogate key for customer', + customer.c_customer_id AS customer.c_customer_id COMMENT = 'Business key for customer', + customer.c_first_name AS customer.c_first_name COMMENT = 'Customer first name', + customer.c_last_name AS customer.c_last_name COMMENT = 'Customer last name', + customer.customer_full_name AS (customer.c_first_name || ' ' || customer.c_last_name) COMMENT = 'Customer full name (computed field)', + customer.c_email_address AS customer.c_email_address COMMENT = 'Customer email address', + item.i_item_sk AS item.i_item_sk COMMENT = 'Surrogate key for item', + item.i_item_id AS item.i_item_id COMMENT = 'Business key for item', + item.i_item_desc AS item.i_item_desc COMMENT = 'Item description', + item.i_brand AS item.i_brand COMMENT = 'Brand name', + item.i_category AS item.i_category COMMENT = 'Item category', + item.i_current_price AS item.i_current_price COMMENT = 'Current price of the item', + store.s_store_sk AS store.s_store_sk COMMENT = 'Surrogate key for store', + store.s_store_id AS store.s_store_id COMMENT = 'Business key for store', + store.s_store_name AS store.s_store_name COMMENT = 'Store name', + store.s_city AS store.s_city COMMENT = 'City where store is located', + store.s_state AS store.s_state COMMENT = 'State where store is located', + store.s_number_employees AS store.s_number_employees COMMENT = 'Number of employees at the store' + ) + METRICS ( + store_sales.total_sales AS SUM(store_sales.ss_ext_sales_price) COMMENT = 'Total sales revenue across all transactions', + store_sales.total_profit AS SUM(store_sales.ss_net_profit) COMMENT = 'Total net profit from store sales', + store_sales.sales_by_brand AS SUM(store_sales.ss_ext_sales_price) COMMENT = 'Total sales by brand (requires grouping by item.i_brand)' + ) + COMMENT = 'TPC-DS retail semantic model for sales and customer analytics'; diff --git a/converters/hologres/tests/test_cli.py b/converters/hologres/tests/test_cli.py new file mode 100644 index 00000000..c5bcc5f1 --- /dev/null +++ b/converters/hologres/tests/test_cli.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. + +"""Tests for the ossie-hologres command line interface.""" + +import pytest +from _util import FIXTURES +from ossie_hologres.cli import main + + +class TestExport: + def test_writes_ddl_to_stdout(self, capsys): + assert main(["export", "-i", str(FIXTURES / "fixtureB_ossie.yaml")]) == 0 + out = capsys.readouterr().out + assert out == (FIXTURES / "fixtureB_semantic_view.sql").read_text(encoding="utf-8") + + def test_writes_ddl_to_a_file(self, tmp_path): + out = tmp_path / "view.sql" + assert main(["export", "-i", str(FIXTURES / "fixtureA_ossie.yaml"), "-o", str(out)]) == 0 + assert out.read_text(encoding="utf-8").startswith("CREATE SEMANTIC VIEW svacc_order_sv") + + def test_drop_if_exists_flag(self, capsys): + main(["export", "-i", str(FIXTURES / "fixtureA_ossie.yaml"), "--drop-if-exists"]) + assert capsys.readouterr().out.startswith("DROP SEMANTIC VIEW IF EXISTS") + + def test_schema_flag_qualifies_the_view(self, capsys): + main(["export", "-i", str(FIXTURES / "fixtureA_ossie.yaml"), "--schema", "analytics"]) + assert "CREATE SEMANTIC VIEW analytics.svacc_order_sv" in capsys.readouterr().out + + def test_database_mismatch_is_reported_as_an_error(self, capsys): + code = main(["export", "-i", str(FIXTURES / "fixtureA_ossie.yaml"), "--database", "other"]) + assert code == 1 + assert "Error:" in capsys.readouterr().err + + def test_missing_input_file_is_reported_as_an_error(self, capsys): + assert main(["export", "-i", "does-not-exist.yaml"]) == 1 + assert "Error:" in capsys.readouterr().err + + +class TestMetricOwnerOption: + def _model(self, tmp_path): + path = tmp_path / "model.yaml" + path.write_text( + "version: 0.2.0.dev0\n" + "semantic_model:\n" + " - name: sv\n" + " datasets:\n" + " - name: o\n" + " source: public.orders\n" + " primary_key: [id]\n" + " metrics:\n" + " - name: n\n" + " expression:\n" + " dialects:\n" + " - dialect: ANSI_SQL\n" + " expression: COUNT(*)\n", + encoding="utf-8", + ) + return path + + def test_supplies_an_owner_for_count_star(self, tmp_path, capsys): + assert main(["export", "-i", str(self._model(tmp_path)), "--metric-owner", "n=o"]) == 0 + assert "o.n AS COUNT(*)" in capsys.readouterr().out + + def test_without_it_the_metric_cannot_be_converted(self, tmp_path, capsys): + assert main(["export", "-i", str(self._model(tmp_path))]) == 1 + assert "--metric-owner" in capsys.readouterr().err + + def test_skip_unsupported_metrics_drops_it_instead(self, tmp_path, capsys): + with pytest.warns(UserWarning, match="skipped"): + code = main(["export", "-i", str(self._model(tmp_path)), "--skip-unsupported-metrics"]) + assert code == 0 + assert "METRICS" not in capsys.readouterr().out + + @pytest.mark.parametrize("bad", ["n", "=o", "n=", ""]) + def test_malformed_metric_owner_is_rejected(self, tmp_path, bad): + with pytest.raises(SystemExit): + main(["export", "-i", str(self._model(tmp_path)), "--metric-owner", bad]) + + +class TestImport: + def test_writes_ossie_to_stdout(self, capsys): + assert main(["import", "-i", str(FIXTURES / "fixtureB_model_yaml.yaml")]) == 0 + out = capsys.readouterr().out + assert out.startswith("version: 0.2.0.dev0") + assert "name: svacc_sales_sv" in out + + def test_name_flag_overrides_the_model_name(self, capsys): + main(["import", "-i", str(FIXTURES / "fixtureA_model_yaml.yaml"), "--name", "renamed"]) + assert "name: renamed" in capsys.readouterr().out + + def test_writes_ossie_to_a_file(self, tmp_path): + out = tmp_path / "model.yaml" + assert main(["import", "-i", str(FIXTURES / "fixtureA_model_yaml.yaml"), "-o", str(out)]) == 0 + assert "svacc_order_sv" in out.read_text(encoding="utf-8") + + def test_malformed_input_is_reported_as_an_error(self, tmp_path, capsys): + path = tmp_path / "bad.yaml" + path.write_text("- not a mapping\n", encoding="utf-8") + assert main(["import", "-i", str(path)]) == 1 + assert "Error:" in capsys.readouterr().err + + +class TestParser: + def test_a_subcommand_is_required(self): + with pytest.raises(SystemExit): + main([]) + + def test_unknown_subcommand_is_rejected(self): + with pytest.raises(SystemExit): + main(["convert"]) diff --git a/converters/hologres/tests/test_common.py b/converters/hologres/tests/test_common.py new file mode 100644 index 00000000..c3b04c0c --- /dev/null +++ b/converters/hologres/tests/test_common.py @@ -0,0 +1,367 @@ +# 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. + +"""Tests for the shared helpers: quoting, the stash protocol, and the sqlglot layer.""" + +import json + +import pytest +from ossie_hologres._common import ( + DIALECT_ANSI, + ConversionError, + assert_row_level, + column_refs, + dump_yaml, + foreign_vendor_extensions, + load_yaml, + merge_description, + metric_aggregate, + normalize_expression, + parse_expression, + pick_expression, + qualify_columns, + quote_identifier, + quote_literal, + read_stash, + render_expression, + require_str, + unqualify_columns, + write_stash, +) + + +class TestQuoteIdentifier: + @pytest.mark.parametrize( + "name", + ["orders", "svacc_orders", "o", "_private", "a1", "region_dim"], + ) + def test_bare_lowercase_identifiers_are_not_quoted(self, name): + assert quote_identifier(name, "test") == name + + @pytest.mark.parametrize( + ("name", "expected"), + [ + # PostgreSQL folds unquoted identifiers to lower case, so anything that is + # not already lower case must be quoted to survive the round trip. + ("Orders", '"Orders"'), + ("ORDERS", '"ORDERS"'), + ("my table", '"my table"'), + ("with-dash", '"with-dash"'), + ("城市", '"城市"'), + # An embedded double quote is escaped by doubling. + ('a"b', '"a""b"'), + # Reserved keywords are a syntax error when left bare. sqlglot's generator + # does not quote these, which is why we do it here. + ("select", '"select"'), + ("table", '"table"'), + ("group", '"group"'), + ("user", '"user"'), + ("order", '"order"'), + ], + ) + def test_identifiers_needing_quotes_are_quoted(self, name, expected): + assert quote_identifier(name, "test") == expected + + @pytest.mark.parametrize("bad", ["", " ", None, 42]) + def test_empty_or_non_string_is_rejected(self, bad): + with pytest.raises(ConversionError, match="identifier"): + quote_identifier(bad, "test") + + def test_nul_byte_is_rejected(self): + with pytest.raises(ConversionError, match="NUL"): + quote_identifier("a\x00b", "test") + + +class TestQuoteLiteral: + def test_plain_text(self): + assert quote_literal("total revenue", "test") == "'total revenue'" + + def test_single_quote_is_doubled(self): + assert quote_literal("customer's city", "test") == "'customer''s city'" + + def test_backslash_is_literal(self): + # standard_conforming_strings is on by default, so a backslash needs no escape. + assert quote_literal("a\\b", "test") == "'a\\b'" + + def test_unicode_passes_through(self): + assert quote_literal("客户城市", "test") == "'客户城市'" + + def test_nul_byte_is_rejected(self): + with pytest.raises(ConversionError, match="NUL"): + quote_literal("a\x00b", "test") + + def test_non_string_is_rejected(self): + with pytest.raises(ConversionError, match="must be a string"): + quote_literal(7, "test") + + +class TestYaml12Semantics: + def test_bare_on_off_stay_strings(self): + # A YAML 1.1 reader turns these into booleans, silently corrupting a dimension + # named `no` or a description of `on`. + loaded = load_yaml("a: on\nb: no\nc: yes\nd: y\n") + assert loaded == {"a": "on", "b": "no", "c": "yes", "d": "y"} + + def test_real_booleans_still_parse(self): + assert load_yaml("a: true\nb: false\n") == {"a": True, "b": False} + + def test_dump_quotes_bool_like_strings(self): + # Force-quoted on output so a stock yaml.safe_load reader also reads a string. + assert dump_yaml({"a": "on"}).strip() == "a: 'on'" + + def test_dump_preserves_key_order_and_unicode(self): + text = dump_yaml({"name": "b", "description": "客户"}) + assert text == "name: b\ndescription: 客户\n" + + def test_invalid_yaml_raises_conversion_error(self): + with pytest.raises(ConversionError, match="Invalid YAML"): + load_yaml("a: [unclosed\n") + + +class TestStash: + def test_round_trip(self): + obj = {} + write_stash(obj, {"owner": "o"}) + assert read_stash(obj) == {"owner": "o"} + + def test_version_marker_is_written_but_hidden_from_readers(self): + obj = {} + write_stash(obj, {"owner": "o"}) + assert json.loads(obj["custom_extensions"][0]["data"])["_v"] == 1 + assert "_v" not in read_stash(obj) + + def test_empty_data_is_a_no_op(self): + # Keeps hand-authored Ossie free of empty extension blocks. + obj = {} + write_stash(obj, {}) + assert obj == {} + + def test_merges_into_existing_hologres_entry(self): + obj = {} + write_stash(obj, {"owner": "o"}) + write_stash(obj, {"view_schema": "public"}) + assert len(obj["custom_extensions"]) == 1 + assert read_stash(obj) == {"view_schema": "public"} + + def test_absent_stash_reads_as_empty(self): + assert read_stash({}) == {} + assert read_stash(None) == {} + assert read_stash({"custom_extensions": [{"vendor_name": "DBT", "data": "{}"}]}) == {} + + def test_foreign_vendors_are_reported_not_read(self): + obj = { + "custom_extensions": [ + {"vendor_name": "HOLOGRES", "data": '{"owner": "o"}'}, + {"vendor_name": "DBT", "data": "{}"}, + ] + } + assert read_stash(obj) == {"owner": "o"} + assert [e["vendor_name"] for e in foreign_vendor_extensions(obj)] == ["DBT"] + + def test_malformed_json_raises(self): + obj = {"custom_extensions": [{"vendor_name": "HOLOGRES", "data": "{not json"}]} + with pytest.raises(ConversionError, match="not valid JSON"): + read_stash(obj) + + def test_non_object_json_raises(self): + obj = {"custom_extensions": [{"vendor_name": "HOLOGRES", "data": "[1, 2]"}]} + with pytest.raises(ConversionError, match="must be a JSON object"): + read_stash(obj) + + +class TestPickExpression: + def _expr(self, *pairs): + return {"dialects": [{"dialect": d, "expression": e} for d, e in pairs]} + + def test_selects_the_ansi_dialect(self): + assert pick_expression(self._expr((DIALECT_ANSI, "region"))) == "region" + + def test_selects_ansi_from_among_several_dialects(self): + expr = self._expr(("SNOWFLAKE", "region::varchar"), (DIALECT_ANSI, "region")) + assert pick_expression(expr) == "region" + + def test_returns_none_when_no_ansi_dialect(self): + # This converter introduces no dialect token of its own, so anything that is not + # ANSI_SQL is simply not usable here. + assert pick_expression(self._expr(("MDX", "[Region]"))) is None + assert pick_expression(self._expr(("HOLOGRES", "region::text"))) is None + assert pick_expression({}) is None + assert pick_expression(None) is None + + def test_non_string_expression_raises(self): + with pytest.raises(ConversionError, match="must be a string"): + pick_expression({"dialects": [{"dialect": DIALECT_ANSI, "expression": 7}]}) + + +class TestMergeDescription: + def test_string_ai_context_is_appended(self): + assert merge_description("desc", "extra") == "desc\nextra" + + def test_string_ai_context_alone_becomes_the_description(self): + assert merge_description(None, "extra") == "extra" + + def test_object_ai_context_is_left_for_the_caller_to_report(self): + assert merge_description("desc", {"synonyms": ["a"]}) == "desc" + + def test_blank_ai_context_changes_nothing(self): + assert merge_description("desc", " ") == "desc" + + +class TestRequireStr: + def test_returns_value(self): + assert require_str({"name": "o"}, "name", "table") == "o" + + @pytest.mark.parametrize("obj", [{}, {"name": None}, {"name": " "}]) + def test_missing_null_or_blank_raises(self, obj): + with pytest.raises(ConversionError): + require_str(obj, "name", "table") + + def test_non_string_raises(self): + with pytest.raises(ConversionError, match="must be a string"): + require_str({"name": 7}, "name", "table") + + +class TestExpressionLayer: + def test_parse_failure_raises_rather_than_passing_sql_through(self): + with pytest.raises(ConversionError, match="cannot parse expression"): + parse_expression("sum(", "metric 'x'") + + @pytest.mark.parametrize("bad", ["", " ", None]) + def test_empty_expression_raises(self, bad): + with pytest.raises(ConversionError, match="non-empty string"): + parse_expression(bad, "metric 'x'") + + def test_column_refs_reports_qualifier_and_name(self): + node = parse_expression("o.amount + quantity", "test") + assert sorted(column_refs(node)) == [("", "quantity"), ("o", "amount")] + + def test_qualify_adds_the_owning_alias(self): + node = qualify_columns( + parse_expression("upper(region) || city", "test"), "o", {"o", "c"}, "test" + ) + assert render_expression(node) == "UPPER(o.region) || o.city" + + def test_qualify_leaves_matching_qualifier_alone(self): + node = qualify_columns(parse_expression("o.region", "test"), "o", {"o"}, "test") + assert render_expression(node) == "o.region" + + def test_qualify_rejects_a_reference_to_another_known_table(self): + with pytest.raises(ConversionError, match="cannot span tables"): + qualify_columns(parse_expression("c.city", "dim 'x'"), "o", {"o", "c"}, "dim 'x'") + + def test_qualify_rejects_an_unknown_table(self): + with pytest.raises(ConversionError, match="unknown table 'zz'"): + qualify_columns(parse_expression("zz.city", "dim 'x'"), "o", {"o", "c"}, "dim 'x'") + + def test_unqualify_is_the_inverse_of_qualify(self): + original = "UPPER(region) || city" + node = qualify_columns(parse_expression(original, "test"), "o", {"o"}, "test") + assert render_expression(unqualify_columns(node, "o")) == original + + def test_unqualify_keeps_other_qualifiers(self): + node = unqualify_columns(parse_expression("o.a + c.b", "test"), "o") + assert render_expression(node) == "a + c.b" + + def test_normalize_is_stable_under_repetition(self): + # Round-trip fidelity is normalization-stable, not byte-stable: sqlglot + # upper-cases functions and rewrites casts. + once = normalize_expression("sum(o.amount)") + assert once == "SUM(o.amount)" + assert normalize_expression(once) == once + assert normalize_expression("o.region::text") == "CAST(o.region AS TEXT)" + + @pytest.mark.parametrize( + ("expr", "fragment"), + [ + ("sum(o.amount)", "an aggregate function"), + ("sum(o.amount) OVER (PARTITION BY o.region)", "a window function"), + ("(SELECT 1)", "a subquery"), + ("count(o.x) FILTER (WHERE o.y > 1)", "an aggregate FILTER clause"), + ], + ) + def test_assert_row_level_rejects_non_row_shapes(self, expr, fragment): + with pytest.raises(ConversionError, match=fragment): + assert_row_level(parse_expression(expr, "dim 'x'"), "dim 'x'") + + @pytest.mark.parametrize( + "expr", + [ + "o.region", + "region", + "upper(region) || city", + "o.region::text", + "CASE WHEN o.amount > 10 THEN 'hi' ELSE 'lo' END", + "1", + ], + ) + def test_assert_row_level_accepts_row_shapes(self, expr): + assert_row_level(parse_expression(expr, "dim 'x'"), "dim 'x'") + + +class TestMetricAggregate: + @pytest.mark.parametrize( + ("expr", "agg"), + [ + ("sum(o.amount)", "sum"), + ("SUM(o.amount)", "sum"), + ("avg(c.credit_limit)", "avg"), + ("min(o.amount)", "min"), + ("max(o.amount)", "max"), + ("count(*)", "count"), + ("count(o.customer_id)", "count"), + ("count(DISTINCT o.customer_id)", "count"), + # Redundant parentheses do not change the shape. + ("(sum(o.amount))", "sum"), + ], + ) + def test_whitelisted_aggregates_are_accepted(self, expr, agg): + name, _ = metric_aggregate(parse_expression(expr, "metric 'm'"), "metric 'm'") + assert name == agg + + @pytest.mark.parametrize( + "expr", + [ + # Ratio / derived metrics: the whole reason Hologres rejects these is that + # they cannot be aggregated per metric group. + "sum(o.amount) / count(*)", + "SUM(store_sales.ss_ext_sales_price) / COUNT(DISTINCT customer.c_customer_sk)", + "sum(o.amount) + sum(o.tax)", + # Not in the Hologres aggregate whitelist, though sqlglot still calls these + # AggFuncs -- which is why membership is tested by exact node type. + "stddev(o.amount)", + "array_agg(o.amount)", + # An aggregate wrapped in something else is no longer a bare aggregate. + "coalesce(sum(o.amount), 0)", + "CASE WHEN sum(o.amount) > 1 THEN 1 ELSE 0 END", + # Not an aggregate at all. + "o.amount", + ], + ) + def test_unsupported_metric_shapes_are_rejected(self, expr): + with pytest.raises(ConversionError, match="count/sum/avg/min/max"): + metric_aggregate(parse_expression(expr, "metric 'm'"), "metric 'm'") + + def test_nested_aggregate_inside_the_operand_is_rejected(self): + with pytest.raises(ConversionError, match="an aggregate function"): + metric_aggregate(parse_expression("sum(sum(o.amount))", "metric 'm'"), "metric 'm'") + + def test_count_star_has_no_column_reference(self): + # This is why `count(*)` metrics need an explicit owner stash: the owning table + # simply is not recoverable from the expression. + node = parse_expression("count(*)", "metric 'm'") + metric_aggregate(node, "metric 'm'") + assert column_refs(node) == [] diff --git a/converters/hologres/tests/test_live_hologres.py b/converters/hologres/tests/test_live_hologres.py new file mode 100644 index 00000000..38d5958d --- /dev/null +++ b/converters/hologres/tests/test_live_hologres.py @@ -0,0 +1,300 @@ +# 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. + +"""End-to-end verification against a real Hologres instance. + +Skipped unless the connection environment variables are set, so CI and a plain +`uv run pytest` stay hermetic: + + export HOLOGRES_HOST= + export HOLOGRES_PORT=80 + export HOLOGRES_USER='BASIC$account' # single quotes: the $ is literal + export HOLOGRES_PASSWORD='' + export HOLOGRES_DB= + uv sync --group live + uv run pytest -m live -v + +Credentials are only ever read from the environment. Nothing here, in the fixtures, or +in the README contains a real endpoint or password. + +What this proves that the offline tests cannot: that the generated DDL is accepted by +Hologres, that the resulting view answers queries with the right numbers, and that +importing Hologres' own readback of that DDL reproduces the model we started from. +""" + +import os +import warnings + +import pytest +from _util import read_fixture +from ossie_hologres import convert_ossie_to_semantic_view, convert_semantic_view_to_ossie +from ossie_hologres._common import load_yaml + +psycopg = pytest.importorskip("psycopg", reason="install the 'live' dependency group") + +_ENV_VARS = ("HOLOGRES_HOST", "HOLOGRES_USER", "HOLOGRES_PASSWORD", "HOLOGRES_DB") + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + not all(os.environ.get(v) for v in _ENV_VARS), + reason=f"set {', '.join(_ENV_VARS)} to run the live Hologres tests", + ), +] + +# Everything created here is namespaced so a failed run cannot be mistaken for, or +# collide with, anything else in the database. +SCHEMA = "ossie_hologres_it" +VIEW = "it_sales_sv" +REEXPORT_VIEW = "it_sales_sv_reexport" + +# Minimal star schema matching tests/fixtures/fixtureB_ossie.yaml. Rows are inserted so +# the assertions can check numbers, not just that the DDL parses -- in particular that +# order revenue is not multiplied by the joined order_items rows. +_BASE_TABLES = f""" +CREATE TABLE IF NOT EXISTS {SCHEMA}.svacc_customers ( + customer_id int PRIMARY KEY, city text, credit_limit numeric(18,2)); +CREATE TABLE IF NOT EXISTS {SCHEMA}.svacc_orders ( + order_id int PRIMARY KEY, customer_id int, region text, status text, + amount numeric(18,2)); +CREATE TABLE IF NOT EXISTS {SCHEMA}.svacc_order_items ( + item_id int PRIMARY KEY, order_id int, quantity int); +""" + +_ROWS = f""" +INSERT INTO {SCHEMA}.svacc_customers VALUES + (1, 'Beijing', 1000.00), (2, 'Shanghai', 2000.00), (3, 'Beijing', 1500.00); +INSERT INTO {SCHEMA}.svacc_orders VALUES + (101, 1, 'east', 'completed', 100.00), (102, 1, 'east', 'completed', 200.00), + (103, 2, 'west', 'completed', 150.00), (104, 2, 'west', 'pending', 50.00); +INSERT INTO {SCHEMA}.svacc_order_items VALUES + (1001, 101, 1), (1002, 101, 2), (1003, 102, 1), + (1004, 102, 1), (1005, 103, 3), (1006, 104, 1); +""" + + +def _export(ossie_yaml, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return convert_ossie_to_semantic_view(ossie_yaml, **kwargs) + + +def _retarget(ossie_yaml, source_prefix): + """Point a fixture's dataset sources at the integration test schema. + + The --schema option deliberately never overrides a schema already written into a + `source`, and these fixtures name one, so the rewrite has to happen in the input. + """ + return ossie_yaml.replace(source_prefix, f"{os.environ['HOLOGRES_DB']}.{SCHEMA}.") + + +@pytest.fixture(scope="module") +def conn(): + """An autocommit connection, or a skip if the instance is too old.""" + dsn = psycopg.conninfo.make_conninfo( + host=os.environ["HOLOGRES_HOST"], + port=os.environ.get("HOLOGRES_PORT", "80"), + user=os.environ["HOLOGRES_USER"], + password=os.environ["HOLOGRES_PASSWORD"], + dbname=os.environ["HOLOGRES_DB"], + ) + with psycopg.connect(dsn, autocommit=True) as connection: + version = connection.execute("SELECT hg_version()").fetchone()[0] + if not version.startswith("Hologres ") or version.split()[1] < "5.0.0": + pytest.skip(f"Semantic Views need Hologres V5.0.0 or later, got: {version}") + yield connection + + +@pytest.fixture(scope="module") +def star(conn): + """Create the base tables and rows, and remove everything afterwards.""" + try: + conn.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + conn.execute(_BASE_TABLES) + conn.execute(f"TRUNCATE {SCHEMA}.svacc_customers") + conn.execute(f"TRUNCATE {SCHEMA}.svacc_orders") + conn.execute(f"TRUNCATE {SCHEMA}.svacc_order_items") + conn.execute(_ROWS) + yield conn + finally: + for view in (VIEW, REEXPORT_VIEW): + conn.execute(f"DROP SEMANTIC VIEW IF EXISTS {SCHEMA}.{view}") + conn.execute(f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE") + + +@pytest.fixture(scope="module") +def created_view(star): + """Export fixtureB to DDL, execute it, and return the DDL that was run. + + This is the assertion that matters most: if the generated grammar, the aggregate + whitelist, or the REFERENCES/PRIMARY KEY rules were wrong, this fails. + """ + ossie = _retarget( + read_fixture("fixtureB_ossie.yaml").replace( + "name: svacc_sales_sv", f"name: {VIEW}" + ), + "retail.public.", + ) + ddl = _export(ossie, schema=SCHEMA, drop_if_exists=True) + star.execute(ddl) + return ddl + + +def _model_yaml(conn, view_name): + row = conn.execute( + """ + SELECT property_value + FROM hologres.hg_semantic_view_properties + WHERE schema_name = %s AND view_name = %s AND property_key = 'model_yaml' + """, + (SCHEMA, view_name), + ).fetchone() + assert row is not None, f"Hologres published no model_yaml for {view_name}" + return row[0] + + +class TestGeneratedDdlIsAccepted: + def test_the_view_exists_after_running_the_ddl(self, star, created_view): + keys = star.execute( + """ + SELECT property_key FROM hologres.hg_semantic_view_properties + WHERE schema_name = %s AND view_name = %s + """, + (SCHEMA, VIEW), + ).fetchall() + assert {"ddl_text", "model_yaml"} <= {k for (k,) in keys} + + def test_the_tpcds_snowflake_ddl_is_also_accepted(self, star): + # A wider shape than fixtureB: five tables, a composite primary key, four + # relationships, and a computed dimension needing parentheses. + from _util import EXAMPLES + + stubs = f""" + CREATE TABLE IF NOT EXISTS {SCHEMA}.store_sales ( + ss_item_sk int, ss_ticket_number int, ss_sold_date_sk int, ss_customer_sk int, + ss_store_sk int, ss_quantity int, ss_sales_price numeric(7,2), + ss_ext_sales_price numeric(7,2), ss_net_profit numeric(7,2), + PRIMARY KEY (ss_item_sk, ss_ticket_number)); + CREATE TABLE IF NOT EXISTS {SCHEMA}.date_dim ( + d_date_sk int PRIMARY KEY, d_date date, d_year int, d_quarter_name text, + d_month_name text); + CREATE TABLE IF NOT EXISTS {SCHEMA}.customer ( + c_customer_sk int PRIMARY KEY, c_customer_id text, c_first_name text, + c_last_name text, c_email_address text); + CREATE TABLE IF NOT EXISTS {SCHEMA}.item ( + i_item_sk int PRIMARY KEY, i_item_id text, i_item_desc text, i_brand text, + i_category text, i_current_price numeric(7,2)); + CREATE TABLE IF NOT EXISTS {SCHEMA}.store ( + s_store_sk int PRIMARY KEY, s_store_id text, s_store_name text, s_city text, + s_state text, s_number_employees int); + """ + star.execute(stubs) + ossie = _retarget( + (EXAMPLES / "tpcds_semantic_model.yaml").read_text(encoding="utf-8"), + "tpcds.public.", + ) + ddl = _export( + ossie, schema=SCHEMA, drop_if_exists=True, skip_unsupported_metrics=True + ) + star.execute(ddl) + star.execute(f"DROP SEMANTIC VIEW IF EXISTS {SCHEMA}.tpcds_retail_model") + + +class TestGeneratedViewAnswersQueries: + def test_single_metric_group_aggregation(self, star, created_view): + rows = star.execute( + f"SELECT region_dim, AGG(total) FROM {SCHEMA}.{VIEW} " + f"GROUP BY region_dim ORDER BY region_dim" + ).fetchall() + assert rows == [("east", 300), ("west", 200)] + + def test_metrics_are_not_inflated_by_a_fan_out_join(self, star, created_view): + # Each order has several order_items rows. A naive join-then-aggregate would + # multiply revenue; Hologres aggregates each metric group in its own subtree. + rows = star.execute( + f"SELECT city_dim, AGG(total), AGG(credit), AGG(item_qty) FROM {SCHEMA}.{VIEW} " + f"GROUP BY city_dim ORDER BY city_dim" + ).fetchall() + # Beijing credit is 2500 because customer 3 has no orders yet still contributes + # at customer grain -- the point of aggregating before joining. + assert rows == [("Beijing", 300, 2500, 5), ("Shanghai", 200, 2000, 4)] + + def test_where_only_dimension_filters_without_changing_the_grain(self, star, created_view): + rows = star.execute( + f"SELECT region_dim, AGG(total) FROM {SCHEMA}.{VIEW} " + f"WHERE status_dim = 'completed' GROUP BY region_dim ORDER BY region_dim" + ).fetchall() + assert rows == [("east", 300), ("west", 150)] + + def test_global_aggregation_and_having(self, star, created_view): + total, count = star.execute( + f"SELECT AGG(total), AGG(order_count) FROM {SCHEMA}.{VIEW}" + ).fetchone() + assert (total, count) == (500, 4) + + rows = star.execute( + f"SELECT city_dim, AGG(total) FROM {SCHEMA}.{VIEW} " + f"GROUP BY city_dim HAVING AGG(total) > 250" + ).fetchall() + assert rows == [("Beijing", 300)] + + +class TestFullRoundTrip: + def test_importing_the_readback_reproduces_the_original_model(self, star, created_view): + # Ossie -> DDL -> Hologres -> model_yaml -> Ossie, closed against the fixture. + imported = load_yaml(convert_semantic_view_to_ossie(_model_yaml(star, VIEW))) + expected = load_yaml( + _retarget( + read_fixture("fixtureB_ossie.yaml").replace( + "name: svacc_sales_sv", f"name: {VIEW}" + ), + "retail.public.", + ) + ) + assert imported == expected + + def test_re_exporting_produces_an_equivalent_view(self, star, created_view): + # Compare Hologres' normalization of our DDL with its normalization of the DDL we + # regenerate from its own readback. Comparing our text to theirs would only + # measure their formatting choices. + ossie = convert_semantic_view_to_ossie(_model_yaml(star, VIEW)) + ddl = _export( + ossie.replace(f"name: {VIEW}", f"name: {REEXPORT_VIEW}"), + schema=SCHEMA, + drop_if_exists=True, + ) + star.execute(ddl) + + first = _model_yaml(star, VIEW).replace(VIEW, "X") + second = _model_yaml(star, REEXPORT_VIEW).replace(REEXPORT_VIEW, "X") + assert load_yaml(first) == load_yaml(second) + + def test_the_checked_in_model_yaml_fixture_still_matches_the_instance(self, star, created_view): + # If Hologres changes the shape it emits, this fails and says to refresh the + # offline fixture, instead of the offline tests quietly testing a stale format. + live = load_yaml(_model_yaml(star, VIEW)) + fixture = load_yaml(read_fixture("fixtureB_model_yaml.yaml")) + + live["name"] = fixture["name"] = "X" + for doc in (live, fixture): + for table in doc["tables"]: + # Only the location differs: the fixture was captured from public. + table["base_table"] = table["base_table"]["table"] + assert live == fixture, ( + "Hologres' model_yaml no longer matches tests/fixtures/fixtureB_model_yaml.yaml; " + "refresh the fixture from the instance" + ) diff --git a/converters/hologres/tests/test_ossie_to_semantic_view.py b/converters/hologres/tests/test_ossie_to_semantic_view.py new file mode 100644 index 00000000..b4000b0a --- /dev/null +++ b/converters/hologres/tests/test_ossie_to_semantic_view.py @@ -0,0 +1,596 @@ +# 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. + +"""Tests for the Apache Ossie -> Hologres CREATE SEMANTIC VIEW export. + +The two golden .sql fixtures were executed against a real Hologres 5.0.0 instance and +the resulting views queried, so they assert grammar that is known to work rather than +grammar that merely looks right. +""" + +import warnings + +import pytest +from _util import ossie_doc, read_fixture +from ossie_hologres import ConversionError, convert_ossie_to_semantic_view +from ossie_hologres._common import dump_yaml + + +def export(model, **kwargs): + """Convert a single model dict, ignoring the fidelity warnings.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return convert_ossie_to_semantic_view(dump_yaml(ossie_doc(model)), **kwargs) + + +def warnings_from(model, **kwargs): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + convert_ossie_to_semantic_view(dump_yaml(ossie_doc(model)), **kwargs) + return [str(w.message) for w in caught] + + +def ansi(expression): + return {"dialects": [{"dialect": "ANSI_SQL", "expression": expression}]} + + +def dataset(name, source="db.public.t", primary_key=("id",), fields=(), **extra): + ds = {"name": name, "source": source} + if primary_key: + ds["primary_key"] = list(primary_key) + if fields: + ds["fields"] = list(fields) + ds.update(extra) + return ds + + +def field(name, expression, **extra): + return {"name": name, "expression": ansi(expression), **extra} + + +def metric(name, expression, **extra): + return {"name": name, "expression": ansi(expression), **extra} + + +# A minimal single-table model used as the base for most focused tests. +def one_table(fields=(), metrics=(), **extra): + model = { + "name": "sv", + "datasets": [dataset("o", fields=fields)], + } + if metrics: + model["metrics"] = list(metrics) + model.update(extra) + return model + + +class TestGoldenFixtures: + @pytest.mark.parametrize("name", ["fixtureA", "fixtureB"]) + def test_matches_the_instance_verified_ddl(self, name): + with warnings.catch_warnings(): + warnings.simplefilter("error") # these fixtures must convert losslessly + ddl = convert_ossie_to_semantic_view(read_fixture(f"{name}_ossie.yaml")) + assert ddl == read_fixture(f"{name}_semantic_view.sql") + + def test_multi_table_clause_order_and_shape(self): + ddl = read_fixture("fixtureB_semantic_view.sql") + # Clauses must appear in the order the Hologres grammar defines. The view-level + # COMMENT is located with rindex because dimensions carry COMMENTs of their own. + positions = [ + ddl.index(kw) + for kw in ("CREATE SEMANTIC VIEW", "TABLES", "RELATIONSHIPS", "DIMENSIONS", "METRICS") + ] + positions.append(ddl.rindex("COMMENT =")) + assert positions == sorted(positions) + assert ddl.endswith(";\n") + + def test_relationship_direction_is_many_to_one(self): + # `from` is the many side and becomes the referencing table. + assert "rel_oc AS o(customer_id) REFERENCES c(customer_id)" in read_fixture( + "fixtureB_semantic_view.sql" + ) + + def test_metrics_are_namespaced_by_owning_table(self): + ddl = read_fixture("fixtureB_semantic_view.sql") + assert "o.total AS SUM(o.amount)" in ddl + assert "c.credit AS SUM(c.credit_limit)" in ddl + assert "i.item_qty AS SUM(i.quantity)" in ddl + + +class TestDocumentValidation: + def test_non_mapping_root_is_rejected(self): + with pytest.raises(ConversionError, match="expected a mapping"): + convert_ossie_to_semantic_view("- a\n- b\n") + + def test_wrong_version_is_rejected(self): + with pytest.raises(ConversionError, match="Unsupported Apache Ossie version"): + convert_ossie_to_semantic_view("version: 0.1.0\nsemantic_model: []\n") + + def test_empty_model_list_is_rejected(self): + with pytest.raises(ConversionError, match="non-empty list"): + convert_ossie_to_semantic_view("version: 0.2.0.dev0\nsemantic_model: []\n") + + def test_multiple_models_warns_and_converts_the_first(self): + doc = { + "version": "0.2.0.dev0", + "semantic_model": [one_table(metrics=[metric("m", "COUNT(o.id)")]), one_table()], + } + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ddl = convert_ossie_to_semantic_view(dump_yaml(doc)) + assert "multiple semantic models" in " ".join(str(w.message) for w in caught) + assert "CREATE SEMANTIC VIEW sv" in ddl + + def test_model_without_datasets_is_rejected(self): + with pytest.raises(ConversionError, match="has no datasets"): + export({"name": "sv", "datasets": []}) + + def test_duplicate_dataset_name_is_rejected(self): + with pytest.raises(ConversionError, match="duplicate dataset name"): + export({"name": "sv", "datasets": [dataset("o"), dataset("o")]}) + + +class TestSourceParsing: + @pytest.mark.parametrize( + ("source", "expected"), + [ + ("retail.public.orders", "public.orders"), + ("public.orders", "public.orders"), + ("orders", "orders"), + ], + ) + def test_source_forms(self, source, expected): + ddl = export(one_table(metrics=[metric("m", "COUNT(o.id)")]) | { + "datasets": [dataset("o", source=source)] + }) + assert f"o AS {expected} PRIMARY KEY (id)" in ddl + + def test_schema_option_supplies_a_default_for_unqualified_sources(self): + ddl = export( + {"name": "sv", "datasets": [dataset("o", source="orders")]}, schema="analytics" + ) + assert "o AS analytics.orders" in ddl + # It also qualifies the view itself. + assert "CREATE SEMANTIC VIEW analytics.sv" in ddl + + def test_schema_option_never_overrides_an_explicit_source_schema(self): + ddl = export( + {"name": "sv", "datasets": [dataset("o", source="public.orders")]}, + schema="analytics", + ) + assert "o AS public.orders" in ddl + + def test_datasets_spanning_databases_are_rejected(self): + model = { + "name": "sv", + "datasets": [ + dataset("o", source="db1.public.orders"), + dataset("c", source="db2.public.customers"), + ], + } + with pytest.raises(ConversionError, match="cannot span multiple databases"): + export(model) + + def test_database_option_must_agree_with_the_sources(self): + with pytest.raises(ConversionError, match="does not match"): + export({"name": "sv", "datasets": [dataset("o", source="db1.public.o")]}, + database="db2") + + @pytest.mark.parametrize("source", ["", " ", "a..b", "a.b.c.d", "a.b c.d"]) + def test_malformed_sources_are_rejected(self, source): + with pytest.raises(ConversionError): + export({"name": "sv", "datasets": [dataset("o", source=source)]}) + + def test_whitespace_around_source_parts_is_tolerated(self): + ddl = export({"name": "sv", "datasets": [dataset("o", source="db. public . orders")]}) + assert "o AS public.orders" in ddl + + def test_missing_source_is_rejected(self): + with pytest.raises(ConversionError, match="source"): + export({"name": "sv", "datasets": [{"name": "o", "primary_key": ["id"]}]}) + + +class TestPrimaryKeys: + def test_unreferenced_dataset_may_omit_its_primary_key(self): + ddl = export({"name": "sv", "datasets": [dataset("o", primary_key=None)]}) + assert "o AS public.t" in ddl + assert "PRIMARY KEY" not in ddl + + def test_referenced_dataset_without_any_key_is_rejected(self): + model = { + "name": "sv", + "datasets": [dataset("o"), dataset("c", primary_key=None)], + "relationships": [ + {"name": "r", "from": "o", "to": "c", "from_columns": ["cid"], + "to_columns": ["cid"]} + ], + } + with pytest.raises(ConversionError, match="declares no 'primary_key'"): + export(model) + + def test_matching_unique_key_is_promoted_with_a_warning(self): + model = { + "name": "sv", + "datasets": [ + dataset("o"), + dataset("c", primary_key=None, unique_keys=[["cid"]]), + ], + "relationships": [ + {"name": "r", "from": "o", "to": "c", "from_columns": ["cid"], + "to_columns": ["cid"]} + ], + } + assert "c AS public.t PRIMARY KEY (cid)" in export(model) + assert any("promoting unique_keys" in w for w in warnings_from(model)) + + def test_composite_primary_key_is_emitted_in_order(self): + ddl = export({"name": "sv", "datasets": [dataset("ss", primary_key=["item", "ticket"])]}) + assert "PRIMARY KEY (item, ticket)" in ddl + + +class TestRelationships: + def _model(self, **rel_overrides): + rel = { + "name": "rel_oc", + "from": "o", + "to": "c", + "from_columns": ["cid"], + "to_columns": ["cid"], + } + rel.update(rel_overrides) + return { + "name": "sv", + "datasets": [dataset("o"), dataset("c", primary_key=["cid"])], + "relationships": [rel], + } + + def test_simple_relationship(self): + assert "rel_oc AS o(cid) REFERENCES c(cid)" in export(self._model()) + + def test_to_columns_must_be_the_target_primary_key(self): + # Hologres rejects a REFERENCES target that is not the declared primary key, so + # catching it offline gives a far better message than the server's. + with pytest.raises(ConversionError, match="REFERENCES target to be the primary key"): + export(self._model(to_columns=["other"])) + + def test_mismatched_column_counts_are_rejected(self): + with pytest.raises(ConversionError, match="same number of columns"): + export(self._model(from_columns=["a", "b"])) + + def test_empty_column_lists_are_rejected(self): + with pytest.raises(ConversionError, match="must be non-empty"): + export(self._model(from_columns=[], to_columns=[])) + + def test_unknown_dataset_reference_is_rejected(self): + with pytest.raises(ConversionError, match="unknown dataset"): + export(self._model(to="nope")) + + def test_composite_key_columns_are_reordered_to_primary_key_order(self): + # Hologres pairs the two column lists positionally, so to_columns is emitted in + # primary-key order and from_columns moves with it to keep the pairing intact. + model = { + "name": "sv", + "datasets": [dataset("f"), dataset("d", primary_key=["pk1", "pk2"])], + "relationships": [ + { + "name": "r", + "from": "f", + "to": "d", + "from_columns": ["fk2", "fk1"], + "to_columns": ["pk2", "pk1"], + } + ], + } + assert "r AS f(fk1, fk2) REFERENCES d(pk1, pk2)" in export(model) + + +class TestDimensions: + def test_bare_column_is_qualified_with_the_owning_table(self): + ddl = export(one_table(fields=[field("region_dim", "region")])) + assert "o.region_dim AS o.region" in ddl + + def test_already_qualified_column_is_left_alone(self): + ddl = export(one_table(fields=[field("region_dim", "o.region")])) + assert "o.region_dim AS o.region" in ddl + + def test_multi_column_expression_is_fully_qualified(self): + # The naive "only prefix a bare identifier" rule would emit unqualified columns + # here, which Hologres cannot resolve. The parentheses are required too -- see + # TestDefinitionParentheses. + ddl = export(one_table(fields=[field("full_name", "first_name || ' ' || last_name")])) + assert "o.full_name AS (o.first_name || ' ' || o.last_name)" in ddl + + def test_description_becomes_a_comment(self): + ddl = export(one_table(fields=[field("d", "x", description="A dim")])) + assert "o.d AS o.x COMMENT = 'A dim'" in ddl + + def test_apostrophe_in_a_comment_is_escaped(self): + ddl = export(one_table(fields=[field("d", "x", description="customer's city")])) + assert "COMMENT = 'customer''s city'" in ddl + + def test_string_ai_context_is_folded_into_the_comment(self): + ddl = export(one_table(fields=[field("d", "x", description="A", ai_context="B")])) + assert "COMMENT = 'A\nB'" in ddl + + def test_dimension_name_collision_across_datasets_is_rejected(self): + # Ossie only requires field names to be unique per dataset, but Semantic View + # queries reference dimensions by bare name, so the view-wide space must be flat. + model = { + "name": "sv", + "datasets": [ + dataset("o", fields=[field("name", "n")]), + dataset("c", fields=[field("name", "n")]), + ], + } + with pytest.raises(ConversionError, match="defined by both dataset"): + export(model) + + def test_aggregate_in_a_dimension_is_rejected(self): + with pytest.raises(ConversionError, match="an aggregate function"): + export(one_table(fields=[field("d", "sum(x)")])) + + def test_cross_table_dimension_is_rejected(self): + model = { + "name": "sv", + "datasets": [dataset("o", fields=[field("d", "c.city")]), dataset("c")], + } + with pytest.raises(ConversionError, match="cannot span tables"): + export(model) + + def test_missing_usable_dialect_is_rejected(self): + model = one_table( + fields=[{"name": "d", "expression": {"dialects": [{"dialect": "MDX", "expression": "[x]"}]}}] + ) + with pytest.raises(ConversionError, match="no ANSI_SQL"): + export(model) + + def test_non_ansi_dialects_are_ignored(self): + model = one_table( + fields=[ + { + "name": "d", + "expression": { + "dialects": [ + {"dialect": "SNOWFLAKE", "expression": "x::varchar"}, + {"dialect": "ANSI_SQL", "expression": "x"}, + ] + }, + } + ] + ) + assert "o.d AS o.x" in export(model) + + def test_postgres_cast_shorthand_is_normalized_to_standard_cast(self): + # Hologres accepts `x::text`, but the portable spelling is always available and + # sqlglot rewrites to it, which is why no vendor dialect token is needed. + assert "o.d AS CAST(o.x AS TEXT)" in export(one_table(fields=[field("d", "x::text")])) + + +class TestDefinitionParentheses: + """The Hologres DDL grammar rejects a bare top-level operator in a definition. + + Verified against Hologres 5.0.0: `a || b`, `a + 1` and `a::text` are all syntax + errors in a DIMENSIONS clause, while the parenthesised forms and function calls are + accepted. The same operators are fine inside a function call's argument list. + """ + + def _dim(self, expr): + return export(one_table(fields=[field("d", expr)])) + + @pytest.mark.parametrize( + ("expr", "expected"), + [ + ("a || b", "(o.a || o.b)"), + ("a + 1", "(o.a + 1)"), + ("a - b", "(o.a - o.b)"), + ("a > 1", "(o.a > 1)"), + ], + ) + def test_top_level_operators_are_parenthesised(self, expr, expected): + assert f"o.d AS {expected}" in self._dim(expr) + + @pytest.mark.parametrize( + ("expr", "expected"), + [ + # A plain column, a function call and a CASE are all accepted bare, so they + # are left unwrapped to keep the DDL readable. + ("region", "o.region"), + ("upper(region)", "UPPER(o.region)"), + ("concat(a, b)", "CONCAT(o.a, o.b)"), + ("CASE WHEN a > 1 THEN 'x' ELSE 'y' END", "CASE WHEN o.a > 1 THEN 'x' ELSE 'y' END"), + # sqlglot rewrites the PostgreSQL cast shorthand into CAST(...), which is a + # function call and therefore already acceptable bare. + ("a::text", "CAST(o.a AS TEXT)"), + ], + ) + def test_forms_accepted_bare_are_not_wrapped(self, expr, expected): + assert f"o.d AS {expected}" in self._dim(expr) + + def test_an_operator_inside_a_function_call_needs_no_extra_wrapping(self): + assert "o.d AS UPPER(o.a || o.b)" in self._dim("upper(a || b)") + + def test_metrics_are_function_calls_and_stay_unwrapped(self): + ddl = export(one_table(metrics=[metric("m", "SUM(o.a + o.b)")])) + assert "o.m AS SUM(o.a + o.b)" in ddl + + +class TestMetrics: + def test_owner_is_inferred_from_the_qualified_column(self): + ddl = export(one_table(metrics=[metric("total", "SUM(o.amount)")])) + assert "o.total AS SUM(o.amount)" in ddl + + def test_unqualified_columns_are_qualified_with_the_stashed_owner(self): + m = metric("total", "SUM(amount)") + m["custom_extensions"] = [{"vendor_name": "HOLOGRES", "data": '{"owner": "o"}'}] + assert "o.total AS SUM(o.amount)" in export(one_table(metrics=[m])) + + def test_count_star_owner_comes_from_the_stash(self): + m = metric("n", "COUNT(*)") + m["custom_extensions"] = [{"vendor_name": "HOLOGRES", "data": '{"owner": "o"}'}] + assert "o.n AS COUNT(*)" in export(one_table(metrics=[m])) + + def test_count_star_owner_can_come_from_the_option(self): + ddl = export(one_table(metrics=[metric("n", "COUNT(*)")]), metric_owners={"n": "o"}) + assert "o.n AS COUNT(*)" in ddl + + def test_count_star_without_an_owner_is_rejected_with_guidance(self): + # Guessing the owner would silently change the number under a fan-out join, so + # this fails closed and tells the user both ways to fix it. + with pytest.raises(ConversionError, match="--metric-owner"): + export(one_table(metrics=[metric("n", "COUNT(*)")])) + + def test_declared_owner_contradicting_the_expression_is_rejected(self): + m = metric("total", "SUM(o.amount)") + m["custom_extensions"] = [{"vendor_name": "HOLOGRES", "data": '{"owner": "c"}'}] + model = { + "name": "sv", + "datasets": [dataset("o"), dataset("c")], + "metrics": [m], + } + with pytest.raises(ConversionError, match="contradicts the expression"): + export(model) + + def test_owner_must_be_a_real_dataset(self): + with pytest.raises(ConversionError, match="not a dataset in this model"): + export(one_table(metrics=[metric("n", "COUNT(*)")]), metric_owners={"n": "nope"}) + + def test_cross_table_metric_is_rejected(self): + model = { + "name": "sv", + "datasets": [dataset("o"), dataset("c")], + "metrics": [metric("m", "SUM(o.amount + c.credit)")], + } + with pytest.raises(ConversionError, match="Hologres metrics must aggregate over a single table"): + export(model) + + def test_ratio_metric_is_rejected(self): + model = { + "name": "sv", + "datasets": [dataset("o")], + "metrics": [metric("m", "SUM(o.amount) / COUNT(*)")], + } + with pytest.raises(ConversionError, match="count/sum/avg/min/max"): + export(model) + + def test_unsupported_metrics_can_be_skipped(self): + model = { + "name": "sv", + "datasets": [dataset("o")], + "metrics": [ + metric("ratio", "SUM(o.amount) / COUNT(*)"), + metric("total", "SUM(o.amount)"), + ], + } + ddl = export(model, skip_unsupported_metrics=True) + assert "o.total AS SUM(o.amount)" in ddl + assert "ratio" not in ddl + assert any("skipped" in w for w in warnings_from(model, skip_unsupported_metrics=True)) + + def test_metric_description_becomes_a_comment(self): + ddl = export(one_table(metrics=[metric("total", "SUM(o.amount)", description="Rev")])) + assert "o.total AS SUM(o.amount) COMMENT = 'Rev'" in ddl + + +class TestDropIfExists: + def test_drop_is_prefixed_when_requested(self): + ddl = export({"name": "sv", "datasets": [dataset("o")]}, drop_if_exists=True) + assert ddl.startswith("DROP SEMANTIC VIEW IF EXISTS sv;\nCREATE SEMANTIC VIEW sv") + + def test_drop_is_schema_qualified_too(self): + ddl = export( + {"name": "sv", "datasets": [dataset("o")]}, schema="analytics", drop_if_exists=True + ) + assert ddl.startswith("DROP SEMANTIC VIEW IF EXISTS analytics.sv;") + + def test_no_drop_by_default(self): + assert "DROP" not in export({"name": "sv", "datasets": [dataset("o")]}) + + +class TestQuoting: + def test_reserved_words_are_quoted(self): + # sqlglot does not quote these, so the DDL writer must. + model = { + "name": "select", + "datasets": [dataset("order", source="public.table", + fields=[field("group", "user")])], + } + ddl = export(model) + assert 'CREATE SEMANTIC VIEW "select"' in ddl + assert '"order" AS public."table"' in ddl + assert '"order"."group" AS "order"."user"' in ddl + + def test_uppercase_identifiers_are_quoted_to_survive_folding(self): + ddl = export({"name": "SV", "datasets": [dataset("O", source="public.Orders")]}) + assert 'CREATE SEMANTIC VIEW "SV"' in ddl + assert '"O" AS public."Orders"' in ddl + + +class TestFidelityWarnings: + @pytest.mark.parametrize( + ("model", "fragment"), + [ + (one_table(fields=[field("d", "x", datatype="String")]), "datatype dropped"), + (one_table(fields=[field("d", "x", label="L")]), "label dropped"), + ( + one_table(fields=[field("d", "x", dimension={"is_time": True})]), + "dimension.is_time", + ), + ( + one_table(fields=[field("d", "x", ai_context={"synonyms": ["a"]})]), + "ai_context (object) dropped", + ), + (one_table(ai_context={"instructions": "hi"}), "model-level ai_context"), + ( + {"name": "sv", "datasets": [dataset("o", description="A table")]}, + "dataset description dropped", + ), + ( + { + "name": "sv", + "datasets": [dataset("o", unique_keys=[["a"], ["b"]])], + }, + "unique_keys", + ), + ( + one_table(custom_extensions=[{"vendor_name": "DBT", "data": "{}"}]), + "foreign-vendor custom_extensions dropped", + ), + ], + ) + def test_dropped_metadata_is_reported(self, model, fragment): + assert any(fragment in w for w in warnings_from(model)), warnings_from(model) + + def test_relationship_ai_context_is_reported(self): + model = { + "name": "sv", + "datasets": [dataset("o"), dataset("c", primary_key=["cid"])], + "relationships": [ + { + "name": "r", + "from": "o", + "to": "c", + "from_columns": ["cid"], + "to_columns": ["cid"], + "ai_context": "joins them", + } + ], + } + assert any("relationship ai_context dropped" in w for w in warnings_from(model)) + + def test_a_fully_expressible_model_warns_about_nothing(self): + assert warnings_from(one_table(fields=[field("d", "x")], + metrics=[metric("m", "SUM(o.y)")])) == [] diff --git a/converters/hologres/tests/test_roundtrip.py b/converters/hologres/tests/test_roundtrip.py new file mode 100644 index 00000000..85a436ae --- /dev/null +++ b/converters/hologres/tests/test_roundtrip.py @@ -0,0 +1,174 @@ +# 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 closure and TPC-DS coverage. + +A full Ossie -> DDL -> Hologres -> model_yaml -> Ossie loop needs a database, because +only Hologres can turn DDL back into a model_yaml; that lives in test_live_hologres.py. +What is checked here is the offline closure of the same loop: the paired fixtures were +produced by a real instance, so composing the two converters over them must agree. +""" + +import warnings + +import pytest +from _util import EXAMPLES, read_fixture +from ossie_hologres import ( + ConversionError, + convert_ossie_to_semantic_view, + convert_semantic_view_to_ossie, +) +from ossie_hologres._common import load_yaml + +TPCDS = EXAMPLES / "tpcds_semantic_model.yaml" + +# The canonical TPC-DS model defines these as ratios over two datasets at once, which a +# Semantic View cannot express: Hologres aggregates each metric within one table. +TPCDS_INEXPRESSIBLE_METRICS = ["customer_lifetime_value", "store_productivity"] + + +def quiet_export(text, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return convert_ossie_to_semantic_view(text, **kwargs) + + +class TestOfflineRoundTripClosure: + @pytest.mark.parametrize("name", ["fixtureA", "fixtureB"]) + def test_model_yaml_reaches_the_same_ddl_through_ossie(self, name): + # model_yaml -> Ossie -> DDL must land on the same DDL as Ossie -> DDL, which + # closes the loop offline: the model_yaml fixture is the instance's readback of + # exactly that DDL. + via_import = quiet_export( + convert_semantic_view_to_ossie(read_fixture(f"{name}_model_yaml.yaml")) + ) + assert via_import == read_fixture(f"{name}_semantic_view.sql") + + @pytest.mark.parametrize("name", ["fixtureA", "fixtureB"]) + def test_import_is_idempotent(self, name): + once = convert_semantic_view_to_ossie(read_fixture(f"{name}_model_yaml.yaml")) + # Importing cannot be re-applied to its own output, but exporting twice from the + # same Ossie must be deterministic. + assert quiet_export(once) == quiet_export(once) + + @pytest.mark.parametrize("name", ["fixtureA", "fixtureB"]) + def test_export_output_is_stable_under_reimport(self, name): + ossie = convert_semantic_view_to_ossie(read_fixture(f"{name}_model_yaml.yaml")) + assert load_yaml(ossie) == load_yaml(read_fixture(f"{name}_ossie.yaml")) + + +class TestParenthesisRoundTrip: + def _dimension_expr(self, ossie_yaml): + model = load_yaml(ossie_yaml)["semantic_model"][0] + return model["datasets"][0]["fields"][0]["expression"]["dialects"][0]["expression"] + + def test_hologres_normalized_parentheses_are_stripped_on_import(self): + # Export wraps a top-level operator in parentheses because the DDL grammar needs + # it, and Hologres echoes the wrapping back (in fact re-parenthesising further). + # Import must not accumulate it. + model_yaml = ( + "name: sv\n" + "tables:\n" + "- name: c\n" + " base_table:\n" + " database: db\n" + " schema: public\n" + " table: customers\n" + " dimensions:\n" + " - name: full_name\n" + " expr: \"((c.first_name || ' ') || c.last_name)\"\n" + ) + expr = self._dimension_expr(convert_semantic_view_to_ossie(model_yaml)) + assert expr == "(first_name || ' ') || last_name" + + def test_export_re_adds_the_parentheses_the_grammar_requires(self): + ossie = ( + "version: 0.2.0.dev0\n" + "semantic_model:\n" + " - name: sv\n" + " datasets:\n" + " - name: c\n" + " source: public.customers\n" + " primary_key: [id]\n" + " fields:\n" + " - name: full_name\n" + " expression:\n" + " dialects:\n" + " - dialect: ANSI_SQL\n" + " expression: first_name || last_name\n" + ) + ddl = quiet_export(ossie) + assert "c.full_name AS (c.first_name || c.last_name)" in ddl + + +class TestTpcds: + def test_the_canonical_example_is_partially_inexpressible(self): + # Documented limitation rather than a bug: fail closed and name the metric. + with pytest.raises(ConversionError) as excinfo: + convert_ossie_to_semantic_view(TPCDS.read_text(encoding="utf-8")) + assert TPCDS_INEXPRESSIBLE_METRICS[0] in str(excinfo.value) + + def test_skipping_the_inexpressible_metrics_matches_the_golden_ddl(self): + ddl = quiet_export( + TPCDS.read_text(encoding="utf-8"), skip_unsupported_metrics=True + ) + assert ddl == read_fixture("tpcds_semantic_view.sql") + + def test_both_inexpressible_metrics_are_reported(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + convert_ossie_to_semantic_view( + TPCDS.read_text(encoding="utf-8"), skip_unsupported_metrics=True + ) + messages = " ".join(str(w.message) for w in caught) + for name in TPCDS_INEXPRESSIBLE_METRICS: + assert name in messages + + def test_all_five_datasets_become_tables(self): + ddl = read_fixture("tpcds_semantic_view.sql") + for alias in ("store_sales", "date_dim", "customer", "item", "store"): + assert f"{alias} AS public.{alias}" in ddl + + def test_the_composite_primary_key_survives(self): + assert "store_sales AS public.store_sales PRIMARY KEY (ss_item_sk, ss_ticket_number)" in ( + read_fixture("tpcds_semantic_view.sql") + ) + + def test_all_four_relationships_are_emitted(self): + ddl = read_fixture("tpcds_semantic_view.sql") + for rel in ( + "store_sales_to_date AS store_sales(ss_sold_date_sk) REFERENCES date_dim(d_date_sk)", + "store_sales_to_customer AS store_sales(ss_customer_sk) REFERENCES customer(c_customer_sk)", + "store_sales_to_item AS store_sales(ss_item_sk) REFERENCES item(i_item_sk)", + "store_sales_to_store AS store_sales(ss_store_sk) REFERENCES store(s_store_sk)", + ): + assert rel in ddl + + def test_the_computed_dimension_is_fully_qualified_and_parenthesised(self): + # Both properties are required for Hologres to accept it: every column needs its + # table, and a top-level operator needs parentheses. + assert ( + "customer.customer_full_name AS " + "(customer.c_first_name || ' ' || customer.c_last_name)" + ) in read_fixture("tpcds_semantic_view.sql") + + def test_only_the_expressible_metrics_are_emitted(self): + ddl = read_fixture("tpcds_semantic_view.sql") + for name in ("total_sales", "total_profit", "sales_by_brand"): + assert f"store_sales.{name} AS SUM(" in ddl + for name in TPCDS_INEXPRESSIBLE_METRICS: + assert name not in ddl diff --git a/converters/hologres/tests/test_semantic_view_to_ossie.py b/converters/hologres/tests/test_semantic_view_to_ossie.py new file mode 100644 index 00000000..89d46607 --- /dev/null +++ b/converters/hologres/tests/test_semantic_view_to_ossie.py @@ -0,0 +1,303 @@ +# 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. + +"""Tests for the Hologres model_yaml -> Apache Ossie import. + +The two model_yaml fixtures were read back from a real Hologres 5.0.0 instance via +`hologres.hg_semantic_view_properties`, so they are the shape Hologres actually emits +rather than a shape inferred from documentation. +""" + +import subprocess +import sys + +import pytest +from _util import SCHEMA, VALIDATOR, read_fixture +from ossie_hologres import ConversionError, convert_semantic_view_to_ossie +from ossie_hologres._common import dump_yaml, load_yaml + + +def import_view(view, **kwargs): + return load_yaml(convert_semantic_view_to_ossie(dump_yaml(view), **kwargs)) + + +def model_of(view, **kwargs): + return import_view(view, **kwargs)["semantic_model"][0] + + +def table(name="o", table_name="orders", columns=("id",), **extra): + entry = { + "name": name, + "base_table": {"database": "db", "schema": "public", "table": table_name}, + } + if columns: + entry["primary_key"] = {"columns": list(columns)} + entry.update(extra) + return entry + + +def view(*tables, **extra): + return {"name": "sv", "tables": list(tables), **extra} + + +class TestGoldenFixtures: + @pytest.mark.parametrize("name", ["fixtureA", "fixtureB"]) + def test_import_reproduces_the_ossie_fixture_exactly(self, name): + # The full loop is Ossie -> DDL -> Hologres -> model_yaml -> Ossie, and these + # model_yaml fixtures are the instance's own readback of the exported DDL. So a + # byte-identical result here means the pair round-trips losslessly. + imported = load_yaml(convert_semantic_view_to_ossie(read_fixture(f"{name}_model_yaml.yaml"))) + assert imported == load_yaml(read_fixture(f"{name}_ossie.yaml")) + + @pytest.mark.parametrize("name", ["fixtureA", "fixtureB"]) + def test_output_passes_the_official_validator(self, name, tmp_path): + out = tmp_path / "imported.yaml" + out.write_text( + convert_semantic_view_to_ossie(read_fixture(f"{name}_model_yaml.yaml")), + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, str(VALIDATOR), str(out), "--schema", str(SCHEMA)], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "Validation PASSED" in result.stdout + + +class TestDocumentValidation: + def test_non_mapping_root_is_rejected(self): + with pytest.raises(ConversionError, match="expected a mapping"): + convert_semantic_view_to_ossie("- a\n") + + def test_missing_tables_is_rejected(self): + with pytest.raises(ConversionError, match="'tables' must be a non-empty list"): + convert_semantic_view_to_ossie("name: sv\n") + + def test_missing_view_name_is_rejected(self): + with pytest.raises(ConversionError, match="missing required 'name'"): + convert_semantic_view_to_ossie(dump_yaml({"tables": [table()]})) + + def test_duplicate_table_alias_is_rejected(self): + with pytest.raises(ConversionError, match="duplicate table alias"): + import_view(view(table("o"), table("o"))) + + def test_model_name_can_be_overridden(self): + assert model_of(view(table()), model_name="custom")["name"] == "custom" + + def test_invalid_yaml_is_rejected(self): + with pytest.raises(ConversionError, match="Invalid YAML"): + convert_semantic_view_to_ossie("a: [\n") + + +class TestTables: + def test_alias_becomes_the_dataset_name(self): + # The alias is what dimensions, metrics and relationships all reference, so using + # it as the dataset name keeps those references valid with no rewriting. + assert model_of(view(table("o")))["datasets"][0]["name"] == "o" + + def test_base_table_becomes_a_three_part_source(self): + assert model_of(view(table()))["datasets"][0]["source"] == "db.public.orders" + + def test_partial_base_table_omits_the_missing_parts(self): + entry = {"name": "o", "base_table": {"table": "orders"}} + assert model_of(view(entry))["datasets"][0]["source"] == "orders" + + def test_missing_base_table_is_rejected(self): + with pytest.raises(ConversionError, match="missing required 'base_table'"): + import_view(view({"name": "o"})) + + def test_primary_key_columns_are_flattened(self): + assert model_of(view(table(columns=["a", "b"])))["datasets"][0]["primary_key"] == ["a", "b"] + + def test_table_without_a_primary_key_omits_the_field(self): + assert "primary_key" not in model_of(view(table(columns=None)))["datasets"][0] + + def test_table_with_metrics_but_no_dimensions_has_no_fields(self): + # Real Hologres views do this: a table can contribute only a metric. + entry = table("i", metrics=[{"name": "qty", "expr": "sum(i.quantity)"}]) + dataset = model_of(view(entry))["datasets"][0] + assert "fields" not in dataset + + +class TestDimensions: + def _field(self, expr, alias="o", **extra): + entry = table(alias, dimensions=[{"name": "d", "expr": expr, **extra}]) + return model_of(view(entry))["datasets"][0]["fields"][0] + + def test_owning_alias_qualifier_is_stripped(self): + # Hologres stores `o.region`; hand-authored Ossie uses the bare column. + assert self._field("o.region")["expression"]["dialects"][0]["expression"] == "region" + + def test_multi_column_expression_is_fully_unqualified(self): + expr = self._field("o.first || ' ' || o.last")["expression"]["dialects"][0]["expression"] + assert expr == "first || ' ' || last" + + def test_expressions_are_labelled_ansi(self): + assert self._field("o.region")["expression"]["dialects"][0]["dialect"] == "ANSI_SQL" + + def test_postgres_only_syntax_is_still_labelled_ansi(self): + # `->` is PostgreSQL JSON access, so the label is not strictly accurate. It is + # still the better trade: a converter that finds no ANSI_SQL expression drops the + # field outright, whereas this at worst surfaces as a SQL error on the target. + field = self._field("o.payload -> 'k'") + assert field["expression"]["dialects"][0]["dialect"] == "ANSI_SQL" + assert field["expression"]["dialects"][0]["expression"] == "payload -> 'k'" + + def test_postgres_cast_shorthand_becomes_a_standard_cast(self): + # The portable spelling is available, so no vendor dialect is needed to carry it. + expr = self._field("o.region::text")["expression"]["dialects"][0] + assert expr == {"dialect": "ANSI_SQL", "expression": "CAST(region AS TEXT)"} + + def test_description_is_carried_over(self): + assert self._field("o.region", description="A region")["description"] == "A region" + + def test_missing_expr_is_rejected(self): + with pytest.raises(ConversionError, match="missing required 'expr'"): + import_view(view(table("o", dimensions=[{"name": "d"}]))) + + def test_missing_dimension_name_is_rejected(self): + with pytest.raises(ConversionError, match="missing required 'name'"): + import_view(view(table("o", dimensions=[{"expr": "o.x"}]))) + + +class TestMetrics: + def _metrics(self, *entries, alias="o"): + return model_of(view(table(alias, metrics=list(entries)))).get("metrics", []) + + def test_metrics_are_lifted_to_the_model_level(self): + metrics = self._metrics({"name": "total", "expr": "sum(o.amount)"}) + assert metrics[0]["name"] == "total" + assert metrics[0]["expression"]["dialects"][0]["expression"] == "SUM(o.amount)" + + def test_metric_expressions_stay_dataset_qualified(self): + # Ossie model-level metrics are qualified by dataset name, and because the + # dataset name *is* the Hologres alias, the expression needs no rewriting. + expr = self._metrics({"name": "m", "expr": "sum(o.amount)"})[0] + assert "o.amount" in expr["expression"]["dialects"][0]["expression"] + + def test_count_star_records_its_owner_in_a_stash(self): + # The owner is not recoverable from `count(*)`, so it must be recorded for the + # export direction to reconstruct `o.order_count`. + metric = self._metrics({"name": "n", "expr": "count(*)"})[0] + assert metric["custom_extensions"] == [ + {"vendor_name": "HOLOGRES", "data": '{"_v": 1, "owner": "o"}'} + ] + + def test_metrics_with_a_column_reference_need_no_stash(self): + # The owner is implied by the expression, so stashing it would be noise. + assert "custom_extensions" not in self._metrics({"name": "m", "expr": "sum(o.amount)"})[0] + + def test_metrics_from_several_tables_are_merged_in_table_order(self): + model = model_of( + view( + table("o", metrics=[{"name": "total", "expr": "sum(o.amount)"}]), + table("c", table_name="customers", columns=["cid"], + metrics=[{"name": "credit", "expr": "sum(c.credit)"}]), + ) + ) + assert [m["name"] for m in model["metrics"]] == ["total", "credit"] + + def test_description_is_carried_over(self): + metric = self._metrics({"name": "m", "expr": "sum(o.amount)", "description": "Rev"})[0] + assert metric["description"] == "Rev" + + def test_a_view_without_metrics_omits_the_key(self): + assert "metrics" not in model_of(view(table())) + + +class TestRelationships: + def _rel(self, **overrides): + rel = { + "name": "rel_oc", + "left_table": "o", + "right_table": "c", + "relationship_columns": [{"left_column": "cid", "right_column": "cid"}], + "relationship_type": "many_to_one", + } + rel.update(overrides) + return view( + table("o"), + table("c", table_name="customers", columns=["cid"]), + relationships=[rel], + ) + + def test_left_becomes_from_and_right_becomes_to(self): + # Hologres' left is the many side holding the foreign key, which is Ossie's + # `from`; right is the one side holding the primary key, which is `to`. + rel = model_of(self._rel())["relationships"][0] + assert (rel["from"], rel["to"]) == ("o", "c") + assert (rel["from_columns"], rel["to_columns"]) == (["cid"], ["cid"]) + + def test_many_to_one_is_not_stashed(self): + # It is the only type Hologres defines and is already implied by from/to, so + # recording it would pollute every clean star schema. + rel = model_of(self._rel())["relationships"][0] + assert "custom_extensions" not in rel + + def test_relationship_type_defaults_to_many_to_one(self): + rel = self._rel() + del rel["relationships"][0]["relationship_type"] + assert model_of(rel)["relationships"][0]["from"] == "o" + + def test_unexpected_relationship_type_is_rejected(self): + with pytest.raises(ConversionError, match="unsupported relationship_type"): + import_view(self._rel(relationship_type="many_to_many")) + + def test_composite_relationship_columns_keep_their_pairing(self): + rel = self._rel( + relationship_columns=[ + {"left_column": "fk1", "right_column": "pk1"}, + {"left_column": "fk2", "right_column": "pk2"}, + ] + ) + imported = model_of(rel)["relationships"][0] + assert imported["from_columns"] == ["fk1", "fk2"] + assert imported["to_columns"] == ["pk1", "pk2"] + + def test_reference_to_an_unknown_table_is_rejected(self): + with pytest.raises(ConversionError, match="is not a table in this view"): + import_view(self._rel(right_table="nope")) + + def test_empty_relationship_columns_is_rejected(self): + with pytest.raises(ConversionError, match="'relationship_columns' must be a non-empty list"): + import_view(self._rel(relationship_columns=[])) + + def test_a_view_without_relationships_omits_the_key(self): + assert "relationships" not in model_of(view(table())) + + +class TestYamlEdgeCases: + def test_a_dimension_named_like_a_yaml_boolean_survives(self): + # A YAML 1.1 reader turns `name: no` into False, silently renaming the dimension. + raw = ( + "name: sv\n" + "tables:\n" + "- name: o\n" + " base_table:\n" + " table: t\n" + " dimensions:\n" + " - name: no\n" + " expr: o.x\n" + ) + imported = load_yaml(convert_semantic_view_to_ossie(raw)) + assert imported["semantic_model"][0]["datasets"][0]["fields"][0]["name"] == "no" + + def test_unicode_description_is_preserved(self): + model = model_of(view(table(), description="销售分析语义视图")) + assert model["description"] == "销售分析语义视图" diff --git a/converters/hologres/uv.lock b/converters/hologres/uv.lock new file mode 100644 index 00000000..8ff149ae --- /dev/null +++ b/converters/hologres/uv.lock @@ -0,0 +1,419 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "apache-ossie-hologres" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "sqlglot" }, +] + +[package.dev-dependencies] +dev = [ + { name = "jsonschema" }, + { name = "pytest" }, +] +live = [ + { name = "psycopg", extra = ["binary"] }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6.0" }, + { name = "sqlglot", specifier = ">=30.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "jsonschema", specifier = ">=4.26.0" }, + { name = "pytest", specifier = ">=8.0" }, +] +live = [{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[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 = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "sqlglot" +version = "30.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/42/97ae59ab9479f345f97db948792b41bdaf51c38589f994ab97014ffae2b9/sqlglot-30.16.0.tar.gz", hash = "sha256:26abd1ef583fbecde24931eb56a5df4a086ba50ba0b36738233da9da16ffea13", size = 5984853, upload-time = "2026-08-10T15:43:47.176Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/bc/c92985806306174ae08ca84576a62659e8a406d7f670d2b2bc1a96340f87/sqlglot-30.16.0-py3-none-any.whl", hash = "sha256:f14f119e87bb1397316ab2b3c1f12dddd3b098921185dc69935952111be91788", size = 737757, upload-time = "2026-08-10T15:43:45.331Z" }, +] + +[[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 = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +]