From cdfa891373b560e21cdf1d77c8a4d36f0651245b Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Tue, 11 Aug 2026 21:22:23 +0800 Subject: [PATCH 01/13] feat(core-spec): add HOLOGRES expression dialect Hologres V5.0.0+ supports CREATE SEMANTIC VIEW, so an Ossie converter for it needs a way to label Hologres expressions. Without a dedicated token, PostgreSQL-specific syntax such as `col::text` would have to be mislabelled as ANSI_SQL, which misleads consumers about portability. Hologres is PostgreSQL wire- and dialect-compatible, so the validator maps the new token to sqlglot's postgres dialect and keeps SQL parsing enabled rather than skipping validation. --- core-spec/osi-schema.json | 2 +- core-spec/spec.md | 1 + core-spec/spec.yaml | 1 + python/src/ossie/models.py | 1 + validation/validate.py | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/core-spec/osi-schema.json b/core-spec/osi-schema.json index f24e45f1..418e1007 100644 --- a/core-spec/osi-schema.json +++ b/core-spec/osi-schema.json @@ -23,7 +23,7 @@ "$defs": { "Dialect": { "type": "string", - "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "BIGQUERY"], + "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "BIGQUERY", "HOLOGRES"], "description": "Supported SQL and expression language dialects" }, "Vendor": { diff --git a/core-spec/spec.md b/core-spec/spec.md index 156cb1db..bfd053a1 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -58,6 +58,7 @@ Supported SQL and expression language dialects for metrics and field definitions | `DATABRICKS` | Databricks SQL | | `MAQL` | GoodData MAQL (Metric Analysis and Query Language) | | `BIGQUERY` | Google BigQuery (GoogleSQL) | +| `HOLOGRES` | Alibaba Cloud Hologres (PostgreSQL-compatible) | ### Data types diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index 32fbb3e1..f79cafd0 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -37,6 +37,7 @@ dialects: - "DATABRICKS" # Databricks SQL - "MAQL" # GoodData MAQL (Multi-Dimensional Analytical Query Language) - "BIGQUERY" # Google BigQuery GoogleSQL + - "HOLOGRES" # Alibaba Cloud Hologres (PostgreSQL-compatible) # Supported logical data types for fields and metrics # TODO: Generate this list from the authoritative DataType enum in diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py index 5406a743..54404960 100644 --- a/python/src/ossie/models.py +++ b/python/src/ossie/models.py @@ -32,6 +32,7 @@ class OSIDialect(str, Enum): TABLEAU = "TABLEAU" DATABRICKS = "DATABRICKS" BIGQUERY = "BIGQUERY" + HOLOGRES = "HOLOGRES" class OSIDataType(str, Enum): diff --git a/validation/validate.py b/validation/validate.py index 258d34f1..7b148591 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -66,6 +66,7 @@ "SNOWFLAKE": "snowflake", "DATABRICKS": "databricks", "BIGQUERY": "bigquery", + "HOLOGRES": "postgres", # Hologres is PostgreSQL-compatible "MDX": None, # Not supported by sqlglot, skip validation "TABLEAU": None, # Not supported by sqlglot, skip validation "MAQL": None, # Not supported by sqlglot, skip validation From c4097085b9253fc9bf3c765c4e03d9596c888f96 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 06:32:38 +0800 Subject: [PATCH 02/13] feat(hologres): scaffold converter package and shared helpers Sets up the converter sub-project and the cross-cutting helpers both directions need: the custom_extensions stash protocol, YAML 1.2 loading, SQL quoting, and a sqlglot expression layer. Two details are worth calling out because they are easy to get wrong: - sqlglot does not quote reserved words on output (it emits a bare `select` for the identifier `select`, a syntax error), so quote_identifier carries its own reserved-word set. It also quotes anything not already lower case, since PostgreSQL folds unquoted identifiers. - Metric aggregates are matched by exact sqlglot node type rather than isinstance(node, AggFunc): `stddev` and `array_agg` are AggFuncs too, but Hologres only accepts count/sum/avg/min/max. The live dependency group holds the database driver and is excluded from default-groups so CI never installs a driver it cannot use. --- converters/hologres/README.md | 42 ++ converters/hologres/pyproject.toml | 74 +++ .../hologres/src/ossie_hologres/__init__.py | 34 ++ .../hologres/src/ossie_hologres/_common.py | 453 ++++++++++++++++++ converters/hologres/tests/conftest.py | 23 + converters/hologres/tests/test_common.py | 365 ++++++++++++++ converters/hologres/uv.lock | 419 ++++++++++++++++ 7 files changed, 1410 insertions(+) create mode 100644 converters/hologres/README.md create mode 100644 converters/hologres/pyproject.toml create mode 100644 converters/hologres/src/ossie_hologres/__init__.py create mode 100644 converters/hologres/src/ossie_hologres/_common.py create mode 100644 converters/hologres/tests/conftest.py create mode 100644 converters/hologres/tests/test_common.py create mode 100644 converters/hologres/uv.lock diff --git a/converters/hologres/README.md b/converters/hologres/README.md new file mode 100644 index 00000000..99c9a59d --- /dev/null +++ b/converters/hologres/README.md @@ -0,0 +1,42 @@ + + +# 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. + +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. + +## Development + +```bash +uv sync +uv run pytest +``` + +The live tests against a real Hologres instance are skipped unless the `HOLOGRES_*` +environment variables are set. See the Development section below once implemented. 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..263caa50 --- /dev/null +++ b/converters/hologres/src/ossie_hologres/__init__.py @@ -0,0 +1,34 @@ +# 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 + +__all__ = [ + "ConversionError", +] diff --git a/converters/hologres/src/ossie_hologres/_common.py b/converters/hologres/src/ossie_hologres/_common.py new file mode 100644 index 00000000..e523e802 --- /dev/null +++ b/converters/hologres/src/ossie_hologres/_common.py @@ -0,0 +1,453 @@ +# 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 and for dialect selection. +VENDOR = "HOLOGRES" + +# Expression dialects this converter understands, in preference order. +DIALECT_HOLOGRES = "HOLOGRES" +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 quote_identifier(name, what): + """Render `name` as a SQL identifier, double-quoting it only when necessary. + + Quoting is required for anything that is not already a bare lower-case identifier + (PostgreSQL folds unquoted identifiers to lower case) and for reserved keywords, + which are a syntax error when left bare. + """ + 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 is_simple_identifier(name) and name not in _RESERVED_WORDS: + 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: HOLOGRES, else ANSI_SQL. + + Returns None if neither dialect is present, 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_HOLOGRES) or 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) + + +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 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)) + 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))})" + ) + 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/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/test_common.py b/converters/hologres/tests/test_common.py new file mode 100644 index 00000000..9155cb1e --- /dev/null +++ b/converters/hologres/tests/test_common.py @@ -0,0 +1,365 @@ +# 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, + DIALECT_HOLOGRES, + 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_prefers_hologres_over_ansi(self): + expr = self._expr((DIALECT_ANSI, "region"), (DIALECT_HOLOGRES, "region::text")) + assert pick_expression(expr) == "region::text" + + def test_falls_back_to_ansi(self): + assert pick_expression(self._expr((DIALECT_ANSI, "region"))) == "region" + + def test_returns_none_when_no_usable_dialect(self): + assert pick_expression(self._expr(("MDX", "[Region]"))) 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/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" }, +] From d357184597a7a780494ea847f182d186484f5b45 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 06:44:39 +0800 Subject: [PATCH 03/13] feat(hologres): export OSSIE to CREATE SEMANTIC VIEW DDL Emits SQL DDL rather than YAML because Hologres has no YAML import function -- the DDL is the only way to create a Semantic View. The conversion is fail-closed: Hologres enforces single-table definitions, a five-function aggregate whitelist, and REFERENCES targets that must be the referenced table's primary key. Each of those is checked offline with the offending field named, rather than emitting DDL the server will reject. Two mappings do real work. Ossie field expressions are unqualified bare columns while Hologres needs alias.column, so columns are qualified through the sqlglot AST -- a "prefix only bare identifiers" shortcut would leave `first || last` half-qualified and unresolvable. And Ossie metrics are model-level while Hologres namespaces them under an owning alias, so the owner is inferred from the expression's column references; `count(*)` has none, so it needs an explicit owner and fails closed without one, since guessing would silently change the number under a fan-out join. Also fixes reserved-word quoting inside expressions: sqlglot renders the identifier `user` bare, so a column named `user` on an alias named `order` produced the invalid `order.user`. Both golden .sql fixtures were executed against a real Hologres 5.0.0 instance and the resulting views queried, including the fan-out case where order revenue must not be inflated by joined order_items rows. --- .../hologres/src/ossie_hologres/__init__.py | 2 + .../hologres/src/ossie_hologres/_common.py | 32 +- .../ossie_hologres/ossie_to_semantic_view.py | 528 +++++++++++++++++ converters/hologres/tests/_util.py | 37 ++ .../tests/fixtures/fixtureA_ossie.yaml | 35 ++ .../tests/fixtures/fixtureA_semantic_view.sql | 13 + .../tests/fixtures/fixtureB_ossie.yaml | 72 +++ .../tests/fixtures/fixtureB_semantic_view.sql | 23 + .../tests/test_ossie_to_semantic_view.py | 542 ++++++++++++++++++ 9 files changed, 1277 insertions(+), 7 deletions(-) create mode 100644 converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py create mode 100644 converters/hologres/tests/_util.py create mode 100644 converters/hologres/tests/fixtures/fixtureA_ossie.yaml create mode 100644 converters/hologres/tests/fixtures/fixtureA_semantic_view.sql create mode 100644 converters/hologres/tests/fixtures/fixtureB_ossie.yaml create mode 100644 converters/hologres/tests/fixtures/fixtureB_semantic_view.sql create mode 100644 converters/hologres/tests/test_ossie_to_semantic_view.py diff --git a/converters/hologres/src/ossie_hologres/__init__.py b/converters/hologres/src/ossie_hologres/__init__.py index 263caa50..6c7cff92 100644 --- a/converters/hologres/src/ossie_hologres/__init__.py +++ b/converters/hologres/src/ossie_hologres/__init__.py @@ -28,7 +28,9 @@ """ from ._common import ConversionError +from .ossie_to_semantic_view import convert_ossie_to_semantic_view __all__ = [ "ConversionError", + "convert_ossie_to_semantic_view", ] diff --git a/converters/hologres/src/ossie_hologres/_common.py b/converters/hologres/src/ossie_hologres/_common.py index e523e802..66716031 100644 --- a/converters/hologres/src/ossie_hologres/_common.py +++ b/converters/hologres/src/ossie_hologres/_common.py @@ -206,19 +206,24 @@ def is_simple_identifier(text): return isinstance(text, str) and bool(_BARE_IDENTIFIER_RE.match(text.strip())) -def quote_identifier(name, what): - """Render `name` as a SQL identifier, double-quoting it only when necessary. +def needs_quoting(name): + """True if `name` must be double-quoted to be a valid SQL identifier. - Quoting is required for anything that is not already a bare lower-case identifier - (PostgreSQL folds unquoted identifiers to lower case) and for reserved keywords, - which are a syntax error when left bare. + 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 is_simple_identifier(name) and name not in _RESERVED_WORDS: + if not needs_quoting(name): return name escaped = name.replace('"', '""') return f'"{escaped}"' @@ -390,6 +395,16 @@ def assert_row_level(node, what): ) +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. @@ -401,7 +416,7 @@ def qualify_columns(node, alias, known_aliases, what): for col in node.find_all(exp.Column): qualifier = col.table if not qualifier: - col.set("table", exp.to_identifier(alias)) + col.set("table", exp.to_identifier(alias, quoted=needs_quoting(alias))) elif qualifier != alias: if qualifier in known_aliases: raise ConversionError( @@ -412,6 +427,9 @@ def qualify_columns(node, alias, known_aliases, what): 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 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..b5cca1d5 --- /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_expression, + 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 HOLOGRES or 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_expression(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 HOLOGRES or 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_expression(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/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/fixtures/fixtureA_ossie.yaml b/converters/hologres/tests/fixtures/fixtureA_ossie.yaml new file mode 100644 index 00000000..d59c75d7 --- /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: test50.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_ossie.yaml b/converters/hologres/tests/fixtures/fixtureB_ossie.yaml new file mode 100644 index 00000000..0e4812f2 --- /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: test50.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: test50.public.svacc_customers + primary_key: [customer_id] + fields: + - name: city_dim + expression: + dialects: + - dialect: ANSI_SQL + expression: city + description: 客户城市 + - name: i + source: test50.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/test_ossie_to_semantic_view.py b/converters/hologres/tests/test_ossie_to_semantic_view.py new file mode 100644 index 00000000..94506cf4 --- /dev/null +++ b/converters/hologres/tests/test_ossie_to_semantic_view.py @@ -0,0 +1,542 @@ +# 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"), + [ + ("test50.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. + 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 HOLOGRES or ANSI_SQL"): + export(model) + + def test_hologres_dialect_wins_over_ansi(self): + model = one_table( + fields=[ + { + "name": "d", + "expression": { + "dialects": [ + {"dialect": "ANSI_SQL", "expression": "x"}, + {"dialect": "HOLOGRES", "expression": "x::text"}, + ] + }, + } + ] + ) + assert "o.d AS CAST(o.x AS TEXT)" in export(model) + + +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)")])) == [] From e47d18cb0d9792e4e6fef688c106ee87555a203f Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 06:57:42 +0800 Subject: [PATCH 04/13] feat(hologres): import Semantic View model_yaml to OSSIE Consumes the model_yaml that Hologres publishes in hologres.hg_semantic_view_properties, rather than the ddl_text beside it, because it is already structured and needs no SQL statement parser. The Hologres table alias becomes the Ossie dataset name. That keeps every dimension, metric and relationship reference valid without rewriting, and makes Hologres' own `sum(o.amount)` already correct as a dataset-qualified Ossie metric expression. Expressions are labelled ANSI_SQL unless they actually need PostgreSQL syntax, decided by checking whether sqlglot renders the AST identically outside the postgres dialect. Labelling an ordinary SUM(x) as HOLOGRES would hide it from every other Ossie converter looking for a portable expression; `j -> 'k'` and the 1-based `arr[1]` genuinely do differ and are labelled HOLOGRES. Both model_yaml fixtures were read back from a real Hologres 5.0.0 instance after executing the exported DDL, so the full loop is verified end to end: importing them reproduces the original Ossie fixtures exactly, and the output passes the repository's own validation/validate.py. --- .../hologres/src/ossie_hologres/__init__.py | 2 + .../hologres/src/ossie_hologres/_common.py | 13 + .../ossie_hologres/semantic_view_to_ossie.py | 220 +++++++++++++ .../tests/fixtures/fixtureA_model_yaml.yaml | 24 ++ .../tests/fixtures/fixtureB_model_yaml.yaml | 65 ++++ .../tests/test_semantic_view_to_ossie.py | 294 ++++++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py create mode 100644 converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml create mode 100644 converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml create mode 100644 converters/hologres/tests/test_semantic_view_to_ossie.py diff --git a/converters/hologres/src/ossie_hologres/__init__.py b/converters/hologres/src/ossie_hologres/__init__.py index 6c7cff92..6195b61c 100644 --- a/converters/hologres/src/ossie_hologres/__init__.py +++ b/converters/hologres/src/ossie_hologres/__init__.py @@ -29,8 +29,10 @@ 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 index 66716031..76b2215f 100644 --- a/converters/hologres/src/ossie_hologres/_common.py +++ b/converters/hologres/src/ossie_hologres/_common.py @@ -355,6 +355,19 @@ def render_expression(node): return node.sql(dialect=SQLGLOT_DIALECT) +def is_portable_expression(node): + """True if the expression carries no PostgreSQL-specific syntax. + + Decided by asking sqlglot to render the node with and without the postgres dialect: + if both agree the expression is portable. `SUM(x)`, `COUNT(*)` and `CASE` render the + same either way, while `j -> 'k'`, `x ~ 'abc'` and the 1-based `arr[1]` do not. + + This keeps the import direction from labelling ordinary SQL as HOLOGRES, which would + hide it from every other Ossie converter looking for an ANSI_SQL expression. + """ + return node.sql() == node.sql(dialect=SQLGLOT_DIALECT) + + def normalize_expression(text, what="expression"): """Round-trip an expression through sqlglot to get its canonical form. 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..7bbd3a26 --- /dev/null +++ b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py @@ -0,0 +1,220 @@ +# 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, + DIALECT_HOLOGRES, + OSSIE_VERSION, + STASH_OWNER, + ConversionError, + column_refs, + dump_yaml, + is_portable_expression, + load_yaml, + ossie_expression, + parse_expression, + render_expression, + require_str, + 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. + node = unqualify_columns(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): + """Label an expression with the narrowest dialect that honestly describes it. + + Anything that renders the same outside the postgres dialect is portable and gets + ANSI_SQL, so an ordinary `SUM(o.amount)` stays usable by every other Ossie + converter. Only genuinely PostgreSQL-specific syntax is labelled HOLOGRES. + """ + dialect = DIALECT_ANSI if is_portable_expression(node) else DIALECT_HOLOGRES + return ossie_expression(render_expression(node), dialect) + + +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/fixtures/fixtureA_model_yaml.yaml b/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml new file mode 100644 index 00000000..fd4cf4ff --- /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: test50 + 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/fixtureB_model_yaml.yaml b/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml new file mode 100644 index 00000000..8c120b72 --- /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: test50 + 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: test50 + 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: test50 + 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/test_semantic_view_to_ossie.py b/converters/hologres/tests/test_semantic_view_to_ossie.py new file mode 100644 index 00000000..3c1d6c99 --- /dev/null +++ b/converters/hologres/tests/test_semantic_view_to_ossie.py @@ -0,0 +1,294 @@ +# 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_portable_expression_is_labelled_ansi(self): + assert self._field("o.region")["expression"]["dialects"][0]["dialect"] == "ANSI_SQL" + + def test_postgres_specific_expression_is_labelled_hologres(self): + # `->` is PostgreSQL JSON access; labelling it ANSI_SQL would misrepresent it. + assert self._field("o.payload -> 'k'")["expression"]["dialects"][0]["dialect"] == "HOLOGRES" + + 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"] == "销售分析语义视图" From 061d1b7fc5212ceb42a049e78e2745d4842d4110 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 06:59:26 +0800 Subject: [PATCH 05/13] feat(hologres): add ossie-hologres CLI Wires both directions up to a command line, following the export/import subcommand shape the other converters use. Two options exist because of Hologres constraints rather than preference: --drop-if-exists, since there is no CREATE OR REPLACE or ALTER SEMANTIC VIEW and a definition can only be changed by recreating it; and --metric-owner, to supply the owning table for a COUNT(*) metric whose expression names no column. --- converters/hologres/README.md | 50 ++++++- converters/hologres/src/ossie_hologres/cli.py | 130 ++++++++++++++++++ converters/hologres/tests/test_cli.py | 124 +++++++++++++++++ 3 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 converters/hologres/src/ossie_hologres/cli.py create mode 100644 converters/hologres/tests/test_cli.py diff --git a/converters/hologres/README.md b/converters/hologres/README.md index 99c9a59d..2f9e8887 100644 --- a/converters/hologres/README.md +++ b/converters/hologres/README.md @@ -31,6 +31,54 @@ its Semantic View definitions in different formats: - **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) +``` + ## Development ```bash @@ -39,4 +87,4 @@ uv run pytest ``` The live tests against a real Hologres instance are skipped unless the `HOLOGRES_*` -environment variables are set. See the Development section below once implemented. +environment variables are set. 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/tests/test_cli.py b/converters/hologres/tests/test_cli.py new file mode 100644 index 00000000..e2f37fa4 --- /dev/null +++ b/converters/hologres/tests/test_cli.py @@ -0,0 +1,124 @@ +# 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): + 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"]) From 587eb38106298bf79cf06ebbf234b7334e3ffaf0 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 07:22:12 +0800 Subject: [PATCH 06/13] test(hologres): round-trip closure and TPC-DS coverage Also fixes a real grammar bug found by running the TPC-DS DDL against the instance: Hologres rejects a bare top-level operator in a definition expression. `a || b`, `a + 1` and `a::text` are all syntax errors in a DIMENSIONS clause, 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. The manual's "operators are supported" does not mention this, so the exporter now adds the required parentheses and the importer strips them back off, keeping round trips from accumulating wrapping. The canonical examples/tpcds_semantic_model.yaml is referenced directly rather than copied into a fixture, so these tests cannot drift from it. Two of its five metrics are structurally inexpressible -- customer_lifetime_value and store_productivity are ratios spanning two datasets -- so the default is to fail closed naming the metric, with --skip-unsupported-metrics to convert the rest. The resulting 5-table snowflake was created on a real Hologres 5.0.0 instance and queried across all four dimension tables, including the computed customer_full_name dimension that needs both full qualification and the parentheses above. --- .../hologres/src/ossie_hologres/_common.py | 28 +++ .../ossie_hologres/ossie_to_semantic_view.py | 6 +- .../ossie_hologres/semantic_view_to_ossie.py | 5 +- .../tests/fixtures/tpcds_semantic_view.sql | 53 ++++++ converters/hologres/tests/test_cli.py | 3 +- .../tests/test_ossie_to_semantic_view.py | 53 +++++- converters/hologres/tests/test_roundtrip.py | 174 ++++++++++++++++++ 7 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 converters/hologres/tests/fixtures/tpcds_semantic_view.sql create mode 100644 converters/hologres/tests/test_roundtrip.py diff --git a/converters/hologres/src/ossie_hologres/_common.py b/converters/hologres/src/ossie_hologres/_common.py index 76b2215f..dd554809 100644 --- a/converters/hologres/src/ossie_hologres/_common.py +++ b/converters/hologres/src/ossie_hologres/_common.py @@ -368,6 +368,34 @@ def is_portable_expression(node): return node.sql() == 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. diff --git a/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py b/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py index b5cca1d5..04e1de4f 100644 --- a/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py +++ b/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py @@ -46,7 +46,7 @@ quote_identifier, quote_literal, read_stash, - render_expression, + render_definition, require_str, ) @@ -378,7 +378,7 @@ def _render_dimensions(datasets, aliases): entry = ( f"{quote_identifier(alias, what)}.{quote_identifier(field_name, what)} " - f"AS {render_expression(node)}" + f"AS {render_definition(node)}" ) comment = merge_description(field.get("description"), field.get("ai_context")) if comment: @@ -454,7 +454,7 @@ def _render_metrics(model, aliases, metric_owners, skip_unsupported): entry = ( f"{quote_identifier(owner, what)}.{quote_identifier(metric_name, what)} " - f"AS {render_expression(node)}" + f"AS {render_definition(node)}" ) comment = merge_description(metric.get("description"), metric.get("ai_context")) if comment: diff --git a/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py index 7bbd3a26..9a6b5c5a 100644 --- a/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py +++ b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py @@ -49,6 +49,7 @@ parse_expression, render_expression, require_str, + strip_parens, unqualify_columns, write_stash, ) @@ -135,7 +136,9 @@ def _convert_dimension(dim, alias, table_what): # Hologres always writes dimension expressions alias-qualified (`o.region`). Ossie # field expressions are conventionally bare column names, so drop the owning alias. - node = unqualify_columns(parse_expression(expr_text, what), 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") 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 index e2f37fa4..c5bcc5f1 100644 --- a/converters/hologres/tests/test_cli.py +++ b/converters/hologres/tests/test_cli.py @@ -81,7 +81,8 @@ def test_without_it_the_metric_cannot_be_converted(self, tmp_path, capsys): assert "--metric-owner" in capsys.readouterr().err def test_skip_unsupported_metrics_drops_it_instead(self, tmp_path, capsys): - code = main(["export", "-i", str(self._model(tmp_path)), "--skip-unsupported-metrics"]) + 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 diff --git a/converters/hologres/tests/test_ossie_to_semantic_view.py b/converters/hologres/tests/test_ossie_to_semantic_view.py index 94506cf4..cd6b1e43 100644 --- a/converters/hologres/tests/test_ossie_to_semantic_view.py +++ b/converters/hologres/tests/test_ossie_to_semantic_view.py @@ -309,9 +309,10 @@ def test_already_qualified_column_is_left_alone(self): 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. + # 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 + 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")])) @@ -374,6 +375,54 @@ def test_hologres_dialect_wins_over_ansi(self): assert "o.d AS CAST(o.x AS TEXT)" in export(model) +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)")])) 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 From 18c74f58f96e602508ebb91bfc2653c5e75493f8 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 07:26:20 +0800 Subject: [PATCH 07/13] test(hologres): env-gated live Hologres verification A CREATE SEMANTIC VIEW statement can only really be validated by a Hologres server, so these tests create the view, query it, and read the model back. They prove three things the offline tests cannot: the generated DDL is accepted, the resulting view answers queries with the right numbers, and importing Hologres' own readback reproduces the model we started from. The fan-out case is checked with real rows rather than just parsed: each order has several order_items rows, and order revenue must stay 300/200 rather than being multiplied by them. Beijing's credit of 2500 likewise includes a customer with no orders at all, which is what aggregating before joining buys. Skipped unless HOLOGRES_HOST/USER/PASSWORD/DB are set, and the driver lives in a non-default dependency group, so CI stays hermetic. Credentials are read only from the environment and none are stored in the repository. Everything is created inside an ossie_hologres_it schema that is dropped on teardown. Also renames the fixtures' placeholder database from a real instance's name to `retail`, so no internal identifier ships upstream. --- converters/hologres/README.md | 25 +- .../tests/fixtures/fixtureA_model_yaml.yaml | 2 +- .../tests/fixtures/fixtureA_ossie.yaml | 2 +- .../tests/fixtures/fixtureB_model_yaml.yaml | 6 +- .../tests/fixtures/fixtureB_ossie.yaml | 6 +- .../hologres/tests/test_live_hologres.py | 300 ++++++++++++++++++ .../tests/test_ossie_to_semantic_view.py | 2 +- 7 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 converters/hologres/tests/test_live_hologres.py diff --git a/converters/hologres/README.md b/converters/hologres/README.md index 2f9e8887..f2f16f20 100644 --- a/converters/hologres/README.md +++ b/converters/hologres/README.md @@ -86,5 +86,26 @@ uv sync uv run pytest ``` -The live tests against a real Hologres instance are skipped unless the `HOLOGRES_*` -environment variables are set. +### 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. diff --git a/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml b/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml index fd4cf4ff..dd39c814 100644 --- a/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml +++ b/converters/hologres/tests/fixtures/fixtureA_model_yaml.yaml @@ -3,7 +3,7 @@ description: Single-table order analysis tables: - name: o base_table: - database: test50 + database: retail schema: public table: svacc_orders primary_key: diff --git a/converters/hologres/tests/fixtures/fixtureA_ossie.yaml b/converters/hologres/tests/fixtures/fixtureA_ossie.yaml index d59c75d7..db8aaf3f 100644 --- a/converters/hologres/tests/fixtures/fixtureA_ossie.yaml +++ b/converters/hologres/tests/fixtures/fixtureA_ossie.yaml @@ -4,7 +4,7 @@ semantic_model: description: Single-table order analysis datasets: - name: o - source: test50.public.svacc_orders + source: retail.public.svacc_orders primary_key: [order_id] fields: - name: region_dim diff --git a/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml b/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml index 8c120b72..bf471a4b 100644 --- a/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml +++ b/converters/hologres/tests/fixtures/fixtureB_model_yaml.yaml @@ -3,7 +3,7 @@ description: 销售分析语义视图 tables: - name: o base_table: - database: test50 + database: retail schema: public table: svacc_orders primary_key: @@ -21,7 +21,7 @@ tables: expr: "count(*)" - name: c base_table: - database: test50 + database: retail schema: public table: svacc_customers primary_key: @@ -38,7 +38,7 @@ tables: expr: avg(c.credit_limit) - name: i base_table: - database: test50 + database: retail schema: public table: svacc_order_items primary_key: diff --git a/converters/hologres/tests/fixtures/fixtureB_ossie.yaml b/converters/hologres/tests/fixtures/fixtureB_ossie.yaml index 0e4812f2..c5d9ec45 100644 --- a/converters/hologres/tests/fixtures/fixtureB_ossie.yaml +++ b/converters/hologres/tests/fixtures/fixtureB_ossie.yaml @@ -4,7 +4,7 @@ semantic_model: description: 销售分析语义视图 datasets: - name: o - source: test50.public.svacc_orders + source: retail.public.svacc_orders primary_key: [order_id] fields: - name: region_dim @@ -18,7 +18,7 @@ semantic_model: - dialect: ANSI_SQL expression: status - name: c - source: test50.public.svacc_customers + source: retail.public.svacc_customers primary_key: [customer_id] fields: - name: city_dim @@ -28,7 +28,7 @@ semantic_model: expression: city description: 客户城市 - name: i - source: test50.public.svacc_order_items + source: retail.public.svacc_order_items primary_key: [item_id] relationships: - name: rel_oc 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 index cd6b1e43..3720734c 100644 --- a/converters/hologres/tests/test_ossie_to_semantic_view.py +++ b/converters/hologres/tests/test_ossie_to_semantic_view.py @@ -148,7 +148,7 @@ class TestSourceParsing: @pytest.mark.parametrize( ("source", "expected"), [ - ("test50.public.orders", "public.orders"), + ("retail.public.orders", "public.orders"), ("public.orders", "public.orders"), ("orders", "orders"), ], From 2994ced03aa5f035f65e66da8801dc396a178fff Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 07:27:37 +0800 Subject: [PATCH 08/13] ci(hologres): add path-filtered converter CI Mirrors the sibling converter workflows, reusing their pinned action SHAs and the 3.11-3.14 matrix. The path filter additionally watches examples/tpcds_semantic_model.yaml, validation/validate.py and core-spec/osi-schema.json. This converter's tests read those three files directly instead of copying them, so without listing them a change to the canonical example could break the golden fixture without CI noticing on the PR that caused it. `uv sync` deliberately omits the live group: those tests need a Hologres instance and skip themselves without one. --- .github/workflows/converter-hologres-ci.yml | 75 +++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/converter-hologres-ci.yml 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 From d5795226a13ad4fe35cdc1a44de792d9b3b72050 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 07:28:55 +0800 Subject: [PATCH 09/13] docs(hologres): document the Hologres converter Records the mapping, and separates limitations that come from Hologres from choices the converter makes. The limitations section is the part worth reading before using this: single-table definitions, the five-function aggregate whitelist, no derived or ratio metrics, and REFERENCES targets that must be a primary key. Also notes what the fixtures actually are -- DDL executed against a real instance and that instance's own readback -- so a future maintainer knows they are verified artifacts rather than hand-written guesses, and that a live test guards them against Hologres changing its output shape. --- converters/README.md | 1 + converters/hologres/README.md | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) 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 index f2f16f20..d9daedc7 100644 --- a/converters/hologres/README.md +++ b/converters/hologres/README.md @@ -22,6 +22,11 @@ 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: @@ -79,6 +84,93 @@ 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. + +## 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 @@ -109,3 +201,18 @@ Credentials are read only from the environment; none are stored in this reposito 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. From b76db782400f4a77656e43117585020c60f167e1 Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 07:42:01 +0800 Subject: [PATCH 10/13] docs(core-spec): document the HOLOGRES dialect Follow-up to adding the HOLOGRES token to the Dialect enum. Two places described the available dialects but not this one. expression_language.md is where someone writing a dialect-specific expression looks, and its Common Dialect Variations table already carries a PostgreSQL column, so the note points HOLOGRES at that column and names the cases that actually justify the tag rather than ANSI_SQL. docs/index.md listed the dialects as if exhaustively but was already missing BIGQUERY and MAQL, so the list is now completed against the schema enum instead of having HOLOGRES appended to a wrong list. --- core-spec/expression_language.md | 6 ++++++ docs/index.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core-spec/expression_language.md b/core-spec/expression_language.md index 42299977..31b6ae7b 100644 --- a/core-spec/expression_language.md +++ b/core-spec/expression_language.md @@ -669,6 +669,12 @@ expression: | Current timestamp | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP` | | Substring | `SUBSTRING(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTRING(s, start, len)` | `SUBSTRING(s, start, len)` | +The `HOLOGRES` dialect follows the PostgreSQL column: Alibaba Cloud Hologres is +PostgreSQL wire- and dialect-compatible. Prefer `ANSI_SQL` for expressions that need no +PostgreSQL-specific syntax, and reserve `HOLOGRES` for the ones that do — such as `a || b` +for string concatenation, `j -> 'k'` for JSON access, `s ~ 'pattern'` for regular +expression matching, or the 1-based `arr[1]` array indexing. + ### ### Dialect-Specific Extensions diff --git a/docs/index.md b/docs/index.md index 738c5724..6831aa9b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,7 +59,7 @@ The Ossie core specification (current version: **0.2.0.dev0**, latest released: | **Custom Extensions** | Vendor-specific metadata stored as JSON, allowing platforms to carry additional information without breaking core compatibility. | | **AI Context** | Optional annotations at every level (model, dataset, field, relationship, metric) to help AI tools understand business meaning — including instructions, synonyms, and example queries. | -The specification supports multiple SQL dialects (`ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`, `MDX`, `TABLEAU`) so that expressions can be tailored to each platform while maintaining a common model structure. +The specification supports multiple SQL dialects (`ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`, `BIGQUERY`, `HOLOGRES`, `MAQL`, `MDX`, `TABLEAU`) so that expressions can be tailored to each platform while maintaining a common model structure. For the full specification, see [core-spec/spec.md](../core-spec/spec.md). For validation tooling, see [validation/validate.py](../validation/validate.py). For a complete example, see the [TPC-DS semantic model](../examples/tpcds_semantic_model.yaml). From e20ceac75d931b18901addc99fce74e56947be1b Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 07:47:11 +0800 Subject: [PATCH 11/13] docs(hologres): list the Hologres converter in the roadmap Adds it to the Existing Artifacts list under Tooling & Ecosystem Support, noting the asymmetry that distinguishes it from the other spokes: export emits CREATE SEMANTIC VIEW DDL because Hologres has no YAML import function, and import consumes the model_yaml Hologres publishes for every Semantic View. --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) 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:** From e770f1df2a8edab9798c391b98141bd5305f045d Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 08:25:47 +0800 Subject: [PATCH 12/13] refactor(hologres): read and write ANSI_SQL only Drops the per-expression dialect decision. Every expression is now labelled ANSI_SQL, which removes this converter's need for a vendor dialect token. The original motivation was to label `x::text` honestly, but that does not hold up: the portable spelling is always available and sqlglot already normalizes to `CAST(x AS TEXT)` in both directions, so `::text` never reached the output. The per-expression test that remained was also unsound. It compared sqlglot's postgres rendering against its default rendering, but sqlglot's default dialect is not ANSI SQL, so it passed `ILIKE` as portable while flagging the standard `SUBSTRING`, `EXTRACT` and `DATE_TRUNC` as vendor-specific. And over-labelling turned out to be the more damaging error: a converter that looks for an ANSI_SQL expression and finds none drops the field (see converters/databricks/ossie_to_metric_view.py), so tagging a `DATE_TRUNC` dimension as vendor-specific loses it silently, whereas an optimistic label at worst surfaces as a SQL error on the target engine. PostgreSQL-only syntax such as `j -> 'k'` is therefore labelled slightly optimistically, which the README states plainly. This matches the NVIDIA GSF converter's reasoning that anything else stays ANSI_SQL rather than being labelled inaccurately. --- converters/hologres/README.md | 15 ++++++++ .../hologres/src/ossie_hologres/_common.py | 35 ++++++++----------- .../ossie_hologres/ossie_to_semantic_view.py | 4 +-- .../ossie_hologres/semantic_view_to_ossie.py | 20 ++++++----- converters/hologres/tests/test_common.py | 16 +++++---- .../tests/test_ossie_to_semantic_view.py | 13 ++++--- .../tests/test_semantic_view_to_ossie.py | 17 ++++++--- 7 files changed, 74 insertions(+), 46 deletions(-) diff --git a/converters/hologres/README.md b/converters/hologres/README.md index d9daedc7..84064b5a 100644 --- a/converters/hologres/README.md +++ b/converters/hologres/README.md @@ -119,6 +119,21 @@ 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 diff --git a/converters/hologres/src/ossie_hologres/_common.py b/converters/hologres/src/ossie_hologres/_common.py index dd554809..38b0a3e2 100644 --- a/converters/hologres/src/ossie_hologres/_common.py +++ b/converters/hologres/src/ossie_hologres/_common.py @@ -39,11 +39,19 @@ # Apache Ossie files. OSSIE_VERSION = "0.2.0.dev0" -# Vendor id used for the `custom_extensions` stash and for dialect selection. +# 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" -# Expression dialects this converter understands, in preference order. -DIALECT_HOLOGRES = "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 @@ -293,16 +301,16 @@ def foreign_vendor_extensions(obj): def pick_expression(ossie_expression): - """Choose the SQL string for an Apache Ossie expression: HOLOGRES, else ANSI_SQL. + """Choose the SQL string for an Apache Ossie expression. - Returns None if neither dialect is present, so the caller can raise with the name of - the offending field or metric. + 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_HOLOGRES) or dialects.get(DIALECT_ANSI) + 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 @@ -355,19 +363,6 @@ def render_expression(node): return node.sql(dialect=SQLGLOT_DIALECT) -def is_portable_expression(node): - """True if the expression carries no PostgreSQL-specific syntax. - - Decided by asking sqlglot to render the node with and without the postgres dialect: - if both agree the expression is portable. `SUM(x)`, `COUNT(*)` and `CASE` render the - same either way, while `j -> 'k'`, `x ~ 'abc'` and the 1-based `arr[1]` do not. - - This keeps the import direction from labelling ordinary SQL as HOLOGRES, which would - hide it from every other Ossie converter looking for an ANSI_SQL expression. - """ - return node.sql() == 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)`, diff --git a/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py b/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py index 04e1de4f..b1e174af 100644 --- a/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py +++ b/converters/hologres/src/ossie_hologres/ossie_to_semantic_view.py @@ -370,7 +370,7 @@ def _render_dimensions(datasets, aliases): expr_text = pick_expression(field.get("expression")) if expr_text is None: raise ConversionError( - f"{what}: no HOLOGRES or ANSI_SQL expression dialect available" + f"{what}: no ANSI_SQL expression dialect available" ) node = parse_expression(expr_text, what) assert_row_level(node, what) @@ -439,7 +439,7 @@ def _render_metrics(model, aliases, metric_owners, skip_unsupported): expr_text = pick_expression(metric.get("expression")) if expr_text is None: raise ConversionError( - f"{what}: no HOLOGRES or ANSI_SQL expression dialect available" + f"{what}: no ANSI_SQL expression dialect available" ) node = parse_expression(expr_text, what) metric_aggregate(node, what) diff --git a/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py index 9a6b5c5a..f38ed414 100644 --- a/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py +++ b/converters/hologres/src/ossie_hologres/semantic_view_to_ossie.py @@ -37,13 +37,11 @@ from ._common import ( DIALECT_ANSI, - DIALECT_HOLOGRES, OSSIE_VERSION, STASH_OWNER, ConversionError, column_refs, dump_yaml, - is_portable_expression, load_yaml, ossie_expression, parse_expression, @@ -176,14 +174,18 @@ def _convert_metrics(table, alias, view_name): def _expression_for(node): - """Label an expression with the narrowest dialect that honestly describes it. - - Anything that renders the same outside the postgres dialect is portable and gets - ANSI_SQL, so an ordinary `SUM(o.amount)` stays usable by every other Ossie - converter. Only genuinely PostgreSQL-specific syntax is labelled HOLOGRES. + """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. """ - dialect = DIALECT_ANSI if is_portable_expression(node) else DIALECT_HOLOGRES - return ossie_expression(render_expression(node), dialect) + return ossie_expression(render_expression(node), DIALECT_ANSI) def _convert_relationships(view, aliases, view_name): diff --git a/converters/hologres/tests/test_common.py b/converters/hologres/tests/test_common.py index 9155cb1e..c3b04c0c 100644 --- a/converters/hologres/tests/test_common.py +++ b/converters/hologres/tests/test_common.py @@ -22,7 +22,6 @@ import pytest from ossie_hologres._common import ( DIALECT_ANSI, - DIALECT_HOLOGRES, ConversionError, assert_row_level, column_refs, @@ -188,15 +187,18 @@ class TestPickExpression: def _expr(self, *pairs): return {"dialects": [{"dialect": d, "expression": e} for d, e in pairs]} - def test_prefers_hologres_over_ansi(self): - expr = self._expr((DIALECT_ANSI, "region"), (DIALECT_HOLOGRES, "region::text")) - assert pick_expression(expr) == "region::text" - - def test_falls_back_to_ansi(self): + def test_selects_the_ansi_dialect(self): assert pick_expression(self._expr((DIALECT_ANSI, "region"))) == "region" - def test_returns_none_when_no_usable_dialect(self): + 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 diff --git a/converters/hologres/tests/test_ossie_to_semantic_view.py b/converters/hologres/tests/test_ossie_to_semantic_view.py index 3720734c..b4000b0a 100644 --- a/converters/hologres/tests/test_ossie_to_semantic_view.py +++ b/converters/hologres/tests/test_ossie_to_semantic_view.py @@ -355,24 +355,29 @@ 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 HOLOGRES or ANSI_SQL"): + with pytest.raises(ConversionError, match="no ANSI_SQL"): export(model) - def test_hologres_dialect_wins_over_ansi(self): + 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"}, - {"dialect": "HOLOGRES", "expression": "x::text"}, ] }, } ] ) - assert "o.d AS CAST(o.x AS TEXT)" in export(model) + 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: diff --git a/converters/hologres/tests/test_semantic_view_to_ossie.py b/converters/hologres/tests/test_semantic_view_to_ossie.py index 3c1d6c99..89d46607 100644 --- a/converters/hologres/tests/test_semantic_view_to_ossie.py +++ b/converters/hologres/tests/test_semantic_view_to_ossie.py @@ -148,12 +148,21 @@ 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_portable_expression_is_labelled_ansi(self): + def test_expressions_are_labelled_ansi(self): assert self._field("o.region")["expression"]["dialects"][0]["dialect"] == "ANSI_SQL" - def test_postgres_specific_expression_is_labelled_hologres(self): - # `->` is PostgreSQL JSON access; labelling it ANSI_SQL would misrepresent it. - assert self._field("o.payload -> 'k'")["expression"]["dialects"][0]["dialect"] == "HOLOGRES" + 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" From d087d5cd31b2b069ccf517263044e88f50ec1e2d Mon Sep 17 00:00:00 2001 From: TimothyDing Date: Wed, 12 Aug 2026 08:26:53 +0800 Subject: [PATCH 13/13] Revert "feat(core-spec): add HOLOGRES expression dialect" This reverts commit cdfa891 and its follow-up documentation in b76db78, restoring core-spec/, python/, validation/ and docs/ to their previous state. The converter no longer needs the token. Its stated motivation -- labelling `x::text` honestly -- does not survive scrutiny: Hologres is PostgreSQL-compatible but the portable `CAST(x AS TEXT)` spelling is always available, and sqlglot normalizes to it, so the shorthand never reached the output in the first place. Removing it keeps the contribution out of the specification-change process (7-day discussion, three binding +1) for a token that would have been used only for occasional PostgreSQL-only operators, and where over-labelling costs more than it saves: a converter that finds no ANSI_SQL expression drops the field. The Vendor name HOLOGRES used by the custom_extensions stash is unaffected -- Ossie's Vendor field is free-form and needs no spec change. --- core-spec/expression_language.md | 6 ------ core-spec/osi-schema.json | 2 +- core-spec/spec.md | 1 - core-spec/spec.yaml | 1 - docs/index.md | 2 +- python/src/ossie/models.py | 1 - validation/validate.py | 1 - 7 files changed, 2 insertions(+), 12 deletions(-) diff --git a/core-spec/expression_language.md b/core-spec/expression_language.md index 31b6ae7b..42299977 100644 --- a/core-spec/expression_language.md +++ b/core-spec/expression_language.md @@ -669,12 +669,6 @@ expression: | Current timestamp | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP` | | Substring | `SUBSTRING(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTRING(s, start, len)` | `SUBSTRING(s, start, len)` | -The `HOLOGRES` dialect follows the PostgreSQL column: Alibaba Cloud Hologres is -PostgreSQL wire- and dialect-compatible. Prefer `ANSI_SQL` for expressions that need no -PostgreSQL-specific syntax, and reserve `HOLOGRES` for the ones that do — such as `a || b` -for string concatenation, `j -> 'k'` for JSON access, `s ~ 'pattern'` for regular -expression matching, or the 1-based `arr[1]` array indexing. - ### ### Dialect-Specific Extensions diff --git a/core-spec/osi-schema.json b/core-spec/osi-schema.json index 418e1007..f24e45f1 100644 --- a/core-spec/osi-schema.json +++ b/core-spec/osi-schema.json @@ -23,7 +23,7 @@ "$defs": { "Dialect": { "type": "string", - "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "BIGQUERY", "HOLOGRES"], + "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "BIGQUERY"], "description": "Supported SQL and expression language dialects" }, "Vendor": { diff --git a/core-spec/spec.md b/core-spec/spec.md index bfd053a1..156cb1db 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -58,7 +58,6 @@ Supported SQL and expression language dialects for metrics and field definitions | `DATABRICKS` | Databricks SQL | | `MAQL` | GoodData MAQL (Metric Analysis and Query Language) | | `BIGQUERY` | Google BigQuery (GoogleSQL) | -| `HOLOGRES` | Alibaba Cloud Hologres (PostgreSQL-compatible) | ### Data types diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index f79cafd0..32fbb3e1 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -37,7 +37,6 @@ dialects: - "DATABRICKS" # Databricks SQL - "MAQL" # GoodData MAQL (Multi-Dimensional Analytical Query Language) - "BIGQUERY" # Google BigQuery GoogleSQL - - "HOLOGRES" # Alibaba Cloud Hologres (PostgreSQL-compatible) # Supported logical data types for fields and metrics # TODO: Generate this list from the authoritative DataType enum in diff --git a/docs/index.md b/docs/index.md index 6831aa9b..738c5724 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,7 +59,7 @@ The Ossie core specification (current version: **0.2.0.dev0**, latest released: | **Custom Extensions** | Vendor-specific metadata stored as JSON, allowing platforms to carry additional information without breaking core compatibility. | | **AI Context** | Optional annotations at every level (model, dataset, field, relationship, metric) to help AI tools understand business meaning — including instructions, synonyms, and example queries. | -The specification supports multiple SQL dialects (`ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`, `BIGQUERY`, `HOLOGRES`, `MAQL`, `MDX`, `TABLEAU`) so that expressions can be tailored to each platform while maintaining a common model structure. +The specification supports multiple SQL dialects (`ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`, `MDX`, `TABLEAU`) so that expressions can be tailored to each platform while maintaining a common model structure. For the full specification, see [core-spec/spec.md](../core-spec/spec.md). For validation tooling, see [validation/validate.py](../validation/validate.py). For a complete example, see the [TPC-DS semantic model](../examples/tpcds_semantic_model.yaml). diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py index 54404960..5406a743 100644 --- a/python/src/ossie/models.py +++ b/python/src/ossie/models.py @@ -32,7 +32,6 @@ class OSIDialect(str, Enum): TABLEAU = "TABLEAU" DATABRICKS = "DATABRICKS" BIGQUERY = "BIGQUERY" - HOLOGRES = "HOLOGRES" class OSIDataType(str, Enum): diff --git a/validation/validate.py b/validation/validate.py index 7b148591..258d34f1 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -66,7 +66,6 @@ "SNOWFLAKE": "snowflake", "DATABRICKS": "databricks", "BIGQUERY": "bigquery", - "HOLOGRES": "postgres", # Hologres is PostgreSQL-compatible "MDX": None, # Not supported by sqlglot, skip validation "TABLEAU": None, # Not supported by sqlglot, skip validation "MAQL": None, # Not supported by sqlglot, skip validation