From f900a29a7c4fb010c0269cfbef656dde7a49285b Mon Sep 17 00:00:00 2001 From: William Kelley Date: Tue, 25 Aug 2026 10:18:30 -0400 Subject: [PATCH 01/12] scaffold hex converter --- .github/workflows/converter-hex-ci.yml | 116 ++++ converters/README.md | 1 + converters/hex/.python-version | 1 + converters/hex/.vscode/settings.json | 5 + converters/hex/CONTRIBUTING.md | 139 +++++ converters/hex/README.md | 103 ++++ converters/hex/justfile | 124 +++++ converters/hex/pyproject.toml | 92 ++++ converters/hex/scripts/test-python-matrix.sh | 36 ++ converters/hex/src/ossie_hex/__init__.py | 18 + converters/hex/src/ossie_hex/cli/__init__.py | 20 + converters/hex/src/ossie_hex/cli/__main__.py | 22 + converters/hex/src/ossie_hex/cli/main.py | 20 + converters/hex/tests/__init__.py | 17 + converters/hex/tests/test_smoke.py | 27 + converters/hex/uv.lock | 542 +++++++++++++++++++ core-spec/ossie-schema.json | 2 +- core-spec/spec.md | 1 + core-spec/spec.yaml | 2 +- python/src/ossie/models.py | 5 +- 20 files changed, 1290 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/converter-hex-ci.yml create mode 100644 converters/hex/.python-version create mode 100644 converters/hex/.vscode/settings.json create mode 100644 converters/hex/CONTRIBUTING.md create mode 100644 converters/hex/README.md create mode 100644 converters/hex/justfile create mode 100644 converters/hex/pyproject.toml create mode 100755 converters/hex/scripts/test-python-matrix.sh create mode 100644 converters/hex/src/ossie_hex/__init__.py create mode 100644 converters/hex/src/ossie_hex/cli/__init__.py create mode 100644 converters/hex/src/ossie_hex/cli/__main__.py create mode 100644 converters/hex/src/ossie_hex/cli/main.py create mode 100644 converters/hex/tests/__init__.py create mode 100644 converters/hex/tests/test_smoke.py create mode 100644 converters/hex/uv.lock diff --git a/.github/workflows/converter-hex-ci.yml b/.github/workflows/converter-hex-ci.yml new file mode 100644 index 00000000..885d2764 --- /dev/null +++ b/.github/workflows/converter-hex-ci.yml @@ -0,0 +1,116 @@ +# +# 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 Hex CI + +permissions: + contents: read + +on: + push: + branches: ["main"] + paths: + - "converters/hex/**" + - "python/**" + - ".github/**" + pull_request: + branches: ["main"] + paths: + - "converters/hex/**" + - "python/**" + - ".github/**" + +env: + UV_VERSION: "0.12.9" + PYTHON_VERSION: "3.14" + JUST_VERSION: "1.58.0" + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: converters/hex + steps: + - name: Checkout project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: ${{ env.JUST_VERSION }} + - name: Install uv and python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: ${{ env.UV_VERSION }} + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: true + working-directory: converters/hex + - name: Install project + run: just setup + - name: Run checks + run: just check + + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: converters/hex + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - name: Checkout project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: ${{ env.JUST_VERSION }} + - name: Install uv and python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: ${{ env.UV_VERSION }} + python-version: ${{ matrix.python-version }} + enable-cache: true + working-directory: converters/hex + - name: Install project + run: just setup + - name: Run tests + run: just test + + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: converters/hex + steps: + - name: Checkout project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: ${{ env.JUST_VERSION }} + - name: Install uv and python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: ${{ env.UV_VERSION }} + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: true + working-directory: converters/hex + - name: Build distribution + run: just build diff --git a/converters/README.md b/converters/README.md index 014a7350..9a5caa58 100644 --- a/converters/README.md +++ b/converters/README.md @@ -77,6 +77,7 @@ The Ossie specification currently defines extensions for the following vendors: | `WISDOM` | WisdomAI domain | | `NVIDIA_GSF` | NVIDIA Generative Semantic Fabric standalone YAML | | `SIGMA` | Sigma Computing data model | +| `HEX` | Hex semantic project | 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/hex/.python-version b/converters/hex/.python-version new file mode 100644 index 00000000..6324d401 --- /dev/null +++ b/converters/hex/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/converters/hex/.vscode/settings.json b/converters/hex/.vscode/settings.json new file mode 100644 index 00000000..028c7504 --- /dev/null +++ b/converters/hex/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.languageServer": "Pylance", + "ty.disableLanguageServices": true +} diff --git a/converters/hex/CONTRIBUTING.md b/converters/hex/CONTRIBUTING.md new file mode 100644 index 00000000..b5a7b7dd --- /dev/null +++ b/converters/hex/CONTRIBUTING.md @@ -0,0 +1,139 @@ + + +# Contributing to the Ossie–Hex converter + +This document covers development of the `ossie-hex` package. Repository-wide +contribution, review, and Apache release requirements are documented in the +[project contribution guide][ossie-contributing]. + +## Setup + +Ensure you have the following tools available in your environment: + +- [just][just-install] as a command runner +- [uv][uv-install] as a Python package and project manager +- Python 3.11 or newer, which can be [installed through uv][uv-install-python] + +Checkout the Apache Ossie repository and install the project dependencies: + +```bash +cd converters/hex +uv sync +``` + +## Development + +TODO(development): {workflow-description} + +### Python + +TODO(development): {python-description} + +### CLI + +TODO(development): {cli-description} + +## Verification + +Run the following commands to verify your changes. + +```bash +# lint and formatting checks +just check + +# apply automatic fixes for lint and formatting issues +just format + +# run complete test suite +just test + +# run a single file or test +just test tests/.py +just test tests/.py:: +``` + +### Snapshots + +There are two tools available for managing test snapshots [syrupy] and +[inline-snapshot]. + +In almost all cases, [inline-snapshot] is the preferred tool since it +facilitates a straightforward review where results live beside arrangement +and act blocks. + +By default, snapshot differences will fail `just test`. To update snapshots, +use the following commands: + +```bash +# Update snapshots interactively (only available for inline-snapshot) +just snapshot-review + +# Update snapshots non-interactively +just snapshot-fix +``` + +### CI/CD + +The [continuous integration (CI) workflow][ci-workflow] runs installation, +linting, formatting, and testing on the full range of supported tool versions. + +To reproduce the CI tool version matrix locally: + +```bash +just test-matrix +``` + +## Building + +Build the source distribution(s) and wheel(s) from this directory: + +```bash +just build +``` + +The artifacts are written to `dist/`. Before publishing, inspect their metadata +and contents: + +```bash +just build-inspect +``` + +Also test installation in a clean environment. This will only work from package +repositories (e.g. PyPI) after all dependencies have been published: + +```bash +just build-install +``` + +## Publishing + +Publishing is deferred to the Apache Ossie project, which governs the broader +release cycle. Contributors should not publish this package independently. + + +[ossie-contributing]: ../../CONTRIBUTING.md +[ci-workflow]: ../../.github/workflows/converter-hex-ci.yml + + +[uv-install]: https://docs.astral.sh/uv/installation/ +[uv-install-python]: https://docs.astral.sh/uv/guides/install-python/ +[syrupy]: https://github.com/syrupy-project/syrupy +[inline-snapshot]: https://15r10nk.github.io/inline-snapshot/ +[just-install]: https://just.systems/man/en/installation.html diff --git a/converters/hex/README.md b/converters/hex/README.md new file mode 100644 index 00000000..b7b704a8 --- /dev/null +++ b/converters/hex/README.md @@ -0,0 +1,103 @@ + + + + +# Ossie ↔ Hex converter + +Bidirectional, offline conversion between +[Apache Ossie][apache-ossie] and [Hex][hex-semantic-spec]. + +- **Export** (`ossie-hex export`): Ossie → Hex +- **Import** (`ossie-hex import`): Hex → Ossie + +TODO(main): {description} + +## Installation + +This converter is distributed as a Python package. Install it with `uv` or +`pip`: + +```bash +uv tool install ossie-hex + +# Alternatively +pip install ossie-hex +``` + +Requires Python 3.11 or newer. + +## Usage + +### Command line + +#### `export` + +TODO(export): {description} + +TODO(export): {code-signature} + +TODO(export): {options} + +TODO(export): {code-example} + +#### `import` + +TODO(import): {description} + +TODO(import): {code-signature} + +TODO(import): {options} + +TODO(import): {code-example} + +### Python API + +TODO(python): {description} + +TODO(python): {code-example} + +## Conversion + +### Problems + +TODO(problems): {error-description} + +TODO(problems): {warning-description} + +### Concepts + +TODO(concepts): {description} + +TODO(concepts): {table} + +### Data types + +TODO(data-types): {description} + +TODO(data-types): {table} + +### Custom extension + +TODO(custom-extension): {description} + +TODO(custom-extension): {table} + +[apache-ossie]: https://ossie.apache.org/ +[hex-semantic-spec]: https://learn.hex.tech/docs/connect-to-data/semantic-models/semantic-authoring/modeling-specification diff --git a/converters/hex/justfile b/converters/hex/justfile new file mode 100644 index 00000000..3a6ec8cc --- /dev/null +++ b/converters/hex/justfile @@ -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. + +check: format-check lint typecheck + +fix: format lint-fix + +# --- +# Setup + +setup: setup-uv + +setup-uv: + uv sync --locked --all-extras --dev + +# --- +# Build + +build: build-python + +# build python distribution(s) +build-python: + uv build + +# inspect distribution metadata and contents +build-inspect: build-inspect-python + +build-inspect-python: + uvx twine check dist/* + uv run python -m zipfile -l dist/*.whl + tar -tf dist/*.tar.gz + + +# test installation in a clean environment +build-install: build-install-python + +build-install-python: + uv venv /tmp/ossie-hex-release-test + uv pip install --python /tmp/ossie-hex-release-test/bin/python dist/*.whl + /tmp/ossie-hex-release-test/bin/ossie-hex --help + +# --- +# Format + +format: format-python format-markdown + +format-python: + uv run ruff format + +format-markdown: + uv run rumdl fmt + +format-check: format-check-python format-check-markdown + +format-check-python: + uv run ruff format --check + +format-check-markdown: + uv run rumdl fmt --check + +# --- +# Lint + +lint: lint-python lint-markdown + +lint-python: + uv run ruff check + +lint-markdown: + uv run rumdl check + +lint-fix: lint-fix-python lint-fix-markdown + +lint-fix-python: + uv run ruff check --fix + +lint-fix-markdown: + uv run rumdl check --fix + + +# --- +# Typecheck + +typecheck: typecheck-python + +typecheck-python: + uv run ty check + +# --- +# Test + +test *args: (test-python args) + +test-python *args: + uv run pytest --inline-snapshot=disable {{ args }} + +test-matrix: test-python-matrix + +# run the CI Python version matrix locally in isolated environments +test-python-matrix *args: + ./scripts/test-python-matrix.sh {{ args }} + +# --- +# Snapshots + +# interactively update snapshots for inline-snapshot (not available for syrupy) +snapshot-review *args: (test-python "--inline-snapshot=review" args) + +# non-interactively regenerate snapshots for inline-snapshot and syrupy +snapshot-fix *args: (test-python "--inline-snapshot=fix --snapshot-update" args) \ No newline at end of file diff --git a/converters/hex/pyproject.toml b/converters/hex/pyproject.toml new file mode 100644 index 00000000..0e60fabc --- /dev/null +++ b/converters/hex/pyproject.toml @@ -0,0 +1,92 @@ +# 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>=9.1.1", + "ruff>=0.16.4", + "syrupy>=6.0.0", + "inline-snapshot>=0.35.4", + "rumdl>=0.2.60", + "ty>=0.0.77", +] + +[project] +name = "ossie-hex" +version = "0.2.0.dev0" +description = "Bidirectional converter between Apache Ossie semantic models and Hex semantic projects" +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", + "Hex", +] +dependencies = [ + "apache-ossie>=0.2.0.dev0", + "hex-sl-utils>=0.2.0.dev0", + "pydantic>=2.13.4", + "pydantic-core>=2.46.4", + "PyYAML>=6.0", + "typing-extensions>=4.16.0", +] + +[project.scripts] +ossie-hex = "ossie_hex.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_hex"] + +[tool.uv] +default-groups = ["dev"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src", "."] + +[tool.inline-snapshot] +format-command = "ruff format --stdin-filename={filename} -" + +[tool.ruff] +target-version = "py311" + +[tool.rumdl] +flavor = "gfm" + +[tool.rumdl.MD013] +reflow = true + +[tool.ty.environment] +python-version = "3.11" + +[tool.ty.src] +include = ["src", "tests"] + +# apache-ossie is not yet published to PyPI; resolve it from the in-repo +# package for now. Remove this block once apache-ossie is published to PyPI. +[tool.uv.sources] +apache-ossie = { path = "../../python", editable = true } diff --git a/converters/hex/scripts/test-python-matrix.sh b/converters/hex/scripts/test-python-matrix.sh new file mode 100755 index 00000000..4469249d --- /dev/null +++ b/converters/hex/scripts/test-python-matrix.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# 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. + +# Run Python tests against the full range of supported Python versions. +# Replicates a CI workflow locally in isolated environments. + +# The isolated environments leave the project's `.venv` unchanged. Keep `uv` +# up to date so version selectors resolve to stable Python releases rather than +# an older prerelease installation. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +python_versions=(3.11 3.12 3.13 3.14) + +uv python install "${python_versions[@]}" + +for python_version in "${python_versions[@]}"; do + uv run --isolated --python "${python_version}" pytest "$@" +done diff --git a/converters/hex/src/ossie_hex/__init__.py b/converters/hex/src/ossie_hex/__init__.py new file mode 100644 index 00000000..0826ac8a --- /dev/null +++ b/converters/hex/src/ossie_hex/__init__.py @@ -0,0 +1,18 @@ +# 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. + +__all__ = [] diff --git a/converters/hex/src/ossie_hex/cli/__init__.py b/converters/hex/src/ossie_hex/cli/__init__.py new file mode 100644 index 00000000..5581ce28 --- /dev/null +++ b/converters/hex/src/ossie_hex/cli/__init__.py @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .main import main + +__all__ = ["main"] diff --git a/converters/hex/src/ossie_hex/cli/__main__.py b/converters/hex/src/ossie_hex/cli/__main__.py new file mode 100644 index 00000000..6155c5dc --- /dev/null +++ b/converters/hex/src/ossie_hex/cli/__main__.py @@ -0,0 +1,22 @@ +# 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 sys + +from .main import main + +sys.exit(main()) diff --git a/converters/hex/src/ossie_hex/cli/main.py b/converters/hex/src/ossie_hex/cli/main.py new file mode 100644 index 00000000..02a1694f --- /dev/null +++ b/converters/hex/src/ossie_hex/cli/main.py @@ -0,0 +1,20 @@ +# 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. + + +def main() -> int: + return 0 diff --git a/converters/hex/tests/__init__.py b/converters/hex/tests/__init__.py new file mode 100644 index 00000000..24569233 --- /dev/null +++ b/converters/hex/tests/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + diff --git a/converters/hex/tests/test_smoke.py b/converters/hex/tests/test_smoke.py new file mode 100644 index 00000000..826f9da6 --- /dev/null +++ b/converters/hex/tests/test_smoke.py @@ -0,0 +1,27 @@ +# 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 ossie_hex +from ossie_hex.cli.main import main + + +def test_import() -> None: + assert ossie_hex.__all__ == [] + + +def test_cli_exits_zero() -> None: + assert main() == 0 diff --git a/converters/hex/uv.lock b/converters/hex/uv.lock new file mode 100644 index 00000000..52e0557f --- /dev/null +++ b/converters/hex/uv.lock @@ -0,0 +1,542 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "apache-ossie" +version = "0.2.0.dev0" +source = { editable = "../../python" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[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 = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "hex-sl-utils" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "rapidyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/f4/d68e0ca33d0c9a3efa12cf22b01e6e6ea26374d110d5eb32294d73a69fd3/hex_sl_utils-0.2.0.tar.gz", hash = "sha256:71f893043ee2712afd48380fa03b3bc1256f281151c21a9e06e7198a46f3b840", size = 619098, upload-time = "2026-09-02T17:15:45.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/55/2299c14c3c41f5356bd0de37c37583e64fca47fde33f98d39559247ba2e1/hex_sl_utils-0.2.0-py3-none-any.whl", hash = "sha256:918ac734f3a3bf092d56facb97258f919cab45dd1d728e4be81db0457c603a6a", size = 728551, upload-time = "2026-09-02T17:15:44.049Z" }, +] + +[[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 = "inline-snapshot" +version = "0.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/46/3c459f0350e88d6a5ca58a4792a7215c3b0990cf9bb6a2b37ac8d74a2504/inline_snapshot-0.35.4.tar.gz", hash = "sha256:82432a58b7ea77a172515f5024cd26fdea8dc0f873c7a4251e4e0471a914db14", size = 2537089, upload-time = "2026-08-11T07:07:56.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/b3/dc18eb603de18daf5cacc27bb4c5e80c5403d9ca3eade7342fe4228652bf/inline_snapshot-0.35.4-py3-none-any.whl", hash = "sha256:0ad221c1bc6fe4ce249f7c1c4131284099ecac1a5c1023aa7b1a95921c07265d", size = 95725, upload-time = "2026-08-11T07:07:54.24Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "ossie-hex" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "apache-ossie" }, + { name = "hex-sl-utils" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "inline-snapshot" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "rumdl" }, + { name = "syrupy" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "apache-ossie", editable = "../../python" }, + { name = "hex-sl-utils", specifier = ">=0.2.0.dev0" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-core", specifier = ">=2.46.4" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "typing-extensions", specifier = ">=4.16.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "inline-snapshot", specifier = ">=0.35.4" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.4" }, + { name = "rumdl", specifier = ">=0.2.60" }, + { name = "syrupy", specifier = ">=6.0.0" }, + { name = "ty", specifier = ">=0.0.77" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rapidyaml" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/5e/80ea1e8ca5785ad52f835f663e8384961bdb258de71a5bd332e99a2d7b20/rapidyaml-0.15.2.tar.gz", hash = "sha256:a3b075636e6b5673ddf7a50122b029cd44623f18e84bcd4f18425df328425a09", size = 500792, upload-time = "2026-06-25T18:56:18.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/b9/07ff6c58188a0c72e35a9c5f2f9930497b35bff07dbc0f34efbe069110cc/rapidyaml-0.15.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b32d85b66f29237a881ddf293c5662f8b6af886bffd80fdeed9f0d5605978c6b", size = 4905176, upload-time = "2026-06-25T18:55:14.555Z" }, + { url = "https://files.pythonhosted.org/packages/01/57/daa90101d90eb770147c4da9fda5d605fba621f0f112cb0d6c910143731b/rapidyaml-0.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a9ba9682adbc08d35bfcd5adb2e60bee3e95c1bc885fa2327c945c08c4c93a1", size = 4930210, upload-time = "2026-06-25T18:55:15.846Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/e3e8ca412fdbd83fea39690fe1cf04bbbc10e021bcb73c85f9b4befc53de/rapidyaml-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:581535642dec5af8771e523174f6b8f378bfff008df0178f0d12a57fbef58c8c", size = 4905174, upload-time = "2026-06-25T18:55:17.224Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/77f1e923a8425bcbceaef6439b8892adaa1bc00a92bf43fdf2c0f8011c05/rapidyaml-0.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:feceb1865528e5355282c9ad9f1b6c8c9f23e3e926e94b6fe998e54f54430948", size = 299882, upload-time = "2026-06-25T18:55:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d4/b83a999788b1d1982fa20da02db165b73a196aa6dc3b3488245de57a1c01/rapidyaml-0.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1fc68f397b20a00d5a1c5b587bbbde02248500ff7cf610ee0ae0c51c9e6b92f", size = 600618, upload-time = "2026-06-25T18:55:19.539Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/9054feaf23cfb28c7f5fb1e72d551baa194938218756e74a9720816956cc/rapidyaml-0.15.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9f182735db604c1c2e586048cd6728beeed8f6a61b80fbfa89b516738d31ad8", size = 613369, upload-time = "2026-06-25T18:55:20.567Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/00e0a3f38c7576ddba35558530e92a9fa83ef6f324205005726560461e83/rapidyaml-0.15.2-cp311-cp311-win32.whl", hash = "sha256:3cb85fcaa963311dbe0deb3b7c73ea7a2122e620b6fd98795b44d796314efed0", size = 315047, upload-time = "2026-06-25T18:55:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/72/66/4120ce93db6b752d765f03a2cfc8c347377e0e4003010949f0456b1c8ae1/rapidyaml-0.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:d062e6d1d34bd44648c42871a94774c1c17603301c4265671c6243f412593ed4", size = 378897, upload-time = "2026-06-25T18:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1b/54f1555539909af639396dba59bf21c835904a5c6b0fd54acfd913cd24ab/rapidyaml-0.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:5daf7f0a067904c92e919266d87dcc437560c66c4a5c25b1aad6fbdfb469c4cc", size = 372705, upload-time = "2026-06-25T18:55:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/58/cd/57cd56664362d3f77e621e88f41e5c46929a57b11c2c61be459e1519dfc3/rapidyaml-0.15.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f11c3e1934c29ac9b3db3f460b8d119efec6bb629b18b30b31df664324251bb", size = 5105921, upload-time = "2026-06-25T18:55:25.57Z" }, + { url = "https://files.pythonhosted.org/packages/42/e9/cdd320e5505ced82fad764f0b9ba6b61ce35813a613e6bde1e9097d6345c/rapidyaml-0.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d50dfe1732a455ee31c8310f341041967634d208bc5321280c5cf234235a8ac5", size = 5152285, upload-time = "2026-06-25T18:55:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/80/17/18bfe2a4141443b2f24febe8337f93f3e19199da24c3495f75f5eaf8dc4c/rapidyaml-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc5bbdc38ebff5c58f8e0dd3cbbdf84db604f5e219ecf9b3ab2dab86f2036c9b", size = 5105916, upload-time = "2026-06-25T18:55:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/20/5d6d8e06e9a410ab71cf08043d007854a25ed3f07c0c13189964f42e9f56/rapidyaml-0.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:10b633e710e7f1790911e9592ed0848a1f3b4449cd080ace41288c284f572750", size = 298941, upload-time = "2026-06-25T18:55:29.846Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ed/2120bc4be7f6104c01dce8f00c8eb7cddf8ae5905d78087235d1e7c361fd/rapidyaml-0.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff076a6c657c73ba449d4fe5865ceac19d8c9f8f435df31776acfe06ddb3628", size = 600991, upload-time = "2026-06-25T18:55:30.91Z" }, + { url = "https://files.pythonhosted.org/packages/19/a1/6f1af855e5189d942e8845e67d014500477fd423d0dd1ec9729a3a2d86c7/rapidyaml-0.15.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb2381f52f33cec42017361cd2fb0f7eb411527afe9273e9ce0f984584b87db1", size = 612930, upload-time = "2026-06-25T18:55:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/e52475516dfec0850b8c78e6db8c9ed0e7c0fe93d455f769ebb429fe1982/rapidyaml-0.15.2-cp312-cp312-win32.whl", hash = "sha256:f2cf9bbb2f588b07977c735c16d2156adf435ce9e36b3516d17496d03239ca29", size = 315231, upload-time = "2026-06-25T18:55:33.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/15/915131fc32c9f2aa56a130a9933bf5cfd64064c1d9fb1cdb0c77733ee5d0/rapidyaml-0.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:5cd14c077ce5183eba9a43ca93ceca1cc644eb6912f714e7972ade1de5d47770", size = 378988, upload-time = "2026-06-25T18:55:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f9/1e38225856d750b3416d51a6e0c862e386b34b87d36d6044f39a6b5fc390/rapidyaml-0.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:bd2face6f6339e9b8d269401546acc2de66ae5ee62e62e8dece84ae238139581", size = 373522, upload-time = "2026-06-25T18:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2a/4e5c79a06387b0a9f4e3f391e77be077ba7122c297604f577579017ac62c/rapidyaml-0.15.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:59368d6a8926fc2780813525fb9cb40b52a5e5c6e345c86e67a79873c635d8ec", size = 5057817, upload-time = "2026-06-25T18:55:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/a7c9e7e77abfd81c02c991674ee06491d0cc85984e779df5830a348c787e/rapidyaml-0.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f94e9be9067a0ef2eba4facd703ea82445e744130de4e428e8f5fb553e19116", size = 5173580, upload-time = "2026-06-25T18:55:37.882Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e6/6820d8d060d2e622c00a4f896bcdecbb0dfca866054262246bf61d3572e6/rapidyaml-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0a378be79a305026c76e98e841a054672df058bc1a5ab1552edc48323f91c68e", size = 5151913, upload-time = "2026-06-25T18:55:39.388Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/8d3aaa4bc383108c60c016d32fe2024795dea95124a7ec0b58d94222a38f/rapidyaml-0.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ef2c7857d445eeba9ccd078e3f0f5d838047bf283c733b075c71b74d824806f6", size = 298683, upload-time = "2026-06-25T18:55:40.64Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fb/5e138e4d3edc7065262608d4054f68e12f7820cd09a47ff711ec2f9a4755/rapidyaml-0.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28d577e06e6b66bb9c5ee5a33bedbaa8828ec429dacc4f346a5ca70d696024e0", size = 600748, upload-time = "2026-06-25T18:55:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/e2/57/d32ed8b2945bd2495105b07e096f9746be58419aef0ece7c23303014efae/rapidyaml-0.15.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a3e978663f7d34f7e06bfda3444d9ada252d160d59135a6019762d76a880d6", size = 612788, upload-time = "2026-06-25T18:55:42.998Z" }, + { url = "https://files.pythonhosted.org/packages/91/c3/7f9f826a32fa38289b3b9395ef27e879192031daf33de1c66ee45304c2db/rapidyaml-0.15.2-cp313-cp313-win32.whl", hash = "sha256:cbd761583c48ef9167e3a021bb315db41cc49f51eeda89e79c61d0af6d2f264b", size = 315150, upload-time = "2026-06-25T18:55:44.114Z" }, + { url = "https://files.pythonhosted.org/packages/06/1a/574581636b94daf9b0d55b01573bc7f16083f5c7eb1c8d7f67aceeafd0e0/rapidyaml-0.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:a025f1a39a530d1d65e3d57028b92a1163c2e580a25ec584fc080570535207f4", size = 378774, upload-time = "2026-06-25T18:55:45.246Z" }, + { url = "https://files.pythonhosted.org/packages/af/6d/efb07b38dbeb1e5e6092dd9ef988a374dbe2941fd55e72c25a6173e90140/rapidyaml-0.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:b60e80125bc198a0462e547aaa4bb32027efb036fe33f6f06db3f96ed391c3f4", size = 373310, upload-time = "2026-06-25T18:55:46.485Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/4e9b60e49972cc8a5ec43965da392050ce6a2ada56e1514cb50f5516e57a/rapidyaml-0.15.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:45cd97f0197647254510f7a87f396f7eec906a2a60789997501b6af107bb474d", size = 5595911, upload-time = "2026-06-25T18:55:47.807Z" }, + { url = "https://files.pythonhosted.org/packages/48/d8/2caaee06a66cc5cbdaa85775f3667811e04712f0c7fe0fcbedd6a25d2a78/rapidyaml-0.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0bf166372a0ffe961ce34b4dc5de8746553ac5cabc14a740fb604a0b916588fd", size = 5617870, upload-time = "2026-06-25T18:55:49.487Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/00f9efbbb95865c83735ffeaa75ccbf5e93ca1d400c7452799a67635d0f9/rapidyaml-0.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0027ebd90d0bb8ec773b34dfac45878d228211a74bd710c283927dc1d56d5955", size = 5595906, upload-time = "2026-06-25T18:55:50.869Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/b560a34da534740bf405af7d8a7929349fe5e391366072174a9f2eeff4d3/rapidyaml-0.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:74ff75b2bbbad2fcdbe838b241cb7ec781c1046c469f67d18182a884650737ed", size = 298577, upload-time = "2026-06-25T18:55:52.244Z" }, + { url = "https://files.pythonhosted.org/packages/c9/82/434405a32cf536ca450494151126f95ec1b5c340412c03dd09ae341880a7/rapidyaml-0.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a33e5125cb55497e8124ddcdaf554cdd6b996c154c1133c7b97aecb415b5195", size = 600970, upload-time = "2026-06-25T18:55:53.226Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/cf6226c805de21f7b168db20bcc0878f7a3cc5a858d4a3355146908da9e3/rapidyaml-0.15.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff157603c519791c630443e13250ce10a9055281c591b24cd807ac615ea371b1", size = 612818, upload-time = "2026-06-25T18:55:54.291Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2e/76094c4723d197b535c670593f872fb2b3f2cd8d0bac6451d5e69b5e8de3/rapidyaml-0.15.2-cp314-cp314-win32.whl", hash = "sha256:433353f9fce16783157afce7065862c1ebb9e3070e963b99be05be2921464f9d", size = 326085, upload-time = "2026-06-25T18:55:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/89/56/81eca4ac9c7868a7f24df5a7f572aec0ac84bddad69678e8db31a1812421/rapidyaml-0.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:3edfa1049f4d0fc2fb35cbef7690e7c05d2c79e1f8813413d84d1a07f412ed57", size = 392285, upload-time = "2026-06-25T18:55:56.737Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d7/cbcf6d233439b857ad82306ab2d513854b197da97a9e94e2a7c02f2ca2d4/rapidyaml-0.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:b64909d0c97d389f9c919e15b8a55c3ef6eff1c807b611fbefed2fb71f975a1b", size = 385299, upload-time = "2026-06-25T18:55:58.051Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "rumdl" +version = "0.2.60" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/e4/7d17a97cadda43b8e54efcfbde75e8bc358afed4d0d829fb304c66c0d986/rumdl-0.2.60.tar.gz", hash = "sha256:76b4a75169af1143fca6e235b37ab33d4835570d6e9725669383ec77f326c82d", size = 3374947, upload-time = "2026-08-22T10:40:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/30/33912533ca5cdac8faf98e923527a70f4eeeb79f8878c049bada096aa9d5/rumdl-0.2.60-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2946bd5f6e7d3f3a5d05ceb423ee0578113e8a2b292e50cea87c75480f1f195e", size = 6469433, upload-time = "2026-08-22T10:40:38.48Z" }, + { url = "https://files.pythonhosted.org/packages/df/b1/94f8429a84b468570df5ff4d0a99c44a47fb2ca80a9138f4e417f72ad978/rumdl-0.2.60-py3-none-macosx_11_0_arm64.whl", hash = "sha256:878458f6b39a30beb014b595191de6bc54026070924ad90f81f3466b94e36415", size = 6111604, upload-time = "2026-08-22T10:40:32.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/6a/94d9fc65869f4fb5eec07426452a9252ced8fecbe21879af3626d77ab0e6/rumdl-0.2.60-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c8882956d198f765dda0ef4e9965366f7bef5eadfd8c466036046e93e54b8a81", size = 6233560, upload-time = "2026-08-22T10:40:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/d556041f8ea32f2db2aa1e5a6d8d591009e0a64d32dc7110705f2c94e6de/rumdl-0.2.60-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e0c2be099742c8a37d8494eb8095e085f228c104143591ae0b5acf0b63c9353", size = 6657559, upload-time = "2026-08-22T10:40:41.831Z" }, + { url = "https://files.pythonhosted.org/packages/28/29/216a060d3d2089c5eee732615af69a0975579dd811502403a5b0193e0bbd/rumdl-0.2.60-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b7b349fb0e3bce826797c875eccf73ad5431cff308da762adef22fc84b9c1f51", size = 6226121, upload-time = "2026-08-22T10:40:36.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/c3/211a68824979b8c22ea706fc10609d4b8d50efda3cb484ea26c2134cc1d2/rumdl-0.2.60-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32514e9c9fb7f9bff7327e7d4f7644f4d3cc5a0d98583171731db49275dca4dc", size = 6635011, upload-time = "2026-08-22T10:40:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e7/2d6b6515c86df0da142f98846d79549ec3c269bbee586affe9f6c036be13/rumdl-0.2.60-py3-none-win_amd64.whl", hash = "sha256:20ce0c5b33e739f7dcd2797eca5a85e1300df4040a486b05214139ebf2b19fb2", size = 6596699, upload-time = "2026-08-22T10:40:40.195Z" }, +] + +[[package]] +name = "syrupy" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/3c797c76994dc817582abdda90ee31867a9883567b0add47dbc0ccb12ce4/syrupy-6.0.0.tar.gz", hash = "sha256:bf4f5f662a7b23a9e5711bd5597e301cd09c00f31be5fa4095f79da7b0585f3d", size = 99435, upload-time = "2026-08-22T20:48:39.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ff/4d8ae6d6070ea73ee9d466701584a75db9586ce09de98f8f1b3276efad5f/syrupy-6.0.0-py3-none-any.whl", hash = "sha256:3efa16d767bd8614413cc20057552e4aec14428c6e5ea788c1f7e03047e8a5e4", size = 58047, upload-time = "2026-08-22T20:48:38.213Z" }, +] + +[[package]] +name = "ty" +version = "0.0.77" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ef/a2024d1d33d5ba8436677a6fd734f87a137150aba99906fc009f7c5de956/ty-0.0.77.tar.gz", hash = "sha256:8898f3097610f4a772ead6bfad7b204c7d76e8eb3a010c7285b9186278e0fb82", size = 7005734, upload-time = "2026-09-01T00:26:26.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/03/930f7454a6d797abc09aebbf537017825ebb08d9f8c2e2d905e74341dbb1/ty-0.0.77-py3-none-linux_armv6l.whl", hash = "sha256:ea76012f1ed387b6fc6500894fb8a7654b724c38713e263a23f12756f00e2469", size = 13241626, upload-time = "2026-09-01T00:25:51.02Z" }, + { url = "https://files.pythonhosted.org/packages/65/41/7e064045775718673851f6c0e1cfde70d6e380b0db9d91d4484a362f2d67/ty-0.0.77-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:237a58c389e01d65c4693e0394feb0f0adea3fba0345c3b932d209084372f3d3", size = 12830037, upload-time = "2026-09-01T00:25:53.172Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/66f0b0dc89bd239922bdf12fa3e6d066aa7a425e8b70414368c4eb44a6e6/ty-0.0.77-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bbb8ae7a753b9a6af25725c314d0eca967e5fde5eebb7a35b04b0d763dfb9d7c", size = 12612071, upload-time = "2026-09-01T00:25:55.116Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5c/bef8b1f471931a9395792be023613b6cee0ef1449815bf97b18be07f883c/ty-0.0.77-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:603620c063b718ddbe302f83fb0d7c01a13f1941c3b9763512a89c9bbfabba96", size = 12641403, upload-time = "2026-09-01T00:25:57.305Z" }, + { url = "https://files.pythonhosted.org/packages/43/66/e4d32a0145162f79bc3dd6fca4770f88966c3b5206121ffeecd5f7b05e8b/ty-0.0.77-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03f128cb8b204e22a18f4b01ef0194aa445f4edb080cb7d77621cb2ab353885c", size = 13013526, upload-time = "2026-09-01T00:25:59.278Z" }, + { url = "https://files.pythonhosted.org/packages/aa/be/33493ee43d4db7d402ceaee55c5d6c22e2b1105f10b1ac5928c220f79650/ty-0.0.77-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d1600ef69fc8e3b2310de1915cd34ba3b712fdc730cb02b713520956baec0a", size = 13828076, upload-time = "2026-09-01T00:26:01.343Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/b6335fdc8c09aaaaf6541322076c5a7205fa16c29dbeddc1f24dd89b3894/ty-0.0.77-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dced0924a83b6d945fd48a29aa85e131fb98dc574f4c9a0e7c43d03eff373402", size = 14247770, upload-time = "2026-09-01T00:26:03.388Z" }, + { url = "https://files.pythonhosted.org/packages/11/64/bbe96c1da26585919ac10f33df1d337d9b498013fc60c5178e76955c2a53/ty-0.0.77-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b14b002233a954115a04932b3a93d75a387569848276cf7bb0b5dc6b040001c4", size = 13926528, upload-time = "2026-09-01T00:26:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cdc94e9a1b69e9b7dd0ec3ad3ca75741245b8b5b2e3ffe9b365ca31d8052/ty-0.0.77-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:581adf1ae1e48e00e89ec162913d559ddf75a7805646a13ffd68943c1b5e8daa", size = 13290834, upload-time = "2026-09-01T00:26:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/92/8c/4929dd8e924ddbd6284dcb3cfbda488bd6cb091e5384f8b7fb5dd3de9122/ty-0.0.77-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9bda523b40e06c643feb6d5d5bcbacc56a2bd9eed3d7cae1119452502bcd69b0", size = 13829048, upload-time = "2026-09-01T00:26:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f4/441a74ddc5e2db2690264c1ee0e478d46b8e42bd529472fdb1656b63bff2/ty-0.0.77-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4b8b04d8c3af8148e963e950094bd53c8cc4d3a11f5f8764ff8d39627bd6e64a", size = 12803755, upload-time = "2026-09-01T00:26:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/85/39/875bb7092ee1edb090b62eca74b7311f6416dc3aa7da442b4a1f95408251/ty-0.0.77-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:33a9ff9be488edf4d6fac46c456198cc848d9641f418f1a83aa123825e93c6ec", size = 13028244, upload-time = "2026-09-01T00:26:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/e3/76/4b0a48f118dca6a32e1609f2d0bea2c3f7be9051af645fe5044ca35cbbb4/ty-0.0.77-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61ad5467206e5ea6531c8aebebf1276c6150989a94a1d5974a84d9e0eeed5c38", size = 13321068, upload-time = "2026-09-01T00:26:16.418Z" }, + { url = "https://files.pythonhosted.org/packages/8d/46/cd21bc6441df4b221d9e131551bab2a5f9c0b724e60e1c960622ee175128/ty-0.0.77-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1293b94f26d1f330424e3b97c20b434f7e2ac1938287b8588466c75ece6c0b10", size = 13609112, upload-time = "2026-09-01T00:26:18.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/0da4537a7eca4eb3ca72a2cb4539cfbb6fb3138d1dc4ae7a66299b941fb3/ty-0.0.77-py3-none-win32.whl", hash = "sha256:cb71152bdf56860375c790682cc3efc120aaf8e36f1cd6367773312905e82b50", size = 12573821, upload-time = "2026-09-01T00:26:20.404Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/aec75b11bfaa5a358f5a505afa6307fc4bee1c5f4c6444e18fa82c25f3be/ty-0.0.77-py3-none-win_amd64.whl", hash = "sha256:86f01d4af8b006c9442a2de0ab949cc24462f8f9431bebc0b73f6166fbbca19f", size = 13154851, upload-time = "2026-09-01T00:26:22.389Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e5/77772f95ff81bf7c817595cd08b52d007acab90dac0f2339faa486e6a525/ty-0.0.77-py3-none-win_arm64.whl", hash = "sha256:942a3bb2a4786c80bd8ef4503e5024046617cdfcc550b56c1acc4817ce7ac144", size = 12992392, upload-time = "2026-09-01T00:26:24.44Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json index 4e50f1ee..08e5a8b6 100644 --- a/core-spec/ossie-schema.json +++ b/core-spec/ossie-schema.json @@ -42,7 +42,7 @@ }, "Vendor": { "type": "string", - "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM"], + "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM", "HEX"], "description": "Vendor name for custom extensions. Any string value is accepted." }, "AIContext": { diff --git a/core-spec/spec.md b/core-spec/spec.md index 307318e4..c1446fd9 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -449,6 +449,7 @@ The following are well-known examples: | `HONEYDEW` | Honeydew-specific attributes | | `WISDOM` | WisdomAI-specific attributes | | `SIGMA` | Sigma Computing-specific attributes | +| `HEX` | Hex-specific attributes | ### Examples diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index 7bcb122f..d16d51e2 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -55,7 +55,7 @@ datatypes: - "Opaque" # Known type outside the portable vocabulary # Vendor name for custom extensions (free-form string) -# Examples: "COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM" +# Examples: "COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM", "HEX" vendor_name: string diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py index ebf6b0e0..85789a08 100644 --- a/python/src/ossie/models.py +++ b/python/src/ossie/models.py @@ -73,6 +73,7 @@ class OssieVendor(str, Enum): SEMANTIDO = "SEMANTIDO" WISDOM = "WISDOM" SIGMA = "SIGMA" + HEX = "HEX" class OssieAIContextObject(BaseModel): @@ -219,7 +220,9 @@ class OssieDocument(BaseModel): def to_ossie_yaml(self, **kwargs: Any) -> str: """Serialize to Ossie-compliant YAML (uses field aliases and excludes None values).""" data = self.model_dump(by_alias=True, exclude_none=True, mode="json", **kwargs) - return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True) + return yaml.dump( + data, default_flow_style=False, sort_keys=False, allow_unicode=True + ) def to_ossie_json(self, **kwargs: Any) -> str: """Serialize to Ossie-compliant JSON (uses field aliases and excludes None values).""" From 1b48acb069e40bdfb34e1df9175566b31e593377 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Tue, 25 Aug 2026 15:04:15 -0400 Subject: [PATCH 02/12] add export (the easy parts) --- converters/hex/README.md | 114 +++++- converters/hex/src/ossie_hex/__init__.py | 6 +- converters/hex/src/ossie_hex/hex/__init__.py | 50 +++ converters/hex/src/ossie_hex/hex/_brand.py | 44 +++ converters/hex/src/ossie_hex/hex/hex_sql.py | 36 ++ converters/hex/src/ossie_hex/hex/utils.py | 30 ++ .../hex/src/ossie_hex/ossie/__init__.py | 26 ++ .../src/ossie_hex/ossie/ossie_dialect_name.py | 28 ++ .../src/ossie_hex/ossie/ossie_field_expr.py | 49 +++ .../src/ossie_hex/ossie_to_hex/__init__.py | 20 ++ .../ossie_to_hex/context/__init__.py | 22 ++ .../ossie_hex/ossie_to_hex/context/context.py | 113 ++++++ .../ossie_to_hex/convert_ossie_dataset.py | 113 ++++++ .../ossie_to_hex/convert_ossie_datatype.py | 48 +++ .../ossie_to_hex/convert_ossie_dialect.py | 48 +++ .../convert_ossie_dialect_expression.py | 69 ++++ .../ossie_to_hex/convert_ossie_document.py | 46 +++ .../ossie_to_hex/convert_ossie_expression.py | 76 ++++ .../ossie_to_hex/convert_ossie_field.py | 91 +++++ .../ossie_to_hex/convert_ossie_metric.py | 78 ++++ .../ossie_to_hex/convert_ossie_name.py | 75 ++++ .../convert_ossie_relationship.py | 53 +++ .../convert_ossie_semantic_model.py | 74 ++++ .../ossie_to_hex/convert_ossie_to_hex.py | 75 ++++ .../ossie_to_hex/dump_hex_project.py | 67 ++++ .../ossie_to_hex/dump_hex_resource.py | 122 +++++++ .../ossie_to_hex/load_ossie_dataset.py | 59 +++ .../ossie_to_hex/load_ossie_datatype.py | 32 ++ .../ossie_to_hex/load_ossie_dialect.py | 45 +++ .../load_ossie_dialect_expression.py | 114 ++++++ .../ossie_to_hex/load_ossie_document.py | 141 ++++++++ .../ossie_to_hex/load_ossie_expression.py | 86 +++++ .../ossie_to_hex/load_ossie_field.py | 40 +++ .../ossie_to_hex/load_ossie_metric.py | 43 +++ .../ossie_to_hex/load_ossie_relationship.py | 49 +++ .../ossie_to_hex/load_ossie_semantic_model.py | 110 ++++++ converters/hex/src/ossie_hex/util/context.py | 142 ++++++++ .../hex/src/ossie_hex/util/database_table.py | 29 ++ .../hex/src/ossie_hex/util/parse_sql.py | 35 ++ converters/hex/src/ossie_hex/util/problem.py | 122 +++++++ converters/hex/src/ossie_hex/util/yaml.py | 101 ++++++ .../test_convert_ossie_dataset.py | 222 ++++++++++++ .../test_convert_ossie_datatype.py | 51 +++ .../test_convert_ossie_dialect.py | 63 ++++ .../test_convert_ossie_dialect_expression.py | 95 +++++ .../ossie_to_hex/test_convert_ossie_field.py | 122 +++++++ .../ossie_to_hex/test_convert_ossie_metric.py | 108 ++++++ .../ossie_to_hex/test_convert_ossie_name.py | 107 ++++++ .../test_convert_ossie_relationship.py | 67 ++++ .../test_convert_ossie_semantic_model.py | 80 +++++ .../ossie_to_hex/test_convert_ossie_to_hex.py | 339 ++++++++++++++++++ .../ossie_to_hex/test_dump_hex_project.py | 113 ++++++ .../ossie_to_hex/test_dump_hex_resource.py | 91 +++++ .../ossie_to_hex/test_load_ossie_dataset.py | 68 ++++ .../ossie_to_hex/test_load_ossie_dialect.py | 69 ++++ .../test_load_ossie_dialect_expression.py | 95 +++++ .../ossie_to_hex/test_load_ossie_document.py | 121 +++++++ .../test_load_ossie_expression.py | 97 +++++ .../ossie_to_hex/test_load_ossie_field.py | 68 ++++ .../ossie_to_hex/test_load_ossie_metric.py | 157 ++++++++ .../test_load_ossie_relationship.py | 65 ++++ .../test_load_ossie_semantic_model.py | 105 ++++++ converters/hex/tests/ossie_to_hex/utils.py | 172 +++++++++ converters/hex/tests/test_smoke.py | 4 +- converters/hex/tests/utils.py | 24 ++ python/src/ossie/models.py | 2 +- 66 files changed, 5213 insertions(+), 13 deletions(-) create mode 100644 converters/hex/src/ossie_hex/hex/__init__.py create mode 100644 converters/hex/src/ossie_hex/hex/_brand.py create mode 100644 converters/hex/src/ossie_hex/hex/hex_sql.py create mode 100644 converters/hex/src/ossie_hex/hex/utils.py create mode 100644 converters/hex/src/ossie_hex/ossie/__init__.py create mode 100644 converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py create mode 100644 converters/hex/src/ossie_hex/ossie/ossie_field_expr.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/__init__.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/context/context.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_datatype.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_document.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_expression.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_name.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_to_hex.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_project.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_resource.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dataset.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_document.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_expression.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_relationship.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_semantic_model.py create mode 100644 converters/hex/src/ossie_hex/util/context.py create mode 100644 converters/hex/src/ossie_hex/util/database_table.py create mode 100644 converters/hex/src/ossie_hex/util/parse_sql.py create mode 100644 converters/hex/src/ossie_hex/util/problem.py create mode 100644 converters/hex/src/ossie_hex/util/yaml.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_datatype.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect_expression.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_name.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py create mode 100644 converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py create mode 100644 converters/hex/tests/ossie_to_hex/test_dump_hex_project.py create mode 100644 converters/hex/tests/ossie_to_hex/test_dump_hex_resource.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_dataset.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_dialect.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_dialect_expression.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_document.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_expression.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_field.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_metric.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_relationship.py create mode 100644 converters/hex/tests/ossie_to_hex/test_load_ossie_semantic_model.py create mode 100644 converters/hex/tests/ossie_to_hex/utils.py create mode 100644 converters/hex/tests/utils.py diff --git a/converters/hex/README.md b/converters/hex/README.md index b7b704a8..e8d7a8ec 100644 --- a/converters/hex/README.md +++ b/converters/hex/README.md @@ -69,29 +69,123 @@ TODO(import): {code-example} ### Python API -TODO(python): {description} +#### `convert_ossie_to_hex` -TODO(python): {code-example} +Convert an Ossie semantic model(s) into a Hex semantic project(s). + +```python +from ossie_hex import convert_ossie_to_hex + +hex_projects, problems = convert_ossie_to_hex( + input="ossie.yaml", + output="hex/", + dialect="snowflake", +) +``` + +Options: + +- `input` — Required. Ossie YAML file to export. +- `output` — Optional. Directory where Hex YAML files are written. If omitted, + the current working directory is used. +- `dialect` — Optional. Ossie dialect to pick from Ossie expressions. If + omitted, the first dialect an expression declares is used, falling back to + `ANSI_SQL`. + +Returns: a tuple of + +- `hex_projects`: A list of Hex semantic project(s). +- `problems`: A list of problems encountered. ## Conversion +The conversion between Ossie and Hex is a multi-step process that is not +one-to-one. It is designed such that the maximal set of information is +returned to the caller. Problems are returned in such a way that +the source can be identified and problem understood from the message alone. + +### Phases + +Conversion proceeds in three distinct phases. + +- `load`: Read file(s) from disk. Deserialize and parse file contents. Validate + and construct in-memory representation(s). +- `convert`: Transform syntax. Validate and construct target domain + model(s). +- `dump`: Serialize and encode in-memory representation(s) into the target + format. Write file(s) to disk. + ### Problems -TODO(problems): {error-description} +Conversion reports problems encountered. The severity of a problem is one of: -TODO(problems): {warning-description} +- `fatal`: The problem causes invalidation that cannot be recovered from, or + an unexpected internal error. +- `error`: The problem invalidates a definition which must be omitted from + the result. The associated definition(s) have been omitted + from the result. +- `warning`: The problem is a potential issue that probably should be addressed, + but is not critical. The associated definitions may behave unexpectedly, + but are included in the result. +- `info`: The problem is a general informational message that is not an issue. -### Concepts +Each problem contains a `cause_path` which identifies the key of the source +data where the problem occurred. The conversion `phase` in which the problem +occurred is also reported. -TODO(concepts): {description} +### Concepts -TODO(concepts): {table} +The table below shows how Ossie and Hex concepts correspond, and where a concept +in one format has no direct equivalent in the other. Backticks identify literal +fields in each format. Where a concept exists in Hex but is not supported in +Ossie, then data is preserved in the `HEX` custom extension on import. Where a +concept exists in Ossie but is not supported in Hex, then data is omitted on +export. + +| Concept | Ossie | Hex | +| ------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ | +| Container | Semantic model | Semantic project | +| Logical entity | Dataset | Model | +| Data source | Dataset `source` | Model `base_sql_table` or `base_sql_query` | +| Primary key | Dataset `primary_key` (simple, composite) | _Not supported; translated to Dimension `unique: true`; drops composite_ | +| Unique key | Dataset `unique_keys` (simple, composite) | Dimension `unique: true` (simple); _Composite not supported_ | +| Row-level attribute | Field | Dimension | +| Data type | Field / Metric `datatype` | Dimension / Measure `type` | +| Connections | Relationship | Relation | +| Arbitrary join condition | _Not supported beyond column-pair equality._ | Relation `join_sql` | +| Explicit join cardinality | _Not supported beyond many-to-one direction._ | Relation `type` | +| Quantitative measures | Metric | Measure | +| Unique identifier | Dataset / Field / Metric / Relationship `name` | Model / Dimension / Measure / Relation `id` | +| Display metadata | Field / Metric `description`, Field `label` | Model / Dimension / Measure / Relation `name`, `description` | +| AI context | `ai_context` | _Not supported._ | +| Custom extension | `custom_extensions` | _Not supported._ | +| Cross-dataset reference | `dataset.field` qualifier | `${relation.dimension}` qualifier | +| Time role | Dimension `is_time`, temporal `datatype` | Temporal `type`; _Additional time metadata not supported._ | +| Curated view | _Not supported._ | View | +| Visibility | _Not supported._ | Model / Dimension / Measure / Relation `visibility` | +| Calculation formula | _Not supported._ | Measure `func_calc` / Dimension `expr_calc` | +| Structured filter | _Not supported._ | Measure `filters` | +| Semi-additive measure | _Not supported._ | Measure `semi_additive` | ### Data types -TODO(data-types): {description} - -TODO(data-types): {table} +Data types translate between the two formats as follows, with notes where +conversion is not one-to-one. + +| Ossie `datatype` | Hex `type` | Notes | +| ---------------- | ----------------- | ---------------------------------------------------- | +| `String` | `string` | | +| `Decimal` | `number` | | +| `Integer` | `number` | | +| `Float` | `number` | | +| `Boolean` | `boolean` | | +| `Date` | `date` | | +| `DateTime` | `timestamp_naive` | | +| `DateTimeTz` | `timestamp_tz` | | +| `Opaque` | `null` | | +| `Opaque` | `other` | | +| `Time` | `other` | No Hex equivalent. | +| _omitted_ | `string`/`number` | Warning. String for dimensions, number for measures. | ### Custom extension diff --git a/converters/hex/src/ossie_hex/__init__.py b/converters/hex/src/ossie_hex/__init__.py index 0826ac8a..e74ec30b 100644 --- a/converters/hex/src/ossie_hex/__init__.py +++ b/converters/hex/src/ossie_hex/__init__.py @@ -15,4 +15,8 @@ # specific language governing permissions and limitations # under the License. -__all__ = [] +from .ossie_to_hex import convert_ossie_to_hex + +__all__ = [ + "convert_ossie_to_hex", +] diff --git a/converters/hex/src/ossie_hex/hex/__init__.py b/converters/hex/src/ossie_hex/hex/__init__.py new file mode 100644 index 00000000..5b1f1f8a --- /dev/null +++ b/converters/hex/src/ossie_hex/hex/__init__.py @@ -0,0 +1,50 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ._brand import ( + HexDataType, + HexDialect, + HexDialectName, + HexDimension, + HexEntityId, + HexMeasure, + HexModel, + HexProject, + HexRelation, + HexRelationType, + HexResource, + HexVisibility, +) +from .hex_sql import HexSql +from .utils import is_temporal_hex_datatype + +__all__ = [ + "HexDataType", + "HexDialect", + "HexDialectName", + "HexDimension", + "HexEntityId", + "HexMeasure", + "HexModel", + "HexProject", + "HexRelation", + "HexRelationType", + "HexResource", + "HexSql", + "HexVisibility", + "is_temporal_hex_datatype", +] diff --git a/converters/hex/src/ossie_hex/hex/_brand.py b/converters/hex/src/ossie_hex/hex/_brand.py new file mode 100644 index 00000000..1526cdc5 --- /dev/null +++ b/converters/hex/src/ossie_hex/hex/_brand.py @@ -0,0 +1,44 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from hex_sl_utils.spec.types import DataType as HexDataType +from hex_sl_utils.spec.types import Dialect as HexDialect +from hex_sl_utils.spec.types import DialectName as HexDialectName +from hex_sl_utils.spec.types import Dimension as HexDimension +from hex_sl_utils.spec.types import EntityId as HexEntityId +from hex_sl_utils.spec.types import Measure as HexMeasure +from hex_sl_utils.spec.types import Model as HexModel +from hex_sl_utils.spec.types import Project as HexProject +from hex_sl_utils.spec.types import Relation as HexRelation +from hex_sl_utils.spec.types import RelationType as HexRelationType +from hex_sl_utils.spec.types import Resource as HexResource +from hex_sl_utils.spec.types import Visibility as HexVisibility + +__all__ = [ + "HexDataType", + "HexDialect", + "HexDialectName", + "HexDimension", + "HexEntityId", + "HexMeasure", + "HexModel", + "HexProject", + "HexRelation", + "HexRelationType", + "HexResource", + "HexVisibility", +] diff --git a/converters/hex/src/ossie_hex/hex/hex_sql.py b/converters/hex/src/ossie_hex/hex/hex_sql.py new file mode 100644 index 00000000..49d0277e --- /dev/null +++ b/converters/hex/src/ossie_hex/hex/hex_sql.py @@ -0,0 +1,36 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Annotated + +HexSql = Annotated[ + str, + """A SQL expression in the context of a Hex entity. + + Possibly contains Hex semantic references. + + Dimension `expr_sql` examples: + - local logical, unqualified: `${dimension}` + - foreign logical, qualified: `${relation.dimension}` + - foreign physical, qualified: `${relation}.column` + + Measure `func_sql` examples: + - local logical, unqualified: `${dimension}` or `${measure}` + - foreign logical, qualified: `${relation.dimension}` or `${relation.measure}` + - foreign physical, qualified: `${relation}.column` + """, +] diff --git a/converters/hex/src/ossie_hex/hex/utils.py b/converters/hex/src/ossie_hex/hex/utils.py new file mode 100644 index 00000000..3c0dd357 --- /dev/null +++ b/converters/hex/src/ossie_hex/hex/utils.py @@ -0,0 +1,30 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ._brand import HexDataType + +_TEMPORAL_TYPES = frozenset( + [ + HexDataType.DATE, + HexDataType.TIMESTAMP_NAIVE, + HexDataType.TIMESTAMP_TZ, + ] +) + + +def is_temporal_hex_datatype(value: HexDataType) -> bool: + return value in _TEMPORAL_TYPES diff --git a/converters/hex/src/ossie_hex/ossie/__init__.py b/converters/hex/src/ossie_hex/ossie/__init__.py new file mode 100644 index 00000000..0341f8b0 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie/__init__.py @@ -0,0 +1,26 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .ossie_dialect_name import OssieDialectName +from .ossie_field_expr import ( + OSSIE_QUALIFIED_FIELD_EXPR_SCAN_RE, +) + +__all__ = [ + "OSSIE_QUALIFIED_FIELD_EXPR_SCAN_RE", + "OssieDialectName", +] diff --git a/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py b/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py new file mode 100644 index 00000000..2e053011 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py @@ -0,0 +1,28 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Literal + +OssieDialectName = Literal[ + "ANSI_SQL", + "SNOWFLAKE", + "MDX", + "MAQL", + "TABLEAU", + "DATABRICKS", + "BIGQUERY", +] diff --git a/converters/hex/src/ossie_hex/ossie/ossie_field_expr.py b/converters/hex/src/ossie_hex/ossie/ossie_field_expr.py new file mode 100644 index 00000000..dab9d692 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie/ossie_field_expr.py @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import re + +_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*" +"""The Ossie expression_language.md spec states that identifiers must follow +ANSI SQL naming rules: + + - First character: Must be a letter (A-Z, case-insensitive by default). + - Subsequent characters: Letters, numbers (0-9), or underscores (_). + - Length: Usually up to 128 characters. + - Reserved words: Cannot be an unquoted SQL reserved keyword. +""" + +# Matcher for the unqualified form of an Ossie field expression, such as `field` +# Can be used to validate a standalone field expression +OSSIE_UNQUALIFIED_FIELD_EXPR_PATTERN = rf"^{_IDENTIFIER}$" +OSSIE_UNQUALIFIED_FIELD_EXPR_RE = re.compile(OSSIE_UNQUALIFIED_FIELD_EXPR_PATTERN) + + +# Matcher for the qualified form of an Ossie field expression, such as `dataset.field` +# Can be used to validate a standalone field expression +OSSIE_QUALIFIED_FIELD_EXPR_PATTERN = rf"^({_IDENTIFIER})\.({_IDENTIFIER})$" +OSSIE_QUALIFIED_FIELD_EXPR_RE = re.compile(OSSIE_QUALIFIED_FIELD_EXPR_PATTERN) + + +# Matcher for the qualified form of an Ossie field expression, left unanchored. +# Can be used to find references embedded in a larger expression +OSSIE_QUALIFIED_FIELD_EXPR_SCAN_PATTERN = rf"\b({_IDENTIFIER})\.({_IDENTIFIER})\b" +OSSIE_QUALIFIED_FIELD_EXPR_SCAN_RE = re.compile( + rf"\b({_IDENTIFIER})\.({_IDENTIFIER})\b" +) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/__init__.py b/converters/hex/src/ossie_hex/ossie_to_hex/__init__.py new file mode 100644 index 00000000..f331e8de --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/__init__.py @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .convert_ossie_to_hex import convert_ossie_to_hex + +__all__ = ["convert_ossie_to_hex"] diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py new file mode 100644 index 00000000..b99867d3 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .context import ExportContext + +__all__ = [ + "ExportContext", +] diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py new file mode 100644 index 00000000..346d131d --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py @@ -0,0 +1,113 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager + +from ossie import OssieDialect + +from ...hex import HexDialect, HexDialectName, HexEntityId, HexModel +from ...ossie import OssieDialectName +from ...util.context import Context + +logger = logging.getLogger(__name__) + + +class ExportContext(Context): + """Context for exporting from Ossie specification to Hex specification.""" + + # global scope + ossie_dialect: OssieDialect + hex_dialect: HexDialect + + # semantic model scope + _hex_models: dict[HexEntityId, HexModel] | None + + # fields scope + _unique_field_names: set[str] | None + + def __init__(self) -> None: + super().__init__(logger=logger) + + def set_dialects( + self, ossie_dialect: OssieDialect, hex_dialect: HexDialect + ) -> None: + self.ossie_dialect = ossie_dialect + self.hex_dialect = hex_dialect + + def _set_dialects( + self, ossie_dialect: OssieDialectName, hex_dialect: HexDialectName + ) -> None: + """Convenience method for tests to set the dialects.""" + self.ossie_dialect = OssieDialect(ossie_dialect) + self.hex_dialect = HexDialect(hex_dialect) + + @contextmanager + def semantic_model_scope(self, semantic_model_name: str) -> Iterator[None]: + """Use isolated identifiers and assignments for one semantic model.""" + self._hex_models = {} + try: + with self.problem_scope(semantic_model_name): + yield + finally: + self._hex_models = None + + def add_hex_model(self, value: HexModel | None) -> None: + if value is None: + return + if self._hex_models is None: + raise ExportSemanticModelScopeNotSetError + self._hex_models[value.id] = value + + def hex_models(self) -> list[HexModel]: + if self._hex_models is None: + raise ExportSemanticModelScopeNotSetError + return list(self._hex_models.values()) + + def hex_model_by_id(self, id: HexEntityId) -> HexModel | None: + if self._hex_models is None: + raise ExportSemanticModelScopeNotSetError + return self._hex_models.get(id) + + @contextmanager + def fields_scope( + self, + *, + unique_field_names: set[str], + ) -> Iterator[None]: + self._unique_field_names = unique_field_names + try: + with self.problem_scope("fields"): + yield + finally: + self._unique_field_names = None + + def is_unique_field(self, field_name: str) -> bool: + if self._unique_field_names is None: + raise _ExportFieldsScopeNotSetError + return field_name in self._unique_field_names + + +class _ExportFieldsScopeNotSetError(ValueError): + """Internal logic error. Fields scope not set.""" + + +class ExportSemanticModelScopeNotSetError(ValueError): + """Internal logic error. Semantic model scope not set.""" diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py new file mode 100644 index 00000000..1dac2319 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py @@ -0,0 +1,113 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Any + +from ossie import OssieDataset + +from ..hex import HexModel +from ..util.database_table import is_table_name +from .context import ExportContext +from .convert_ossie_field import convert_ossie_field +from .convert_ossie_name import convert_ossie_name + + +def convert_ossie_dataset( + ossie_dataset: OssieDataset, + *, + ctx: ExportContext, +) -> HexModel | None: + with ctx.problem_scope(ossie_dataset.name): + spec: dict[str, Any] = {} + + with ctx.problem_scope("name"): + hex_entity_id = convert_ossie_name(ossie_dataset.name, ctx=ctx) + if hex_entity_id is None: + return None + spec["id"] = hex_entity_id + + spec["type"] = "model" + + if ossie_dataset.description: + spec["description"] = ossie_dataset.description + + with ctx.problem_scope("source"): + stripped = ossie_dataset.source.strip() + if not stripped: + ctx.warn("Dataset source is empty") + spec["base_sql_query"] = ossie_dataset.source + elif is_table_name(stripped): + spec["base_sql_table"] = ossie_dataset.source + else: + spec["base_sql_query"] = ossie_dataset.source + + # ASSUMPTION: It's not clear whether values of `primary_key` and `unique_keys` + # are physical or logical columns, or both. For now, I'm assuming they are + # logical columns (i.e. field names). So we'll collect them here to use when + # assembling a HexDimension to mark it as `unique: true`. + unique_field_names = set[str]() + + with ctx.problem_scope("primary_key"): + if ossie_dataset.primary_key: + if len(ossie_dataset.primary_key) > 1: + ctx.warn( + f"Composite primary key is not supported: {ossie_dataset.primary_key}", + ) + else: + unique_field_names.add(ossie_dataset.primary_key[0]) + + with ctx.problem_scope("unique_keys"): + for key in ossie_dataset.unique_keys or []: + if len(key) == 0: + # load phase should drop this case + continue + elif len(key) == 1: + unique_field_names.add(key[0]) + else: + ctx.warn( + f"Composite unique key is not supported: {key}", + code="composite-unique-key", + ) + + with ctx.fields_scope(unique_field_names=unique_field_names): + if ossie_dataset.fields: + spec["dimensions"] = [] + for ossie_field in ossie_dataset.fields: + if hex_dimension := convert_ossie_field(ossie_field, ctx=ctx): + spec["dimensions"].append(hex_dimension) + else: + ctx.warn("Dataset fields are empty") + + with ctx.problem_scope("ai_context"): + if ossie_dataset.ai_context is not None: + ctx.warn("Not supported") + + with ctx.problem_scope("custom_extensions"): + if ossie_dataset.custom_extensions is not None: + ctx.warn("Not supported") + + # Attributes not set: + # - name: Ossie does not encode a display name + # - visibility: Ossie does not support this concept + + if spec["id"] is None: + return None + + spec["measures"] = [] + spec["relations"] = [] + + return HexModel(**spec) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_datatype.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_datatype.py new file mode 100644 index 00000000..98eaccbe --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_datatype.py @@ -0,0 +1,48 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from collections.abc import Mapping + +from ossie import OssieDataType + +from ..hex import HexDataType + +OSSIE_TO_HEX: Mapping[OssieDataType, HexDataType] = { + OssieDataType.STRING: HexDataType.STRING, + OssieDataType.INTEGER: HexDataType.NUMBER, + OssieDataType.DECIMAL: HexDataType.NUMBER, + OssieDataType.FLOAT: HexDataType.NUMBER, + OssieDataType.BOOLEAN: HexDataType.BOOLEAN, + OssieDataType.DATE: HexDataType.DATE, + OssieDataType.DATE_TIME: HexDataType.TIMESTAMP_NAIVE, + OssieDataType.DATE_TIME_TZ: HexDataType.TIMESTAMP_TZ, + OssieDataType.TIME: HexDataType.OTHER, # Hex has no time datatype + OssieDataType.OPAQUE: HexDataType.OTHER, # Hex has no opaque datatype +} + + +def convert_ossie_datatype( + ossie_datatype: OssieDataType | None, + default: HexDataType, +) -> HexDataType: + """Map an Ossie datatype to a Hex type.""" + if ossie_datatype is None: + # Already warned during load phase + return default + return OSSIE_TO_HEX[ossie_datatype] diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect.py new file mode 100644 index 00000000..a15830a8 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect.py @@ -0,0 +1,48 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import Mapping + +from ossie import OssieDialect + +from ..hex import HexDialect, HexDialectName +from .context import ExportContext + +_DIALECT_NAME_MAP: Mapping[OssieDialect, HexDialectName | None] = { + OssieDialect.ANSI_SQL: "duckdb", + OssieDialect.BIGQUERY: "bigquery", + OssieDialect.DATABRICKS: "databricks", + OssieDialect.SNOWFLAKE: "snowflake", + # These are expression languages, not SQL dialects or engines + OssieDialect.MAQL: None, + OssieDialect.MDX: None, + OssieDialect.TABLEAU: None, +} + + +def convert_ossie_dialect( + ossie_dialect: OssieDialect, + *, + ctx: ExportContext, +) -> HexDialect: + hex_dialect_name = _DIALECT_NAME_MAP.get(ossie_dialect) + if hex_dialect_name is None: + ctx.info( + "Ossie dialect is an expression language; using DuckDB as SQL dialect for Hex semantic project" + ) + return HexDialect("duckdb") + return HexDialect(hex_dialect_name) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py new file mode 100644 index 00000000..3e8c996f --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + + +from collections.abc import Callable + +from ossie import OssieDialectExpression + +from ..hex import HexSql +from ..util.parse_sql import exp +from .context import ExportContext +from .load_ossie_dialect_expression import parse_ossie_dialect_expression + +OssieRefResolver = Callable[[str, str], tuple[str | None, str]] +"""A function that resolves components of an Ossie field expression (i.e. +reference) to components of a Hex semantic reference. + +In this case, only qualified field expressions (e.g. `dataset.field`) are supported. +""" + + +def convert_ossie_dialect_expression( + ossie_dialect_expression: OssieDialectExpression | None, + *, + resolve: OssieRefResolver | None, + ctx: ExportContext, +) -> HexSql | None: + """Convert an Ossie dialect expression to an equivalent Hex SQL string. + + If a resolver is provided, it is used to resolve Ossie field expression syntax + to Hex semantic reference syntax. + """ + if ossie_dialect_expression is None: + return None + + if resolve is None: + sql: HexSql = ossie_dialect_expression.expression + return sql + + def replace_reference(node: exp.Expression) -> exp.Expression: + if not isinstance(node, exp.Column) or not node.table: + return node + hex_relation_id, hex_item_id = resolve(node.table, node.name) + if hex_relation_id is None: + replacement = f"${{{hex_item_id}}}" + else: + replacement = f"${{{hex_relation_id}.{hex_item_id}}}" + return exp.Var(this=replacement) + + parsed = parse_ossie_dialect_expression(ossie_dialect_expression, ctx=ctx) + if parsed is None: + return None + transformed = parsed.expr.transform(replace_reference) + sql = transformed.sql(dialect=parsed.dialect) + return sql diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_document.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_document.py new file mode 100644 index 00000000..6b0e20fc --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_document.py @@ -0,0 +1,46 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieDocument + +from ..hex import HexProject +from .context import ExportContext +from .convert_ossie_semantic_model import convert_ossie_semantic_model + + +def convert_ossie_document( + ossie_document: OssieDocument | None, + *, + ctx: ExportContext, +) -> list[HexProject]: + """Convert an Ossie document to Hex semantic projects. + + A Hex semantic project is created for each Ossie semantic model + defined in the document. + + Returns a list of Hex semantic projects. + """ + hex_projects: list[HexProject] = [] + if ossie_document is not None: + for ossie_semantic_model in ossie_document.semantic_model: + with ctx.problem_scope("semantic_model"): + hex_project = convert_ossie_semantic_model( + ossie_semantic_model, ctx=ctx + ) + hex_projects.append(hex_project) + + return hex_projects diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_expression.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_expression.py new file mode 100644 index 00000000..f5505171 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_expression.py @@ -0,0 +1,76 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + + +from ossie import OssieDialect, OssieDialectExpression, OssieExpression + +from ..hex import HexSql +from .context import ExportContext +from .convert_ossie_dialect_expression import ( + OssieRefResolver, + convert_ossie_dialect_expression, +) + + +def convert_ossie_expression( + ossie_expression: OssieExpression, + *, + resolve: OssieRefResolver | None, + ctx: ExportContext, +) -> HexSql | None: + """Convert an Ossie expression to a Hex SQL string. + + Picks the dialect expression according to the value set in context. + If a resolver is provided, it is used to resolve Ossie expression syntax + to Hex semantic reference syntax. + """ + ossie_dialect_expression = pick_ossie_expression(ossie_expression, ctx=ctx) + hex_sql = convert_ossie_dialect_expression( + ossie_dialect_expression, + resolve=resolve, + ctx=ctx, + ) + return hex_sql + + +def pick_ossie_expression( + ossie_expression: OssieExpression, + *, + ctx: ExportContext, +) -> OssieDialectExpression | None: + """Pick an Ossie dialect expression from an Ossie expression. + + Prefers to pick the current dialect, then falls back to ANSI SQL, then the first in the list. + + Returns a dialect expression or None if no fallback is found. + """ + fallback: OssieDialectExpression | None = None + for entry in ossie_expression.dialects: + if entry.dialect == ctx.ossie_dialect: + return entry + # fallback preference: (1) ansi sql, (2) first in the list + elif entry.dialect == OssieDialect.ANSI_SQL or fallback is None: + fallback = entry + if fallback is not None: + ctx.warn( + f"Preferred dialect {ctx.ossie_dialect} not found for expression; using fallback dialect {fallback.dialect}" + ) + else: + ctx.error( + f"Preferred dialect {ctx.ossie_dialect} not found for expression and no fallback dialect found" + ) + return fallback diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py new file mode 100644 index 00000000..abfc36bf --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py @@ -0,0 +1,91 @@ +# 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 + +from typing import Any + +from ossie import OssieField + +from ..hex import HexDataType, HexDimension, is_temporal_hex_datatype +from .context import ExportContext +from .convert_ossie_datatype import convert_ossie_datatype +from .convert_ossie_expression import convert_ossie_expression +from .convert_ossie_name import convert_ossie_name + + +def convert_ossie_field( + ossie_field: OssieField, + *, + ctx: ExportContext, +) -> HexDimension | None: + with ctx.problem_scope(ossie_field.name): + spec: dict[str, Any] = {} + + with ctx.problem_scope("name"): + spec["id"] = convert_ossie_name(ossie_field.name, ctx=ctx) + + if ossie_field.label: + # this property is described by the spec as "label for categorization". that + # doesn't seem it's a display name, but other converters use it as such, so + # we'll do the same for now. + spec["name"] = ossie_field.label + + if ossie_field.description: + spec["description"] = ossie_field.description + + with ctx.problem_scope("datatype"): + hex_datatype: HexDataType = convert_ossie_datatype( + ossie_field.datatype, HexDataType.STRING + ) + spec["type"] = hex_datatype + + with ctx.problem_scope("expression"): + # ASSUMPTION: A field expression does not contain references to other fields + # (either on the parent dataset or fields on datasets connected by a + # relationship). + resolve = None + hex_expr_sql = convert_ossie_expression( + ossie_field.expression, resolve=resolve, ctx=ctx + ) + spec["expr_sql"] = hex_expr_sql + + with ctx.problem_scope("dimension"): + if ossie_field.dimension: + with ctx.problem_scope("is_time"): + if ossie_field.dimension.is_time and not is_temporal_hex_datatype( + hex_datatype + ): + ctx.warn("Not supported") + # in the case that it's _not_ a time dimension, the datatype should fully + # describe it, so no action is needed + + with ctx.problem_scope("ai_context"): + if ossie_field.ai_context is not None: + ctx.warn("Not supported") + + with ctx.problem_scope("custom_extensions"): + if ossie_field.custom_extensions is not None: + ctx.warn("Not supported") + + spec["unique"] = ctx.is_unique_field(ossie_field.name) + + if spec["id"] is None: + return None + + # Attributes not set: + # - HexDimension.visibility: Ossie does not support this concept + # - HexDimension.expr_calc: Ossie does not express Hex calculation language + + return HexDimension(**spec) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py new file mode 100644 index 00000000..f98611d5 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py @@ -0,0 +1,78 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Any + +from ossie import OssieMetric + +from ..hex import HexDataType, HexMeasure +from .context import ExportContext +from .convert_ossie_datatype import convert_ossie_datatype +from .convert_ossie_expression import convert_ossie_expression +from .convert_ossie_name import convert_ossie_name + + +def convert_ossie_metric( + ossie_metric: OssieMetric, + *, + ctx: ExportContext, +) -> HexMeasure | None: + """Convert and assign an analyzed Ossie metric when possible.""" + + with ctx.problem_scope(ossie_metric.name): + spec: dict[str, Any] = {} + + with ctx.problem_scope("name"): + spec["id"] = convert_ossie_name(ossie_metric.name, ctx=ctx) + + if ossie_metric.description: + spec["description"] = ossie_metric.description + + with ctx.problem_scope("datatype"): + spec["type"] = convert_ossie_datatype( + ossie_metric.datatype, + HexDataType.NUMBER, + ) + + with ctx.problem_scope("expression"): + # Since a metric expression can contain references to fields on datasets, those + # references need to be converted to the corresponding Hex IDs and syntax. + resolve = None # TODO: implement + + spec["func_sql"] = convert_ossie_expression( + ossie_metric.expression, + resolve=resolve, + ctx=ctx, + ) + + with ctx.problem_scope("ai_context"): + if ossie_metric.ai_context is not None: + ctx.warn("Not supported") + + with ctx.problem_scope("custom_extensions"): + if ossie_metric.custom_extensions is not None: + ctx.warn("Not supported") + + if spec["id"] is None: + return None + + # Attributes not set: + # - name: Ossie does not support display names + # - visibility: Ossie does not support visibility + # - func_calc: Ossie does not express Hex calculation language + + return HexMeasure(**spec) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_name.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_name.py new file mode 100644 index 00000000..15cadf4b --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_name.py @@ -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 + +import re + +from pydantic import TypeAdapter, ValidationError + +from ..hex import HexEntityId +from .context import ExportContext + +_HEX_ENTITY_ID_ADAPTER = TypeAdapter(HexEntityId) + + +def convert_ossie_name( + ossie_name: str, + *, + ctx: ExportContext, +) -> HexEntityId | None: + try: + # if already valid, early return + hex_entity_id = _HEX_ENTITY_ID_ADAPTER.validate_python(ossie_name) + return hex_entity_id + except ValidationError as _e: + pass + + # attempt to normalize an Ossie name to a Hex entity ID. + + # basic transformation (doesn't require notice) + # - lowercasing + # - removing leading/trailing whitespace + out = ossie_name.lower().strip() + try: + hex_entity_id = _HEX_ENTITY_ID_ADAPTER.validate_python(out) + return hex_entity_id + except ValidationError as _e: + pass + + # more aggressive transformation + # - replace invalid characters with underscores + before = out + out = re.sub(r"[^a-z0-9_]+", "_", out) + + if not out: + ctx.error(f"Unable to convert identifier: '{ossie_name}'.") + return None + if out[0].isdigit(): + out = f"_{out}" + if len(out) < 2: + out = f"{out}_" + elif len(out) > 128: + out = out[:128] + + if before != out: + try: + hex_entity_id = _HEX_ENTITY_ID_ADAPTER.validate_python(out) + ctx.info(f"Normalized identifier: '{ossie_name}' -> '{out}'.") + return hex_entity_id + except ValidationError as _e: + pass + + ctx.error(f"Unable to convert identifier: '{ossie_name}'.") + return None diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py new file mode 100644 index 00000000..9d933bd0 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieRelationship + +from .context import ExportContext +from .convert_ossie_name import convert_ossie_name + +# ASSUMPTION: it's kind of ambiguous whether the spec is defining "columns" as +# physical or logical columns (i.e. a column on the underlying table/view/query +# or a field on the respective dataset). For now, we assume a physical column. +# In the case that it can be a logical column, we would need to resolve each +# value to the corresponding HexDimension id and wrap in Hex semantic reference +# syntax (${}) like the relation_id + + +def convert_ossie_relationship( + ossie_relationship: OssieRelationship, + *, + ctx: ExportContext, +) -> None: + """Convert and assign every planned direction of a relationship.""" + + with ctx.problem_scope(ossie_relationship.name): + with ctx.problem_scope("name"): + convert_ossie_name(ossie_relationship.name, ctx=ctx) + + with ctx.problem_scope("ai_context"): + if ossie_relationship.ai_context is not None: + ctx.warn("Not supported") + + with ctx.problem_scope("custom_extensions"): + if ossie_relationship.custom_extensions is not None: + ctx.warn("Not supported") + + # Attributes not set: + # - HexRelation.visibility: Ossie does not support this concept + + return diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py new file mode 100644 index 00000000..0048e673 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py @@ -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. + +from __future__ import annotations + +from ossie import OssieSemanticModel + +from ..hex import HexProject, HexResource +from .context import ExportContext +from .convert_ossie_dataset import convert_ossie_dataset +from .convert_ossie_metric import convert_ossie_metric +from .convert_ossie_relationship import convert_ossie_relationship + + +def convert_ossie_semantic_model( + ossie_semantic_model: OssieSemanticModel, + *, + ctx: ExportContext, +) -> HexProject: + """Convert an Ossie semantic model to a Hex project. + + Returns the converted Hex project. + """ + with ctx.semantic_model_scope(ossie_semantic_model.name): + with ctx.problem_scope("datasets"): + for ossie_dataset in ossie_semantic_model.datasets: + hex_model = convert_ossie_dataset(ossie_dataset, ctx=ctx) + ctx.add_hex_model(hex_model) + + with ctx.problem_scope("relationships"): + for ossie_relationship in ossie_semantic_model.relationships or []: + convert_ossie_relationship(ossie_relationship, ctx=ctx) + + with ctx.problem_scope("metrics"): + for ossie_metric in ossie_semantic_model.metrics or []: + convert_ossie_metric(ossie_metric, ctx=ctx) + + with ctx.problem_scope("description"): + if ossie_semantic_model.description is not None: + ctx.warn("Not supported") + + with ctx.problem_scope("ai_context"): + if ossie_semantic_model.ai_context is not None: + ctx.warn("Not supported") + + with ctx.problem_scope("custom_extensions"): + if ossie_semantic_model.custom_extensions is not None: + ctx.warn("Not supported") + + hex_models = ctx.hex_models() + hex_resources: list[HexResource] = [] + hex_resources.extend(hex_models) + + hex_project = HexProject( + name=ossie_semantic_model.name, + dialect=ctx.hex_dialect, + resources=hex_resources, + ) + + return hex_project diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_to_hex.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_to_hex.py new file mode 100644 index 00000000..753cdc60 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_to_hex.py @@ -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. + +from __future__ import annotations + +from pathlib import Path + +from ossie import OssieDialect + +from ..hex import HexProject +from ..util.problem import Problem +from .context import ExportContext +from .convert_ossie_dialect import convert_ossie_dialect +from .convert_ossie_document import convert_ossie_document +from .dump_hex_project import dump_hex_project +from .load_ossie_dialect import load_ossie_dialect +from .load_ossie_document import load_ossie_document + + +def convert_ossie_to_hex( + input: Path | str, + output: Path | str | None = None, + *, + dialect: OssieDialect | str | None = None, +) -> tuple[list[HexProject], list[Problem]]: + """Convert an Ossie document to a Hex semantic project. + + Args: + - `input`: A path to the Ossie document file. + - `output`: Optional. A path to the desired output directory. If not provided, + the output is not written to a file. + - `dialect`: Optional. An Ossie dialect to prefer to pick expressions from (only + a single dialect is preserved in Hex expressions). If not provided, the `ANSI_SQL` + dialect will be used when available. Otherwise, the first dialect expression is used. + + Returns: a tuple of: + - `hex_projects`: A list of Hex semantic project(s). + - `problems`: A list of problems encountered. + """ + ctx = ExportContext() + + with ctx.phase_scope("load"): + ossie_document = load_ossie_document(document_path=input, ctx=ctx) + ossie_dialect = load_ossie_dialect(dialect, ctx=ctx) + + with ctx.phase_scope("convert"): + hex_dialect = convert_ossie_dialect(ossie_dialect, ctx=ctx) + ctx.set_dialects(ossie_dialect, hex_dialect) + hex_projects = convert_ossie_document( + ossie_document, + ctx=ctx, + ) + + with ctx.phase_scope("dump"): + if output is not None: + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=True) + for hex_project in hex_projects: + dump_hex_project(hex_project, dir=output, ctx=ctx) + + return hex_projects, ctx.problems diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_project.py b/converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_project.py new file mode 100644 index 00000000..fdf82d36 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_project.py @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path + +from ..hex import HexProject +from .context import ExportContext +from .dump_hex_resource import dump_hex_resource + + +def dump_hex_project( + hex_project: HexProject, + *, + dir: Path, + ctx: ExportContext, +) -> None: + with ctx.problem_scope(hex_project.name): + project_dir = _resolve_project_dir(dir, hex_project.name, ctx=ctx) + project_dir = _write_project_dir(project_dir, ctx=ctx) + if project_dir is None: + return + + with ctx.problem_scope("resources"): + for hex_resource in hex_project.resources: + dump_hex_resource(hex_resource, project_dir, ctx=ctx) + + +def _resolve_project_dir( + dir: Path, project_name: str, *, ctx: ExportContext +) -> Path | None: + try: + dir = dir.resolve() + project_dir = (dir / project_name).resolve() + except (OSError, ValueError) as e: + ctx.error(f"Failed to resolve project directory: {e}") + return None + if not project_dir.is_relative_to(dir): + ctx.error( + f"Project name would write files outside the output directory: {project_name}" + ) + return None + return project_dir + + +def _write_project_dir(project_dir: Path | None, *, ctx: ExportContext) -> Path | None: + if project_dir is None: + return None + try: + project_dir.mkdir(parents=True, exist_ok=True) + except (OSError, ValueError) as e: + ctx.error(f"Failed to create project directory: {e}") + return None + return project_dir diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_resource.py b/converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_resource.py new file mode 100644 index 00000000..88e42ea8 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/dump_hex_resource.py @@ -0,0 +1,122 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path +from typing import Any + +from pydantic_core import PydanticSerializationError +from yaml import YAMLError + +from ..hex import HexResource +from ..util.yaml import dump_yaml +from .context import ExportContext + + +def dump_hex_resource( + hex_resource: HexResource, + project_dir: Path, + *, + ctx: ExportContext, +) -> None: + """Serialize and write a Hex resource to a file. + + Args: + - `hex_resource`: A Hex resource + - `project_dir`: A canonical path to the directory for the Hex project + """ + with ctx.problem_scope(hex_resource.id): + file_path = _resolve_file_path(project_dir, hex_resource, ctx=ctx) + json_data = _serialize_to_json(hex_resource, ctx=ctx) + yaml_data = _serialize_to_yaml(json_data, ctx=ctx) + _write_file(file_path, yaml_data, ctx=ctx) + + +def _serialize_to_json(hex_resource: HexResource, *, ctx: ExportContext) -> dict | None: + try: + json_data = hex_resource.model_dump( + mode="json", + exclude_none=True, + exclude_unset=True, + exclude_defaults=True, + ) + json_data = _reorder_fields(json_data) + return json_data + except PydanticSerializationError as e: + ctx.error(f"Failed to serialize to JSON: {e}") + + +def _serialize_to_yaml(json_data: dict | None, *, ctx: ExportContext) -> str | None: + if json_data is None: + return None + try: + return dump_yaml(json_data) + except YAMLError as e: + ctx.error(f"Failed to serialize to YAML: {e}") + + +def _resolve_file_path( + project_dir: Path, hex_resource: HexResource, *, ctx: ExportContext +) -> Path | None: + try: + file_path = (project_dir / f"{hex_resource.id}.yml").resolve() + except (OSError, ValueError) as e: + ctx.error(f"Failed to resolve file path: {e}") + return None + if not file_path.is_relative_to(project_dir): + ctx.error("Resource id would write a file outside the project directory") + return None + return file_path + + +def _write_file( + file_path: Path | None, + data: str | None, + *, + ctx: ExportContext, +) -> None: + if file_path is None or data is None: + return + try: + file_path.write_text(data, encoding="utf-8") + except OSError as e: + ctx.error(f"Failed to write to file: {e}") + + +_FIELD_ORDER = ("id", "name", "description") + + +def _reorder_fields(value: Any) -> Any: + """Pydantic's model_dump() method always follows field declaration order, without + options to customize. But we can reorder the fields recursively. + + This is presently useful so that `description` does not appear at the end of the + output. + """ + if isinstance(value, list): + return [_reorder_fields(item) for item in value] + if not isinstance(value, dict): + return value + + ordered = {key: _reorder_fields(value[key]) for key in _FIELD_ORDER if key in value} + ordered.update( + { + key: _reorder_fields(item) + for key, item in value.items() + if key not in ordered + } + ) + return ordered diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dataset.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dataset.py new file mode 100644 index 00000000..3ed0c72e --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dataset.py @@ -0,0 +1,59 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from ossie import OssieDataset, OssieField + +from .context import ExportContext +from .load_ossie_field import load_ossie_field + + +def load_ossie_dataset( + dataset: OssieDataset, + *, + ctx: ExportContext, +) -> OssieDataset: + """Load an Ossie dataset. + + Removes invalid fields. + + Returns an Ossie dataset with only valid fields. + """ + with ctx.problem_scope(dataset.name): + fields = list[OssieField]() + with ctx.problem_scope("fields"): + for field in dataset.fields or []: + if field := load_ossie_field(field, ctx=ctx): + fields.append(field) + + unique_keys = list[list[str]]() + with ctx.problem_scope("unique_keys"): + for key in dataset.unique_keys or []: + if len(key) == 0: + # kind of an error because we drop the key entry, but doesn't make + # an effective difference, so just warn + ctx.warn("Each unique key entry must have at least one field name") + else: + unique_keys.append(key) + + return dataset.model_copy( + update={ + "fields": fields, + "unique_keys": unique_keys, + } + ) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py new file mode 100644 index 00000000..77582bf7 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieDataType + +from .context import ExportContext + + +def load_ossie_datatype( + ossie_datatype: OssieDataType | None, + *, + default: OssieDataType, + ctx: ExportContext, +) -> OssieDataType: + if ossie_datatype is None: + ctx.warn(f"Missing. Hex requires a datatype. Using default '{default.value}'.") + return default + return ossie_datatype diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py new file mode 100644 index 00000000..52a58a67 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieDialect + +from .context import ExportContext + + +def load_ossie_dialect( + ossie_dialect: OssieDialect | str | None, + *, + ctx: ExportContext, +) -> OssieDialect: + # Even though OssieDocument specifies `dialects` as a top-level field, this isn't + # technically part of the core spec. So we don't use it. Otherwise we would validate + # against it and pick the first as a fallback. + + if isinstance(ossie_dialect, OssieDialect): + return ossie_dialect + elif isinstance(ossie_dialect, str): + try: + value = ossie_dialect.upper() + return OssieDialect(value) + except ValueError: + ctx.warn( + message=f"Invalid Ossie dialect: {ossie_dialect}. Using ANSI_SQL instead.", + ) + return OssieDialect.ANSI_SQL + else: + ctx.info("No Ossie dialect specified; using ANSI_SQL") + return OssieDialect.ANSI_SQL diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py new file mode 100644 index 00000000..650d1d84 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py @@ -0,0 +1,114 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import assert_never + +from ossie import OssieDialect, OssieDialectExpression + +from ..util.parse_sql import ParsedExpr, SQLGlotDialect, exp, parse_one +from .context import ExportContext + + +def validate_ossie_field_dialect_expression( + ossie_dialect_expression: OssieDialectExpression, + *, + ctx: ExportContext, +) -> bool: + """Validate an Ossie dialect expression of an Ossie field. + + An expression is valid if it can be parsed according to its dialect. + + Returns whether the expression is valid. + """ + parsed = parse_ossie_dialect_expression(ossie_dialect_expression, ctx=ctx) + return parsed is not None + + +def validate_ossie_metric_dialect_expression( + ossie_dialect_expression: OssieDialectExpression, + *, + field_names: list[tuple[str, str]], + ctx: ExportContext, +) -> bool: + """Validate an Ossie dialect expression of an Ossie metric. + + An expression is valid if + - it can be parsed according to its dialect. + - all referenced fields are in the semantic model. + + Returns whether the expression is valid. + """ + valid = True + parsed = parse_ossie_dialect_expression(ossie_dialect_expression, ctx=ctx) + if not parsed: + valid = False + else: + for identifier in parsed.expr.find_all(exp.Column): + pair = (identifier.table, identifier.name) + if pair not in field_names: + ctx.error( + f"Field expression references field not in semantic model: {pair[0]}.{pair[1]}" + ) + valid = False + return valid + + +def parse_ossie_dialect_expression( + ossie_dialect_expression: OssieDialectExpression, + *, + ctx: ExportContext, +) -> ParsedExpr | None: + """Parse an Ossie dialect expression. + + Returns the parsed expression, or None if the expression cannot be parsed. + """ + dialect = _to_sqlglot_dialect(ossie_dialect_expression.dialect) + with ctx.problem_scope("expression"): + try: + expr = parse_one(ossie_dialect_expression.expression, read=dialect) + except Exception as e: # noqa: BLE001 + ctx.error(f"Unable to parse: {e}") + return None + return ParsedExpr(expr=expr, dialect=dialect) + + +def _to_sqlglot_dialect(ossie_dialect: OssieDialect) -> SQLGlotDialect | None: + """Translate an Ossie dialect to a sqlglot dialect. + + Returns a sqlglot dialect or None (signal for sqlglot base behavior). + """ + + if ossie_dialect == OssieDialect.BIGQUERY: + read = SQLGlotDialect.BIGQUERY + elif ossie_dialect == OssieDialect.DATABRICKS: + read = SQLGlotDialect.DATABRICKS + elif ossie_dialect == OssieDialect.SNOWFLAKE: + read = SQLGlotDialect.SNOWFLAKE + elif ossie_dialect == OssieDialect.TABLEAU: + read = SQLGlotDialect.TABLEAU + elif ossie_dialect in ( + OssieDialect.ANSI_SQL, + OssieDialect.MDX, + OssieDialect.MAQL, + OssieDialect.THOUGHTSPOT, + ): + # There's no parallel for these + read = None + else: + assert_never(ossie_dialect) + + return read diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_document.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_document.py new file mode 100644 index 00000000..34e31148 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_document.py @@ -0,0 +1,141 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from pathlib import Path + +import yaml +from ossie import OssieDocument, OssieSemanticModel +from pydantic import ValidationError + +from ..util.yaml import load_yaml +from .context import ExportContext +from .load_ossie_semantic_model import load_ossie_semantic_model + + +def load_ossie_document( + *, + document_path: str | Path, + ctx: ExportContext, +) -> OssieDocument | None: + """Parse file contents as an Ossie document. + + ``document_path`` is the path to a file, expected to be in Ossie YAML format. + + Returns ``ossie_document``. + """ + file_path = _validate_document_file_path(document_path, ctx=ctx) + file_contents = _read_document_file_contents(file_path, ctx=ctx) + file_data = _parse_document_yaml(file_path, file_contents, ctx=ctx) + document = _validate_document_yaml(file_data, ctx=ctx) + + # additional validation + if document: + semantic_model = _load_ossie_semantic_models(document.semantic_model, ctx=ctx) + document = document.model_copy(update={"semantic_model": semantic_model}) + + return document + + +def _validate_document_file_path( + document_path: str | Path, + *, + ctx: ExportContext, +) -> Path | None: + document_path = Path(document_path).resolve() + if not document_path.exists(): + ctx.fatal(f"File does not exist: `{document_path}`") + return None + elif not document_path.is_file(): + ctx.fatal(f"File is not a file: `{document_path}`") + return None + return document_path + + +def _read_document_file_contents( + file_path: Path | None, + *, + ctx: ExportContext, +) -> str | None: + if file_path is None: + return None + try: + file_contents = file_path.read_text(encoding="utf-8") + except Exception as e: # noqa: BLE001 + ctx.fatal( + f"Failed to read file `{file_path}`: {e}", + path=[str(file_path)], + ) + return None + if not file_contents: + ctx.fatal( + f"File is empty: `{file_path}`", + path=[str(file_path)], + ) + return None + return file_contents + + +def _parse_document_yaml( + file_path: Path | None, + file_contents: str | None, + *, + ctx: ExportContext, +) -> dict | None: + if file_path is None or file_contents is None: + return None + try: + raw = load_yaml(file_contents) + except yaml.YAMLError as e: + ctx.fatal( + f"Invalid YAML in file `{file_path}`: {e}", + path=[str(file_path)], + ) + return None + if not isinstance(raw, dict): + ctx.fatal(f"YAML document must be a mapping: `{file_contents}`") + return None + return raw + + +def _validate_document_yaml( + data: dict | None, + *, + ctx: ExportContext, +) -> OssieDocument | None: + if data is None: + return None + try: + document = OssieDocument.model_validate(data) + except ValidationError as e: + ctx.fatal(f"Invalid Ossie document: {e}") + return None + return document + + +def _load_ossie_semantic_models( + semantic_models: list[OssieSemanticModel], + *, + ctx: ExportContext, +) -> list[OssieSemanticModel]: + with ctx.problem_scope("semantic_model"): # field name is singular in the spec + result = list[OssieSemanticModel]() + for semantic_model in semantic_models: + if semantic_model := load_ossie_semantic_model(semantic_model, ctx=ctx): + result.append(semantic_model) + return result diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_expression.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_expression.py new file mode 100644 index 00000000..3766e16f --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_expression.py @@ -0,0 +1,86 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieDialectExpression, OssieExpression + +from .context import ExportContext +from .load_ossie_dialect_expression import ( + validate_ossie_field_dialect_expression, + validate_ossie_metric_dialect_expression, +) + + +def load_ossie_field_expression( + ossie_expression: OssieExpression, + *, + ctx: ExportContext, +) -> OssieExpression | None: + """Load an Ossie expression declared on an Ossie field. + + An expression is valid if it contains at least one valid Ossie dialect expression. + + Returns an Ossie expression with only valid dialects, or None if the expression is invalid. + """ + # ASSUMPTION: Ossie field expressions are only allowed to reference underlying + # physical columns (on database table/view/query) and do not contain logical + # field references (Ossie FieldExpr of any shape). So we do nothing to validate + # the parsed output of the dialect expression(s). + result: OssieExpression | None = None + with ctx.problem_scope("dialects"): + dialects = list[OssieDialectExpression]() + for entry in ossie_expression.dialects: + with ctx.problem_scope(entry.dialect.value): + valid = validate_ossie_field_dialect_expression(entry, ctx=ctx) + if valid: + dialects.append(entry) + result = OssieExpression(dialects=dialects) + if not result.dialects: + ctx.error("Expression must have at least one valid dialect") + result = None + return result + + +def load_ossie_metric_expression( + expression: OssieExpression, + *, + field_names: list[tuple[str, str]], + ctx: ExportContext, +) -> OssieExpression | None: + """Load an Ossie expression declared on an Ossie metric. + + Args: + - `field_names`: reachable (dataset, field) name pairs in the semantic model. + + An expression is valid if it contains at least one valid Ossie dialect expression. + + Returns an Ossie expression with only valid dialects, or None if the expression is invalid. + """ + result: OssieExpression | None = None + with ctx.problem_scope("dialects"): + dialects = list[OssieDialectExpression]() + for entry in expression.dialects: + with ctx.problem_scope(entry.dialect.value): + valid = validate_ossie_metric_dialect_expression( + entry, field_names=field_names, ctx=ctx + ) + if valid: + dialects.append(entry) + result = OssieExpression(dialects=dialects) + if not result.dialects: + ctx.error("Expression must have at least one valid dialect") + result = None + return result diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py new file mode 100644 index 00000000..8fb245e7 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py @@ -0,0 +1,40 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieDataType, OssieField + +from .context import ExportContext +from .load_ossie_datatype import load_ossie_datatype +from .load_ossie_expression import load_ossie_field_expression + + +def load_ossie_field( + field: OssieField, + *, + ctx: ExportContext, +) -> OssieField | None: + with ctx.problem_scope(field.name): + with ctx.problem_scope("expression"): + expression = load_ossie_field_expression(field.expression, ctx=ctx) + with ctx.problem_scope("datatype"): + datatype = load_ossie_datatype( + field.datatype, default=OssieDataType.STRING, ctx=ctx + ) + + if expression is None: + return None + return field.model_copy(update={"expression": expression, "datatype": datatype}) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py new file mode 100644 index 00000000..5ccff0a9 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieDataType, OssieMetric + +from .context import ExportContext +from .load_ossie_datatype import load_ossie_datatype +from .load_ossie_expression import load_ossie_metric_expression + + +def load_ossie_metric( + metric: OssieMetric, + *, + field_names: list[tuple[str, str]], + ctx: ExportContext, +) -> OssieMetric | None: + with ctx.problem_scope(metric.name): + with ctx.problem_scope("expression"): + expression = load_ossie_metric_expression( + metric.expression, field_names=field_names, ctx=ctx + ) + with ctx.problem_scope("datatype"): + datatype = load_ossie_datatype( + metric.datatype, default=OssieDataType.DECIMAL, ctx=ctx + ) + + if not expression: + return None + return metric.model_copy(update={"expression": expression, "datatype": datatype}) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_relationship.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_relationship.py new file mode 100644 index 00000000..8a6e01d9 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_relationship.py @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from ossie import OssieRelationship + +from .context import ExportContext + + +def validate_ossie_relationship( + ossie_relationship: OssieRelationship, + *, + dataset_names: set[str], + ctx: ExportContext, +) -> bool: + r = ossie_relationship + valid = True + with ctx.problem_scope(ossie_relationship.name): + with ctx.problem_scope("from"): + if r.from_dataset not in dataset_names: + ctx.error(f"Could not resolve dataset name: '{r.from_dataset}'.") + valid = False + with ctx.problem_scope("to"): + if r.to not in dataset_names: + ctx.error(f"Could not resolve dataset name: '{r.to}'.") + valid = False + + from_columns_length = len(r.from_columns) + to_columns_length = len(r.to_columns) + if from_columns_length != to_columns_length: + ctx.error( + "from_columns and to_columns must have equal length: " + f"{from_columns_length} != {to_columns_length}" + ) + valid = False + return valid diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_semantic_model.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_semantic_model.py new file mode 100644 index 00000000..3d57a5e4 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_semantic_model.py @@ -0,0 +1,110 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from ossie import OssieDataset, OssieMetric, OssieRelationship, OssieSemanticModel + +from .context import ExportContext +from .load_ossie_dataset import load_ossie_dataset +from .load_ossie_metric import load_ossie_metric +from .load_ossie_relationship import validate_ossie_relationship + + +def load_ossie_semantic_model( + semantic_model: OssieSemanticModel, + *, + ctx: ExportContext, +) -> OssieSemanticModel: + """Load an Ossie semantic model + + Removes invalid dataset fields, metrics, and relationships. + + Returns an Ossie semantic model with only valid members. + """ + with ctx.problem_scope(semantic_model.name): + datasets = _load_ossie_datasets(semantic_model.datasets, ctx=ctx) + + relationships: list[OssieRelationship] | None = None + if semantic_model.relationships: + dataset_names = set[str](d.name for d in datasets) + relationships = _load_ossie_relationships( + semantic_model.relationships, dataset_names=dataset_names, ctx=ctx + ) + + metrics: list[OssieMetric] | None = None + if semantic_model.metrics: + field_names = list[tuple[str, str]]( + (d.name, f.name) for d in datasets for f in (d.fields or []) + ) + metrics = _load_ossie_metrics( + semantic_model.metrics, field_names=field_names, ctx=ctx + ) + + semantic_model = semantic_model.model_copy( + update={ + "datasets": datasets, + "relationships": relationships, + "metrics": metrics, + } + ) + return semantic_model + + +def _load_ossie_datasets( + datasets: list[OssieDataset], + *, + ctx: ExportContext, +) -> list[OssieDataset]: + result = list[OssieDataset]() + with ctx.problem_scope("datasets"): + for dataset in datasets: + if dataset := load_ossie_dataset(dataset, ctx=ctx): + result.append(dataset) + return result + + +def _load_ossie_relationships( + relationships: list[OssieRelationship], + *, + dataset_names: set[str], + ctx: ExportContext, +) -> list[OssieRelationship]: + result = list[OssieRelationship]() + with ctx.problem_scope("relationships"): + for relationship in relationships: + if validate_ossie_relationship( + relationship, + dataset_names=dataset_names, + ctx=ctx, + ): + result.append(relationship) + return result + + +def _load_ossie_metrics( + metrics: list[OssieMetric], + *, + field_names: list[tuple[str, str]], + ctx: ExportContext, +) -> list[OssieMetric]: + result = list[OssieMetric]() + with ctx.problem_scope("metrics"): + for metric in metrics: + if metric := load_ossie_metric(metric, field_names=field_names, ctx=ctx): + result.append(metric) + return result diff --git a/converters/hex/src/ossie_hex/util/context.py b/converters/hex/src/ossie_hex/util/context.py new file mode 100644 index 00000000..a9c059b9 --- /dev/null +++ b/converters/hex/src/ossie_hex/util/context.py @@ -0,0 +1,142 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Generic, TypeVar + +from .problem import KeyPath, PhaseName, Problem, ProblemSeverity + +ProblemCodeT = TypeVar("ProblemCodeT", bound=str) + + +class Context(Generic[ProblemCodeT]): + """Base context for all conversion processes.""" + + def __init__(self, *, logger: logging.Logger) -> None: + self.problems: list[Problem] = [] + self.current_problem_path: KeyPath = [] + self.current_phase_name: PhaseName | None = None + self._logger = logger + + @contextmanager + def phase_scope(self, phase_name: PhaseName) -> Iterator[None]: + self.current_phase_name = phase_name + try: + yield + finally: + self.current_phase_name = None + + @contextmanager + def problem_scope(self, *keys: str | int) -> Iterator[None]: + self.current_problem_path.extend(keys) + try: + yield + finally: + if keys: + del self.current_problem_path[-len(keys) :] + + def report_problem( + self, + severity: ProblemSeverity, + message: str, + *, + path: KeyPath, + internal_message: str = "", + ) -> None: + phase = self.current_phase_name + cause_path: KeyPath = [*self.current_problem_path, *path] + problem = Problem( + severity=severity, + message=message, + cause_path=cause_path, + phase=phase, + ) + if severity == "fatal" or internal_message: + self._logger.error( + "%s\nINTERNAL: %s", + problem.to_str(), + internal_message, + ) + self.problems.append(problem) + + def fatal( + self, + message: str, + *, + path: KeyPath | None = None, + internal_message: str = "", + ) -> None: + """Report a critical error that cannot be recovered from, + or an unexpected internal error. + """ + self.report_problem( + severity="fatal", + message=message, + path=path or [], + internal_message=internal_message, + ) + + def error( + self, + message: str, + *, + path: KeyPath | None = None, + internal_message: str = "", + ) -> None: + """Report an issue that invalidates a definition.""" + self.report_problem( + severity="error", + message=message, + path=path or [], + internal_message=internal_message, + ) + + def warn( + self, + message: str, + *, + path: KeyPath | None = None, + internal_message: str = "", + ) -> None: + """Report a potential issue that may cause unexpected behavior, + but does not invalidate a definition. + """ + self.report_problem( + severity="warning", + message=message, + path=path or [], + internal_message=internal_message, + ) + + def info( + self, + message: str, + *, + path: KeyPath | None = None, + internal_message: str = "", + ) -> None: + """Report an informational message that is not an issue.""" + self.report_problem( + severity="info", + message=message, + path=path or [], + internal_message=internal_message, + ) diff --git a/converters/hex/src/ossie_hex/util/database_table.py b/converters/hex/src/ossie_hex/util/database_table.py new file mode 100644 index 00000000..37d084f8 --- /dev/null +++ b/converters/hex/src/ossie_hex/util/database_table.py @@ -0,0 +1,29 @@ +# 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 re + +_TABLE_REF_PART = r'(?:"(?:[^"]|"")+"|`[^`]+`|[A-Za-z_][A-Za-z0-9_$-]*)' +_TABLE_REF_RE = re.compile(rf"^{_TABLE_REF_PART}(?:\s*\.\s*{_TABLE_REF_PART}){{0,3}}$") + + +def is_table_name(value: str) -> bool: + """Check if a value is a valid database table name. + + For example, of the form database.schema.table. + """ + return _TABLE_REF_RE.match(value) is not None diff --git a/converters/hex/src/ossie_hex/util/parse_sql.py b/converters/hex/src/ossie_hex/util/parse_sql.py new file mode 100644 index 00000000..e7c5012d --- /dev/null +++ b/converters/hex/src/ossie_hex/util/parse_sql.py @@ -0,0 +1,35 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass + +from hex_sl_utils._vendor.sqlglot import Dialects as SQLGlotDialect +from hex_sl_utils._vendor.sqlglot import exp, parse_one + + +@dataclass +class ParsedExpr: + expr: exp.Expression + dialect: SQLGlotDialect | None + + +__all__ = [ + "ParsedExpr", + "SQLGlotDialect", + "exp", + "parse_one", +] diff --git a/converters/hex/src/ossie_hex/util/problem.py b/converters/hex/src/ossie_hex/util/problem.py new file mode 100644 index 00000000..6d6eaa70 --- /dev/null +++ b/converters/hex/src/ossie_hex/util/problem.py @@ -0,0 +1,122 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class Problem(BaseModel): + """ + A problem encountered during the conversion between Ossie and Hex. + """ + + model_config = ConfigDict(title="Problem") + severity: ProblemSeverity + message: str = Field(json_schema_extra={"title": "ProblemMessage"}) + + cause_path: KeyPath = Field( + default_factory=list, + json_schema_extra={ + "title": "ProblemCausePath", + }, + ) + """ + The path to the key that caused the problem. + + If empty, a cause cannot be determined. + """ + + phase: PhaseName | None = Field( + default=None, + json_schema_extra={ + "title": "ProblemPhase", + }, + ) + """The phase of the conversion process in which the problem occurred.""" + + def to_str(self, *, include_cause: bool = True, include_phase: bool = False) -> str: + sev = self.severity.upper() + cause = str(self.cause_path) + phase = self.phase.upper() if self.phase else "UNKNOWN" + return "\n".join( + s + for s in [ + (f"[{phase}] " if include_phase else "") + f"[{sev}] {self.message}", + *([f"Cause: {cause}"] if include_cause else []), + ] + if s + ) + + +ProblemSeverity = Annotated[ + Literal["fatal", "error", "warning", "info"], + Field(title="ProblemSeverity"), +] +""" +The severity of a problem. + +- `fatal`: The problem causes invalidation that cannot be recovered from, or + an unexpected internal error. + +- `error`: The problem invalidates a definition which must be omitted from + the result. The associated definition(s) have been omitted + from the result. + +- `warning`: The problem is a potential issue that probably should be addressed, + but is not critical. The associated definitions may behave unexpectedly, + but are included in the result. + +- `info`: The problem is a general informational message that is not an issue. +""" + + +KeyPath = Annotated[ + list[str | int], + Field(title="ProblemKeyPath"), +] +""" +The key path to a problem's cause or impacted key, starting from the root of +the specification and ending at the key that caused the problem. Each key is +a declared identifier. + +If a key begins with `?` then it is a best guess at the location. +If a keypath ends with `:`, then the problem should be reported on the key +itself, not the value the key points to. + +This is an empty list if it applies globally. +""" + + +PhaseName = Annotated[ + Literal["load", "convert", "dump"], Field(title="ProblemPhaseName") +] +""" +The stage of processing that the problem occurred in. + +- `load`: The problem occurred during the loading phase: reading, parsing, and + validating data from the file system into memory. + +- `convert`: The problem occurred during the conversion phase: transforming + representation from the source specification into the target + specification. + +- `dump`: The problem occurred during the dumping phase: serializing and writing + data in-memory to the file system. +""" diff --git a/converters/hex/src/ossie_hex/util/yaml.py b/converters/hex/src/ossie_hex/util/yaml.py new file mode 100644 index 00000000..e2c068a2 --- /dev/null +++ b/converters/hex/src/ossie_hex/util/yaml.py @@ -0,0 +1,101 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import re +from typing import Any + +import yaml + +from .problem import Problem + +# region: Upgrading PyYAML's default YAML 1.1 semantics to YAML 1.2 +# preserve boolean-adjacent strings as strings. only treat true/false as booleans. + + +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): + _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: yaml.SafeDumper, data: str) -> Any: + style = "'" if data in _YAML11_BOOL_STRS else None + if "\n" in data: + style = "|" + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style) + + +_Yaml12Dumper.add_representer(str, _represent_str) + + +def load_yaml(text: str) -> Any: + """ + Parse a single YAML document with 1.2 boolean semantics. + + Raises: + - yaml.YAMLError: If the YAML is invalid. + + Returns: The parsed YAML document. + """ + return yaml.load(text, Loader=_Yaml12Loader) + + +def load_yaml_all(text: str, what: str = "input") -> list[Any] | Problem: + """Parse all YAML documents in a multi-document stream.""" + try: + docs = list(yaml.load_all(text, Loader=_Yaml12Loader)) + except yaml.YAMLError as e: + return Problem( + severity="fatal", + message=f"Invalid YAML in {what}: {e}", + cause_path=[], + ) + return [doc for doc in docs if doc is not None] + + +def dump_yaml(obj: Any) -> str: + """Serialize to YAML with 1.2 boolean semantics.""" + return yaml.dump( + obj, + Dumper=_Yaml12Dumper, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + ) + + +# endregion diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py new file mode 100644 index 00000000..6124398b --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py @@ -0,0 +1,222 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import Iterator + +import pytest +from inline_snapshot import snapshot +from ossie import OssieAIContextObject, OssieCustomExtension + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_dataset import convert_ossie_dataset +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> Iterator[ExportContext]: + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + yield ctx + + +def test_preserve_name(ctx: ExportContext) -> None: + name = "foo" + foo = Quick.dataset( + name, + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.id == name + assert not ctx.problems + + +def test_preserve_description(ctx: ExportContext) -> None: + description = "Foo bar baz" + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + description=description, + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.description == description + assert not ctx.problems + + +def test_detects_table_source(ctx: ExportContext) -> None: + source = "public.foo" + foo = Quick.dataset( + "foo", + source, + [("id", "String", [("ANSI_SQL", "id")])], + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.base_sql_table == source + assert result.base_sql_query is None + assert not ctx.problems + + +def test_detects_query_source(ctx: ExportContext) -> None: + source = "SELECT * FROM foo" + foo = Quick.dataset( + "foo", + source, + [("id", "String", [("ANSI_SQL", "id")])], + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.base_sql_table is None + assert result.base_sql_query == source + assert not ctx.problems + + +def test_warns_about_empty_source(ctx: ExportContext) -> None: + source = " " + foo = Quick.dataset( + "foo", + source, + [("id", "String", [("ANSI_SQL", "id")])], + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.base_sql_query == foo.source + assert result.base_sql_table is None + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Dataset source is empty" + ) + + +def test_warns_about_empty_fields(ctx: ExportContext) -> None: + fields = [] + foo = Quick.dataset( + "foo", + "public.foo", + fields, + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.dimensions == [] + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Dataset fields are empty" + ) + + +def test_single_primary_key_marks_field_unique(ctx: ExportContext) -> None: + primary_key = ["id"] + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + primary_key=primary_key, + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert len(result.dimensions) == 1 + assert result.dimensions[0].id == "id" + assert result.dimensions[0].unique is True + assert not ctx.problems + + +def test_warns_about_composite_primary_key(ctx: ExportContext) -> None: + primary_key = ["id", "created_at"] + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + primary_key=primary_key, + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.dimensions[0].unique is False + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Composite primary key is not supported: ['id', 'created_at']" + ) + + +def test_single_unique_key_marks_field_unique(ctx: ExportContext) -> None: + unique_keys = [["id"]] + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + unique_keys=unique_keys, + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert len(result.dimensions) == 1 + assert result.dimensions[0].id == "id" + assert result.dimensions[0].unique is True + assert not ctx.problems + + +def test_warns_about_composite_unique_key(ctx: ExportContext) -> None: + unique_keys = [["id", "status"]] + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + unique_keys=unique_keys, + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.dimensions[0].unique is False + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Composite unique key is not supported: ['id', 'status']" + ) + + +def test_warns_about_ai_context(ctx: ExportContext) -> None: + ai_context = OssieAIContextObject(synonyms=("bar", "baz")) + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + ai_context=ai_context, + ) + convert_ossie_dataset(foo, ctx=ctx) + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_custom_extensions(ctx: ExportContext) -> None: + custom_extension = OssieCustomExtension(vendor_name="foo", data="bar") + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + custom_extensions=[custom_extension], + ) + convert_ossie_dataset(foo, ctx=ctx) + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_empty_measures_relations(ctx: ExportContext) -> None: + foo = Quick.dataset( + "foo", + "public.foo", + [("id", "String", [("ANSI_SQL", "id")])], + ) + result = convert_ossie_dataset(foo, ctx=ctx) + assert result is not None + assert result.measures == [] + assert result.relations == [] + assert not ctx.problems diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_datatype.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_datatype.py new file mode 100644 index 00000000..a30a2b49 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_datatype.py @@ -0,0 +1,51 @@ +# 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 pytest +from ossie import OssieDataType + +from ossie_hex.hex import HexDataType +from ossie_hex.ossie_to_hex.convert_ossie_datatype import convert_ossie_datatype + + +@pytest.mark.parametrize( + ("ossie_datatype", "hex_datatype"), + [ + (OssieDataType.STRING, HexDataType.STRING), + (OssieDataType.INTEGER, HexDataType.NUMBER), + (OssieDataType.DECIMAL, HexDataType.NUMBER), + (OssieDataType.FLOAT, HexDataType.NUMBER), + (OssieDataType.BOOLEAN, HexDataType.BOOLEAN), + (OssieDataType.DATE, HexDataType.DATE), + (OssieDataType.DATE_TIME, HexDataType.TIMESTAMP_NAIVE), + (OssieDataType.DATE_TIME_TZ, HexDataType.TIMESTAMP_TZ), + (OssieDataType.TIME, HexDataType.OTHER), + (OssieDataType.OPAQUE, HexDataType.OTHER), + ], +) +def test_maps_datatype( + ossie_datatype: OssieDataType, + hex_datatype: HexDataType, +) -> None: + result = convert_ossie_datatype(ossie_datatype, HexDataType.STRING) + assert result == hex_datatype + + +def test_uses_default_when_missing() -> None: + default = HexDataType.BOOLEAN + result = convert_ossie_datatype(None, default) + assert result == default diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect.py new file mode 100644 index 00000000..46ade975 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect.py @@ -0,0 +1,63 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest +from inline_snapshot import snapshot +from ossie import OssieDialect + +from ossie_hex.hex import HexDialect, HexDialectName +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_dialect import convert_ossie_dialect +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +@pytest.mark.parametrize( + ("ossie_dialect", "hex_dialect_name"), + [ + (OssieDialect.ANSI_SQL, "duckdb"), + (OssieDialect.BIGQUERY, "bigquery"), + (OssieDialect.DATABRICKS, "databricks"), + (OssieDialect.SNOWFLAKE, "snowflake"), + ], +) +def test_maps_sql_dialect( + ctx: ExportContext, + ossie_dialect: OssieDialect, + hex_dialect_name: HexDialectName, +) -> None: + result = convert_ossie_dialect(ossie_dialect, ctx=ctx) + assert result == HexDialect(hex_dialect_name) + assert not ctx.problems + + +@pytest.mark.parametrize( + "ossie_dialect", + [OssieDialect.MAQL, OssieDialect.MDX, OssieDialect.TABLEAU], +) +def test_falls_back_to_duckdb_for_expression_language( + ctx: ExportContext, ossie_dialect: OssieDialect +) -> None: + result = convert_ossie_dialect(ossie_dialect, ctx=ctx) + assert result == HexDialect("duckdb") + assert problems_snapshot(ctx.problems) == snapshot( + "[INFO] Ossie dialect is an expression language; using DuckDB as SQL dialect for Hex semantic project" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect_expression.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect_expression.py new file mode 100644 index 00000000..4ab30d68 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dialect_expression.py @@ -0,0 +1,95 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_dialect_expression import ( + convert_ossie_dialect_expression, +) +from tests.ossie_to_hex.utils import Quick + + +def test_converts_without_resolver() -> None: + dialect_expression = Quick.dialect_expression("ANSI_SQL", "amount") + ctx = ExportContext() + result = convert_ossie_dialect_expression(dialect_expression, resolve=None, ctx=ctx) + assert result == snapshot("amount") + assert not ctx.problems + + +def test_converts_with_no_references() -> None: + dialect_expression = Quick.dialect_expression("ANSI_SQL", "SUM(1)") + ctx = ExportContext() + references: list[tuple[str, str]] = [] + + def resolve(dataset_name: str, field_name: str) -> tuple[str | None, str]: + references.append((dataset_name, field_name)) + return dataset_name, field_name + + result = convert_ossie_dialect_expression( + dialect_expression, + resolve=resolve, + ctx=ctx, + ) + + assert result == snapshot("SUM(1)") + assert references == [] + assert not ctx.problems + + +def test_converts_with_one_qualified_reference() -> None: + dialect_expression = Quick.dialect_expression("ANSI_SQL", "SUM(orders.amount)") + ctx = ExportContext() + references: list[tuple[str, str]] = [] + + def resolve(dataset_name: str, field_name: str) -> tuple[str | None, str]: + references.append((dataset_name, field_name)) + return dataset_name, field_name + + result = convert_ossie_dialect_expression( + dialect_expression, + resolve=resolve, + ctx=ctx, + ) + + assert result == snapshot("SUM(${orders.amount})") + assert references == [("orders", "amount")] + assert not ctx.problems + + +def test_converts_with_many_qualified_references() -> None: + dialect_expression = Quick.dialect_expression( + "ANSI_SQL", + "SUM(orders.amount) + customers.id", + ) + ctx = ExportContext() + references: list[tuple[str, str]] = [] + + def resolve(dataset_name: str, field_name: str) -> tuple[str | None, str]: + references.append((dataset_name, field_name)) + return dataset_name, field_name + + result = convert_ossie_dialect_expression( + dialect_expression, + resolve=resolve, + ctx=ctx, + ) + + assert result == snapshot("SUM(${orders.amount}) + ${customers.id}") + assert references == [("orders", "amount"), ("customers", "id")] + assert not ctx.problems diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py new file mode 100644 index 00000000..0e277580 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py @@ -0,0 +1,122 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import Iterator + +import pytest +from inline_snapshot import snapshot +from ossie import ( + OssieAIContextObject, + OssieCustomExtension, + OssieDimension, +) + +from ossie_hex.hex import HexDataType +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_field import convert_ossie_field +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> Iterator[ExportContext]: + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + unique_field_names = set() + with ctx.fields_scope(unique_field_names=unique_field_names): + yield ctx + + +def test_preserve_name(ctx: ExportContext) -> None: + name = "foo" + foo = Quick.field(name, "String", [("ANSI_SQL", "foo")]) + result = convert_ossie_field(foo, ctx=ctx) + assert result is not None + assert result.id == name + assert not ctx.problems + + +def test_preserve_label_description(ctx: ExportContext) -> None: + label = "Foo" + description = "Foo bar baz" + foo = Quick.field( + "foo", + "String", + [("ANSI_SQL", "foo")], + label=label, + description=description, + ) + result = convert_ossie_field(foo, ctx=ctx) + assert result is not None + assert result.name == label + assert result.description == description + assert not ctx.problems + + +def test_defaults_to_string_datatype(ctx: ExportContext) -> None: + datatype = None + foo = Quick.field("foo", datatype, [("ANSI_SQL", "foo")]) + result = convert_ossie_field(foo, ctx=ctx) + assert result is not None + assert result.type == HexDataType.STRING + assert not ctx.problems + + +def test_does_not_warn_about_dimension_is_time_with_temporal( + ctx: ExportContext, +) -> None: + datatype = "Date" + dimension = OssieDimension(is_time=True) + foo = Quick.field("foo", datatype, [("ANSI_SQL", "foo")], dimension=dimension) + convert_ossie_field(foo, ctx=ctx) + assert not ctx.problems + + +def test_warns_about_dimension_is_time_when_not_temporal(ctx: ExportContext) -> None: + datatype = "Integer" + dimension = OssieDimension(is_time=True) + foo = Quick.field("foo", datatype, [("ANSI_SQL", "foo")], dimension=dimension) + convert_ossie_field(foo, ctx=ctx) + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_ai_context(ctx: ExportContext) -> None: + ai_context = OssieAIContextObject(synonyms=("bar", "baz")) + foo = Quick.field("foo", "String", [("ANSI_SQL", "foo")], ai_context=ai_context) + convert_ossie_field(foo, ctx=ctx) + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_custom_extensions(ctx: ExportContext) -> None: + custom_extension = OssieCustomExtension(vendor_name="foo", data="bar") + foo = Quick.field( + "foo", + "String", + [("ANSI_SQL", "foo")], + custom_extensions=[custom_extension], + ) + convert_ossie_field(foo, ctx=ctx) + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_converts_expression(ctx: ExportContext) -> None: + foo = Quick.field("foo", "String", [("ANSI_SQL", "foo")]) + result = convert_ossie_field(foo, ctx=ctx) + assert result is not None + assert result.expr_sql == snapshot("foo") + assert not ctx.problems diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py new file mode 100644 index 00000000..af001682 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py @@ -0,0 +1,108 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import Iterator + +import pytest +from inline_snapshot import snapshot +from ossie import OssieAIContextObject, OssieCustomExtension + +from ossie_hex.hex import HexDataType +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_metric import convert_ossie_metric +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> Iterator[ExportContext]: + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + yield ctx + + +def test_preserve_name(ctx: ExportContext) -> None: + name = "foo" + foo = Quick.metric(name, "Integer", [("ANSI_SQL", "bar.baz")]) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + assert result.id == name + assert not ctx.problems + + +def test_preserve_description(ctx: ExportContext) -> None: + description = "Foo bar baz" + foo = Quick.metric( + "foo", "Integer", [("ANSI_SQL", "bar.baz")], description=description + ) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + assert result.description == description + assert not ctx.problems + + +def test_defaults_to_number_datatype(ctx: ExportContext) -> None: + datatype = None + foo = Quick.metric("foo", datatype, [("ANSI_SQL", "bar.baz")]) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + assert result.type == HexDataType.NUMBER + assert not ctx.problems + + +def test_warns_about_ai_context(ctx: ExportContext) -> None: + ai_context = OssieAIContextObject(synonyms=("bar", "baz")) + foo = Quick.metric( + "foo", "Integer", [("ANSI_SQL", "bar.baz")], ai_context=ai_context + ) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_custom_extensions(ctx: ExportContext) -> None: + custom_extension = OssieCustomExtension(vendor_name="foo", data="bar") + foo = Quick.metric( + "foo", + "Integer", + [("ANSI_SQL", "bar.baz")], + custom_extensions=[custom_extension], + ) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_compiles_local_field_reference(ctx: ExportContext) -> None: + foo = Quick.metric("foo", "Integer", [("ANSI_SQL", "bar.baz")]) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + assert result.func_sql == snapshot("bar.baz") + assert not ctx.problems diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_name.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_name.py new file mode 100644 index 00000000..0cb69717 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_name.py @@ -0,0 +1,107 @@ +# 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 pytest +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_name import convert_ossie_name +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def test_preserves_valid_id(ctx: ExportContext) -> None: + ossie_name = "foo" + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result == ossie_name + assert not ctx.problems + + +def test_lowercases_and_strips_without_notice(ctx: ExportContext) -> None: + ossie_name = " Foo " + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result == "foo" + assert not ctx.problems + + +def test_replaces_invalid_characters(ctx: ExportContext) -> None: + ossie_name = "foo-bar" + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result == "foo_bar" + assert problems_snapshot(ctx.problems) == snapshot( + "[INFO] Normalized identifier: 'foo-bar' -> 'foo_bar'." + ) + + +def test_prefixes_leading_digit(ctx: ExportContext) -> None: + ossie_name = "123" + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result == "_123" + assert problems_snapshot(ctx.problems) == snapshot( + "[INFO] Normalized identifier: '123' -> '_123'." + ) + + +def test_pads_short_name(ctx: ExportContext) -> None: + ossie_name = "a" + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result == "a_" + assert problems_snapshot(ctx.problems) == snapshot( + "[INFO] Normalized identifier: 'a' -> 'a_'." + ) + + +def test_truncates_long_name(ctx: ExportContext) -> None: + ossie_name = "a" * 129 + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result == "a" * 128 + assert problems_snapshot(ctx.problems) == ( + f"[INFO] Normalized identifier: '{'a' * 129}' -> '{'a' * 128}'." + ) + + +@pytest.mark.parametrize("ossie_name", ["", " "]) +def test_rejects_empty_identifier(ctx: ExportContext, ossie_name: str) -> None: + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result is None + assert ( + problems_snapshot(ctx.problems) + == f"[ERROR] Unable to convert identifier: '{ossie_name}'." + ) + + +@pytest.mark.parametrize("ossie_name", ["dataset", "model", "this", "self", "env"]) +def test_rejects_reserved_id(ctx: ExportContext, ossie_name: str) -> None: + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result is None + assert ( + problems_snapshot(ctx.problems) + == f"[ERROR] Unable to convert identifier: '{ossie_name}'." + ) + + +def test_rejects_reserved_prefix(ctx: ExportContext) -> None: + ossie_name = "__hex_foo" + result = convert_ossie_name(ossie_name, ctx=ctx) + assert result is None + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Unable to convert identifier: '__hex_foo'." + ) diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py new file mode 100644 index 00000000..519ee836 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import Iterator + +import pytest +from inline_snapshot import snapshot +from ossie import OssieAIContextObject, OssieCustomExtension + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_relationship import convert_ossie_relationship +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> Iterator[ExportContext]: + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + yield ctx + + +def test_warns_about_ai_context(ctx: ExportContext) -> None: + ai_context = OssieAIContextObject(synonyms=("bar", "baz")) + foo = Quick.relationship( + "foo", + "bar", + "baz", + ["qux"], + ["qoz"], + ai_context=ai_context, + ) + + convert_ossie_relationship(foo, ctx=ctx) + + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_custom_extensions(ctx: ExportContext) -> None: + custom_extension = OssieCustomExtension(vendor_name="foo", data="bar") + foo = Quick.relationship( + "foo", + "bar", + "baz", + ["qux"], + ["qoz"], + custom_extensions=[custom_extension], + ) + + convert_ossie_relationship(foo, ctx=ctx) + + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py new file mode 100644 index 00000000..d277565d --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py @@ -0,0 +1,80 @@ +# 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 pytest +from inline_snapshot import snapshot +from ossie import ( + OssieAIContextObject, + OssieCustomExtension, + OssieSemanticModel, +) + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.convert_ossie_semantic_model import ( + convert_ossie_semantic_model, +) +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + return ctx + + +def test_preserves_name(ctx: ExportContext) -> None: + name = "sales" + semantic_model = OssieSemanticModel(name=name, datasets=[]) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert result.name == name + assert problems_snapshot(ctx.problems) == snapshot("") + + +def test_warns_about_description(ctx: ExportContext) -> None: + description = "Sales are important." + semantic_model = OssieSemanticModel( + name="sales", datasets=[], description=description + ) + + convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_ai_context(ctx: ExportContext) -> None: + ai_context = OssieAIContextObject(synonyms=("bar", "baz")) + semantic_model = OssieSemanticModel( + name="sales", datasets=[], ai_context=ai_context + ) + + convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_warns_about_custom_extensions(ctx: ExportContext) -> None: + custom_extension = OssieCustomExtension(vendor_name="foo", data="bar") + semantic_model = OssieSemanticModel( + name="sales", datasets=[], custom_extensions=[custom_extension] + ) + + convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py new file mode 100644 index 00000000..a83865ee --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py @@ -0,0 +1,339 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path + +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.convert_ossie_to_hex import convert_ossie_to_hex +from tests.utils import hex_project_snapshot, problems_snapshot + +TPCDS = Path(__file__).resolve().parents[4] / "examples" / "tpcds_semantic_model.yaml" + + +def test_convert_tpcds() -> None: + hex_projects, problems = convert_ossie_to_hex( + TPCDS, + None, + dialect=None, + ) + assert len(hex_projects) == 1 + assert hex_project_snapshot(hex_projects[0]) == snapshot("""\ +name: tpcds_retail_model +dialect: duckdb +resources: +- id: store_sales + description: Fact table containing all store sales transactions + base_sql_table: tpcds.public.store_sales + dimensions: + - id: ss_sold_date_sk + description: Foreign key to date dimension + type: number + expr_sql: ss_sold_date_sk + - id: ss_item_sk + description: Foreign key to item dimension + type: number + expr_sql: ss_item_sk + - id: ss_customer_sk + description: Foreign key to customer dimension + type: number + expr_sql: ss_customer_sk + - id: ss_store_sk + description: Foreign key to store dimension + type: number + expr_sql: ss_store_sk + - id: ss_quantity + description: Quantity of items sold + type: number + expr_sql: ss_quantity + - id: ss_sales_price + description: Sales price per unit + type: number + expr_sql: ss_sales_price + - id: ss_ext_sales_price + description: Extended sales price (quantity * price) + type: number + expr_sql: ss_ext_sales_price + - id: ss_net_profit + description: Net profit from the sale + type: number + expr_sql: ss_net_profit +- id: date_dim + description: Date dimension with calendar attributes + base_sql_table: tpcds.public.date_dim + dimensions: + - id: d_date_sk + description: Surrogate key for date + type: number + expr_sql: d_date_sk + unique: true + - id: d_date + description: Actual date value + type: date + expr_sql: d_date + - id: d_year + description: Year + type: number + expr_sql: d_year + - id: d_quarter_name + description: Quarter name (e.g., 2024Q1) + type: string + expr_sql: d_quarter_name + - id: d_month_name + description: Month name + type: string + expr_sql: d_month_name +- id: customer + description: Customer dimension with demographic information + base_sql_table: tpcds.public.customer + dimensions: + - id: c_customer_sk + description: Surrogate key for customer + type: number + expr_sql: c_customer_sk + unique: true + - id: c_customer_id + description: Business key for customer + type: string + expr_sql: c_customer_id + - id: c_first_name + description: Customer first name + type: string + expr_sql: c_first_name + - id: c_last_name + description: Customer last name + type: string + expr_sql: c_last_name + - id: customer_full_name + description: Customer full name (computed field) + type: string + expr_sql: c_first_name || ' ' || c_last_name + - id: c_email_address + description: Customer email address + type: string + expr_sql: c_email_address +- id: item + description: Item/Product dimension with product attributes + base_sql_table: tpcds.public.item + dimensions: + - id: i_item_sk + description: Surrogate key for item + type: number + expr_sql: i_item_sk + unique: true + - id: i_item_id + description: Business key for item + type: string + expr_sql: i_item_id + - id: i_item_desc + description: Item description + type: string + expr_sql: i_item_desc + - id: i_brand + description: Brand name + type: string + expr_sql: i_brand + - id: i_category + description: Item category + type: string + expr_sql: i_category + - id: i_current_price + description: Current price of the item + type: number + expr_sql: i_current_price +- id: store + description: Store dimension with location and store attributes + base_sql_table: tpcds.public.store + dimensions: + - id: s_store_sk + description: Surrogate key for store + type: number + expr_sql: s_store_sk + unique: true + - id: s_store_id + description: Business key for store + type: string + expr_sql: s_store_id + unique: true + - id: s_store_name + description: Store name + type: string + expr_sql: s_store_name + - id: s_city + description: City where store is located + type: string + expr_sql: s_city + - id: s_state + description: State where store is located + type: string + expr_sql: s_state + - id: s_number_employees + description: Number of employees at the store + type: number + expr_sql: s_number_employees +""") + assert problems_snapshot(problems, include_causes=True) == snapshot("""\ +[WARNING] Missing. Hex requires a datatype. Using default 'String'. +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_quarter_name', 'datatype'] + +[WARNING] Missing. Hex requires a datatype. Using default 'String'. +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_month_name', 'datatype'] + +[INFO] No Ossie dialect specified; using ANSI_SQL +Cause: [] + +[WARNING] Composite primary key is not supported: ['ss_item_sk', 'ss_ticket_number'] +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'primary_key'] + +[WARNING] Composite unique key is not supported: ['ss_item_sk', 'ss_ticket_number'] +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'unique_keys'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_sold_date_sk', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_item_sk', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_customer_sk', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_store_sk', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_quantity', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_sales_price', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_ext_sales_price', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'fields', 'ss_net_profit', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store_sales', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_date', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_year', 'dimension', 'is_time'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_year', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_quarter_name', 'dimension', 'is_time'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_quarter_name', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_month_name', 'dimension', 'is_time'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'fields', 'd_month_name', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'date_dim', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'customer', 'fields', 'c_customer_id', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'customer', 'fields', 'customer_full_name', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'customer', 'fields', 'c_email_address', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'customer', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'item', 'fields', 'i_item_id', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'item', 'fields', 'i_item_desc', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'item', 'fields', 'i_brand', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'item', 'fields', 'i_category', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'item', 'fields', 'i_current_price', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'item', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store', 'fields', 's_store_id', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store', 'fields', 's_store_name', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store', 'fields', 's_city', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store', 'fields', 's_state', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store', 'fields', 's_number_employees', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'datasets', 'store', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'relationships', 'store_sales_to_date', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'relationships', 'store_sales_to_customer', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'relationships', 'store_sales_to_item', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'relationships', 'store_sales_to_store', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'metrics', 'total_sales', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'metrics', 'total_profit', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'metrics', 'customer_lifetime_value', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'metrics', 'sales_by_brand', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'metrics', 'store_productivity', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'description'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'ai_context'] + +[WARNING] Not supported +Cause: ['semantic_model', 'tpcds_retail_model', 'custom_extensions']\ +""") diff --git a/converters/hex/tests/ossie_to_hex/test_dump_hex_project.py b/converters/hex/tests/ossie_to_hex/test_dump_hex_project.py new file mode 100644 index 00000000..b3337562 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_dump_hex_project.py @@ -0,0 +1,113 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path + +import pytest +from inline_snapshot import snapshot + +from ossie_hex.hex import HexDialect, HexModel, HexProject +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.dump_hex_project import dump_hex_project +from tests.utils import problems_snapshot + + +@pytest.fixture +def foo() -> HexProject: + return HexProject( + name="foo", + dialect=HexDialect("duckdb"), + resources=[ + HexModel(id="bar", base_sql_table="public.bar"), + HexModel(id="baz", base_sql_table="public.baz"), + ], + ) + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def test_writes_resource_files( + ctx: ExportContext, foo: HexProject, tmp_path: Path +) -> None: + output_dir = tmp_path / "out" + dump_hex_project(foo, dir=output_dir, ctx=ctx) + project_dir = output_dir / foo.name + assert (project_dir / "bar.yml").read_text(encoding="utf-8") == snapshot( + """\ +id: bar +base_sql_table: public.bar +""" + ) + assert (project_dir / "baz.yml").read_text(encoding="utf-8") == snapshot( + """\ +id: baz +base_sql_table: public.baz +""" + ) + assert not ctx.problems + + +def test_creates_project_dir_when_missing(ctx: ExportContext, tmp_path: Path) -> None: + project = HexProject(name="foo", dialect=HexDialect("duckdb"), resources=[]) + output_dir = tmp_path / "nested" / "out" + dump_hex_project(project, dir=output_dir, ctx=ctx) + assert output_dir.is_dir() + project_dir = output_dir / project.name + assert project_dir.is_dir() + assert list(project_dir.iterdir()) == [] + assert not ctx.problems + + +def test_resolves_project_dir( + ctx: ExportContext, foo: HexProject, tmp_path: Path +) -> None: + output_dir = tmp_path / "nested" / ".." / "out" + dump_hex_project(foo, dir=output_dir, ctx=ctx) + project_dir = (tmp_path / "out" / foo.name).resolve() + assert (project_dir / "bar.yml").is_file() + assert not (tmp_path / "nested").exists() + assert not ctx.problems + + +@pytest.mark.parametrize( + "project_name", + [ + "../secret", + "../../secret", + "foo/../../secret", + "/tmp/secret", + ], +) +def test_rejects_path_traversal( + ctx: ExportContext, tmp_path: Path, project_name: str +) -> None: + project = HexProject.model_construct( + name=project_name, + dialect=HexDialect("duckdb"), + resources=[HexModel.model_construct(id="bar", base_sql_table="public.bar")], + ) + output_dir = tmp_path / "out" + dump_hex_project(project, dir=output_dir, ctx=ctx) + escaped = (output_dir / project_name).resolve() + assert not escaped.exists() + assert not list(output_dir.glob("**/*")) + assert problems_snapshot(ctx.problems) == ( + f"[ERROR] Project name would write files outside the output directory: {project_name}" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_dump_hex_resource.py b/converters/hex/tests/ossie_to_hex/test_dump_hex_resource.py new file mode 100644 index 00000000..d42cc88b --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_dump_hex_resource.py @@ -0,0 +1,91 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path + +import pytest +from inline_snapshot import snapshot + +from ossie_hex.hex import HexModel +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.dump_hex_resource import dump_hex_resource +from tests.utils import problems_snapshot + + +@pytest.fixture +def foo() -> HexModel: + return HexModel(id="foo", base_sql_table="public.foo") + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +@pytest.fixture +def project_dir(tmp_path: Path) -> Path: + return tmp_path.resolve() + + +def test_writes_yaml_file(ctx: ExportContext, foo: HexModel, project_dir: Path) -> None: + dump_hex_resource(foo, project_dir, ctx=ctx) + file_path = project_dir / "foo.yml" + assert file_path.read_text(encoding="utf-8") == snapshot( + """\ +id: foo +base_sql_table: public.foo +""" + ) + assert not ctx.problems + + +def test_errors_when_write_fails( + ctx: ExportContext, + foo: HexModel, + project_dir: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def boom(self: Path, *_args: object, **_kwargs: object) -> int: + raise OSError("permission denied") + + monkeypatch.setattr(Path, "write_text", boom) + dump_hex_resource(foo, project_dir, ctx=ctx) + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Failed to write to file: permission denied" + ) + + +@pytest.mark.parametrize( + "resource_id", + [ + "../secret", + "../../secret", + "foo/../../secret", + "/tmp/secret", + ], +) +def test_rejects_path_traversal( + ctx: ExportContext, project_dir: Path, resource_id: str +) -> None: + foo = HexModel.model_construct(id=resource_id, base_sql_table="public.foo") + dump_hex_resource(foo, project_dir, ctx=ctx) + escaped = (project_dir / f"{resource_id}.yml").resolve() + assert not escaped.exists() + assert not list(project_dir.glob("*.yml")) + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Resource id would write a file outside the project directory" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_dataset.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_dataset.py new file mode 100644 index 00000000..331556ab --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_dataset.py @@ -0,0 +1,68 @@ +# 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 pytest +from ossie import OssieDataset + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_dataset import load_ossie_dataset +from tests.ossie_to_hex.utils import Quick + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +@pytest.fixture +def foo() -> OssieDataset: + return Quick.dataset( + name="foo", + source="public.foo", + fields=[("id", "String", [("ANSI_SQL", "id")])], + ) + + +def test_keeps_valid_fields(ctx: ExportContext) -> None: + foo = Quick.dataset( + name="foo", + source="public.foo", + fields=[("id", "String", [("ANSI_SQL", "id")])], + ) + result = load_ossie_dataset(foo, ctx=ctx) + assert result.name == foo.name + assert result.fields is not None + assert len(result.fields) == 1 + assert result.fields[0].name == "id" + assert not ctx.problems + + +def test_removes_invalid_fields(ctx: ExportContext) -> None: + + foo = Quick.dataset( + name="foo", + source="public.foo", + fields=[ + ("good", "String", [("ANSI_SQL", "id")]), + ("bad", "String", [("ANSI_SQL", "SELECT FROM")]), + ], + ) + result = load_ossie_dataset(foo, ctx=ctx) + assert result.fields is not None + assert len(result.fields) == 1 + assert result.fields[0].name == "good" + assert ctx.problems diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_dialect.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_dialect.py new file mode 100644 index 00000000..97f2d284 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_dialect.py @@ -0,0 +1,69 @@ +# 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 pytest +from inline_snapshot import snapshot +from ossie import OssieDialect + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_dialect import load_ossie_dialect +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +@pytest.mark.parametrize("ossie_dialect", list(OssieDialect)) +def test_preserves_enum(ctx: ExportContext, ossie_dialect: OssieDialect) -> None: + result = load_ossie_dialect(ossie_dialect, ctx=ctx) + assert result is ossie_dialect + assert not ctx.problems + + +@pytest.mark.parametrize("ossie_dialect", list(OssieDialect)) +def test_parses_valid_string(ctx: ExportContext, ossie_dialect: OssieDialect) -> None: + result = load_ossie_dialect(ossie_dialect.value, ctx=ctx) + assert result is ossie_dialect + assert not ctx.problems + + +@pytest.mark.parametrize("ossie_dialect", list(OssieDialect)) +def test_parses_lowercase_string( + ctx: ExportContext, ossie_dialect: OssieDialect +) -> None: + result = load_ossie_dialect(ossie_dialect.value.lower(), ctx=ctx) + assert result is ossie_dialect + assert not ctx.problems + + +def test_warns_and_defaults_for_invalid_string(ctx: ExportContext) -> None: + ossie_dialect = "not_a_dialect" + result = load_ossie_dialect(ossie_dialect, ctx=ctx) + assert result is OssieDialect.ANSI_SQL + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Invalid Ossie dialect: not_a_dialect. Using ANSI_SQL instead." + ) + + +def test_defaults_when_missing(ctx: ExportContext) -> None: + result = load_ossie_dialect(None, ctx=ctx) + assert result is OssieDialect.ANSI_SQL + assert problems_snapshot(ctx.problems) == snapshot( + "[INFO] No Ossie dialect specified; using ANSI_SQL" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_dialect_expression.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_dialect_expression.py new file mode 100644 index 00000000..5bbb0630 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_dialect_expression.py @@ -0,0 +1,95 @@ +# 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 pytest +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_dialect_expression import ( + parse_ossie_dialect_expression, + validate_ossie_field_dialect_expression, + validate_ossie_metric_dialect_expression, +) +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def test_valid_field_expression(ctx: ExportContext) -> None: + expression = Quick.dialect_expression("ANSI_SQL", "id") + result = validate_ossie_field_dialect_expression(expression, ctx=ctx) + assert result is True + assert not ctx.problems + + +def test_invalid_field_expression(ctx: ExportContext) -> None: + expression = Quick.dialect_expression("ANSI_SQL", "SELECT FROM") + result = validate_ossie_field_dialect_expression(expression, ctx=ctx) + assert result is False + assert problems_snapshot(ctx.problems) == snapshot( + """\ +[ERROR] Unable to parse: Expected table name but got None. Line 1, Col: 11. + SELECT \x1b[4mFROM\x1b[0m\ +""" + ) + + +def test_parses_valid_expression(ctx: ExportContext) -> None: + expression = Quick.dialect_expression("ANSI_SQL", "id") + result = parse_ossie_dialect_expression(expression, ctx=ctx) + assert result is not None + assert not ctx.problems + + +def test_valid_metric_expression(ctx: ExportContext) -> None: + field_names = [("foo", "bar")] + expression = Quick.dialect_expression("ANSI_SQL", "SUM(foo.bar)") + result = validate_ossie_metric_dialect_expression( + expression, field_names=field_names, ctx=ctx + ) + assert result is True + assert not ctx.problems + + +def test_warns_about_unknown_field_reference(ctx: ExportContext) -> None: + field_names = [("other", "bar")] + expression = Quick.dialect_expression("ANSI_SQL", "foo.bar") + result = validate_ossie_metric_dialect_expression( + expression, field_names=field_names, ctx=ctx + ) + assert result is False + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Field expression references field not in semantic model: foo.bar" + ) + + +def test_invalid_metric_expression(ctx: ExportContext) -> None: + expression = Quick.dialect_expression("ANSI_SQL", "SELECT FROM") + result = validate_ossie_metric_dialect_expression( + expression, field_names=[], ctx=ctx + ) + assert result is False + assert problems_snapshot(ctx.problems) == snapshot( + """\ +[ERROR] Unable to parse: Expected table name but got None. Line 1, Col: 11. + SELECT \x1b[4mFROM\x1b[0m\ +""" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_document.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_document.py new file mode 100644 index 00000000..dae2ce5c --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_document.py @@ -0,0 +1,121 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path +from textwrap import dedent + +import pytest +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_document import load_ossie_document +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def _problems(ctx: ExportContext, path: Path) -> str: + return problems_snapshot(ctx.problems).replace(str(path.resolve()), "PATH") + + +def test_loads_valid_document(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "foo.yml" + data = dedent( + """\ + semantic_model: + - name: foo + datasets: + - name: bar + source: public.bar + """ + ) + path.write_text(data, encoding="utf-8") + result = load_ossie_document(document_path=str(path), ctx=ctx) + assert result is not None + assert result.semantic_model[0].name == "foo" + assert result.semantic_model[0].datasets[0].name == "bar" + assert result.semantic_model[0].datasets[0].source == "public.bar" + assert not ctx.problems + + +def test_missing_file(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "missing.yml" + result = load_ossie_document(document_path=path, ctx=ctx) + assert result is None + assert _problems(ctx, path) == snapshot("[FATAL] File does not exist: `PATH`") + + +def test_rejects_directory(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "adir" + path.mkdir() + result = load_ossie_document(document_path=path, ctx=ctx) + assert result is None + assert _problems(ctx, path) == snapshot("[FATAL] File is not a file: `PATH`") + + +def test_empty_file(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "empty.yml" + path.write_text("", encoding="utf-8") + result = load_ossie_document(document_path=path, ctx=ctx) + assert result is None + assert _problems(ctx, path) == snapshot("[FATAL] File is empty: `PATH`") + + +def test_invalid_yaml(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "bad.yml" + path.write_text("invalid: yaml: [", encoding="utf-8") + result = load_ossie_document(document_path=path, ctx=ctx) + assert result is None + assert _problems(ctx, path) == snapshot( + """\ +[FATAL] Invalid YAML in file `PATH`: mapping values are not allowed here + in "", line 1, column 14: + invalid: yaml: [ + ^\ +""" + ) + + +def test_yaml_must_be_a_mapping(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "list.yml" + path.write_text("- foo\n", encoding="utf-8") + result = load_ossie_document(document_path=path, ctx=ctx) + assert result is None + assert _problems(ctx, path) == snapshot( + """\ +[FATAL] YAML document must be a mapping: `- foo +`\ +""" + ) + + +def test_invalid_ossie_document(ctx: ExportContext, tmp_path: Path) -> None: + path = tmp_path / "invalid.yml" + path.write_text("foo: bar\n", encoding="utf-8") + result = load_ossie_document(document_path=path, ctx=ctx) + assert result is None + assert _problems(ctx, path) == snapshot( + """\ +[FATAL] Invalid Ossie document: 1 validation error for OssieDocument +semantic_model + Field required [type=missing, input_value={'foo': 'bar'}, input_type=dict] + For further information visit https://errors.pydantic.dev/2.13/v/missing\ +""" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_expression.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_expression.py new file mode 100644 index 00000000..df17c017 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_expression.py @@ -0,0 +1,97 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_expression import ( + load_ossie_field_expression, + load_ossie_metric_expression, +) +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def test_valid_field_expression(ctx: ExportContext) -> None: + expr = Quick.expression([("ANSI_SQL", "id")]) + result = load_ossie_field_expression(expr, ctx=ctx) + assert result is not None + assert len(result.dialects) == 1 + assert not ctx.problems + + +def test_keeps_valid_dialects_when_mixed(ctx: ExportContext) -> None: + expr = Quick.expression( + [ + ("ANSI_SQL", "SELECT FROM"), + ("SNOWFLAKE", "id"), + ] + ) + result = load_ossie_field_expression(expr, ctx=ctx) + assert result is not None + assert len(result.dialects) == 1 + assert result.dialects[0].dialect == "SNOWFLAKE" + assert result.dialects[0].expression == "id" + assert problems_snapshot(ctx.problems) == snapshot( + """\ +[ERROR] Unable to parse: Expected table name but got None. Line 1, Col: 11. + SELECT \x1b[4mFROM\x1b[0m\ +""" + ) + + +def test_rejects_expression_with_no_valid_dialects(ctx: ExportContext) -> None: + expression = Quick.expression([("ANSI_SQL", "SELECT FROM")]) + result = load_ossie_field_expression(expression, ctx=ctx) + assert result is None + assert problems_snapshot(ctx.problems) == snapshot( + """\ +[ERROR] Unable to parse: Expected table name but got None. Line 1, Col: 11. + SELECT \x1b[4mFROM\x1b[0m + +[ERROR] Expression must have at least one valid dialect\ +""" + ) + + +def test_valid_metric_expression(ctx: ExportContext) -> None: + field_names = [("foo", "bar")] + expression = Quick.expression([("ANSI_SQL", "foo.bar")]) + result = load_ossie_metric_expression(expression, field_names=field_names, ctx=ctx) + assert result is not None + assert len(result.dialects) == 1 + assert not ctx.problems + + +def test_rejects_metric_expression_with_unknown_field(ctx: ExportContext) -> None: + field_names = [("other", "bar")] + expression = Quick.expression([("ANSI_SQL", "foo.bar")]) + result = load_ossie_metric_expression(expression, field_names=field_names, ctx=ctx) + assert result is None + assert problems_snapshot(ctx.problems) == snapshot( + """\ +[ERROR] Field expression references field not in semantic model: foo.bar + +[ERROR] Expression must have at least one valid dialect\ +""" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_field.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_field.py new file mode 100644 index 00000000..2bfb289d --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_field.py @@ -0,0 +1,68 @@ +# 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 pytest +from inline_snapshot import snapshot +from ossie import OssieDataType + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_field import load_ossie_field +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def test_returns_field_with_valid_expression(ctx: ExportContext) -> None: + foo = Quick.field( + "foo", + "String", + [("ANSI_SQL", "foo")], + ) + result = load_ossie_field(foo, ctx=ctx) + assert result is not None + assert result.name == foo.name + assert result.expression == foo.expression + assert not ctx.problems + + +def test_returns_none_for_invalid_expression(ctx: ExportContext) -> None: + foo = Quick.field( + "foo", + "String", + [("ANSI_SQL", "SELECT FROM")], + ) + result = load_ossie_field(foo, ctx=ctx) + assert result is None + assert ctx.problems + + +def test_defaults_to_string_datatype(ctx: ExportContext) -> None: + foo = Quick.field( + "foo", + None, + [("ANSI_SQL", "foo")], + ) + result = load_ossie_field(foo, ctx=ctx) + assert result is not None + assert result.datatype == OssieDataType.STRING + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Missing. Hex requires a datatype. Using default 'String'." + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_metric.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_metric.py new file mode 100644 index 00000000..121cece2 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_metric.py @@ -0,0 +1,157 @@ +# 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 pytest +from inline_snapshot import snapshot +from ossie import OssieDataType + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_metric import load_ossie_metric +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +def test_reports_empty_expression(ctx: ExportContext) -> None: + field_names = [] + foo = Quick.metric( + "foo", + "Integer", + [], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result is None + assert problems_snapshot(ctx.problems) == snapshot("""\ +[ERROR] Expression must have at least one valid dialect\ +""") + + +def test_returns_metric_with_valid_expression(ctx: ExportContext) -> None: + field_names = [] + foo = Quick.metric( + "foo", + "Integer", + [("ANSI_SQL", "SUM(1)")], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result == foo + assert not ctx.problems + + +def test_returns_none_for_invalid_expression(ctx: ExportContext) -> None: + field_names = [] + foo = Quick.metric( + "foo", + "Integer", + [("ANSI_SQL", "SELECT FROM")], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result == None + assert problems_snapshot(ctx.problems) == snapshot("""\ +[ERROR] Unable to parse: Expected table name but got None. Line 1, Col: 11. + SELECT \x1b[4mFROM\x1b[0m + +[ERROR] Expression must have at least one valid dialect\ +""") + + +def test_omits_invalid_expression(ctx: ExportContext) -> None: + field_names = [("orders", "amount")] + foo = Quick.metric( + "foo", + "Integer", + [ + ("ANSI_SQL", "SELECT FROM"), + ("SNOWFLAKE", "SUM(1)"), + ], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result is not None + assert len(result.expression.dialects) == 1 + assert result.expression.dialects[0].dialect == "SNOWFLAKE" + assert result.expression.dialects[0].expression == "SUM(1)" + assert problems_snapshot(ctx.problems) == snapshot("""\ +[ERROR] Unable to parse: Expected table name but got None. Line 1, Col: 11. + SELECT \x1b[4mFROM\x1b[0m\ +""") + + +def test_returns_metric_with_valid_reference(ctx: ExportContext) -> None: + field_names = [("orders", "amount")] + foo = Quick.metric( + "foo", + "Integer", + [("ANSI_SQL", "orders.amount")], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result == foo + assert not ctx.problems + + +def test_omits_expression_with_invalid_reference(ctx: ExportContext) -> None: + field_names = [("orders", "amount")] + foo = Quick.metric( + "foo", + "Integer", + [ + ("ANSI_SQL", "foo.amount"), + ("SNOWFLAKE", "orders.amount"), + ], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result is not None + assert len(result.expression.dialects) == 1 + assert result.expression.dialects[0].dialect == "SNOWFLAKE" + assert result.expression.dialects[0].expression == "orders.amount" + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Field expression references field not in semantic model: foo.amount" + ) + + +def test_returns_none_for_invalid_reference(ctx: ExportContext) -> None: + field_names = [("orders", "amount")] + foo = Quick.metric( + "foo", + "Integer", + [("ANSI_SQL", "foo.amount")], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result is None + assert problems_snapshot(ctx.problems) == snapshot("""\ +[ERROR] Field expression references field not in semantic model: foo.amount + +[ERROR] Expression must have at least one valid dialect\ +""") + + +def test_defaults_to_number_datatype(ctx: ExportContext) -> None: + field_names = [] + foo = Quick.metric( + "foo", + None, + [("ANSI_SQL", "SUM(1)")], + ) + result = load_ossie_metric(foo, field_names=field_names, ctx=ctx) + assert result is not None + assert result.datatype == OssieDataType.DECIMAL + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Missing. Hex requires a datatype. Using default 'Decimal'." + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_relationship.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_relationship.py new file mode 100644 index 00000000..a989c003 --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_relationship.py @@ -0,0 +1,65 @@ +# 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 pytest +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_relationship import validate_ossie_relationship +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + return ctx + + +def test_warns_about_invalid_from(ctx: ExportContext) -> None: + from_dataset = "bar" + foo = Quick.relationship("foo", from_dataset, "baz", ["qux"], ["qoz"]) + dataset_names = set[str](["baz"]) + result = validate_ossie_relationship(foo, dataset_names=dataset_names, ctx=ctx) + assert result is False + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Could not resolve dataset name: 'bar'." + ) + + +def test_warns_about_invalid_to(ctx: ExportContext) -> None: + to_dataset = "baz" + foo = Quick.relationship("foo", "bar", to_dataset, ["qux"], ["qoz"]) + dataset_names = set[str](["bar"]) + result = validate_ossie_relationship(foo, dataset_names=dataset_names, ctx=ctx) + assert result is False + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Could not resolve dataset name: 'baz'." + ) + + +def test_warns_about_mismatched_columns(ctx: ExportContext) -> None: + from_columns = ["qux", "quux"] + to_columns = ["qoz"] + foo = Quick.relationship("foo", "bar", "baz", from_columns, to_columns) + dataset_names = set[str](["bar", "baz"]) + result = validate_ossie_relationship(foo, dataset_names=dataset_names, ctx=ctx) + assert result is False + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] from_columns and to_columns must have equal length: 2 != 1" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_load_ossie_semantic_model.py b/converters/hex/tests/ossie_to_hex/test_load_ossie_semantic_model.py new file mode 100644 index 00000000..3867462b --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/test_load_ossie_semantic_model.py @@ -0,0 +1,105 @@ +# 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 pytest +from inline_snapshot import snapshot + +from ossie_hex.ossie_to_hex.context import ExportContext +from ossie_hex.ossie_to_hex.load_ossie_semantic_model import load_ossie_semantic_model +from tests.ossie_to_hex.utils import Quick +from tests.utils import problems_snapshot + + +@pytest.fixture +def ctx() -> ExportContext: + return ExportContext() + + +orders = Quick.dataset( + "orders", + "public.orders", + [("amount", "Decimal", [("ANSI_SQL", "amount")])], +) + +customers = Quick.dataset( + "customers", + "public.customers", + [("id", "String", [("ANSI_SQL", "id")])], +) + +total_amount = Quick.metric( + "total_amount", + "Integer", + [("ANSI_SQL", "orders.amount")], +) + +orders_to_customers = Quick.relationship( + "orders_to_customers", + "orders", + "customers", + ["customer_id"], + ["id"], +) + + +def test_keeps_valid_members(ctx: ExportContext) -> None: + datasets = [orders, customers] + metrics = [total_amount] + relationships = [orders_to_customers] + foo = Quick.semantic_model("foo", datasets, metrics, relationships) + result = load_ossie_semantic_model(foo, ctx=ctx) + assert result.name == foo.name + assert len(result.datasets) == 2 + assert len(result.relationships or []) == 1 + assert len(result.metrics or []) == 1 + assert not ctx.problems + + +def test_removes_invalid_relationship(ctx: ExportContext) -> None: + datasets = [orders, customers] + metrics = [] + relationships = [orders_to_customers] + bad = Quick.relationship("bad", "orders", "missing", ["customer_id"], ["id"]) + foo = Quick.semantic_model("foo", datasets, metrics, [bad, *relationships]) + result = load_ossie_semantic_model(foo, ctx=ctx) + assert result.relationships is not None + assert len(result.relationships) == 1 + assert result.relationships[0].name == "orders_to_customers" + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Could not resolve dataset name: 'missing'." + ) + + +def test_removes_invalid_metric(ctx: ExportContext) -> None: + datasets = [orders, customers] + metrics = [total_amount] + relationships = [] + bad = Quick.metric( + "bad", + "Integer", + [("ANSI_SQL", "orders.missing")], + ) + foo = Quick.semantic_model("foo", datasets, [bad, *metrics], relationships) + result = load_ossie_semantic_model(foo, ctx=ctx) + assert result.metrics is not None + assert len(result.metrics) == 1 + assert result.metrics[0].name == "total_amount" + assert problems_snapshot(ctx.problems) == snapshot("""\ +[ERROR] Field expression references field not in semantic model: orders.missing + +[ERROR] Expression must have at least one valid dialect\ +""") diff --git a/converters/hex/tests/ossie_to_hex/utils.py b/converters/hex/tests/ossie_to_hex/utils.py new file mode 100644 index 00000000..6a093f1d --- /dev/null +++ b/converters/hex/tests/ossie_to_hex/utils.py @@ -0,0 +1,172 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass + +from ossie import ( + OssieAIContext, + OssieCustomExtension, + OssieDataset, + OssieDataType, + OssieDialect, + OssieDialectExpression, + OssieDimension, + OssieExpression, + OssieField, + OssieMetric, + OssieRelationship, + OssieSemanticModel, +) + + +@dataclass +class Quick: + """Test declarations done quick™.""" + + @staticmethod + def dialect(name: str) -> OssieDialect: + return OssieDialect(name) + + @staticmethod + def datatype(name: str) -> OssieDataType: + return OssieDataType(name) + + @staticmethod + def dialect_expression(dialect: str, sql: str) -> OssieDialectExpression: + return OssieDialectExpression( + dialect=Quick.dialect(dialect), + expression=sql, + ) + + @staticmethod + def expression(dialects: list[tuple[str, str]]) -> OssieExpression: + return OssieExpression( + dialects=[ + Quick.dialect_expression(dialect, sql) for dialect, sql in dialects + ], + ) + + # TODO: add datatype + @staticmethod + def field( + name: str, + datatype: str | None, + expression: list[tuple[str, str]], + *, + description: str | None = None, + label: str | None = None, + dimension: OssieDimension | None = None, + ai_context: OssieAIContext | None = None, + custom_extensions: list[OssieCustomExtension] | None = None, + ) -> OssieField: + return OssieField( + name=name, + datatype=Quick.datatype(datatype) if datatype else None, + expression=Quick.expression(expression), + dimension=dimension, + label=label, + description=description, + ai_context=ai_context, + custom_extensions=custom_extensions, + ) + + @staticmethod + def dataset( + name: str, + source: str, + fields: list[tuple[str, str, list[tuple[str, str]]]], + *, + description: str | None = None, + primary_key: list[str] | None = None, + unique_keys: list[list[str]] | None = None, + ai_context: OssieAIContext | None = None, + custom_extensions: list[OssieCustomExtension] | None = None, + ) -> OssieDataset: + return OssieDataset( + name=name, + source=source, + fields=[ + Quick.field(name, datatype, expression) + for name, datatype, expression in fields + ], + description=description, + primary_key=primary_key, + unique_keys=unique_keys, + ai_context=ai_context, + custom_extensions=custom_extensions, + ) + + @staticmethod + def metric( + name: str, + datatype: str | None, + expression: list[tuple[str, str]], + *, + description: str | None = None, + ai_context: OssieAIContext | None = None, + custom_extensions: list[OssieCustomExtension] | None = None, + ) -> OssieMetric: + return OssieMetric( + name=name, + datatype=Quick.datatype(datatype) if datatype else None, + expression=Quick.expression(expression), + description=description, + ai_context=ai_context, + custom_extensions=custom_extensions, + ) + + @staticmethod + def relationship( + name: str, + from_dataset: str, + to_dataset: str, + from_columns: list[str], + to_columns: list[str], + *, + ai_context: OssieAIContext | None = None, + custom_extensions: list[OssieCustomExtension] | None = None, + ) -> OssieRelationship: + return OssieRelationship( + name=name, + from_dataset=from_dataset, + to=to_dataset, + from_columns=from_columns, + to_columns=to_columns, + ai_context=ai_context, + custom_extensions=custom_extensions, + ) + + @staticmethod + def semantic_model( + name: str, + datasets: list[OssieDataset], + metrics: list[OssieMetric], + relationships: list[OssieRelationship], + *, + description: str | None = None, + ai_context: OssieAIContext | None = None, + custom_extensions: list[OssieCustomExtension] | None = None, + ) -> OssieSemanticModel: + return OssieSemanticModel( + name=name, + datasets=datasets, + metrics=metrics, + relationships=relationships, + description=description, + ai_context=ai_context, + custom_extensions=custom_extensions, + ) diff --git a/converters/hex/tests/test_smoke.py b/converters/hex/tests/test_smoke.py index 826f9da6..da199e04 100644 --- a/converters/hex/tests/test_smoke.py +++ b/converters/hex/tests/test_smoke.py @@ -15,12 +15,14 @@ # specific language governing permissions and limitations # under the License. +from inline_snapshot import snapshot + import ossie_hex from ossie_hex.cli.main import main def test_import() -> None: - assert ossie_hex.__all__ == [] + assert ossie_hex.__all__ == snapshot(["convert_ossie_to_hex"]) def test_cli_exits_zero() -> None: diff --git a/converters/hex/tests/utils.py b/converters/hex/tests/utils.py new file mode 100644 index 00000000..ae506dc7 --- /dev/null +++ b/converters/hex/tests/utils.py @@ -0,0 +1,24 @@ +from ossie_hex.hex import HexProject +from ossie_hex.ossie_to_hex.dump_hex_resource import _reorder_fields +from ossie_hex.util.problem import Problem +from ossie_hex.util.yaml import dump_yaml + + +def problems_snapshot( + problems: list[Problem], + *, + include_causes: bool = False, +) -> str: + return "\n\n".join(p.to_str(include_cause=include_causes) for p in problems) + + +def hex_project_snapshot(hex_project: HexProject) -> str: + json_data = hex_project.model_dump( + mode="json", + exclude_none=True, + exclude_unset=True, + exclude_defaults=True, + ) + json_data = _reorder_fields(json_data) + yaml_str = dump_yaml(json_data) + return yaml_str diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py index 85789a08..1f17ea5f 100644 --- a/python/src/ossie/models.py +++ b/python/src/ossie/models.py @@ -172,7 +172,7 @@ class OssieRelationship(BaseModel): model_config = ConfigDict(frozen=True, populate_by_name=True) name: str - from_dataset: str = Field(..., alias="from") + from_dataset: str = Field(..., validation_alias="from", serialization_alias="from") to: str from_columns: list[str] to_columns: list[str] From e5dc26b0f7162c2c3e85a1c133ff7f4a7aed00c9 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Tue, 1 Sep 2026 23:01:42 -0400 Subject: [PATCH 03/12] add hex measure, relation assignment --- converters/hex/README.md | 5 +- .../ossie_to_hex/build_assignments.py | 131 ++++++++ .../ossie_to_hex/context/__init__.py | 13 + .../ossie_to_hex/context/analysis.py | 107 +++++++ .../ossie_to_hex/context/assignment.py | 165 ++++++++++ .../ossie_hex/ossie_to_hex/context/context.py | 48 ++- .../ossie_hex/ossie_to_hex/context/hex_ids.py | 61 ++++ .../ossie_to_hex/convert_ossie_dataset.py | 13 +- .../convert_ossie_dialect_expression.py | 28 +- .../ossie_to_hex/convert_ossie_field.py | 5 +- .../ossie_to_hex/convert_ossie_metric.py | 178 +++++++++-- .../convert_ossie_relationship.py | 137 ++++++++- .../convert_ossie_semantic_model.py | 57 +++- .../test_convert_ossie_dataset.py | 2 + .../ossie_to_hex/test_convert_ossie_field.py | 8 +- .../ossie_to_hex/test_convert_ossie_metric.py | 142 ++++++++- .../test_convert_ossie_relationship.py | 127 +++++++- .../test_convert_ossie_semantic_model.py | 284 ++++++++++++++++++ .../ossie_to_hex/test_convert_ossie_to_hex.py | 34 +++ 19 files changed, 1486 insertions(+), 59 deletions(-) create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/build_assignments.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/context/analysis.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/context/assignment.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/context/hex_ids.py diff --git a/converters/hex/README.md b/converters/hex/README.md index e8d7a8ec..ade5717b 100644 --- a/converters/hex/README.md +++ b/converters/hex/README.md @@ -106,12 +106,15 @@ the source can be identified and problem understood from the message alone. ### Phases -Conversion proceeds in three distinct phases. +Conversion proceeds in three distinct phases, sometimes with sub-phases. - `load`: Read file(s) from disk. Deserialize and parse file contents. Validate and construct in-memory representation(s). - `convert`: Transform syntax. Validate and construct target domain model(s). + - `analyze`: Parse expressions and validate semantic references. + - `assign`: Decide where incongruities between source and target data should + be resolved. - `dump`: Serialize and encode in-memory representation(s) into the target format. Write file(s) to disk. diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/build_assignments.py b/converters/hex/src/ossie_hex/ossie_to_hex/build_assignments.py new file mode 100644 index 00000000..dca4d0ef --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/build_assignments.py @@ -0,0 +1,131 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .context import ( + ExportContext, + MetricAnalysis, + MetricAssignment, + RelationshipAssignment, +) + + +def build_assignments(*, ctx: ExportContext) -> None: + """Choose metric parents and every relationship direction to emit.""" + + for metric_analysis in ctx.analysis.for_metrics(): + assignment = _assign_metric(metric_analysis, ctx=ctx) + if assignment is None: + continue + metric_assignment, relationship_assignment = assignment + ctx.assignment.set_for_metric(metric_assignment) + ctx.assignment.set_for_relationship(relationship_assignment) + + for relationship_analysis in ctx.analysis.for_relationships(): + assignments = ctx.assignment.for_relationship(relationship_analysis.name) + if assignments: + continue + # An unused relationship can live on either endpoint. + # Arbitrarily choose the first edge. + edge = relationship_analysis.edges[0] + relationship_assignment = RelationshipAssignment( + name=relationship_analysis.name, + source=edge.source, + target=edge.target, + direction=edge.direction, + ) + assignments.append(relationship_assignment) + ctx.assignment.set_for_relationship(relationship_assignment) + + +def _assign_metric( + analysis: MetricAnalysis, + *, + ctx: ExportContext, +) -> tuple[MetricAssignment, RelationshipAssignment | None] | None: + if len(analysis.dataset_names) == 0: + return None + elif len(analysis.dataset_names) == 1: + relationship_assignment = None + metric_assignment = MetricAssignment( + name=analysis.name, + source=analysis.dataset_names[0], + relationship=relationship_assignment, + ) + return metric_assignment, relationship_assignment + elif len(analysis.dataset_names) == 2: + eligible_relationship_names: set[str] = set() + eligible_relationship_assignments: list[RelationshipAssignment] = [] + source_target_pairs: set[tuple[str, str]] = set() + for dataset_name in analysis.dataset_names: + # for each referenced dataset, treat it as the parent dataset, + # and find an eligible relationship assignment + source = dataset_name + target = next(name for name in analysis.dataset_names if name != source) + source_target_pair = (source, target) + source_target_pairs.add(source_target_pair) + for relationship_analysis in ctx.analysis.for_relationships(): + for edge in relationship_analysis.edges: + if relationship_analysis.name in eligible_relationship_names: + continue + if (edge.source, edge.target) not in source_target_pairs: + continue + relationship_assignment = RelationshipAssignment( + name=relationship_analysis.name, + source=edge.source, + target=edge.target, + direction=edge.direction, + ) + eligible_relationship_assignments.append(relationship_assignment) + eligible_relationship_names.add(relationship_analysis.name) + break + + if len(eligible_relationship_assignments) == 0: + ctx.error( + "Cannot assign metric." + + f" Unable to find a relationship between referenced datasets: {', '.join(analysis.dataset_names)}.", + path=["metrics", analysis.name], + ) + return None + elif len(eligible_relationship_assignments) == 1: + relationship_assignment = eligible_relationship_assignments[0] + else: + ctx.warn( + "Ambiguous metric assignment." + + f" Multiple relationships found between referenced datasets: {', '.join(analysis.dataset_names)}." + + f" Possible relationships: {', '.join([r.name for r in eligible_relationship_assignments])}.", + path=["metrics", analysis.name], + ) + # prefer from_to direction + relationship_assignment = next( + ( + r + for r in eligible_relationship_assignments + if r.direction == "from_to" + ), + eligible_relationship_assignments[0], + ) + + metric_assignment = MetricAssignment( + name=analysis.name, + source=relationship_assignment.source, + relationship=relationship_assignment, + ) + return metric_assignment, relationship_assignment + elif len(analysis.dataset_names) > 2: + return None + + return None diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py index b99867d3..17647d09 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/__init__.py @@ -15,8 +15,21 @@ # specific language governing permissions and limitations # under the License. +from .analysis import ( + RELATIONSHIP_DIRECTIONS, + MetricAnalysis, + RelationshipAnalysis, + RelationshipAnalysisEdge, +) +from .assignment import MetricAssignment, RelationshipAssignment from .context import ExportContext __all__ = [ + "RELATIONSHIP_DIRECTIONS", "ExportContext", + "MetricAnalysis", + "MetricAssignment", + "RelationshipAnalysis", + "RelationshipAnalysisEdge", + "RelationshipAssignment", ] diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/analysis.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/analysis.py new file mode 100644 index 00000000..f4c000d3 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/analysis.py @@ -0,0 +1,107 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Literal + +from ...util.parse_sql import SQLGlotDialect, exp + + +class ExportAnalysis: + """Analyze expressions of Ossie entities.""" + + _metric_analyses: dict[str, MetricAnalysis] + """A mapping of Ossie metric names to their analyses.""" + + _relationship_analyses: dict[str, RelationshipAnalysis] + """A mapping of Ossie relationship names to their analyses.""" + + def __init__(self) -> None: + self._metric_analyses = {} + self._relationship_analyses = {} + + def set_for_metric( + self, + analysis: MetricAnalysis | None, + ) -> None: + """Set the analysis for an Ossie metric.""" + if analysis is None: + return + self._metric_analyses[analysis.name] = analysis + + def for_metric(self, name: str) -> MetricAnalysis | None: + """Get the analysis for an Ossie metric.""" + return self._metric_analyses.get(name) + + def for_metrics(self) -> Iterator[MetricAnalysis]: + """Get all analyses for Ossie metrics.""" + return iter(self._metric_analyses.values()) + + def set_for_relationship(self, analysis: RelationshipAnalysis | None) -> None: + """Set the analysis for an Ossie relationship.""" + if analysis is None: + return + self._relationship_analyses[analysis.name] = analysis + + def for_relationship(self, name: str) -> RelationshipAnalysis | None: + """Get the analysis for an Ossie relationship.""" + return self._relationship_analyses.get(name) + + def for_relationships(self) -> Iterator[RelationshipAnalysis]: + """Get all analyses for Ossie relationships.""" + return iter(self._relationship_analyses.values()) + + +@dataclass(frozen=True) +class MetricAnalysis: + """Dependencies data needed to assign and convert an Ossie metric.""" + + name: str + expr: exp.Expression + dialect: SQLGlotDialect | None + dataset_names: tuple[str, ...] + + +@dataclass(frozen=True) +class RelationshipAnalysis: + """Dependencies data needed to assign and convert an Ossie relationship.""" + + name: str + """The name of the Ossie relationship.""" + edges: list[RelationshipAnalysisEdge] + """The edges of the Ossie relationship.""" + + +RelationshipDirection = Literal["from_to", "to_from"] + +# tuple keeps them ordered +RELATIONSHIP_DIRECTIONS: tuple[RelationshipDirection, ...] = ("from_to", "to_from") + + +@dataclass(frozen=True) +class RelationshipAnalysisEdge: + """A directed placement of an Ossie relationship on a Hex model.""" + + direction: RelationshipDirection + """A direction of the Ossie relationship (from -> to or to -> from).""" + source: str + """The name of the Ossie dataset that is the start of the directed edge.""" + target: str + """The name of the Ossie dataset that is the end of the directed edge.""" diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/assignment.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/assignment.py new file mode 100644 index 00000000..e3289d18 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/assignment.py @@ -0,0 +1,165 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from .analysis import ( + ExportAnalysis, + MetricAnalysis, + RelationshipAnalysis, + RelationshipDirection, +) + + +class ExportAssignment: + """Assign Ossie entities with ambiguous scope to Hex entities.""" + + def __init__(self) -> None: + self._metric_assignments: dict[str, MetricAssignment] = {} + self._relationship_assignments: dict[str, list[RelationshipAssignment]] = {} + + def for_metric(self, name: str) -> MetricAssignment | None: + return self._metric_assignments.get(name) + + def for_relationship(self, name: str) -> list[RelationshipAssignment]: + return self._relationship_assignments.get(name, []) + + def set_for_relationship(self, assignment: RelationshipAssignment | None) -> None: + if assignment is None: + return + self._relationship_assignments[assignment.name] = [assignment] + + def set_for_metric(self, assignment: MetricAssignment) -> None: + self._metric_assignments[assignment.name] = assignment + + def decide_all(self, analysis: ExportAnalysis) -> None: + """Choose metric parents and every relationship direction to emit.""" + + relationship_analyses = analysis.for_relationships() + for metric_analysis in analysis.for_metrics(): + assignment = self._assign_metric(metric_analysis, relationship_analyses) + if assignment is None: + continue + metric_assignment, relationship_assignment = assignment + self.set_for_metric(metric_assignment) + self.set_for_relationship(relationship_assignment) + + for relationship_analysis in analysis.for_relationships(): + assignments = self._relationship_assignments.get( + relationship_analysis.name, [] + ) + if assignments: + continue + # An unused relationship can live on either endpoint. + # Arbitrarily choose the first edge. + edge = relationship_analysis.edges[0] + relationship_assignment = RelationshipAssignment( + name=relationship_analysis.name, + source=edge.source, + target=edge.target, + direction=edge.direction, + ) + assignments.append(relationship_assignment) + self._relationship_assignments[relationship_analysis.name] = assignments + + def _assign_metric( + self, + analysis: MetricAnalysis, + relationship_analyses: Iterable[RelationshipAnalysis], + ) -> tuple[MetricAssignment, RelationshipAssignment | None] | None: + if len(analysis.dataset_names) == 0: + return None + elif len(analysis.dataset_names) == 1: + relationship_assignment = None + metric_assignment = MetricAssignment( + name=analysis.name, + source=analysis.dataset_names[0], + relationship=relationship_assignment, + ) + return metric_assignment, relationship_assignment + elif len(analysis.dataset_names) == 2: + eligible_relationship_assignments: list[RelationshipAssignment] = [] + for dataset_name in analysis.dataset_names: + # for each referenced dataset, treat it as the parent dataset, + # and find an eligible relationship assignment + source = dataset_name + target = next(name for name in analysis.dataset_names if name != source) + for relationship_analysis in relationship_analyses: + for edge in relationship_analysis.edges: + if edge.source != source or edge.target != target: + continue + relationship_assignment = RelationshipAssignment( + name=relationship_analysis.name, + source=edge.source, + target=edge.target, + direction=edge.direction, + ) + eligible_relationship_assignments.append( + relationship_assignment + ) + break + if len(eligible_relationship_assignments) == 0: + return None + + if len(eligible_relationship_assignments) == 0: + # TODO: missing relationship + return None + elif len(eligible_relationship_assignments) == 1: + relationship_assignment = eligible_relationship_assignments[0] + else: + # TODO: ambiguous relationship + relationship_assignment = eligible_relationship_assignments[0] + + metric_assignment = MetricAssignment( + name=analysis.name, + source=relationship_assignment.source, + relationship=relationship_assignment, + ) + return metric_assignment, relationship_assignment + elif len(analysis.dataset_names) > 2: + return None + + return None + + +@dataclass(frozen=True) +class MetricAssignment: + """The assignment of a metric to a parent dataset and optional relationship.""" + + name: str + """The name of the Ossie metric.""" + source: str + """The name of the Ossie dataset that should be the parent of the metric.""" + relationship: RelationshipAssignment | None + """The assignment for the Ossie relationship that's required for the metric, if any.""" + + +@dataclass(frozen=True) +class RelationshipAssignment: + """The assignment of a relationship from a parent dataset to a target dataset.""" + + name: str + """The name of the Ossie relationship.""" + direction: RelationshipDirection + """A direction of the Ossie relationship (from -> to or to -> from).""" + source: str + """The name of the Ossie dataset that is the start of the directed edge.""" + target: str + """The name of the Ossie dataset that is the end of the directed edge.""" diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py index 346d131d..8c3d3cb8 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py @@ -26,6 +26,9 @@ from ...hex import HexDialect, HexDialectName, HexEntityId, HexModel from ...ossie import OssieDialectName from ...util.context import Context +from .analysis import ExportAnalysis +from .assignment import ExportAssignment +from .hex_ids import ExportHexIds logger = logging.getLogger(__name__) @@ -38,10 +41,14 @@ class ExportContext(Context): hex_dialect: HexDialect # semantic model scope + _hex_ids: ExportHexIds | None + _analysis: ExportAnalysis | None + _assignment: ExportAssignment | None _hex_models: dict[HexEntityId, HexModel] | None # fields scope _unique_field_names: set[str] | None + _dataset_name: str | None def __init__(self) -> None: super().__init__(logger=logger) @@ -62,28 +69,52 @@ def _set_dialects( @contextmanager def semantic_model_scope(self, semantic_model_name: str) -> Iterator[None]: """Use isolated identifiers and assignments for one semantic model.""" + self._hex_ids = ExportHexIds() + self._analysis = ExportAnalysis() + self._assignment = ExportAssignment() self._hex_models = {} try: with self.problem_scope(semantic_model_name): yield finally: + self._hex_ids = None + self._analysis = None + self._assignment = None self._hex_models = None + @property + def hex_ids(self) -> ExportHexIds: + if self._hex_ids is None: + raise _ExportSemanticModelScopeNotSetError + return self._hex_ids + + @property + def analysis(self) -> ExportAnalysis: + if self._analysis is None: + raise _ExportSemanticModelScopeNotSetError + return self._analysis + + @property + def assignment(self) -> ExportAssignment: + if self._assignment is None: + raise _ExportSemanticModelScopeNotSetError + return self._assignment + def add_hex_model(self, value: HexModel | None) -> None: if value is None: return if self._hex_models is None: - raise ExportSemanticModelScopeNotSetError + raise _ExportSemanticModelScopeNotSetError self._hex_models[value.id] = value def hex_models(self) -> list[HexModel]: if self._hex_models is None: - raise ExportSemanticModelScopeNotSetError + raise _ExportSemanticModelScopeNotSetError return list(self._hex_models.values()) def hex_model_by_id(self, id: HexEntityId) -> HexModel | None: if self._hex_models is None: - raise ExportSemanticModelScopeNotSetError + raise _ExportSemanticModelScopeNotSetError return self._hex_models.get(id) @contextmanager @@ -91,23 +122,32 @@ def fields_scope( self, *, unique_field_names: set[str], + dataset_name: str, ) -> Iterator[None]: self._unique_field_names = unique_field_names + self._dataset_name = dataset_name try: with self.problem_scope("fields"): yield finally: self._unique_field_names = None + self._dataset_name = None def is_unique_field(self, field_name: str) -> bool: if self._unique_field_names is None: raise _ExportFieldsScopeNotSetError return field_name in self._unique_field_names + @property + def dataset_name(self) -> str: + if self._dataset_name is None: + raise _ExportFieldsScopeNotSetError + return self._dataset_name + class _ExportFieldsScopeNotSetError(ValueError): """Internal logic error. Fields scope not set.""" -class ExportSemanticModelScopeNotSetError(ValueError): +class _ExportSemanticModelScopeNotSetError(ValueError): """Internal logic error. Semantic model scope not set.""" diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/hex_ids.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/hex_ids.py new file mode 100644 index 00000000..6df91b16 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/hex_ids.py @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import MutableMapping +from dataclasses import dataclass, field + +from ...hex import HexEntityId + + +@dataclass +class ExportHexIds: + """Hex identifiers indexed by the Ossie names that produced them.""" + + datasets: MutableMapping[str, HexEntityId] = field(default_factory=dict) + fields: MutableMapping[tuple[str, str], HexEntityId] = field(default_factory=dict) + metrics: MutableMapping[str, HexEntityId] = field(default_factory=dict) + relationships: MutableMapping[str, HexEntityId] = field(default_factory=dict) + + def set_for_dataset(self, name: str, hex_id: HexEntityId) -> None: + self.datasets[name] = hex_id + + def for_dataset(self, name: str) -> HexEntityId | None: + return self.datasets.get(name) + + def set_for_field( + self, dataset_name: str, field_name: str, hex_id: HexEntityId + ) -> None: + self.fields[(dataset_name, field_name)] = hex_id + + def for_field( + self, + dataset_name: str, + field_name: str, + ) -> HexEntityId | None: + return self.fields.get((dataset_name, field_name)) + + def set_for_metric(self, name: str, hex_id: HexEntityId) -> None: + self.metrics[name] = hex_id + + def for_metric(self, name: str) -> HexEntityId | None: + return self.metrics.get(name) + + def set_for_relationship(self, name: str, hex_id: HexEntityId) -> None: + self.relationships[name] = hex_id + + def for_relationship(self, name: str) -> HexEntityId | None: + return self.relationships.get(name) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py index 1dac2319..a1b4b1e4 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py @@ -23,7 +23,6 @@ from ..util.database_table import is_table_name from .context import ExportContext from .convert_ossie_field import convert_ossie_field -from .convert_ossie_name import convert_ossie_name def convert_ossie_dataset( @@ -34,12 +33,7 @@ def convert_ossie_dataset( with ctx.problem_scope(ossie_dataset.name): spec: dict[str, Any] = {} - with ctx.problem_scope("name"): - hex_entity_id = convert_ossie_name(ossie_dataset.name, ctx=ctx) - if hex_entity_id is None: - return None - spec["id"] = hex_entity_id - + spec["id"] = ctx.hex_ids.for_dataset(ossie_dataset.name) spec["type"] = "model" if ossie_dataset.description: @@ -83,7 +77,10 @@ def convert_ossie_dataset( code="composite-unique-key", ) - with ctx.fields_scope(unique_field_names=unique_field_names): + with ctx.fields_scope( + unique_field_names=unique_field_names, + dataset_name=ossie_dataset.name, + ): if ossie_dataset.fields: spec["dimensions"] = [] for ossie_field in ossie_dataset.fields: diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py index 3e8c996f..ea772887 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dialect_expression.py @@ -21,7 +21,7 @@ from ossie import OssieDialectExpression from ..hex import HexSql -from ..util.parse_sql import exp +from ..util.parse_sql import SQLGlotDialect, exp from .context import ExportContext from .load_ossie_dialect_expression import parse_ossie_dialect_expression @@ -51,6 +51,24 @@ def convert_ossie_dialect_expression( sql: HexSql = ossie_dialect_expression.expression return sql + parsed = parse_ossie_dialect_expression(ossie_dialect_expression, ctx=ctx) + if parsed is None: + return None + return convert_parsed_ossie_dialect_expression( + parsed.expr, + parsed.dialect, + resolve=resolve, + ) + + +def convert_parsed_ossie_dialect_expression( + expr: exp.Expression, + dialect: SQLGlotDialect | None, + *, + resolve: OssieRefResolver, +) -> HexSql: + """Convert an already parsed Ossie expression to Hex SQL.""" + def replace_reference(node: exp.Expression) -> exp.Expression: if not isinstance(node, exp.Column) or not node.table: return node @@ -61,9 +79,5 @@ def replace_reference(node: exp.Expression) -> exp.Expression: replacement = f"${{{hex_relation_id}.{hex_item_id}}}" return exp.Var(this=replacement) - parsed = parse_ossie_dialect_expression(ossie_dialect_expression, ctx=ctx) - if parsed is None: - return None - transformed = parsed.expr.transform(replace_reference) - sql = transformed.sql(dialect=parsed.dialect) - return sql + transformed = expr.transform(replace_reference) + return transformed.sql(dialect=dialect) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py index abfc36bf..42618d66 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py @@ -22,7 +22,6 @@ from .context import ExportContext from .convert_ossie_datatype import convert_ossie_datatype from .convert_ossie_expression import convert_ossie_expression -from .convert_ossie_name import convert_ossie_name def convert_ossie_field( @@ -33,8 +32,8 @@ def convert_ossie_field( with ctx.problem_scope(ossie_field.name): spec: dict[str, Any] = {} - with ctx.problem_scope("name"): - spec["id"] = convert_ossie_name(ossie_field.name, ctx=ctx) + dataset_name = ctx.dataset_name + spec["id"] = ctx.hex_ids.for_field(dataset_name, ossie_field.name) if ossie_field.label: # this property is described by the spec as "label for categorization". that diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py index f98611d5..e4aa4c57 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py @@ -19,25 +19,72 @@ from ossie import OssieMetric -from ..hex import HexDataType, HexMeasure -from .context import ExportContext +from ..hex import HexDataType, HexEntityId, HexMeasure, HexSql +from ..util.parse_sql import exp +from .context import ExportContext, MetricAnalysis, MetricAssignment from .convert_ossie_datatype import convert_ossie_datatype -from .convert_ossie_expression import convert_ossie_expression -from .convert_ossie_name import convert_ossie_name +from .convert_ossie_dialect_expression import ( + convert_parsed_ossie_dialect_expression, +) +from .convert_ossie_expression import pick_ossie_expression +from .load_ossie_dialect_expression import parse_ossie_dialect_expression + + +def analyze_ossie_metric( + ossie_metric: OssieMetric, + *, + ctx: ExportContext, +) -> MetricAnalysis | None: + """Return expression data needed to assign and convert a metric.""" + + with ctx.problem_scope(ossie_metric.name): + with ctx.problem_scope("expression"): + ossie_dialect_expression = pick_ossie_expression( + ossie_metric.expression, + ctx=ctx, + ) + if ossie_dialect_expression is None: + return + parsed = parse_ossie_dialect_expression( + ossie_dialect_expression, + ctx=ctx, + ) + if parsed is None: + ctx.error("Unable to parse expression") + return + elif not _references_have_hex_ids(parsed.expr, ctx=ctx): + ctx.error("Unable to resolve all references to Hex IDs") + return + + dataset_names = _find_dataset_names(parsed.expr) + if len(dataset_names) == 0: + ctx.error("Referencing no datasets is not supported.") + elif len(dataset_names) > 2: + ctx.error( + "Referencing more than two datasets is not supported." + + f" Found {len(dataset_names)}: {', '.join(dataset_names)}." + ) + + return MetricAnalysis( + name=ossie_metric.name, + expr=parsed.expr, + dialect=parsed.dialect, + dataset_names=dataset_names, + ) def convert_ossie_metric( ossie_metric: OssieMetric, *, ctx: ExportContext, -) -> HexMeasure | None: +) -> tuple[HexMeasure, HexEntityId] | None: """Convert and assign an analyzed Ossie metric when possible.""" with ctx.problem_scope(ossie_metric.name): spec: dict[str, Any] = {} + source_model_id: HexEntityId | None = None - with ctx.problem_scope("name"): - spec["id"] = convert_ossie_name(ossie_metric.name, ctx=ctx) + spec["id"] = ctx.hex_ids.for_metric(ossie_metric.name) if ossie_metric.description: spec["description"] = ossie_metric.description @@ -48,14 +95,22 @@ def convert_ossie_metric( HexDataType.NUMBER, ) - with ctx.problem_scope("expression"): - # Since a metric expression can contain references to fields on datasets, those - # references need to be converted to the corresponding Hex IDs and syntax. - resolve = None # TODO: implement + analysis = ctx.analysis.for_metric(ossie_metric.name) + assignment = ctx.assignment.for_metric(ossie_metric.name) + if analysis is None: + ctx.fatal("Unexpected error.", internal_message="Should have been analyzed") + return None - spec["func_sql"] = convert_ossie_expression( - ossie_metric.expression, - resolve=resolve, + if analysis is not None and assignment is not None: + source_dataset_name = assignment.source + source_model_id = ctx.hex_ids.for_dataset(source_dataset_name) + if source_model_id is None: + ctx.error(f"Source model not available: {source_dataset_name}") + return None + with ctx.problem_scope("expression"): + spec["func_sql"] = _convert_metric_expression( + analysis, + assignment, ctx=ctx, ) @@ -67,12 +122,101 @@ def convert_ossie_metric( if ossie_metric.custom_extensions is not None: ctx.warn("Not supported") - if spec["id"] is None: + if spec["id"] is None or spec["func_sql"] is None or source_model_id is None: return None - # Attributes not set: + # not set: # - name: Ossie does not support display names # - visibility: Ossie does not support visibility # - func_calc: Ossie does not express Hex calculation language - return HexMeasure(**spec) + return HexMeasure(**spec), source_model_id + + +def assign_ossie_metric( + ossie_metric: OssieMetric, + conversion: tuple[HexMeasure, HexEntityId] | None, + *, + ctx: ExportContext, +) -> None: + if conversion is None: + return + with ctx.problem_scope(ossie_metric.name): + hex_measure, hex_model_id = conversion + hex_model = ctx.hex_model_by_id(hex_model_id) + if hex_model is None: + ctx.error(f"Unable to find model to attach measure. Model: {hex_model_id}") + return + hex_model.measures.append(hex_measure) + + +def _convert_metric_expression( + analysis: MetricAnalysis | None, + assignment: MetricAssignment | None, + *, + ctx: ExportContext, +) -> HexSql | None: + if analysis is None or assignment is None: + return None + + # Metric references are local dimensions or dimensions reached through the + # exact relationship selected by assignment planning. + def resolve(dataset_name: str, field_name: str) -> tuple[str | None, str]: + dimension_id = ctx.hex_ids.for_field(dataset_name, field_name) + if dimension_id is None: + raise ResolveError( + f"Unable to resolve field {dataset_name}.{field_name} to a Hex ID" + ) + if dataset_name == assignment.source: + return None, dimension_id + if assignment.relationship is None: + raise ResolveError( + f"Metric assignment has no relationship to {dataset_name!r}" + ) + relation_id = ctx.hex_ids.for_relationship(assignment.relationship.name) + if relation_id is None: + raise ResolveError( + f"Relationship {assignment.relationship.name!r} has no Hex ID" + ) + return relation_id, dimension_id + + try: + return convert_parsed_ossie_dialect_expression( + analysis.expr, + analysis.dialect, + resolve=resolve, + ) + except ResolveError as e: + ctx.error(f"Unable to convert expression: {e}") + return None + + +class ResolveError(ValueError): + """Internal logic error. Unable to resolve field to a Hex ID.""" + + +def _find_dataset_names(expr: exp.Expression) -> tuple[str, ...]: + """Return unique, referenced dataset names in order.""" + + return tuple( + dict.fromkeys( + node.table + for node in expr.walk(bfs=False) + if isinstance(node, exp.Column) and node.table + ) + ) + + +def _references_have_hex_ids( + expr: exp.Expression, + *, + ctx: ExportContext, +) -> bool: + """Return whether every validated Ossie reference has a Hex ID.""" + + return all( + column.table + and ctx.hex_ids.for_dataset(column.table) is not None + and ctx.hex_ids.for_field(column.table, column.name) is not None + for column in expr.find_all(exp.Column) + ) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py index 9d933bd0..88694de6 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py @@ -15,10 +15,18 @@ # specific language governing permissions and limitations # under the License. +from typing import Any + from ossie import OssieRelationship -from .context import ExportContext -from .convert_ossie_name import convert_ossie_name +from ..hex import HexEntityId, HexRelation, HexRelationType +from .context import ( + RELATIONSHIP_DIRECTIONS, + ExportContext, + RelationshipAnalysis, + RelationshipAnalysisEdge, + RelationshipAssignment, +) # ASSUMPTION: it's kind of ambiguous whether the spec is defining "columns" as # physical or logical columns (i.e. a column on the underlying table/view/query @@ -28,16 +36,53 @@ # syntax (${}) like the relation_id +def analyze_ossie_relationship( + ossie_relationship: OssieRelationship, + *, + ctx: ExportContext, +) -> RelationshipAnalysis | None: + """Return both possible directions of a valid Ossie relationship.""" + + with ctx.problem_scope(ossie_relationship.name): + if ( + ctx.hex_ids.for_relationship(ossie_relationship.name) is None + or ctx.hex_ids.for_dataset(ossie_relationship.from_dataset) is None + or ctx.hex_ids.for_dataset(ossie_relationship.to) is None + ): + return None + + edges: list[RelationshipAnalysisEdge] = [] + for direction in RELATIONSHIP_DIRECTIONS: + if direction == "from_to": + source = ossie_relationship.from_dataset + target = ossie_relationship.to + else: + source = ossie_relationship.to + target = ossie_relationship.from_dataset + edges.append( + RelationshipAnalysisEdge( + direction=direction, + source=source, + target=target, + ) + ) + analysis = RelationshipAnalysis( + name=ossie_relationship.name, + edges=edges, + ) + return analysis + + def convert_ossie_relationship( ossie_relationship: OssieRelationship, *, ctx: ExportContext, -) -> None: +) -> list[tuple[HexRelation, HexEntityId]] | None: """Convert and assign every planned direction of a relationship.""" with ctx.problem_scope(ossie_relationship.name): - with ctx.problem_scope("name"): - convert_ossie_name(ossie_relationship.name, ctx=ctx) + hex_relation_id = ctx.hex_ids.for_relationship(ossie_relationship.name) + results: list[tuple[HexRelation, HexEntityId]] = [] with ctx.problem_scope("ai_context"): if ossie_relationship.ai_context is not None: @@ -47,7 +92,89 @@ def convert_ossie_relationship( if ossie_relationship.custom_extensions is not None: ctx.warn("Not supported") + if not hex_relation_id: + return None + + assignments = ctx.assignment.for_relationship(ossie_relationship.name) + if not assignments: + ctx.error("No assignments found") + return None + for assignment in assignments: + source_model_id = ctx.hex_ids.for_dataset(assignment.source) + if not source_model_id: + ctx.error(f"Source model not available: {assignment.source}") + continue + target_model_id = ctx.hex_ids.for_dataset(assignment.target) + if not target_model_id: + ctx.error(f"Target model not available: {assignment.target}") + continue + relation_type = _hex_relation_type(assignment) + local_columns, remote_columns = _ordered_columns( + ossie_relationship, assignment + ) + join_sql = _hex_join_sql(hex_relation_id, local_columns, remote_columns) + + spec: dict[str, Any] = { + "id": hex_relation_id, + "type": relation_type, + "join_sql": join_sql, + } + if target_model_id != hex_relation_id: + spec["target"] = target_model_id + hex_relation = HexRelation(**spec) + results.append((hex_relation, source_model_id)) + # Attributes not set: # - HexRelation.visibility: Ossie does not support this concept + return results + + +def assign_ossie_relationship( + ossie_relationship: OssieRelationship, + conversion: list[tuple[HexRelation, str]] | None, + *, + ctx: ExportContext, +) -> None: + if conversion is None: return + with ctx.problem_scope(ossie_relationship.name): + for hex_relation, hex_model_id in conversion: + hex_model = ctx.hex_model_by_id(hex_model_id) + if hex_model is None: + ctx.error( + f"Unable to find model to attach relation. Model: {hex_model_id}" + ) + return + hex_model.relations.append(hex_relation) + + +def _hex_relation_type(assignment: RelationshipAssignment) -> HexRelationType: + if assignment.direction == "from_to": + return HexRelationType.MANY_TO_ONE + else: + return HexRelationType.ONE_TO_MANY + + +def _ordered_columns( + ossie_relationship: OssieRelationship, assignment: RelationshipAssignment +) -> tuple[list[str], list[str]]: + if assignment.direction == "from_to": + local_columns = ossie_relationship.from_columns + remote_columns = ossie_relationship.to_columns + else: + local_columns = ossie_relationship.to_columns + remote_columns = ossie_relationship.from_columns + return local_columns, remote_columns + + +def _hex_join_sql( + hex_relation_id: HexEntityId, + local_columns: list[str], + remote_columns: list[str], +) -> str: + join_sql = " AND ".join( + f"{local} = ${{{hex_relation_id}}}.{remote}" + for local, remote in zip(local_columns, remote_columns, strict=True) + ) + return join_sql diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py index 0048e673..f80a3b73 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py @@ -20,10 +20,20 @@ from ossie import OssieSemanticModel from ..hex import HexProject, HexResource +from .build_assignments import build_assignments from .context import ExportContext from .convert_ossie_dataset import convert_ossie_dataset -from .convert_ossie_metric import convert_ossie_metric -from .convert_ossie_relationship import convert_ossie_relationship +from .convert_ossie_metric import ( + analyze_ossie_metric, + assign_ossie_metric, + convert_ossie_metric, +) +from .convert_ossie_name import convert_ossie_name +from .convert_ossie_relationship import ( + analyze_ossie_relationship, + assign_ossie_relationship, + convert_ossie_relationship, +) def convert_ossie_semantic_model( @@ -36,6 +46,8 @@ def convert_ossie_semantic_model( Returns the converted Hex project. """ with ctx.semantic_model_scope(ossie_semantic_model.name): + _store_converted_names(ossie_semantic_model, ctx=ctx) + with ctx.problem_scope("datasets"): for ossie_dataset in ossie_semantic_model.datasets: hex_model = convert_ossie_dataset(ossie_dataset, ctx=ctx) @@ -43,11 +55,25 @@ def convert_ossie_semantic_model( with ctx.problem_scope("relationships"): for ossie_relationship in ossie_semantic_model.relationships or []: - convert_ossie_relationship(ossie_relationship, ctx=ctx) + analysis = analyze_ossie_relationship(ossie_relationship, ctx=ctx) + ctx.analysis.set_for_relationship(analysis) with ctx.problem_scope("metrics"): for ossie_metric in ossie_semantic_model.metrics or []: - convert_ossie_metric(ossie_metric, ctx=ctx) + analysis = analyze_ossie_metric(ossie_metric, ctx=ctx) + ctx.analysis.set_for_metric(analysis) + + build_assignments(ctx=ctx) + + with ctx.problem_scope("relationships"): + for ossie_relationship in ossie_semantic_model.relationships or []: + conversion = convert_ossie_relationship(ossie_relationship, ctx=ctx) + assign_ossie_relationship(ossie_relationship, conversion, ctx=ctx) + + with ctx.problem_scope("metrics"): + for ossie_metric in ossie_semantic_model.metrics or []: + conversion = convert_ossie_metric(ossie_metric, ctx=ctx) + assign_ossie_metric(ossie_metric, conversion, ctx=ctx) with ctx.problem_scope("description"): if ossie_semantic_model.description is not None: @@ -72,3 +98,26 @@ def convert_ossie_semantic_model( ) return hex_project + + +def _store_converted_names( + ossie_semantic_model: OssieSemanticModel, + *, + ctx: ExportContext, +) -> None: + for d in ossie_semantic_model.datasets: + with ctx.problem_scope("datasets", d.name): + if id := convert_ossie_name(d.name, ctx=ctx): + ctx.hex_ids.set_for_dataset(d.name, id) + for f in d.fields or []: + with ctx.problem_scope("datasets", d.name, "fields", f.name): + if id := convert_ossie_name(f.name, ctx=ctx): + ctx.hex_ids.set_for_field(d.name, f.name, id) + for m in ossie_semantic_model.metrics or []: + with ctx.problem_scope("metrics", m.name): + if id := convert_ossie_name(m.name, ctx=ctx): + ctx.hex_ids.set_for_metric(m.name, id) + for r in ossie_semantic_model.relationships or []: + with ctx.problem_scope("relationships", r.name): + if id := convert_ossie_name(r.name, ctx=ctx): + ctx.hex_ids.set_for_relationship(r.name, id) diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py index 6124398b..2b4826e9 100644 --- a/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_dataset.py @@ -32,6 +32,8 @@ def ctx() -> Iterator[ExportContext]: ctx = ExportContext() ctx._set_dialects("ANSI_SQL", "duckdb") with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_dataset("foo", "foo") + ctx.hex_ids.set_for_field("foo", "id", "id") yield ctx diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py index 0e277580..c1b2121b 100644 --- a/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_field.py @@ -37,8 +37,14 @@ def ctx() -> Iterator[ExportContext]: ctx = ExportContext() ctx._set_dialects("ANSI_SQL", "duckdb") with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_dataset("dataset", "dataset") + ctx.hex_ids.set_for_field("dataset", "foo", "foo") unique_field_names = set() - with ctx.fields_scope(unique_field_names=unique_field_names): + dataset_name = "dataset" + with ctx.fields_scope( + unique_field_names=unique_field_names, + dataset_name=dataset_name, + ): yield ctx diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py index af001682..7338ef74 100644 --- a/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_metric.py @@ -22,8 +22,16 @@ from ossie import OssieAIContextObject, OssieCustomExtension from ossie_hex.hex import HexDataType -from ossie_hex.ossie_to_hex.context import ExportContext -from ossie_hex.ossie_to_hex.convert_ossie_metric import convert_ossie_metric +from ossie_hex.ossie_to_hex.context import ( + ExportContext, + MetricAnalysis, + MetricAssignment, +) +from ossie_hex.ossie_to_hex.convert_ossie_metric import ( + analyze_ossie_metric, + convert_ossie_metric, +) +from ossie_hex.util.parse_sql import SQLGlotDialect, parse_one from tests.ossie_to_hex.utils import Quick from tests.utils import problems_snapshot @@ -33,6 +41,13 @@ def ctx() -> Iterator[ExportContext]: ctx = ExportContext() ctx._set_dialects("ANSI_SQL", "duckdb") with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_dataset("bar", "bar") + ctx.hex_ids.set_for_field("bar", "baz", "baz") + ctx.hex_ids.set_for_metric("foo", "foo") + analysis = MetricAnalysis("foo", parse_one("bar.baz"), None, ("bar",)) + ctx.analysis.set_for_metric(analysis) + assignment = MetricAssignment("foo", "bar", None) + ctx.assignment.set_for_metric(assignment) yield ctx @@ -43,7 +58,8 @@ def test_preserve_name(ctx: ExportContext) -> None: result = convert_ossie_metric(foo, ctx=ctx) assert result is not None - assert result.id == name + hex_measure, _ = result + assert hex_measure.id == name assert not ctx.problems @@ -56,7 +72,8 @@ def test_preserve_description(ctx: ExportContext) -> None: result = convert_ossie_metric(foo, ctx=ctx) assert result is not None - assert result.description == description + hex_measure, _ = result + assert hex_measure.description == description assert not ctx.problems @@ -67,7 +84,8 @@ def test_defaults_to_number_datatype(ctx: ExportContext) -> None: result = convert_ossie_metric(foo, ctx=ctx) assert result is not None - assert result.type == HexDataType.NUMBER + hex_measure, _ = result + assert hex_measure.type == HexDataType.NUMBER assert not ctx.problems @@ -98,11 +116,121 @@ def test_warns_about_custom_extensions(ctx: ExportContext) -> None: assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") -def test_compiles_local_field_reference(ctx: ExportContext) -> None: +def test_analyzes_expression_with_no_references(ctx: ExportContext) -> None: + foo = Quick.metric("foo", "Integer", [("ANSI_SQL", "COUNT(1)")]) + + analysis = analyze_ossie_metric(foo, ctx=ctx) + + assert analysis is not None + assert analysis.expr.sql() == snapshot("COUNT(1)") + assert analysis.dialect is None + assert analysis.dataset_names == () + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Referencing no datasets is not supported." + ) + + +def test_analyzes_expression_with_one_reference(ctx: ExportContext) -> None: foo = Quick.metric("foo", "Integer", [("ANSI_SQL", "bar.baz")]) + analysis = analyze_ossie_metric(foo, ctx=ctx) + + assert analysis is not None + assert analysis.expr.sql() == snapshot("bar.baz") + assert analysis.dialect is None + assert analysis.dataset_names == ("bar",) + assert not ctx.problems + + +def test_analyzes_expression_with_two_references(ctx: ExportContext) -> None: + foo = Quick.metric("foo", "Integer", [("ANSI_SQL", "bar.baz + baz.qux")]) + + ctx.hex_ids.set_for_dataset("baz", "baz") + ctx.hex_ids.set_for_field("baz", "qux", "qux") + + analysis = analyze_ossie_metric(foo, ctx=ctx) + + assert analysis is not None + assert analysis.expr.sql() == snapshot("bar.baz + baz.qux") + assert analysis.dialect is None + assert analysis.dataset_names == ("bar", "baz") + assert not ctx.problems + + +def test_analyzes_expression_with_many_references(ctx: ExportContext) -> None: + foo = Quick.metric( + "foo", "Integer", [("ANSI_SQL", "bar.baz + baz.qux + qux.corge")] + ) + + ctx.hex_ids.set_for_dataset("baz", "baz") + ctx.hex_ids.set_for_field("baz", "qux", "qux") + ctx.hex_ids.set_for_dataset("qux", "qux") + ctx.hex_ids.set_for_field("qux", "corge", "corge") + + analysis = analyze_ossie_metric(foo, ctx=ctx) + + assert analysis is not None + assert analysis.expr.sql() == snapshot("bar.baz + baz.qux + qux.corge") + assert analysis.dialect is None + assert analysis.dataset_names == ("bar", "baz", "qux") + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Referencing more than two datasets is not supported. Found 3: bar, baz, qux." + ) + + +def test_compiles_local_field_reference(ctx: ExportContext) -> None: + foo = Quick.metric( + "foo", + "Integer", + [("ANSI_SQL", "bar.baz")], + ) + result = convert_ossie_metric(foo, ctx=ctx) assert result is not None - assert result.func_sql == snapshot("bar.baz") + hex_measure, hex_model_id = result + assert hex_model_id == "bar" + assert hex_measure.func_sql == snapshot("${baz}") assert not ctx.problems + + +def test_preserves_expression_dialect(ctx: ExportContext) -> None: + ctx._set_dialects("BIGQUERY", "bigquery") + foo = Quick.metric( + "foo", + "Integer", + [("BIGQUERY", "COUNTIF(bar.baz)")], + ) + + analysis = MetricAnalysis( + "foo", parse_one("COUNTIF(bar.baz)"), SQLGlotDialect.BIGQUERY, ("bar",) + ) + ctx.analysis.set_for_metric(analysis) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is not None + hex_measure, hex_model_id = result + assert hex_model_id == "bar" + assert hex_measure.func_sql == snapshot("COUNTIF(${baz})") + assert not ctx.problems + + +def test_reports_missing_source_model() -> None: + foo = Quick.metric("foo", "Integer", [("ANSI_SQL", "bar.baz")]) + + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_metric("foo", "foo") + analysis = MetricAnalysis("foo", parse_one("bar.baz"), None, ("bar",)) + ctx.analysis.set_for_metric(analysis) + assignment = MetricAssignment("foo", "bar", None) + ctx.assignment.set_for_metric(assignment) + + result = convert_ossie_metric(foo, ctx=ctx) + + assert result is None + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Source model not available: bar" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py index 519ee836..ad84f611 100644 --- a/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_relationship.py @@ -21,8 +21,15 @@ from inline_snapshot import snapshot from ossie import OssieAIContextObject, OssieCustomExtension -from ossie_hex.ossie_to_hex.context import ExportContext -from ossie_hex.ossie_to_hex.convert_ossie_relationship import convert_ossie_relationship +from ossie_hex.hex import HexRelationType +from ossie_hex.ossie_to_hex.context import ( + ExportContext, + RelationshipAssignment, +) +from ossie_hex.ossie_to_hex.convert_ossie_relationship import ( + analyze_ossie_relationship, + convert_ossie_relationship, +) from tests.ossie_to_hex.utils import Quick from tests.utils import problems_snapshot @@ -32,6 +39,11 @@ def ctx() -> Iterator[ExportContext]: ctx = ExportContext() ctx._set_dialects("ANSI_SQL", "duckdb") with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_dataset("bar", "bar") + ctx.hex_ids.set_for_dataset("baz", "baz") + ctx.hex_ids.set_for_relationship("foo", "foo") + assignment = RelationshipAssignment("foo", "from_to", "bar", "baz") + ctx.assignment.set_for_relationship(assignment) yield ctx @@ -52,6 +64,9 @@ def test_warns_about_ai_context(ctx: ExportContext) -> None: def test_warns_about_custom_extensions(ctx: ExportContext) -> None: + assignment = RelationshipAssignment("foo", "from_to", "bar", "baz") + ctx.assignment.set_for_relationship(assignment) + custom_extension = OssieCustomExtension(vendor_name="foo", data="bar") foo = Quick.relationship( "foo", @@ -65,3 +80,111 @@ def test_warns_about_custom_extensions(ctx: ExportContext) -> None: convert_ossie_relationship(foo, ctx=ctx) assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_analysis(ctx: ExportContext) -> None: + foo = Quick.relationship("foo", "bar", "baz", ["qux"], ["qoz"]) + + analysis = analyze_ossie_relationship(foo, ctx=ctx) + + assert analysis is not None + from_to = next(edge for edge in analysis.edges if edge.direction == "from_to") + assert from_to is not None + assert from_to.direction == "from_to" + assert from_to.source == "bar" + assert from_to.target == "baz" + to_from = next(edge for edge in analysis.edges if edge.direction == "to_from") + assert to_from is not None + assert to_from.direction == "to_from" + assert to_from.source == "baz" + assert to_from.target == "bar" + + assert not ctx.problems + + +def test_converts_unused_relationship_once(ctx: ExportContext) -> None: + foo = Quick.relationship("foo", "bar", "baz", ["qux"], ["qoz"]) + analysis = analyze_ossie_relationship(foo, ctx=ctx) + ctx.analysis.set_for_relationship(analysis) + ctx.assignment.decide_all(ctx.analysis) + + result = convert_ossie_relationship(foo, ctx=ctx) + + assert result is not None + assert len(result) == 1 + hex_relation, _ = result[0] + assert hex_relation.id == "foo" + + assert not ctx.problems + + +def test_direction_from_to(ctx: ExportContext) -> None: + foo = Quick.relationship("foo", "bar", "baz", ["qux"], ["qoz"]) + + assignment = RelationshipAssignment("foo", "from_to", "bar", "baz") + ctx.assignment.set_for_relationship(assignment) + + result = convert_ossie_relationship(foo, ctx=ctx) + + assert result is not None + assert len(result) == 1 + hex_relation, hex_model_id = result[0] + assert hex_model_id == "bar" + assert hex_relation.target == "baz" + assert hex_relation.type == HexRelationType.MANY_TO_ONE + assert hex_relation.join_sql == snapshot("qux = ${foo}.qoz") + + +def test_direction_to_from(ctx: ExportContext) -> None: + foo = Quick.relationship("foo", "bar", "baz", ["qux"], ["qoz"]) + + assignment = RelationshipAssignment("foo", "to_from", "baz", "bar") + ctx.assignment.set_for_relationship(assignment) + + result = convert_ossie_relationship(foo, ctx=ctx) + + assert result is not None + assert len(result) == 1 + hex_relation, hex_model_id = result[0] + assert hex_model_id == "baz" + assert hex_relation.target == "bar" + assert hex_relation.type == HexRelationType.ONE_TO_MANY + assert hex_relation.join_sql == snapshot("qoz = ${foo}.qux") + + +def test_reports_missing_source_model() -> None: + foo = Quick.relationship("foo", "bar", "baz", ["qux"], ["qoz"]) + + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_dataset("bar", "bar") + ctx.hex_ids.set_for_relationship("foo", "foo") + assignment = RelationshipAssignment("foo", "to_from", "baz", "bar") + ctx.assignment.set_for_relationship(assignment) + + result = convert_ossie_relationship(foo, ctx=ctx) + + assert result is not None + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Source model not available: baz" + ) + + +def test_reports_missing_target_model() -> None: + foo = Quick.relationship("foo", "bar", "baz", ["qux"], ["qoz"]) + + ctx = ExportContext() + ctx._set_dialects("ANSI_SQL", "duckdb") + with ctx.semantic_model_scope("model"): + ctx.hex_ids.set_for_dataset("bar", "bar") + ctx.hex_ids.set_for_relationship("foo", "foo") + assignment = RelationshipAssignment("foo", "from_to", "bar", "baz") + ctx.assignment.set_for_relationship(assignment) + + result = convert_ossie_relationship(foo, ctx=ctx) + + assert result is not None + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Target model not available: baz" + ) diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py index d277565d..28d1611e 100644 --- a/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_semantic_model.py @@ -20,16 +20,78 @@ from ossie import ( OssieAIContextObject, OssieCustomExtension, + OssieDataset, + OssieMetric, + OssieRelationship, OssieSemanticModel, ) +from ossie_hex.hex import HexModel, HexRelationType, HexResource from ossie_hex.ossie_to_hex.context import ExportContext from ossie_hex.ossie_to_hex.convert_ossie_semantic_model import ( convert_ossie_semantic_model, ) +from tests.ossie_to_hex.utils import Quick from tests.utils import problems_snapshot +def _models(resources: list[HexResource]) -> list[HexModel]: + return [resource for resource in resources if isinstance(resource, HexModel)] + + +def _model_by_id(resources: list[HexResource], id: str) -> HexModel | None: + return next( + resource + for resource in resources + if resource.id == id and isinstance(resource, HexModel) + ) + + +def _measure_ids(model: HexModel) -> list[str]: + return [measure.id for measure in model.measures] + + +def _relation_ids(model: HexModel) -> list[str]: + return [relation.id for relation in model.relations] + + +@pytest.fixture +def order_customer() -> OssieRelationship: + return Quick.relationship( + "order_customer", "orders", "customers", ["customer_id"], ["id"] + ) + + +@pytest.fixture +def sales_per_customer() -> OssieMetric: + return Quick.metric( + "sales_per_customer", + "Decimal", + [("ANSI_SQL", "SUM(orders.amount) / COUNT(DISTINCT customers.id)")], + ) + + +@pytest.fixture +def orders() -> OssieDataset: + return Quick.dataset( + "orders", + "public.orders", + [ + ("amount", "Integer", [("ANSI_SQL", "amount")]), + ("customer_id", "String", [("ANSI_SQL", "customer_id")]), + ], + ) + + +@pytest.fixture +def customers() -> OssieDataset: + return Quick.dataset( + "customers", + "public.customers", + [("id", "String", [("ANSI_SQL", "id")])], + ) + + @pytest.fixture def ctx() -> ExportContext: ctx = ExportContext() @@ -78,3 +140,225 @@ def test_warns_about_custom_extensions(ctx: ExportContext) -> None: convert_ossie_semantic_model(semantic_model, ctx=ctx) assert problems_snapshot(ctx.problems) == snapshot("[WARNING] Not supported") + + +def test_assigns_metric_and_relationship( + ctx: ExportContext, + orders: OssieDataset, + customers: OssieDataset, + order_customer: OssieRelationship, + sales_per_customer: OssieMetric, +) -> None: + """Assigns a metric and relationship to the correct model. + + Does not + - assign metric more than once + - assign to a model not referenced by the metric + - assign relationship more than once for a single metric + """ + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders, customers], + relationships=[order_customer], + metrics=[sales_per_customer], + ) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert len(result.resources) == 2 + orders_model = _model_by_id(result.resources, "orders") + customers_model = _model_by_id(result.resources, "customers") + assert orders_model is not None + assert customers_model is not None + assert len(customers_model.measures) == 0 + assert len(orders_model.measures) == 1 + measure = orders_model.measures[0] + assert measure.id == "sales_per_customer" + assert measure.func_sql == snapshot( + "SUM(${amount}) / COUNT(DISTINCT ${order_customer.id})" + ) + + assert len(customers_model.relations) == 0 + assert len(orders_model.relations) == 1 + relation = orders_model.relations[0] + assert relation.id == "order_customer" + assert relation.target == "customers" + assert relation.type == HexRelationType.MANY_TO_ONE + assert relation.join_sql == snapshot("customer_id = ${order_customer}.id") + + assert not ctx.problems + + +def test_assignment_prefers_from_to_direction( + ctx: ExportContext, + orders: OssieDataset, + customers: OssieDataset, + order_customer: OssieRelationship, + sales_per_customer: OssieMetric, +) -> None: + semantic_model = OssieSemanticModel( + name="sales", + datasets=[customers, orders], # different dataset order + relationships=[order_customer], + metrics=[sales_per_customer], + ) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert len(result.resources) == 2 + customers_model = _model_by_id(result.resources, "customers") + orders_model = _model_by_id(result.resources, "orders") + assert customers_model is not None + assert orders_model is not None + assert _measure_ids(customers_model) == snapshot([]) + assert _measure_ids(orders_model) == snapshot(["sales_per_customer"]) + + assert _relation_ids(customers_model) == snapshot([]) + assert _relation_ids(orders_model) == snapshot(["order_customer"]) + + +def test_drops_metric_that_no_dataset_can_resolve( + ctx: ExportContext, + orders: OssieDataset, + customers: OssieDataset, + sales_per_customer: OssieMetric, +) -> None: + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders, customers], + relationships=[], + metrics=[sales_per_customer], + ) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + models = _models(result.resources) + assert all(model.measures == [] for model in models) + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Cannot assign metric. Unable to find a relationship between referenced datasets: orders, customers." + ) + + +def test_does_not_choose_between_parallel_relationships( + ctx: ExportContext, + orders: OssieDataset, + customers: OssieDataset, + sales_per_customer: OssieMetric, +) -> None: + billing_customer = Quick.relationship( + "billing_customer", "orders", "customers", ["billing_customer_id"], ["id"] + ) + shipping_customer = Quick.relationship( + "shipping_customer", "orders", "customers", ["shipping_customer_id"], ["id"] + ) + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders, customers], + relationships=[billing_customer, shipping_customer], + metrics=[sales_per_customer], + ) + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + assert len(result.resources) == 2 + orders_model = _model_by_id(result.resources, "orders") + customers_model = _model_by_id(result.resources, "customers") + assert orders_model is not None + assert customers_model is not None + orders_relation_ids = _relation_ids(orders_model) + customers_relation_ids = _relation_ids(customers_model) + assert orders_relation_ids == snapshot(["billing_customer", "shipping_customer"]) + assert customers_relation_ids == snapshot([]) + + assert problems_snapshot(ctx.problems) == snapshot( + "[WARNING] Ambiguous metric assignment. Multiple relationships found between referenced datasets: orders, customers. Possible relationships: billing_customer, shipping_customer." + ) + + +def test_drops_metric_without_a_dataset_reference( + ctx: ExportContext, + orders: OssieDataset, +) -> None: + row_count = Quick.metric("row_count", "Integer", [("ANSI_SQL", "COUNT(*)")]) + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders], + metrics=[row_count], + ) + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + models = _models(result.resources) + assert all(model.measures == [] for model in models) + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Referencing no datasets is not supported." + ) + + +def test_reports_metric_with_more_than_two_dataset_references( + ctx: ExportContext, + orders: OssieDataset, + customers: OssieDataset, +) -> None: + stores = Quick.dataset( + "stores", + "public.stores", + [("id", "String", [("ANSI_SQL", "id")])], + ) + metric = Quick.metric( + "three_dataset_metric", + "Integer", + [("ANSI_SQL", "orders.amount + customers.id + stores.id")], + ) + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders, customers, stores], + metrics=[metric], + ) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + models = _models(result.resources) + assert all(model.measures == [] for model in models) + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Referencing more than two datasets is not supported. Found 3: orders, customers, stores." + ) + + +def test_fully_processes_unassigned_metric( + ctx: ExportContext, + orders: OssieDataset, +) -> None: + metric = Quick.metric("row_count", "Integer", [("ANSI_SQL", "COUNT(*)")]) + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders], + metrics=[metric], + ) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + models = _models(result.resources) + assert all(model.measures == [] for model in models) + assert problems_snapshot(ctx.problems) == snapshot( + "[ERROR] Referencing no datasets is not supported." + ) + + +def test_assigns_relationship_not_used_by_a_metric( + ctx: ExportContext, + orders: OssieDataset, + customers: OssieDataset, + order_customer: OssieRelationship, +) -> None: + semantic_model = OssieSemanticModel( + name="sales", + datasets=[orders, customers], + relationships=[order_customer], + metrics=[], + ) + + result = convert_ossie_semantic_model(semantic_model, ctx=ctx) + + models = _models(result.resources) + assert _relation_ids(models[0]) == snapshot(["order_customer"]) + assert _relation_ids(models[1]) == snapshot([]) + assert not ctx.problems diff --git a/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py b/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py index a83865ee..eba633e7 100644 --- a/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py +++ b/converters/hex/tests/ossie_to_hex/test_convert_ossie_to_hex.py @@ -72,6 +72,40 @@ def test_convert_tpcds() -> None: description: Net profit from the sale type: number expr_sql: ss_net_profit + measures: + - id: total_sales + description: Total sales revenue across all transactions + func_sql: SUM(${ss_ext_sales_price}) + - id: total_profit + description: Total net profit from store sales + func_sql: SUM(${ss_net_profit}) + - id: customer_lifetime_value + description: Average lifetime sales value per customer + func_sql: SUM(${ss_ext_sales_price}) / COUNT(DISTINCT ${store_sales_to_customer.c_customer_sk}) + - id: sales_by_brand + description: Total sales by brand (requires grouping by item.i_brand) + func_sql: SUM(${ss_ext_sales_price}) + - id: store_productivity + description: Sales per employee across stores + func_sql: SUM(${ss_ext_sales_price}) / NULLIF(SUM(${store_sales_to_store.s_number_employees}), + 0) + relations: + - id: store_sales_to_date + target: date_dim + type: many_to_one + join_sql: ss_sold_date_sk = ${store_sales_to_date}.d_date_sk + - id: store_sales_to_customer + target: customer + type: many_to_one + join_sql: ss_customer_sk = ${store_sales_to_customer}.c_customer_sk + - id: store_sales_to_item + target: item + type: many_to_one + join_sql: ss_item_sk = ${store_sales_to_item}.i_item_sk + - id: store_sales_to_store + target: store + type: many_to_one + join_sql: ss_store_sk = ${store_sales_to_store}.s_store_sk - id: date_dim description: Date dimension with calendar attributes base_sql_table: tpcds.public.date_dim From 96b09b698131858022cf2b2d7b59673bd57640a9 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Wed, 2 Sep 2026 10:08:55 -0400 Subject: [PATCH 04/12] add export cli --- converters/hex/README.md | 22 +++- converters/hex/src/ossie_hex/cli/main.py | 119 +++++++++++++++++++- converters/hex/tests/cli/test_cli_export.py | 118 +++++++++++++++++++ converters/hex/tests/test_smoke.py | 5 +- 4 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 converters/hex/tests/cli/test_cli_export.py diff --git a/converters/hex/README.md b/converters/hex/README.md index ade5717b..644c2648 100644 --- a/converters/hex/README.md +++ b/converters/hex/README.md @@ -49,13 +49,27 @@ Requires Python 3.11 or newer. #### `export` -TODO(export): {description} +Convert an Ossie semantic model(s) into a Hex semantic project(s). + +```bash +ossie-hex export -i -o \ + [--dialect ] +``` -TODO(export): {code-signature} +Options: + +- `-i, --input ` — Required. Ossie YAML file to export. +- `-o, --output ` — Optional. Directory where Hex YAML files are + written. If omitted, the current working directory is used. +- `-d, --dialect ` — Optional. Ossie dialect to pick from Ossie + expressions. If omitted, the first dialect an expression declares is used, + falling back to `ANSI_SQL`. -TODO(export): {options} +Example: -TODO(export): {code-example} +```bash +ossie-hex export -i ossie.yaml -o hex/ --dialect snowflake +``` #### `import` diff --git a/converters/hex/src/ossie_hex/cli/main.py b/converters/hex/src/ossie_hex/cli/main.py index 02a1694f..0a54b477 100644 --- a/converters/hex/src/ossie_hex/cli/main.py +++ b/converters/hex/src/ossie_hex/cli/main.py @@ -15,6 +15,123 @@ # specific language governing permissions and limitations # under the License. +"""Command-line interface for the Apache Ossie <-> Hex converter. -def main() -> int: + ossie-hex export -i model.yaml [-o hex_project/] [--dialect DIALECT] + +``export`` converts Apache Ossie semantic model(s) to a Hex project directory(s). +If ``-o`` is omitted, files are written to the current working directory. +""" + +from __future__ import annotations + +import argparse +import sys +from typing import Any, NoReturn + +from ossie import OssieDialect + +from ..ossie_to_hex import convert_ossie_to_hex + +# Ossie spells its dialects in upper case, but it's a bit nicer to show/take them +# in lowercase. Parsing will transform them back. +_OSSIE_DIALECTS = [d.value for d in OssieDialect] +_OSSIE_DIALECT_CHOICES = [d.lower() for d in _OSSIE_DIALECTS] +_OSSIE_DIALECT_LIST = ", ".join(_OSSIE_DIALECT_CHOICES) + + +def _build_parser() -> argparse.ArgumentParser: + parser = _CustomArgumentParser( + prog="ossie-hex", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command") + sub.required = True + + export = sub.add_parser( + "export", + help="Apache Ossie semantic model -> Hex project directory", + dialect_list=_OSSIE_DIALECT_LIST, + ) + export.add_argument( + "-i", + "--input", + required=True, + help="Apache Ossie YAML file", + ) + export.add_argument( + "-o", + "--output", + default=".", + help="output directory for the Hex project files (default: current directory)", + ) + export.add_argument( + "-d", + "--dialect", + type=str.lower, + choices=_OSSIE_DIALECT_CHOICES, + metavar="DIALECT", + help=f"Ossie expression dialect, one of: {_OSSIE_DIALECT_LIST}", + default=OssieDialect.ANSI_SQL.value.lower(), + ) + + return parser + + +class _CustomArgumentParser(argparse.ArgumentParser): + def __init__( + self, + *args: Any, + dialect_list: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.dialect_list = dialect_list + + def error(self, message: str) -> NoReturn: + # Name the accepted dialects in every ``--dialect`` error. The usage line + # abbreviates the option as ``DIALECT`` to stay readable, so a missing or + # misspelled dialect would otherwise leave nothing on screen to infer the valid + # values from. + if ( + "--dialect" in message + and "choose from" not in message + and self.dialect_list is not None + ): + message = f"{message} (choose from {self.dialect_list})" + super().error(message) + + +def main(argv: list[str] | None = None) -> int: + try: + args = _build_parser().parse_args(argv) + if args.command == "export": + hex_projects, problems = convert_ossie_to_hex( + input=args.input, + output=args.output, + dialect=args.dialect, + ) + print( + f"Wrote {len(hex_projects)} hex semantic project(s) to {args.output}", + file=sys.stderr, + ) + if problems: + print( + f"Encountered {len(problems)} problem(s):", + file=sys.stderr, + ) + problems_msg = "\n\n".join( + problem.to_str(include_cause=bool(problem.cause_path)) + for problem in problems + ) + print(problems_msg, file=sys.stderr) + if any(problem.severity == "fatal" for problem in problems): + return 1 + return 0 + else: + raise ValueError(f"Unknown command: {args.command}") + except Exception as e: # noqa: BLE001 + print(f"Unexpected error: {e}", file=sys.stderr) + return 1 return 0 diff --git a/converters/hex/tests/cli/test_cli_export.py b/converters/hex/tests/cli/test_cli_export.py new file mode 100644 index 00000000..9fd78e86 --- /dev/null +++ b/converters/hex/tests/cli/test_cli_export.py @@ -0,0 +1,118 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from pathlib import Path + +import pytest +from inline_snapshot import snapshot + +from ossie_hex.cli import main + +TPCDS = Path(__file__).resolve().parents[4] / "examples" / "tpcds_semantic_model.yaml" + + +def test_tpcds(tmp_path: Path) -> None: + input_file = TPCDS + i = str(input_file) + output_dir = tmp_path / "hex" + o = str(output_dir) + dialect = "snowflake" + + code = main(["export", "-i", i, "-o", o, "--dialect", dialect]) + + assert code == 0 + output_paths = sorted( + str(p.relative_to(tmp_path)) for p in output_dir.rglob("*.yml") + ) + assert output_paths == snapshot( + [ + "hex/tpcds_retail_model/customer.yml", + "hex/tpcds_retail_model/date_dim.yml", + "hex/tpcds_retail_model/item.yml", + "hex/tpcds_retail_model/store.yml", + "hex/tpcds_retail_model/store_sales.yml", + ] + ) + + +def test_missing_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Should write to the current working directory""" + monkeypatch.chdir(tmp_path) + + code = main(["export", "-i", str(TPCDS)]) + + assert code == 0 + output_paths = sorted(str(p.relative_to(tmp_path)) for p in tmp_path.rglob("*.yml")) + assert output_paths == snapshot( + [ + "tpcds_retail_model/customer.yml", + "tpcds_retail_model/date_dim.yml", + "tpcds_retail_model/item.yml", + "tpcds_retail_model/store.yml", + "tpcds_retail_model/store_sales.yml", + ] + ) + + +def test_missing_dialect(tmp_path: Path) -> None: + """Should not error""" + input_file = TPCDS + i = str(input_file) + output_dir = tmp_path / "hex_out" + o = str(output_dir) + + code = main(["export", "-i", i, "-o", o]) + + assert code == 0 + + +def test_missing_input_file(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + missing = tmp_path / "missing.yml" + output_dir = tmp_path / "hex_out" + + code = main(["export", "-i", str(missing), "-o", str(output_dir)]) + + assert code == 1 + message = ( + capsys.readouterr() + .err.replace(str(output_dir.resolve()), "OUTPUT") + .replace(str(missing.resolve()), "INPUT") + ) + assert message == snapshot("""\ +Wrote 0 hex semantic project(s) to OUTPUT +Encountered 1 problem(s): +[FATAL] File does not exist: `INPUT` +""") + + +def test_invalid_dialect(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + """Should error""" + input_file = tmp_path / "model.yaml" + i = str(input_file) + output_dir = tmp_path / "hex_out" + o = str(output_dir) + dialect = "invalid" + + with pytest.raises(SystemExit) as exc: + main(["export", "-i", i, "-o", o, "--dialect", dialect]) + + assert exc.value.code == 2 + message = capsys.readouterr().err + assert message == snapshot("""\ +usage: ossie-hex export [-h] -i INPUT [-o OUTPUT] [-d DIALECT] +ossie-hex export: error: argument -d/--dialect: invalid choice: 'invalid' (choose from 'ansi_sql', 'snowflake', 'mdx', 'maql', 'tableau', 'databricks', 'bigquery', 'thoughtspot') +""") diff --git a/converters/hex/tests/test_smoke.py b/converters/hex/tests/test_smoke.py index da199e04..d594ec88 100644 --- a/converters/hex/tests/test_smoke.py +++ b/converters/hex/tests/test_smoke.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +import pytest from inline_snapshot import snapshot import ossie_hex @@ -26,4 +27,6 @@ def test_import() -> None: def test_cli_exits_zero() -> None: - assert main() == 0 + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 From 033ac6a7b67b3eeaa8368541025a1cdcc2de5eb0 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Thu, 3 Sep 2026 15:30:30 -0400 Subject: [PATCH 05/12] prettify cli output --- converters/hex/CONTRIBUTING.md | 12 +- converters/hex/README.md | 7 +- converters/hex/src/ossie_hex/cli/main.py | 33 +-- converters/hex/src/ossie_hex/cli/report.py | 220 ++++++++++++++++++ .../ossie_hex/ossie_to_hex/context/context.py | 3 +- .../ossie_to_hex/convert_ossie_dataset.py | 5 +- .../ossie_to_hex/convert_ossie_field.py | 6 +- .../ossie_to_hex/convert_ossie_metric.py | 4 +- .../convert_ossie_relationship.py | 4 +- .../convert_ossie_semantic_model.py | 6 +- .../ossie_to_hex/load_ossie_datatype.py | 5 +- .../ossie_to_hex/load_ossie_dialect.py | 5 +- .../ossie_to_hex/load_ossie_field.py | 4 +- .../ossie_to_hex/load_ossie_metric.py | 4 +- .../ossie_hex/ossie_to_hex/problem_code.py | 49 ++++ converters/hex/src/ossie_hex/util/context.py | 10 + converters/hex/src/ossie_hex/util/problem.py | 10 + converters/hex/tests/cli/test_cli_export.py | 92 +++++++- 18 files changed, 437 insertions(+), 42 deletions(-) create mode 100644 converters/hex/src/ossie_hex/cli/report.py create mode 100644 converters/hex/src/ossie_hex/ossie_to_hex/problem_code.py diff --git a/converters/hex/CONTRIBUTING.md b/converters/hex/CONTRIBUTING.md index b5a7b7dd..680c5134 100644 --- a/converters/hex/CONTRIBUTING.md +++ b/converters/hex/CONTRIBUTING.md @@ -44,7 +44,17 @@ TODO(development): {workflow-description} ### Python -TODO(development): {python-description} +Conventions to follow when writing Python code: + +- Define public symbols at the top of a file, and private symbols below them, + prefixed with an underscore. If a function is not used outside of the file, + and is not intended to be public, it should be private. +- Public functions should have descriptions that explain their purpose. Within + reason, try to avoid restating the words in the function name. +- Do not nest functions calls. Assign the inner result to a named local + variable first. +- Arguments for package-public functions should be in the widest reasonable + form (i.e. `str | Path` rather than `Path`). ### CLI diff --git a/converters/hex/README.md b/converters/hex/README.md index 644c2648..9b82b8a7 100644 --- a/converters/hex/README.md +++ b/converters/hex/README.md @@ -53,7 +53,7 @@ Convert an Ossie semantic model(s) into a Hex semantic project(s). ```bash ossie-hex export -i -o \ - [--dialect ] + [--dialect ] [-v] ``` Options: @@ -62,8 +62,9 @@ Options: - `-o, --output ` — Optional. Directory where Hex YAML files are written. If omitted, the current working directory is used. - `-d, --dialect ` — Optional. Ossie dialect to pick from Ossie - expressions. If omitted, the first dialect an expression declares is used, - falling back to `ANSI_SQL`. + expressions. If omitted, `ANSI_SQL` is used. +- `-v, --verbose` — Optional. Show a grouped problem summary. Repeat the flag + (`-vv`) to show every occurrence with its conversion phase and cause path. Example: diff --git a/converters/hex/src/ossie_hex/cli/main.py b/converters/hex/src/ossie_hex/cli/main.py index 0a54b477..1ce2e328 100644 --- a/converters/hex/src/ossie_hex/cli/main.py +++ b/converters/hex/src/ossie_hex/cli/main.py @@ -17,7 +17,7 @@ """Command-line interface for the Apache Ossie <-> Hex converter. - ossie-hex export -i model.yaml [-o hex_project/] [--dialect DIALECT] + ossie-hex export -i model.yaml [-o hex_project/] [--dialect DIALECT] [-v] ``export`` converts Apache Ossie semantic model(s) to a Hex project directory(s). If ``-o`` is omitted, files are written to the current working directory. @@ -32,6 +32,7 @@ from ossie import OssieDialect from ..ossie_to_hex import convert_ossie_to_hex +from .report import format_export_report # Ossie spells its dialects in upper case, but it's a bit nicer to show/take them # in lowercase. Parsing will transform them back. @@ -75,6 +76,13 @@ def _build_parser() -> argparse.ArgumentParser: help=f"Ossie expression dialect, one of: {_OSSIE_DIALECT_LIST}", default=OssieDialect.ANSI_SQL.value.lower(), ) + export.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="show grouped problems; repeat (-vv) for phase and cause details", + ) return parser @@ -105,27 +113,22 @@ def error(self, message: str) -> NoReturn: def main(argv: list[str] | None = None) -> int: try: - args = _build_parser().parse_args(argv) + parser = _build_parser() + args = parser.parse_args(argv) if args.command == "export": hex_projects, problems = convert_ossie_to_hex( input=args.input, output=args.output, dialect=args.dialect, ) - print( - f"Wrote {len(hex_projects)} hex semantic project(s) to {args.output}", - file=sys.stderr, + report = format_export_report( + input=args.input, + output=args.output, + projects=hex_projects, + problems=problems, + verbosity=args.verbose, ) - if problems: - print( - f"Encountered {len(problems)} problem(s):", - file=sys.stderr, - ) - problems_msg = "\n\n".join( - problem.to_str(include_cause=bool(problem.cause_path)) - for problem in problems - ) - print(problems_msg, file=sys.stderr) + print(report, file=sys.stderr) if any(problem.severity == "fatal" for problem in problems): return 1 return 0 diff --git a/converters/hex/src/ossie_hex/cli/report.py b/converters/hex/src/ossie_hex/cli/report.py new file mode 100644 index 00000000..2f3fd80d --- /dev/null +++ b/converters/hex/src/ossie_hex/cli/report.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. + +from __future__ import annotations + +from collections import Counter +from pathlib import Path + +from ..hex import HexProject +from ..ossie_to_hex.problem_code import EXPORT_PROBLEM_SUMMARIES +from ..util.problem import PROBLEM_SEVERITIES, Problem, ProblemSeverity + +_SECTION_TITLES: dict[ProblemSeverity, str] = { + "fatal": "Fatal errors", + "error": "Errors", + "warning": "Warnings", + "info": "Info", +} +_OWNER_MARKERS = { + "fields": "Field", + "datasets": "Dataset", + "metrics": "Metric", + "relationships": "Relationship", + "semantic_model": "SemanticModel", +} + + +def format_export_report( + *, + input: str, + output: str, + projects: list[HexProject], + problems: list[Problem], + verbosity: int, +) -> str: + """Format the human-readable report printed after an export.""" + lines = [_headline(problems)] + + if projects: + destination = Path(output) + if len(projects) == 1: + destination /= projects[0].name + file_count = 0 + for project in projects: + file_count += len(project.resources) + project_count_value = len(projects) + project_count = _count(project_count_value, "project") + file_count_text = _count(file_count, "file") + conversion = ( + f"Converted {input} -> {destination}/ ({project_count}, {file_count_text})." + ) + lines.append(conversion) + else: + lines.append(f"Could not convert {input}.") + + if problems: + counts = Counter(problem.severity for problem in problems) + severity_count_parts: list[str] = [] + for severity in PROBLEM_SEVERITIES: + severity_count = counts[severity] + if severity_count: + count_text = _count(severity_count, severity) + severity_count_parts.append(count_text) + severity_counts = ", ".join(severity_count_parts) + problem_count_value = len(problems) + problem_count = _count(problem_count_value, "problem") + problem_summary = f"Encountered {problem_count}: {severity_counts}." + lines.append(problem_summary) + + if verbosity == 0 and not counts["fatal"]: + lines.append(" (Run with -v to see a grouped summary.)") + + if verbosity >= 1: + problem_sections = _format_problem_sections(problems, verbosity) + lines.extend(["", problem_sections]) + elif counts["fatal"]: + fatal_problems = [p for p in problems if p.severity == "fatal"] + fatal_section = _format_occurrences("fatal", fatal_problems) + lines.extend(["", fatal_section]) + else: + lines.append("Encountered no problems.") + + return "\n".join(lines) + + +def _headline(problems: list[Problem]) -> str: + severities = {problem.severity for problem in problems} + if "fatal" in severities: + return "Failure!" + if "error" in severities: + return "Failed." + return "Success!" + + +def _count(value: int, noun: str) -> str: + return f"{value} {noun if value == 1 else noun + 's'}" + + +def _format_problem_sections(problems: list[Problem], verbosity: int) -> str: + sections: list[str] = [] + for severity in PROBLEM_SEVERITIES: + matching = [problem for problem in problems if problem.severity == severity] + if not matching: + continue + if verbosity >= 2: + section = _format_occurrences(severity, matching) + else: + section = _format_groups(severity, matching) + sections.append(section) + return "\n\n".join(sections) + + +def _format_groups(severity: ProblemSeverity, problems: list[Problem]) -> str: + problem_count = len(problems) + problem_count_text = str(problem_count) + width = len(problem_count_text) + lines = [f"{_SECTION_TITLES[severity]} ({len(problems)})"] + grouped_problems = _group_problems(problems) + groups = sorted(grouped_problems.values(), key=len, reverse=True) + for group in groups: + problem = group[0] + subject = _derive_subject(group) if problem.code else None + summary = _summary(problem) + description = _description(subject, summary) + line = f" {len(group):>{width}}× {description}" + lines.append(line) + return "\n".join(lines) + + +def _format_occurrences(severity: ProblemSeverity, problems: list[Problem]) -> str: + lines = [f"{_SECTION_TITLES[severity]} ({len(problems)})"] + groups = _group_problems(problems) + for problem in problems: + summary = _summary(problem) + group_key = _group_key(problem) + group = groups[group_key] + subject = _derive_subject(group) if problem.code else None + phase = problem.phase or "unknown" + description = _description(subject, summary) + lines.append(f" [{phase}] {description}") + if problem.code and problem.message not in {summary, "Not supported"}: + lines.append(f" Detail: {problem.message}") + if problem.cause_path: + cause_parts = [str(part) for part in problem.cause_path] + cause = " > ".join(cause_parts) + lines.append(f" Cause: {cause}") + return "\n".join(lines) + + +def _description(subject: str | None, summary: str) -> str: + if subject: + return f"`{subject}` — {summary}" + return summary + + +def _summary(problem: Problem) -> str: + if problem.code: + return EXPORT_PROBLEM_SUMMARIES.get(problem.code, problem.message) + return problem.message + + +def _group_key(problem: Problem) -> tuple[str | None, str]: + summary = _summary(problem) + return problem.code, summary + + +def _group_problems( + problems: list[Problem], +) -> dict[tuple[str | None, str], list[Problem]]: + groups: dict[tuple[str | None, str], list[Problem]] = {} + for problem in problems: + key = _group_key(problem) + group = groups.setdefault(key, []) + group.append(problem) + return groups + + +def _derive_subject(problems: list[Problem]) -> str | None: + leaves: set[str] = set() + for problem in problems: + if leaf := _cause_leaf(problem): + leaves.add(leaf) + if len(leaves) != 1: + return None + + leaf = leaves.pop() + owners = {_cause_owner(problem) for problem in problems} + if len(owners) == 1 and None not in owners: + return f"{owners.pop()}.{leaf}" + if "_" not in leaf: + return leaf.capitalize() + return leaf + + +def _cause_leaf(problem: Problem) -> str | None: + if not problem.cause_path or not isinstance(problem.cause_path[-1], str): + return None + leaf = problem.cause_path[-1].removeprefix("?") + return leaf.removesuffix(":") + + +def _cause_owner(problem: Problem) -> str | None: + for part in reversed(problem.cause_path[:-1]): + if isinstance(part, str) and part in _OWNER_MARKERS: + return _OWNER_MARKERS[part] + return None diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py b/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py index 8c3d3cb8..2f3f7b84 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/context/context.py @@ -26,6 +26,7 @@ from ...hex import HexDialect, HexDialectName, HexEntityId, HexModel from ...ossie import OssieDialectName from ...util.context import Context +from ..problem_code import ExportProblemCode from .analysis import ExportAnalysis from .assignment import ExportAssignment from .hex_ids import ExportHexIds @@ -33,7 +34,7 @@ logger = logging.getLogger(__name__) -class ExportContext(Context): +class ExportContext(Context[ExportProblemCode]): """Context for exporting from Ossie specification to Hex specification.""" # global scope diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py index a1b4b1e4..bdf30c36 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_dataset.py @@ -60,6 +60,7 @@ def convert_ossie_dataset( if len(ossie_dataset.primary_key) > 1: ctx.warn( f"Composite primary key is not supported: {ossie_dataset.primary_key}", + code="composite-primary-key", ) else: unique_field_names.add(ossie_dataset.primary_key[0]) @@ -91,11 +92,11 @@ def convert_ossie_dataset( with ctx.problem_scope("ai_context"): if ossie_dataset.ai_context is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="ai-context") with ctx.problem_scope("custom_extensions"): if ossie_dataset.custom_extensions is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="custom-extensions") # Attributes not set: # - name: Ossie does not encode a display name diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py index 42618d66..1cf9a1e1 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_field.py @@ -66,17 +66,17 @@ def convert_ossie_field( if ossie_field.dimension.is_time and not is_temporal_hex_datatype( hex_datatype ): - ctx.warn("Not supported") + ctx.warn("Not supported", code="is-time") # in the case that it's _not_ a time dimension, the datatype should fully # describe it, so no action is needed with ctx.problem_scope("ai_context"): if ossie_field.ai_context is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="ai-context") with ctx.problem_scope("custom_extensions"): if ossie_field.custom_extensions is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="custom-extensions") spec["unique"] = ctx.is_unique_field(ossie_field.name) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py index e4aa4c57..9c5e2dea 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_metric.py @@ -116,11 +116,11 @@ def convert_ossie_metric( with ctx.problem_scope("ai_context"): if ossie_metric.ai_context is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="ai-context") with ctx.problem_scope("custom_extensions"): if ossie_metric.custom_extensions is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="custom-extensions") if spec["id"] is None or spec["func_sql"] is None or source_model_id is None: return None diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py index 88694de6..e142498a 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_relationship.py @@ -86,11 +86,11 @@ def convert_ossie_relationship( with ctx.problem_scope("ai_context"): if ossie_relationship.ai_context is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="ai-context") with ctx.problem_scope("custom_extensions"): if ossie_relationship.custom_extensions is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="custom-extensions") if not hex_relation_id: return None diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py index f80a3b73..de579d4f 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py @@ -77,15 +77,15 @@ def convert_ossie_semantic_model( with ctx.problem_scope("description"): if ossie_semantic_model.description is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="project-description") with ctx.problem_scope("ai_context"): if ossie_semantic_model.ai_context is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="ai-context") with ctx.problem_scope("custom_extensions"): if ossie_semantic_model.custom_extensions is not None: - ctx.warn("Not supported") + ctx.warn("Not supported", code="custom-extensions") hex_models = ctx.hex_models() hex_resources: list[HexResource] = [] diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py index 77582bf7..d0383695 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_datatype.py @@ -27,6 +27,9 @@ def load_ossie_datatype( ctx: ExportContext, ) -> OssieDataType: if ossie_datatype is None: - ctx.warn(f"Missing. Hex requires a datatype. Using default '{default.value}'.") + ctx.warn( + f"Missing. Hex requires a datatype. Using default '{default.value}'.", + code="missing-datatype", + ) return default return ossie_datatype diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py index 52a58a67..3fbbf26f 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect.py @@ -41,5 +41,8 @@ def load_ossie_dialect( ) return OssieDialect.ANSI_SQL else: - ctx.info("No Ossie dialect specified; using ANSI_SQL") + ctx.info( + "No Ossie dialect specified; using ANSI_SQL", + code="missing-dialect", + ) return OssieDialect.ANSI_SQL diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py index 8fb245e7..48b560c4 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_field.py @@ -32,7 +32,9 @@ def load_ossie_field( expression = load_ossie_field_expression(field.expression, ctx=ctx) with ctx.problem_scope("datatype"): datatype = load_ossie_datatype( - field.datatype, default=OssieDataType.STRING, ctx=ctx + field.datatype, + default=OssieDataType.STRING, + ctx=ctx, ) if expression is None: diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py index 5ccff0a9..b93f357d 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_metric.py @@ -35,7 +35,9 @@ def load_ossie_metric( ) with ctx.problem_scope("datatype"): datatype = load_ossie_datatype( - metric.datatype, default=OssieDataType.DECIMAL, ctx=ctx + metric.datatype, + default=OssieDataType.DECIMAL, + ctx=ctx, ) if not expression: diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/problem_code.py b/converters/hex/src/ossie_hex/ossie_to_hex/problem_code.py new file mode 100644 index 00000000..d446c930 --- /dev/null +++ b/converters/hex/src/ossie_hex/ossie_to_hex/problem_code.py @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Literal + +ExportProblemCode = Literal[ + "ai-context", + "composite-primary-key", + "composite-unique-key", + "custom-extensions", + "is-time", + "missing-dialect", + "missing-datatype", + "project-description", +] +"""Codes for problems reported while converting Ossie to Hex.""" + +EXPORT_PROBLEM_SUMMARIES: dict[ExportProblemCode, str] = { + "ai-context": "AI context is not preserved in Hex and was dropped.", + "composite-primary-key": ( + "Composite primary keys are not supported in Hex and were dropped." + ), + "composite-unique-key": ( + "Composite unique keys are not supported in Hex and were dropped." + ), + "custom-extensions": ( + "Custom extensions are not preserved in Hex and were dropped." + ), + "is-time": "Temporal role markers are not supported in Hex and were dropped.", + "missing-dialect": "No dialect was specified; ANSI SQL was used.", + "missing-datatype": "A datatype is required in Hex; a default was used.", + "project-description": ( + "Project descriptions are not supported in Hex and were dropped." + ), +} diff --git a/converters/hex/src/ossie_hex/util/context.py b/converters/hex/src/ossie_hex/util/context.py index a9c059b9..17f80542 100644 --- a/converters/hex/src/ossie_hex/util/context.py +++ b/converters/hex/src/ossie_hex/util/context.py @@ -60,6 +60,7 @@ def report_problem( *, path: KeyPath, internal_message: str = "", + code: ProblemCodeT | None = None, ) -> None: phase = self.current_phase_name cause_path: KeyPath = [*self.current_problem_path, *path] @@ -68,6 +69,7 @@ def report_problem( message=message, cause_path=cause_path, phase=phase, + code=code, ) if severity == "fatal" or internal_message: self._logger.error( @@ -83,6 +85,7 @@ def fatal( *, path: KeyPath | None = None, internal_message: str = "", + code: ProblemCodeT | None = None, ) -> None: """Report a critical error that cannot be recovered from, or an unexpected internal error. @@ -92,6 +95,7 @@ def fatal( message=message, path=path or [], internal_message=internal_message, + code=code, ) def error( @@ -100,6 +104,7 @@ def error( *, path: KeyPath | None = None, internal_message: str = "", + code: ProblemCodeT | None = None, ) -> None: """Report an issue that invalidates a definition.""" self.report_problem( @@ -107,6 +112,7 @@ def error( message=message, path=path or [], internal_message=internal_message, + code=code, ) def warn( @@ -115,6 +121,7 @@ def warn( *, path: KeyPath | None = None, internal_message: str = "", + code: ProblemCodeT | None = None, ) -> None: """Report a potential issue that may cause unexpected behavior, but does not invalidate a definition. @@ -124,6 +131,7 @@ def warn( message=message, path=path or [], internal_message=internal_message, + code=code, ) def info( @@ -132,6 +140,7 @@ def info( *, path: KeyPath | None = None, internal_message: str = "", + code: ProblemCodeT | None = None, ) -> None: """Report an informational message that is not an issue.""" self.report_problem( @@ -139,4 +148,5 @@ def info( message=message, path=path or [], internal_message=internal_message, + code=code, ) diff --git a/converters/hex/src/ossie_hex/util/problem.py b/converters/hex/src/ossie_hex/util/problem.py index 6d6eaa70..53929722 100644 --- a/converters/hex/src/ossie_hex/util/problem.py +++ b/converters/hex/src/ossie_hex/util/problem.py @@ -51,6 +51,9 @@ class Problem(BaseModel): ) """The phase of the conversion process in which the problem occurred.""" + code: str | None = None + """A stable identifier used to group equivalent problems.""" + def to_str(self, *, include_cause: bool = True, include_phase: bool = False) -> str: sev = self.severity.upper() cause = str(self.cause_path) @@ -86,6 +89,13 @@ def to_str(self, *, include_cause: bool = True, include_phase: bool = False) -> - `info`: The problem is a general informational message that is not an issue. """ +PROBLEM_SEVERITIES: tuple[ProblemSeverity, ...] = ( + "fatal", + "error", + "warning", + "info", +) + KeyPath = Annotated[ list[str | int], diff --git a/converters/hex/tests/cli/test_cli_export.py b/converters/hex/tests/cli/test_cli_export.py index 9fd78e86..7cffd1b5 100644 --- a/converters/hex/tests/cli/test_cli_export.py +++ b/converters/hex/tests/cli/test_cli_export.py @@ -21,10 +21,30 @@ from inline_snapshot import snapshot from ossie_hex.cli import main +from ossie_hex.cli.report import format_export_report +from ossie_hex.util.problem import Problem TPCDS = Path(__file__).resolve().parents[4] / "examples" / "tpcds_semantic_model.yaml" +def test_error_report_is_failed() -> None: + problem = Problem(severity="error", message="A definition was omitted.") + + report = format_export_report( + input="INPUT", + output="OUTPUT", + projects=[], + problems=[problem], + verbosity=0, + ) + + assert report == snapshot("""\ +Failed. +Could not convert INPUT. +Encountered 1 problem: 1 error. + (Run with -v to see a grouped summary.)""") + + def test_tpcds(tmp_path: Path) -> None: input_file = TPCDS i = str(input_file) @@ -68,8 +88,8 @@ def test_missing_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None ) -def test_missing_dialect(tmp_path: Path) -> None: - """Should not error""" +def test_default_dialect(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + """Should use ANSI SQL without reporting a fallback.""" input_file = TPCDS i = str(input_file) output_dir = tmp_path / "hex_out" @@ -78,6 +98,63 @@ def test_missing_dialect(tmp_path: Path) -> None: code = main(["export", "-i", i, "-o", o]) assert code == 0 + message = ( + capsys.readouterr() + .err.replace(str(output_dir), "OUTPUT") + .replace(str(input_file), "INPUT") + ) + assert message == snapshot("""\ +Success! +Converted INPUT -> OUTPUT/tpcds_retail_model/ (1 project, 5 files). +Encountered 49 problems: 49 warnings. + (Run with -v to see a grouped summary.) +""") + + +def test_verbose_problem_summary( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + output_dir = tmp_path / "hex_out" + + code = main(["export", "-v", "-i", str(TPCDS), "-o", str(output_dir)]) + + assert code == 0 + message = ( + capsys.readouterr() + .err.replace(str(output_dir), "OUTPUT") + .replace(str(TPCDS), "INPUT") + ) + assert message == snapshot("""\ +Success! +Converted INPUT -> OUTPUT/tpcds_retail_model/ (1 project, 5 files). +Encountered 49 problems: 49 warnings. + +Warnings (49) + 40× `ai_context` — AI context is not preserved in Hex and was dropped. + 3× `Field.is_time` — Temporal role markers are not supported in Hex and were dropped. + 2× `Field.datatype` — A datatype is required in Hex; a default was used. + 1× `Dataset.primary_key` — Composite primary keys are not supported in Hex and were dropped. + 1× `Dataset.unique_keys` — Composite unique keys are not supported in Hex and were dropped. + 1× `SemanticModel.description` — Project descriptions are not supported in Hex and were dropped. + 1× `SemanticModel.custom_extensions` — Custom extensions are not preserved in Hex and were dropped. +""") + + +def test_double_verbose_includes_phase_and_cause( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + output_dir = tmp_path / "hex_out" + + code = main(["export", "-vv", "-i", str(TPCDS), "-o", str(output_dir)]) + + assert code == 0 + message = capsys.readouterr().err + assert "[load] `Field.datatype`" in message + assert ( + "Cause: semantic_model > tpcds_retail_model > datasets > date_dim > " + "fields > d_quarter_name > datatype" + ) in message + assert "[convert] `ai_context`" in message def test_missing_input_file(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: @@ -93,9 +170,12 @@ def test_missing_input_file(capsys: pytest.CaptureFixture[str], tmp_path: Path) .replace(str(missing.resolve()), "INPUT") ) assert message == snapshot("""\ -Wrote 0 hex semantic project(s) to OUTPUT -Encountered 1 problem(s): -[FATAL] File does not exist: `INPUT` +Failure! +Could not convert INPUT. +Encountered 1 problem: 1 fatal. + +Fatal errors (1) + [load] File does not exist: `INPUT` """) @@ -113,6 +193,6 @@ def test_invalid_dialect(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> assert exc.value.code == 2 message = capsys.readouterr().err assert message == snapshot("""\ -usage: ossie-hex export [-h] -i INPUT [-o OUTPUT] [-d DIALECT] +usage: ossie-hex export [-h] -i INPUT [-o OUTPUT] [-d DIALECT] [-v] ossie-hex export: error: argument -d/--dialect: invalid choice: 'invalid' (choose from 'ansi_sql', 'snowflake', 'mdx', 'maql', 'tableau', 'databricks', 'bigquery', 'thoughtspot') """) From 474f0701cd3d868b0267733e165951a2feb265d2 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Thu, 10 Sep 2026 10:40:30 -0400 Subject: [PATCH 06/12] add locality note --- .../ossie_to_hex/convert_ossie_semantic_model.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py index de579d4f..f69e01eb 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/convert_ossie_semantic_model.py @@ -35,6 +35,22 @@ convert_ossie_relationship, ) +# NOTE: Ossie metrics lack "aggregate locality" information, i.e. where an +# aggregate calculation should be anchored. Ossie metrics are expressed +# at a "global" scope, at least as a peer to datasets, rather than inside +# of dataset, which are anchored to a physical source. +# +# On the other hand, Hex expresses aggregations as measures attached to a +# model (equivalent to a dataset). Converting an Ossie metric to a Hex +# measure requires a best-effort guess at the right model to attach the +# measure to (locality), and the relation (Hex relation / Ossie relationship) +# to attach in the case that the metric/measure expression references +# fields/dimensions across multiple datasets/models. +# +# To solve this, we perform "analyze" and "assign" steps for metrics +# and relationships in addition to a single straightforward "convert" step +# that other concepts (datasets, fields) require. + def convert_ossie_semantic_model( ossie_semantic_model: OssieSemanticModel, From db69f0ed3a603a4fd24d52f0ff94b3a2710149d9 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Thu, 10 Sep 2026 15:28:36 -0400 Subject: [PATCH 07/12] improve contributing --- converters/hex/CONTRIBUTING.md | 67 +++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/converters/hex/CONTRIBUTING.md b/converters/hex/CONTRIBUTING.md index 680c5134..71d6d29f 100644 --- a/converters/hex/CONTRIBUTING.md +++ b/converters/hex/CONTRIBUTING.md @@ -40,7 +40,63 @@ uv sync ## Development -TODO(development): {workflow-description} +This converter is written with two main goals: + +**Maximal output.** Return both the converted artifact and every issue +encountered. Continue processing whenever possible rather than stopping +early. + +**Maintainability.** Module boundaries are clear and limited. Public +functions should be short and readable. Complexity is delegated to private +helpers. + +### Architecture + +Conversion follows three phases, always in order: + +1. **load** — read, parse, and validate source documents into in-memory models +2. **convert** — transform source representations into target models +3. **dump** — serialize and write target files (when an output path is given) + +Each direction (export, import) lives in its own module directory. Within a +direction, organize by phase and format: `load__*`, +`convert__*`, `dump__*` + +### Problems + +During conversion, issues are modeled as structured `Problem` values collected +on the conversion context while control flow continues. Each problem indicates +its phase, severity, message (public and internal), stable code, and cause. + +The severities are: + +- **`fatal`** — unrecoverable or unexpected; cannot complete +- **`error`** — invalidates a definition; can continue +- **`warning`** — definition is included but may behave unexpectedly +- **`info`** — informational only + +The cause of a problem should be a logical path into the source document, +sufficient to identify the definition or property the issue belongs to. Nested +`problem_scope` calls on the conversion context build this path automatically. + +Assign a stable problem code when a class of issue should be grouped in +user-facing reports. + +### Modules + +The layout of the package should establish clear module boundaries: + +- **`ossie/`** — Ossie domain type and utilities (additional to `apache-ossie`) +- **`hex/`** — Hex domain types and utilities (additional to `hex-sl-utils`) +- **`ossie_to_hex/`** — export conversion pipeline +- **`hex_to_ossie/`** — import conversion pipeline +- **`util/`** — shared infrastructure (problems, context, YAML) +- **`cli/`** — command-line entry points + +Use official libraries rather than redefining a spec: + +- `apache-ossie` - Ossie +- `hex-sl-utils` - Hex ### Python @@ -58,7 +114,14 @@ Conventions to follow when writing Python code: ### CLI -TODO(development): {cli-description} +The CLI is a thin wrapper around the library API. Keep argument parsing and +human-readable reporting output in `src/ossie_hex/cli/`. Conversion logic +belongs in `ossie_to_hex/` and should be available identically to users +across the CLI and Python library API + +### Tests + +Mirror the source layout of modules, e.g. `tests/ossie_to_hex/`, `tests/cli/`. ## Verification From d8bc370da8878d0283aaac43300326ac73e58f90 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Fri, 11 Sep 2026 10:18:42 -0400 Subject: [PATCH 08/12] add vscode config --- converters/hex/.vscode/extensions.json | 11 +++++++++++ converters/hex/.vscode/settings.json | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 converters/hex/.vscode/extensions.json diff --git a/converters/hex/.vscode/extensions.json b/converters/hex/.vscode/extensions.json new file mode 100644 index 00000000..65bd317d --- /dev/null +++ b/converters/hex/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "astral-sh.ty", + "charliermarsh.ruff", + "rvben.rumdl", + "nefrob.vscode-just-syntax", + "redhat.vscode-yaml", + "ms-python.vscode-python-envs", + "ms-python.python" + ] +} diff --git a/converters/hex/.vscode/settings.json b/converters/hex/.vscode/settings.json index 028c7504..f7b7ce5e 100644 --- a/converters/hex/.vscode/settings.json +++ b/converters/hex/.vscode/settings.json @@ -1,5 +1,17 @@ { "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", - "python.languageServer": "Pylance", - "ty.disableLanguageServices": true + "python.languageServer": "None", + "rumdl.server.path": "${workspaceFolder}/rumdl", + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[markdown]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff" + } } From abe1c1f018f83714b18db70bb669ae540493ff27 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Fri, 11 Sep 2026 10:18:49 -0400 Subject: [PATCH 09/12] format --- converters/hex/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/converters/hex/CONTRIBUTING.md b/converters/hex/CONTRIBUTING.md index 71d6d29f..7c389c69 100644 --- a/converters/hex/CONTRIBUTING.md +++ b/converters/hex/CONTRIBUTING.md @@ -115,7 +115,7 @@ Conventions to follow when writing Python code: ### CLI The CLI is a thin wrapper around the library API. Keep argument parsing and -human-readable reporting output in `src/ossie_hex/cli/`. Conversion logic +human-readable reporting output in `src/ossie_hex/cli/`. Conversion logic belongs in `ossie_to_hex/` and should be available identically to users across the CLI and Python library API From e518b8803da794888023a7cb4c10861743dd7a20 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Fri, 11 Sep 2026 10:18:54 -0400 Subject: [PATCH 10/12] remove unused module --- .../hex/src/ossie_hex/ossie/__init__.py | 4 -- .../src/ossie_hex/ossie/ossie_field_expr.py | 49 ------------------- 2 files changed, 53 deletions(-) delete mode 100644 converters/hex/src/ossie_hex/ossie/ossie_field_expr.py diff --git a/converters/hex/src/ossie_hex/ossie/__init__.py b/converters/hex/src/ossie_hex/ossie/__init__.py index 0341f8b0..a958d986 100644 --- a/converters/hex/src/ossie_hex/ossie/__init__.py +++ b/converters/hex/src/ossie_hex/ossie/__init__.py @@ -16,11 +16,7 @@ # under the License. from .ossie_dialect_name import OssieDialectName -from .ossie_field_expr import ( - OSSIE_QUALIFIED_FIELD_EXPR_SCAN_RE, -) __all__ = [ - "OSSIE_QUALIFIED_FIELD_EXPR_SCAN_RE", "OssieDialectName", ] diff --git a/converters/hex/src/ossie_hex/ossie/ossie_field_expr.py b/converters/hex/src/ossie_hex/ossie/ossie_field_expr.py deleted file mode 100644 index dab9d692..00000000 --- a/converters/hex/src/ossie_hex/ossie/ossie_field_expr.py +++ /dev/null @@ -1,49 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -from __future__ import annotations - -import re - -_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*" -"""The Ossie expression_language.md spec states that identifiers must follow -ANSI SQL naming rules: - - - First character: Must be a letter (A-Z, case-insensitive by default). - - Subsequent characters: Letters, numbers (0-9), or underscores (_). - - Length: Usually up to 128 characters. - - Reserved words: Cannot be an unquoted SQL reserved keyword. -""" - -# Matcher for the unqualified form of an Ossie field expression, such as `field` -# Can be used to validate a standalone field expression -OSSIE_UNQUALIFIED_FIELD_EXPR_PATTERN = rf"^{_IDENTIFIER}$" -OSSIE_UNQUALIFIED_FIELD_EXPR_RE = re.compile(OSSIE_UNQUALIFIED_FIELD_EXPR_PATTERN) - - -# Matcher for the qualified form of an Ossie field expression, such as `dataset.field` -# Can be used to validate a standalone field expression -OSSIE_QUALIFIED_FIELD_EXPR_PATTERN = rf"^({_IDENTIFIER})\.({_IDENTIFIER})$" -OSSIE_QUALIFIED_FIELD_EXPR_RE = re.compile(OSSIE_QUALIFIED_FIELD_EXPR_PATTERN) - - -# Matcher for the qualified form of an Ossie field expression, left unanchored. -# Can be used to find references embedded in a larger expression -OSSIE_QUALIFIED_FIELD_EXPR_SCAN_PATTERN = rf"\b({_IDENTIFIER})\.({_IDENTIFIER})\b" -OSSIE_QUALIFIED_FIELD_EXPR_SCAN_RE = re.compile( - rf"\b({_IDENTIFIER})\.({_IDENTIFIER})\b" -) From 3c59cc4f6cc97572e9ef711005cfe78ed1ca893d Mon Sep 17 00:00:00 2001 From: William Kelley Date: Fri, 11 Sep 2026 10:19:20 -0400 Subject: [PATCH 11/12] add new dialect handling --- converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py | 2 ++ .../src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py | 1 + 2 files changed, 3 insertions(+) diff --git a/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py b/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py index 2e053011..d9421a70 100644 --- a/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py +++ b/converters/hex/src/ossie_hex/ossie/ossie_dialect_name.py @@ -25,4 +25,6 @@ "TABLEAU", "DATABRICKS", "BIGQUERY", + "THOUGHTSPOT", + "SIGMA", ] diff --git a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py index 650d1d84..3a79df02 100644 --- a/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py +++ b/converters/hex/src/ossie_hex/ossie_to_hex/load_ossie_dialect_expression.py @@ -105,6 +105,7 @@ def _to_sqlglot_dialect(ossie_dialect: OssieDialect) -> SQLGlotDialect | None: OssieDialect.MDX, OssieDialect.MAQL, OssieDialect.THOUGHTSPOT, + OssieDialect.SIGMA, ): # There's no parallel for these read = None From bcca5c6bd3035a0db43bbce87a4bb46ca065bc25 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Fri, 11 Sep 2026 11:48:54 -0400 Subject: [PATCH 12/12] remove todo placeholders --- converters/hex/README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/converters/hex/README.md b/converters/hex/README.md index 9b82b8a7..88d5972a 100644 --- a/converters/hex/README.md +++ b/converters/hex/README.md @@ -25,9 +25,6 @@ Bidirectional, offline conversion between [Apache Ossie][apache-ossie] and [Hex][hex-semantic-spec]. - **Export** (`ossie-hex export`): Ossie → Hex -- **Import** (`ossie-hex import`): Hex → Ossie - -TODO(main): {description} ## Installation @@ -72,16 +69,6 @@ Example: ossie-hex export -i ossie.yaml -o hex/ --dialect snowflake ``` -#### `import` - -TODO(import): {description} - -TODO(import): {code-signature} - -TODO(import): {options} - -TODO(import): {code-example} - ### Python API #### `convert_ossie_to_hex` @@ -205,11 +192,5 @@ conversion is not one-to-one. | `Time` | `other` | No Hex equivalent. | | _omitted_ | `string`/`number` | Warning. String for dimensions, number for measures. | -### Custom extension - -TODO(custom-extension): {description} - -TODO(custom-extension): {table} - [apache-ossie]: https://ossie.apache.org/ [hex-semantic-spec]: https://learn.hex.tech/docs/connect-to-data/semantic-models/semantic-authoring/modeling-specification