diff --git a/.asf.yaml b/.asf.yaml new file mode 100644 index 0000000..5127f52 --- /dev/null +++ b/.asf.yaml @@ -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. +# + +# The format of this file is documented at +# https://github.com/apache/infrastructure-asfyaml +# also at +# https://cwiki.apache.org/confluence/display/INFRA/Git+-+.asf.yaml+features + +github: + description: "Apache Ossie, industry wide specification effort to standardize how we exchange semantic metadata across analytics, AI and BI platforms, providing a vendor neutral, single source of truth for semantic data" + homepage: https://ossie.apache.org/ + labels: + - semantic + - metadata + + enabled_merge_buttons: + merge: false + squash: true + rebase: false + + del_branch_on_merge: true + + copilot_code_review: + enabled: true + review_drafts: false + review_on_push: false + + protected_branches: + main: + required_pull_request_reviews: + require_code_owner_reviews: false + dismiss_stale_reviews: true + require_last_push_approval: false + required_approving_review_count: 1 + + required_linear_history: true + + features: + wiki: false + issues: true + projects: true + discussions: true + +notifications: + commits: commits@ossie.apache.org + issues: issues@ossie.apache.org + pullrequests: issues@ossie.apache.org + discussions: dev@ossie.apache.org + jira_options: link label diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..021c172 --- /dev/null +++ b/.editorconfig @@ -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. + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.{py,java,toml}] +indent_style = space +indent_size = 4 + +[{Makefile,go.mod,*.go}] +indent_style = tab diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..45d0ca8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,61 @@ + + +## Summary + + + +## Related Issues + + + +## Checklist + +### Specification +- [ ] Spec changes are included in `core-spec/` and follow the existing structure +- [ ] Spec changes have been discussed on the mailing list or in a linked issue +- [ ] Breaking changes to the spec are clearly called out in the summary + +### Ontology +- [ ] Ontology changes in `ontology/` are consistent with spec changes +- [ ] New or modified terms are defined and documented + +### Converters +- [ ] Converter logic in `converters/` is updated to reflect spec or ontology changes +- [ ] New converters include tests under the converter's test directory + +### Validation +- [ ] Validation rules in `validation/` are updated if the spec changed +- [ ] New validation cases are covered by tests + +### Documentation +- [ ] `docs/` is updated to reflect any user-facing changes +- [ ] New features or behaviors are documented with examples where appropriate +- [ ] `CONTRIBUTING.md` is updated if the contribution process changed + +### Examples +- [ ] `examples/` are added or updated for any new spec constructs or converter support + +### Tests +- [ ] All existing tests pass (`pytest` / CI green) +- [ ] New functionality is covered by tests + +### Compliance +- [ ] ASF license headers are present on all new source files +- [ ] No third-party dependencies are added without PMC/IPMC approval diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml new file mode 100644 index 0000000..e729089 --- /dev/null +++ b/.github/workflows/cli-ci.yml @@ -0,0 +1,38 @@ +name: CLI CI + +on: + push: + paths: + - 'cli/**' + - '.github/workflows/cli-ci.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/cli-ci.yml' + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: go build + run: go build ./... + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... diff --git a/.gitignore b/.gitignore index 63e36ff..77ca2ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,14 @@ **/__pycache__/ **/.venv/ + +**/.pytest_cache/ +**/.ruff_cache/ +**/.coverage +**/htmlcov/ +**/dist/ +**/target/ + +# Go CLI +cli/dist/ +cli/ossie +cli/.tool-versions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9fd4a8..15bf5b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,101 +1,282 @@ -# Contributing to Open Semantic Interchange (OSI) - -We welcome contributions from everyone — whether you are a developer, a data engineer, a BI analyst, or simply someone interested in the future of semantic interoperability. + + +# Contributing to Apache Ossie (incubating) + +Thank you for your interest in contributing to Apache Ossie! We welcome +contributions from everyone — whether you are a developer, a data engineer, a BI +analyst, or simply someone interested in the future of semantic interoperability. + +Apache Ossie was formerly known as **Open Semantic Interchange (OSI)**. + +> **Apache Ossie is an effort undergoing incubation at the Apache Software +> Foundation (ASF), sponsored by the Apache Incubator.** Incubation is required +> of all newly accepted projects until a further review indicates that the +> infrastructure, communications, and decision making process have stabilized in +> a manner consistent with other successful ASF projects. See the +> [DISCLAIMER](DISCLAIMER) for details. + +Apache Ossie is governed by [The Apache Way](https://www.apache.org/theapacheway/), +the ASF's collection of principles and practices for building open, vendor-neutral +communities. If you are new to the ASF, the +[Apache Incubator](https://incubator.apache.org/) and the +[ASF New Committers guide](https://www.apache.org/dev/new-committers-guide.html) +are good starting points. ## Ways to Contribute -- **Specification Feedback**: Review proposed specification changes and share your perspective on GitHub pull requests and issues. +- **Specification Feedback**: Review proposed specification changes and share your perspective on the mailing list, GitHub pull requests, and issues. - **Use Case Discussions**: Share how your organization uses semantic models and what challenges you face — this helps shape the specification to address real-world needs. - **Code Contributions**: Contribute to validation tooling, converters, examples, or any other part of the project. - **Documentation**: Help improve and expand the project documentation. - **Community Support**: Answer questions, participate in discussions, and help onboard new contributors. +## Communication + +At the ASF, **the mailing lists are the primary channel** for the project. Consensus +is built and decisions are recorded on the lists, so please bring discussions there. +A guiding principle of the Apache Way is: *if it didn't happen on the mailing list, +it didn't happen.* + +- **dev@ossie.apache.org** — development and community discussion. Subscribe by + emailing `dev-subscribe@ossie.apache.org`, then browse the + [archives](https://lists.apache.org/list.html?dev@ossie.apache.org). +- **commits@ossie.apache.org** — automated notifications for commits, pull requests. + Subscribe via `commits-subscribe@ossie.apache.org`. +- **issues@ossie.apache.org** — automated notifications for issues. + Subscribe via `issues-subscribe@ossie.apache.org`. +- **private@ossie.apache.org** — the Podling Project Management Committee (PPMC) + private list, used only for confidential matters such as committer nominations. + +Secondary, less formal channels (never a substitute for the lists when a decision +is being made): + +- [GitHub Issues](https://github.com/apache/ossie/issues) and + [Discussions](https://github.com/apache/ossie/discussions) +- [Slack](https://join.slack.com/t/apache-ossie/shared_invite/zt-42zw4rflt-Gpve8_NFJq7AsdAQTY~SCg) + ## Getting Started -1. **Read the Specification**: Familiarize yourself with the [core specification](core-spec/spec.md) to understand the semantic model format. -2. **Explore the Examples**: Review the [TPC-DS example](examples/tpcds_semantic_model.yaml) to see a complete semantic model in practice. -3. **Join the Conversation**: Open or participate in [GitHub Issues](https://github.com/open-semantic-interchange) and Discussions to share ideas and feedback. -4. **Submit a Pull Request**: Fork the repository, make your changes, and submit a pull request. All contributions go through the standard review process described below. -5. **Join the Slack workspace**: Chat directly with the community by [joining Slack](https://join.slack.com/t/opensemanticx/shared_invite/zt-3yuad6c0h-MaoPgVSD1g9MEOf1_QeaiQ). +1. **Subscribe to the dev list**: Email `dev-subscribe@ossie.apache.org` and introduce yourself. +2. **Read the Specification**: Familiarize yourself with the [core specification](core-spec/spec.md) to understand the semantic model format. +3. **Explore the Examples**: Review the [TPC-DS example](examples/tpcds_semantic_model.yaml) to see a complete semantic model in practice. +4. **Find something to work on**: Browse the [issues](https://github.com/apache/ossie/issues), or raise a topic on the dev list. +5. **Submit a Pull Request**: Fork the repository, make your changes, and submit a pull request. All contributions go through the review process described below. -## Contribution Guidelines +## Contributor License Agreement (ICLA) -### Specification Changes +All contributions to Apache Ossie are made under the [Apache License 2.0](LICENSE). +By submitting a pull request or patch, you agree that your contribution is licensed +under those terms (see Section 5 of the license). -Changes to the OSI Specification follow a structured process: +Before your first non-trivial contribution can be merged, and always before you are +granted commit access, you must have an +[Individual Contributor License Agreement (ICLA)](https://www.apache.org/licenses/contributor-agreements.html#clas) +on file with the ASF. If you are contributing on behalf of your employer, a +[Corporate CLA (CCLA)](https://www.apache.org/licenses/contributor-agreements.html#clas) +may also be required. Please keep individual commits signed off and attributed to +the correct author so that provenance is clear. -1. **Proposal**: Submit a GitHub pull request with a clear description of the motivation, the change itself, and its impact on existing implementations. -2. **Discussion period**: The community has a minimum of 7 days to review and discuss the proposed change. Complex changes may require a longer review window. -3. **Vote**: Once the discussion period has elapsed, a vote is called. TSC members and committers cast votes: - - **+1** (Yes): In favor of the change - - **0** (Abstain): No opinion or willing to go with the majority - - **-1** (Veto): Against the change — must be accompanied by a technical justification +## Contribution Workflow - TSC member votes are binding. A specification change passes with at least 2 binding +1 votes and no vetoes. A veto can only be overridden by addressing the stated concern or by a supermajority (two-thirds) vote of the TSC. +The project's canonical repository is hosted at +[github.com/apache/ossie](https://github.com/apache/ossie), mirrored from ASF +infrastructure (GitBox). -### All Other Contributions +### Code, Documentation, and Tooling -Non-specification contributions (bug fixes, tooling, documentation, converters) go through standard GitHub pull request review. Committers review and merge these contributions. +Non-specification contributions (bug fixes, tooling, documentation, converters, +examples) follow standard GitHub pull request review: -## Community Values +1. Open an issue or start a thread on `dev@` for anything non-trivial, so the + approach can be discussed before significant work begins. +2. Fork the repository and create a topic branch for your change. +3. Submit a pull request with a clear description of the motivation and the change. +4. A committer reviews and merges once the change has at least one **+1** from a + committer and no unresolved **-1**. The project follows a review-then-commit (RTC) + model: changes are merged after review rather than committed first. -OSI follows the Apache Way, built on these principles: +### Specification Changes -- **Meritocracy**: Merit is based on contribution and never expires. Any constructive contribution earns merit — code, documentation, testing, community support, and specification reviews all count equally. -- **Peer-based**: Every participant is treated as a peer regardless of employer affiliation or seniority. Decisions are made by the community, not by any single company or individual. -- **Consensus decision making**: We strive for consensus in all decisions. When full consensus cannot be reached, a formal vote may be called. -- **Open Communications**: All technical discussions, design decisions, and specification changes happen in the open — on GitHub issues, pull requests, and discussions. If it didn't happen on a public resource, it didn't happen. -- **Responsible oversight**: The community collectively ensures the specification and tooling remain high quality, secure, and aligned with the project's mission. -- **Independence**: The project operates independently of any single vendor or organization. +Changes to the Apache Ossie specification carry a higher bar and follow a structured +process: + +1. **Proposal**: Announce the proposal on `dev@ossie.apache.org` and open a GitHub + pull request with a clear description of the motivation, the change itself, and + its impact on existing implementations. +2. **Discussion period**: The community has a minimum of 7 days (72 hours for a + formal `[VOTE]`) to review and discuss. Complex changes may require a longer + review window. +3. **Vote**: Once discussion has settled, a `[VOTE]` thread is called on the dev + list. See [Decision Making and Voting](#decision-making-and-voting) below. + +## Decision Making and Voting + +Apache Ossie strives for **lazy consensus** — proceeding when no one objects — and +falls back to a formal vote when consensus cannot be reached. Votes happen on +`dev@ossie.apache.org` so they are publicly recorded. Voters express: + +- **+1**: In favor. +- **0**: Abstain / no strong opinion. +- **-1**: Against. For code and specification changes this is a **veto** and + **must** be accompanied by a technical justification; a valid veto can only be + resolved by addressing the stated concern. + +Conventions: + +- **Code and other changes** pass by lazy consensus: at least one binding **+1** and + no vetoes. +- **Specification changes** require at least three binding **+1** votes and no + vetoes. +- **Committer and PPMC nominations** are held on the private list and pass by lazy + consensus (no **-1** within 72 hours). +- **Procedural votes** (e.g. adopting a policy) pass by simple majority and cannot + be vetoed. + +**Binding votes** on the podling are cast by PPMC members. Everyone is encouraged to +vote; non-binding votes are valued input that informs the outcome. + +### Releases + +As an incubating project, every release is approved in two stages: + +1. A `[VOTE]` on `dev@ossie.apache.org` that passes with at least **three binding + +1 votes** from the PPMC and more +1 than -1 votes. +2. A second `[VOTE]` on `general@incubator.apache.org` that passes with at least + three binding +1 votes from the Incubator PMC (IPMC). + +Releases are source releases distributed through official ASF channels and must +comply with [ASF release policy](https://www.apache.org/legal/release-policy.html). + +## Community Values — The Apache Way + +Apache Ossie follows [The Apache Way](https://www.apache.org/theapacheway/), built +on these principles: + +- **Community over code**: A healthy, welcoming community is the project's most + important asset. +- **Meritocracy**: Merit is based on contribution and never expires. Any constructive + contribution earns merit — code, documentation, testing, community support, and + specification reviews all count. +- **Peer-based**: Every participant is treated as a peer regardless of employer + affiliation or seniority. Decisions are made by the community, not by any single + company or individual. +- **Consensus decision making**: We strive for consensus in all decisions. When full + consensus cannot be reached, a formal vote may be called. +- **Open Communications**: Technical discussions, design decisions, and specification + changes happen in the open on the mailing lists and other public resources. + Decisions are made asynchronously so contributors in every timezone can take part. +- **Responsible oversight**: The community collectively ensures the specification and + tooling remain high quality, secure, and aligned with the project's mission. +- **Vendor neutrality**: The project operates independently of any single vendor or + organization. ## Roles and Responsibilities ### Contributors -Anyone who contributes to the project in any form — code, documentation, bug reports, specification feedback, or community support. Contributors do not have binding vote rights but are encouraged to participate in all discussions. +Anyone who contributes to the project in any form — code, documentation, bug reports, +specification feedback, or community support. Contributors are encouraged to +participate in all discussions and votes; contributor votes are non-binding but valued. -### Specification Reviewers +### Committers -Community members with domain expertise (e.g., in BI, AI, data engineering) who review proposed specification changes for correctness, completeness, and practical applicability. +Contributors who have earned write access to the repository through sustained, +high-quality contributions. Committers review and merge pull requests and have +binding votes on the project's technical decisions. All committers must have an ICLA +on file. -### Committers +### Podling Project Management Committee (PPMC) + +The PPMC is responsible for the overall direction and health of the podling — +technical direction, community growth, and oversight of releases. PPMC members' +votes are binding. During incubation, PPMC members work alongside the project's +mentors and, upon graduation, the PPMC becomes the project's PMC. + +### Mentors -Contributors who have earned write access to the repository through sustained, high-quality contributions. Committers can merge pull requests and have binding votes on non-specification matters. +Experienced ASF members assigned by the Incubator to guide the podling through the +incubation process. Mentors are PPMC members who help the community learn the Apache +Way, and they shepherd release votes to the Incubator PMC. -### Technical Steering Committee (TSC) +### Incubator PMC (IPMC) -The TSC is responsible for the overall technical direction of the OSI Specification. TSC members have binding votes on specification changes. New TSC members are elected by existing TSC members. +The [Apache Incubator](https://incubator.apache.org/) PMC provides oversight for all +podlings, including approving releases and reviewing quarterly podling reports until +the project graduates to a top-level project. ## Becoming a Committer -Committership is earned through sustained contribution to the project. There is no fixed formula — the community recognizes contributors who demonstrate consistent, high-quality involvement over time. +Committership is earned through sustained contribution to the project. There is no +fixed formula — the community recognizes contributors who demonstrate consistent, +high-quality involvement over time. ### What counts -Committer candidacy is based on the breadth and quality of your contributions across any of these areas: +Committer candidacy is based on the breadth and quality of your contributions across +any of these areas: - Code contributions (converters, validation tooling, examples, tests) - Specification feedback and review participation - Documentation improvements -- Community support (answering questions, helping onboard others) +- Community support (answering questions, helping onboard others, discussion on the lists) - Working group participation ### The process -1. Any existing committer or TSC member may nominate a contributor by opening a discussion (GitHub Discussions or the project mailing list). -2. The nomination includes a summary of the candidate's contributions and a rationale for committer status. -3. Existing committers and TSC members discuss the nomination. The standard consensus voting convention applies (+1 / 0 / -1). -4. A nomination passes with lazy consensus — if no -1 votes are received within 72 hours, the candidate is welcomed as a committer. -5. The new committer is added to the repository with write access and listed in the project documentation. - -There is no minimum time requirement. What matters is the quality, consistency, and impact of your contributions. A focused burst of high-value work can carry as much weight as a longer track record. +1. Any PPMC member may nominate a contributor by starting a discussion on the private + list (`private@ossie.apache.org`). +2. The nomination includes a summary of the candidate's contributions and a rationale + for committer status. +3. The PPMC discusses and votes. A nomination passes by lazy consensus — if no **-1** + votes are received within 72 hours. +4. If the vote passes, the candidate is invited to become a committer. Once they + accept and their ICLA is on file, their Apache account is set up and write access + is granted. +5. The new committer is announced to the community and listed in the project + documentation. + +There is no minimum time requirement. What matters is the quality, consistency, and +impact of your contributions. Contributors who show sound judgment and help grow the +community may also be invited to join the PPMC. ## Code of Conduct -All participants in the OSI community are expected to treat each other with respect and professionalism. We are committed to providing a welcoming and inclusive environment for everyone, regardless of background or experience level. +All participants in the Apache Ossie community are expected to follow the +[Apache Software Foundation Code of Conduct](https://www.apache.org/foundation/policies/conduct). +We are committed to providing a welcoming and inclusive environment for everyone, +regardless of background or experience level. Concerns may be raised privately with +the PPMC (`private@ossie.apache.org`) or, for foundation-level matters, with the ASF +per the linked policy. + +## Trademarks + +Apache Ossie, Ossie, Apache, the Apache feather logo, and the Apache Ossie project +logo are trademarks of The Apache Software Foundation. Please review the +[ASF trademark policy](https://www.apache.org/foundation/marks/) before using any of +these marks. ## License -All content in this repository — including code, specification, and documentation — is licensed under the [Apache License 2.0](LICENSE). +All content in this repository — including code, specification, and documentation — +is licensed under the [Apache License 2.0](LICENSE). -By submitting a contribution, you agree that your contribution will be licensed under the same terms. +By submitting a contribution, you agree that your contribution will be licensed under +the same terms, as described in the [Contributor License Agreement](#contributor-license-agreement-icla) +section above. diff --git a/DISCLAIMER b/DISCLAIMER new file mode 100644 index 0000000..a2d9f5d --- /dev/null +++ b/DISCLAIMER @@ -0,0 +1,17 @@ +Apache Ossie is an effort undergoing incubation at the Apache Software +Foundation (ASF), sponsored by the Apache Incubator PMC. + +Incubation is required of all newly accepted projects until a further review +indicates that the infrastructure, communications, and decision making process +have stabilized in a manner consistent with other successful ASF projects. + +While incubation status is not necessarily a reflection of the completeness +or stability of the code, it does indicate that the project has yet to be +fully endorsed by the ASF. + +If you are planning to incorporate this work into your product/project, please +be aware that you will need to conduct a thorough licensing review to determine +the overall implications of including this work. For the current status of this +project through the Apache Incubator visit: +https://incubator.apache.org/projects/ossie.html + diff --git a/LICENSE b/LICENSE index 67db858..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,3 @@ - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -173,3 +172,30 @@ defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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/NOTICE b/NOTICE index 2deb079..e4bc78e 100644 --- a/NOTICE +++ b/NOTICE @@ -1,2 +1,5 @@ -Open Semantic Interchange -Copyright 2026 - Open Semantic Interchange +Apache Ossie (incubating) +Copyright 2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/README.md b/README.md index 721eefb..eee15c8 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,41 @@ -# OSI + + +# Apache Ossie (incubating) + +Apache Ossie is a collaborative, open-source effort dedicated to standardizing and streamlining semantic model exchange and utilization across the diverse array of tools and platforms within the data analytics, AI, and BI ecosystem. Our shared vision is to establish a common, vendor-agnostic semantic model specification, promoting unparalleled interoperability, efficiency, and collaboration among all participants. By providing a single, consistent source of truth, this vendor-agnostic standard ensures that your data's definitions and value remain consistent as they are interchanged between AI agents, BI platforms, and all other tools in your ecosystem, eliminating inconsistencies across your different tools. + +Apache Ossie was formerly known as **Open Semantic Interchange (OSI)**. + +Apache Ossie provides a single JSON- and YAML-based specification that any tool can read and write, addressing the semantic fragmentation common across today's data stack: the same KPI defined differently across tools, teams spending significant effort manually reconciling definitions, and AI agents producing unreliable outputs grounded in inconsistent business logic. ## What's in this repository -- [`core-spec/`](core-spec/) — The OSI core specification (`spec.md`), the machine-readable schema (`spec.yaml`, `osi-schema.json`), and accompanying documentation. -- [`converters/`](converters/) — Reference converters that translate between OSI and other semantic formats (e.g., dbt, GoodData, Polaris, Salesforce). +- [`core-spec/`](core-spec/) — The Ossie core specification (`spec.md`), the machine-readable schema (`spec.yaml`, `osi-schema.json`), and accompanying documentation. +- [`converters/`](converters/) — Reference converters that translate between Ossie and other semantic formats (e.g., dbt, GoodData, Polaris, Salesforce). - [`examples/`](examples/) — Example semantic models, including a complete TPC-DS model. -- [`validation/`](validation/) — Tooling for validating semantic models against the OSI schema. +- [`validation/`](validation/) — Tooling for validating semantic models against the Ossie schema. - [`docs/`](docs/) — Project documentation and overview. ## Get involved - **Contribute:** See [CONTRIBUTING.md](CONTRIBUTING.md) for how to propose specification changes, contribute code, and participate in the community. - **Roadmap:** See [ROADMAP.md](ROADMAP.md) for current working groups, future efforts, and planned enhancements informed by community discussion. -- **Discuss:** Join the conversation on [GitHub Discussions](https://github.com/open-semantic-interchange/OSI/discussions) and [Issues](https://github.com/open-semantic-interchange/OSI/issues). -- **Join the Slack community:** Chat directly with contributors on [Slack](https://join.slack.com/t/opensemanticx/shared_invite/zt-3yuad6c0h-MaoPgVSD1g9MEOf1_QeaiQ). - -# License - -All content in this repository — including code, specification, and documentation — is licensed under the [Apache License 2.0](LICENSE). +- **Discuss:** Join the conversation on [GitHub Discussions](https://github.com/apache/ossie/discussions) and [Issues](https://github.com/apache/ossie/issues). +- **Join the Slack community:** Chat directly with contributors on [Slack](https://join.slack.com/t/apache-ossie/shared_invite/zt-42zw4rflt-Gpve8_NFJq7AsdAQTY~SCg). diff --git a/ROADMAP.md b/ROADMAP.md index 04a8ae9..72d564c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,25 @@ -# OSI Roadmap (Community-Informed) + + +# Apache Ossie Roadmap (Community-Informed) + +This roadmap synthesizes community discussions and voting signals from the [Ossie GitHub Discussions](https://github.com/apache/ossie/discussions) board. It groups work into three categories: - **Current Efforts / Working Groups** — strategic initiatives with active working groups driving spec evolution now - **Future Efforts** — strategic initiatives planned for future working groups @@ -23,20 +42,20 @@ The current model lacks sufficient support for metrics at different grains, filt **Key Discussions:** -- [Top-level "metrics" vs. dataset-level "measures"](https://github.com/open-semantic-interchange/OSI/discussions/29) -- [Cumulative and other "expansions" to metrics](https://github.com/open-semantic-interchange/OSI/discussions/39) -- [Structured aggregation_method for Metrics](https://github.com/open-semantic-interchange/OSI/discussions/19) -- [Add "entity / grain" as a first-class concept](https://github.com/open-semantic-interchange/OSI/discussions/12) -- [Add explicit datasets reference to Metrics](https://github.com/open-semantic-interchange/OSI/discussions/18) -- [Relationship Semantics](https://github.com/open-semantic-interchange/OSI/discussions/24) -- [Complex Relationship Definitions](https://github.com/open-semantic-interchange/OSI/discussions/4) -- [Make Relationship Cardinality Explicit](https://github.com/open-semantic-interchange/OSI/discussions/50) -- [Inner join in relationships](https://github.com/open-semantic-interchange/OSI/discussions/11) -- [Support for cross-dataset dimensions & single-dataset measures](https://github.com/open-semantic-interchange/OSI/discussions/27) -- [Semantic Filters](https://github.com/open-semantic-interchange/OSI/discussions/5) -- [BIG IDEA: add metrics trees / input-output relations between metrics](https://github.com/open-semantic-interchange/OSI/discussions/40) -- [Primary Key vs Unique Keys redundancy](https://github.com/open-semantic-interchange/OSI/discussions/15) -- [Clarifying the Semantic Intent of Primary Keys vs. Unique Constraints](https://github.com/open-semantic-interchange/OSI/discussions/119) +- [Top-level "metrics" vs. dataset-level "measures"](https://github.com/apache/ossie/discussions/29) +- [Cumulative and other "expansions" to metrics](https://github.com/apache/ossie/discussions/39) +- [Structured aggregation_method for Metrics](https://github.com/apache/ossie/discussions/19) +- [Add "entity / grain" as a first-class concept](https://github.com/apache/ossie/discussions/12) +- [Add explicit datasets reference to Metrics](https://github.com/apache/ossie/discussions/18) +- [Relationship Semantics](https://github.com/apache/ossie/discussions/24) +- [Complex Relationship Definitions](https://github.com/apache/ossie/discussions/4) +- [Make Relationship Cardinality Explicit](https://github.com/apache/ossie/discussions/50) +- [Inner join in relationships](https://github.com/apache/ossie/discussions/11) +- [Support for cross-dataset dimensions & single-dataset measures](https://github.com/apache/ossie/discussions/27) +- [Semantic Filters](https://github.com/apache/ossie/discussions/5) +- [BIG IDEA: add metrics trees / input-output relations between metrics](https://github.com/apache/ossie/discussions/40) +- [Primary Key vs Unique Keys redundancy](https://github.com/apache/ossie/discussions/15) +- [Clarifying the Semantic Intent of Primary Keys vs. Unique Constraints](https://github.com/apache/ossie/discussions/119) **Roadmap Deliverables:** @@ -52,7 +71,7 @@ The current model lacks sufficient support for metrics at different grains, filt ### Catalog Integration & Semantic Services -**Goal:** Integrate OSI with data catalogs and enable centralized semantic services. +**Goal:** Integrate Ossie with data catalogs and enable centralized semantic services. **Motivation:** Semantic models need to be discoverable, governable, and shareable across systems. @@ -61,38 +80,38 @@ Semantic models need to be discoverable, governable, and shareable across system - Integration patterns with catalogs (e.g., Polaris) - Standalone semantic service / registry -- Discovery, versioning, and access control for OSI models +- Discovery, versioning, and access control for Ossie models **Related Issues:** -- [Issue #107 — Proposal: Adopt ontology-query as an Ontology Access Layer tool](https://github.com/open-semantic-interchange/OSI/issues/107) +- [Issue #107 — Proposal: Adopt ontology-query as an Ontology Access Layer tool](https://github.com/apache/ossie/issues/107) --- ### Ontology & Semantic Interoperability -**Goal:** Enable OSI to describe business concepts independently of physical data layout, supporting ontology-based semantic models and cross-model conceptual alignment. +**Goal:** Enable Ossie to describe business concepts independently of physical data layout, supporting ontology-based semantic models and cross-model conceptual alignment. **Motivation:** -Many semantic representations (e.g., Palantir, Goldman Sachs Legend) use ontologies to define meaning, and dimensional semantic models naturally layer on top of these. OSI currently solves structural interoperability — any tool can read and write semantic models in a common format — but it does not yet solve conceptual interoperability, where different models may describe the same business concept using different names or structures. An ontology layer would let organizations define canonical business concepts (Customer, Order, Product, etc.) independently of where the data lives and map physical semantic models back to shared definitions. +Many semantic representations (e.g., Palantir, Goldman Sachs Legend) use ontologies to define meaning, and dimensional semantic models naturally layer on top of these. Ossie currently solves structural interoperability — any tool can read and write semantic models in a common format — but it does not yet solve conceptual interoperability, where different models may describe the same business concept using different names or structures. An ontology layer would let organizations define canonical business concepts (Customer, Order, Product, etc.) independently of where the data lives and map physical semantic models back to shared definitions. **Key Discussions:** -- [Proposal: Extend OSI spec to interchange with semantic models based on relational ontologies](https://github.com/open-semantic-interchange/OSI/discussions/22) -- [Support for Ontologies](https://github.com/open-semantic-interchange/OSI/discussions/101) -- [Shared Semantics OSI](https://github.com/open-semantic-interchange/OSI/discussions/108) -- [Plans to support other data models than tabular data?](https://github.com/open-semantic-interchange/OSI/discussions/68) +- [Proposal: Extend Ossie spec to interchange with semantic models based on relational ontologies](https://github.com/apache/ossie/discussions/22) +- [Support for Ontologies](https://github.com/apache/ossie/discussions/101) +- [Shared Semantics Ossie](https://github.com/apache/ossie/discussions/108) +- [Plans to support other data models than tabular data?](https://github.com/apache/ossie/discussions/68) **Roadmap Deliverables:** - Ontology layer describing business concepts above the physical/logical semantic model -- Schema mappings between ontology concepts and OSI datasets/fields +- Schema mappings between ontology concepts and Ossie datasets/fields - Support for relational ontologies and non-tabular data models - Shared semantic definitions enabling conceptual interoperability across models **Related Issues:** -- [Issue #107 — Proposal: Adopt ontology-query as an Ontology Access Layer tool](https://github.com/open-semantic-interchange/OSI/issues/107) +- [Issue #107 — Proposal: Adopt ontology-query as an Ontology Access Layer tool](https://github.com/apache/ossie/issues/107) --- @@ -111,11 +130,11 @@ Users want reusable semantic models independent of underlying tables or views. **Key Discussions:** -- [Add support for "Logical Datasets" (query-defined entities / view definitions)](https://github.com/open-semantic-interchange/OSI/discussions/49) -- [Support one-to-many binding between a logical dataset and the physical table, view, or query](https://github.com/open-semantic-interchange/OSI/discussions/61) -- [Structured Dataset Sources](https://github.com/open-semantic-interchange/OSI/discussions/23) -- [Structured Dataset 'source' representation](https://github.com/open-semantic-interchange/OSI/discussions/109) -- [Support for reusable datasets and relationships across semantic models](https://github.com/open-semantic-interchange/OSI/discussions/103) +- [Add support for "Logical Datasets" (query-defined entities / view definitions)](https://github.com/apache/ossie/discussions/49) +- [Support one-to-many binding between a logical dataset and the physical table, view, or query](https://github.com/apache/ossie/discussions/61) +- [Structured Dataset Sources](https://github.com/apache/ossie/discussions/23) +- [Structured Dataset 'source' representation](https://github.com/apache/ossie/discussions/109) +- [Support for reusable datasets and relationships across semantic models](https://github.com/apache/ossie/discussions/103) **Roadmap Deliverables:** @@ -125,45 +144,45 @@ Users want reusable semantic models independent of underlying tables or views. **Related Issues:** -- [Issue #104 — First-class representation of file-backed datasets (e.g. Parquet)](https://github.com/open-semantic-interchange/OSI/issues/104) +- [Issue #104 — First-class representation of file-backed datasets (e.g. Parquet)](https://github.com/apache/ossie/issues/104) --- ### Semantic Query Language & Reference Engine -**Goal:** Define a standard query interface for interacting with OSI models and provide a canonical implementation for interpreting and executing them. +**Goal:** Define a standard query interface for interacting with Ossie models and provide a canonical implementation for interpreting and executing them. **Motivation:** Consumers (BI tools, AI systems, APIs) need a consistent way to query semantic models independent of underlying SQL dialects. A reference engine ensures consistent interpretation of the spec and accelerates ecosystem adoption. **Roadmap Deliverables:** -- Standard semantic query language (OSI-native or SQL-extended) +- Standard semantic query language (Ossie-native or SQL-extended) - Mapping from semantic queries → execution plans - Support for metrics, dimensions, filters, and relationships -- Reference compiler from OSI → SQL +- Reference compiler from Ossie → SQL - Canonical handling of joins, aggregations, and filters - Test suite to validate conformance across implementations **Related Issues:** -- [Issue #107 — Proposal: Adopt ontology-query as an Ontology Access Layer tool](https://github.com/open-semantic-interchange/OSI/issues/107) +- [Issue #107 — Proposal: Adopt ontology-query as an Ontology Access Layer tool](https://github.com/apache/ossie/issues/107) --- ### SQL Dialect, Expressions, and Execution Boundaries -**Goal:** Clarify the role of SQL and execution within OSI. +**Goal:** Clarify the role of SQL and execution within Ossie. **Motivation:** There is tension between portability and practical execution requirements. **Key Discussions:** -- [Add Default Dialect at Dataset Level](https://github.com/open-semantic-interchange/OSI/discussions/16) -- [Expectations around SQL expression dialects and conversion](https://github.com/open-semantic-interchange/OSI/discussions/28) -- [Use templating engine instead of plain yaml](https://github.com/open-semantic-interchange/OSI/discussions/62) -- [Jinja Templates](https://github.com/open-semantic-interchange/OSI/discussions/6) +- [Add Default Dialect at Dataset Level](https://github.com/apache/ossie/discussions/16) +- [Expectations around SQL expression dialects and conversion](https://github.com/apache/ossie/discussions/28) +- [Use templating engine instead of plain yaml](https://github.com/apache/ossie/discussions/62) +- [Jinja Templates](https://github.com/apache/ossie/discussions/6) **Roadmap Deliverables:** @@ -173,7 +192,7 @@ There is tension between portability and practical execution requirements. **Related Issues:** -- [Issue #52 — Only allow one dialect per OSI document](https://github.com/open-semantic-interchange/OSI/issues/52) +- [Issue #52 — Only allow one dialect per Ossie document](https://github.com/apache/ossie/issues/52) --- @@ -186,11 +205,11 @@ Inconsistent handling of hierarchies and time impacts usability and interoperabi **Key Discussions:** -- [Dimension Hierarchies](https://github.com/open-semantic-interchange/OSI/discussions/21) -- [Dimension Groups](https://github.com/open-semantic-interchange/OSI/discussions/20) -- [Replace is_time with dimension_type Enum](https://github.com/open-semantic-interchange/OSI/discussions/17) -- [Universal calendar support](https://github.com/open-semantic-interchange/OSI/discussions/44) -- [Date Spine models](https://github.com/open-semantic-interchange/OSI/discussions/47) +- [Dimension Hierarchies](https://github.com/apache/ossie/discussions/21) +- [Dimension Groups](https://github.com/apache/ossie/discussions/20) +- [Replace is_time with dimension_type Enum](https://github.com/apache/ossie/discussions/17) +- [Universal calendar support](https://github.com/apache/ossie/discussions/44) +- [Date Spine models](https://github.com/apache/ossie/discussions/47) **Roadmap Deliverables:** @@ -200,23 +219,23 @@ Inconsistent handling of hierarchies and time impacts usability and interoperabi **Related Issues:** -- [Issue #84 — Support field datatype rather than is_time](https://github.com/open-semantic-interchange/OSI/issues/84) +- [Issue #84 — Support field datatype rather than is_time](https://github.com/apache/ossie/issues/84) --- ### AI-Native Semantic Layer -**Goal:** Enable OSI as a reliable foundation for AI-driven analytics. +**Goal:** Enable Ossie as a reliable foundation for AI-driven analytics. **Motivation:** There is growing demand for structured semantic context and grounded query generation. **Key Discussions:** -- [Do not prescribe "AI Context" as a key name](https://github.com/open-semantic-interchange/OSI/discussions/32) -- [Keyword for skipping context for AI](https://github.com/open-semantic-interchange/OSI/discussions/14) -- [Usage guidelines with samples especially for ai_context field](https://github.com/open-semantic-interchange/OSI/discussions/9) -- [Add verified_queries as a core element of the spec](https://github.com/open-semantic-interchange/OSI/discussions/82) +- [Do not prescribe "AI Context" as a key name](https://github.com/apache/ossie/discussions/32) +- [Keyword for skipping context for AI](https://github.com/apache/ossie/discussions/14) +- [Usage guidelines with samples especially for ai_context field](https://github.com/apache/ossie/discussions/9) +- [Add verified_queries as a core element of the spec](https://github.com/apache/ossie/discussions/82) **Roadmap Deliverables:** @@ -235,11 +254,11 @@ Enterprise adoption requires consistent identifiers, validation, and governance **Key Discussions:** -- [Make stable identifiers explicit rather than reusing name](https://github.com/open-semantic-interchange/OSI/discussions/31) -- [Metrics schema - Certified and Certifying Authority](https://github.com/open-semantic-interchange/OSI/discussions/53) -- [Governance metadata hooks](https://github.com/open-semantic-interchange/OSI/discussions/13) -- [Add more rigor to the spec using LinkML](https://github.com/open-semantic-interchange/OSI/discussions/67) -- [OSI-level validations?](https://github.com/open-semantic-interchange/OSI/discussions/35) +- [Make stable identifiers explicit rather than reusing name](https://github.com/apache/ossie/discussions/31) +- [Metrics schema - Certified and Certifying Authority](https://github.com/apache/ossie/discussions/53) +- [Governance metadata hooks](https://github.com/apache/ossie/discussions/13) +- [Add more rigor to the spec using LinkML](https://github.com/apache/ossie/discussions/67) +- [Ossie-level validations?](https://github.com/apache/ossie/discussions/35) **Roadmap Deliverables:** @@ -249,9 +268,9 @@ Enterprise adoption requires consistent identifiers, validation, and governance **Related Issues:** -- [Issue #102 — Add semantic versioning and Git releases for core-spec/osi-schema.json](https://github.com/open-semantic-interchange/OSI/issues/102) -- [Issue #92 — Community Implementation: Trust Control Center — OSI-compatible governance & reconciliation platform](https://github.com/open-semantic-interchange/OSI/issues/92) -- [Issue #87 — New Flags: Restricted and Internal only indicators](https://github.com/open-semantic-interchange/OSI/issues/87) +- [Issue #102 — Add semantic versioning and Git releases for core-spec/osi-schema.json](https://github.com/apache/ossie/issues/102) +- [Issue #92 — Community Implementation: Trust Control Center — Ossie-compatible governance & reconciliation platform](https://github.com/apache/ossie/issues/92) +- [Issue #87 — New Flags: Restricted and Internal only indicators](https://github.com/apache/ossie/issues/87) --- @@ -266,7 +285,7 @@ Organizations repeatedly recreate similar semantic models (e.g., SaaS, finance, - Curated domain-specific semantic model templates - Best-practice metric and dimension definitions by industry -- Interoperable model packages aligned with OSI +- Interoperable model packages aligned with Ossie --- @@ -278,10 +297,10 @@ These items improve usability, clarity, and completeness without fundamentally c ### Naming, Terminology, and UX Improvements -**Goal:** Align OSI vocabulary with how practitioners think about semantic models, and improve the authoring experience. +**Goal:** Align Ossie vocabulary with how practitioners think about semantic models, and improve the authoring experience. **Motivation:** -Several naming conventions in the current spec create confusion or clash with established industry terminology. Clearer naming reduces onboarding friction and improves readability of OSI definitions. +Several naming conventions in the current spec create confusion or clash with established industry terminology. Clearer naming reduces onboarding friction and improves readability of Ossie definitions. **Roadmap Deliverables:** @@ -290,10 +309,10 @@ Several naming conventions in the current spec create confusion or clash with es **Key Discussions:** -- [Rename Field to Dimension](https://github.com/open-semantic-interchange/OSI/discussions/33) -- [Rename Dataset.source to avoid conflation with where an entity was first defined](https://github.com/open-semantic-interchange/OSI/discussions/34) -- [Generalise description field](https://github.com/open-semantic-interchange/OSI/discussions/36) -- [Introduce a concept for "Display name"](https://github.com/open-semantic-interchange/OSI/discussions/37) +- [Rename Field to Dimension](https://github.com/apache/ossie/discussions/33) +- [Rename Dataset.source to avoid conflation with where an entity was first defined](https://github.com/apache/ossie/discussions/34) +- [Generalise description field](https://github.com/apache/ossie/discussions/36) +- [Introduce a concept for "Display name"](https://github.com/apache/ossie/discussions/37) --- @@ -311,47 +330,47 @@ Consuming systems (BI tools, AI agents, dashboards) frequently need to know whet **Key Discussions:** -- [Native support for units](https://github.com/open-semantic-interchange/OSI/discussions/42) -- [Native support for currencies](https://github.com/open-semantic-interchange/OSI/discussions/43) -- [Semantic Field Types: dimension_type, data_type, and pii_classification](https://github.com/open-semantic-interchange/OSI/discussions/55) -- [Add portable field physical metadata to OSI](https://github.com/open-semantic-interchange/OSI/discussions/110) +- [Native support for units](https://github.com/apache/ossie/discussions/42) +- [Native support for currencies](https://github.com/apache/ossie/discussions/43) +- [Semantic Field Types: dimension_type, data_type, and pii_classification](https://github.com/apache/ossie/discussions/55) +- [Add portable field physical metadata to Ossie](https://github.com/apache/ossie/discussions/110) **Related Issues:** -- [Issue #58 — New attribute: contain personal data](https://github.com/open-semantic-interchange/OSI/issues/58) -- [Issue #59 — New attribute: confidential indicator](https://github.com/open-semantic-interchange/OSI/issues/59) +- [Issue #58 — New attribute: contain personal data](https://github.com/apache/ossie/issues/58) +- [Issue #59 — New attribute: confidential indicator](https://github.com/apache/ossie/issues/59) --- -### Extended Metadata for OSI +### Extended Metadata for Apache Ossie **Goal:** Introduce a lightweight, optional metadata layer that improves how data is interpreted, presented, and consumed — without affecting execution semantics. **Motivation:** -OSI standardizes structural and logical semantics well, but there is limited support for conveying interpretability context such as display conventions, default aggregation behavior, KPI polarity, sorting preferences, and alignment to external semantic concepts. These details are often redefined or inferred inconsistently across developers, BI tools, and AI systems. +Ossie standardizes structural and logical semantics well, but there is limited support for conveying interpretability context such as display conventions, default aggregation behavior, KPI polarity, sorting preferences, and alignment to external semantic concepts. These details are often redefined or inferred inconsistently across developers, BI tools, and AI systems. **Roadmap Deliverables:** -- [Extended Metadata Proposal for OSI](https://github.com/open-semantic-interchange/OSI/issues/100) — optional, backward-compatible metadata fields (e.g., `measurement`, `display_format`, `semantic_type`, `default_aggregation`, `desired_direction`, `default_sort`, `semantic_mappings`) +- [Extended Metadata Proposal for Ossie](https://github.com/apache/ossie/issues/100) — optional, backward-compatible metadata fields (e.g., `measurement`, `display_format`, `semantic_type`, `default_aggregation`, `desired_direction`, `default_sort`, `semantic_mappings`) - Richer application-specific extension points beyond `custom_extensions` - Sample value annotations for documentation and AI grounding **Key Discussions:** -- [Expand custom_extensions to be more suitable for application-specific metadata](https://github.com/open-semantic-interchange/OSI/discussions/30) -- [Sample values](https://github.com/open-semantic-interchange/OSI/discussions/7) -- [Governance metadata hooks](https://github.com/open-semantic-interchange/OSI/discussions/13) *(also informs strategic governance work)* -- [Optional "positive direction" on metrics](https://github.com/open-semantic-interchange/OSI/discussions/41) -- [Add default_aggregation to Field](https://github.com/open-semantic-interchange/OSI/discussions/115) +- [Expand custom_extensions to be more suitable for application-specific metadata](https://github.com/apache/ossie/discussions/30) +- [Sample values](https://github.com/apache/ossie/discussions/7) +- [Governance metadata hooks](https://github.com/apache/ossie/discussions/13) *(also informs strategic governance work)* +- [Optional "positive direction" on metrics](https://github.com/apache/ossie/discussions/41) +- [Add default_aggregation to Field](https://github.com/apache/ossie/discussions/115) --- ### Developer Experience & Documentation -**Goal:** Lower the barrier to adopting and correctly using OSI through better guidance, examples, and tooling-friendly formatting. +**Goal:** Lower the barrier to adopting and correctly using Ossie through better guidance, examples, and tooling-friendly formatting. **Motivation:** -New adopters and tool authors need clearer documentation, real-world samples, and support for rich-text descriptions to effectively author and consume OSI models. +New adopters and tool authors need clearer documentation, real-world samples, and support for rich-text descriptions to effectively author and consume Ossie models. **Roadmap Deliverables:** @@ -361,21 +380,21 @@ New adopters and tool authors need clearer documentation, real-world samples, an **Key Discussions:** -- [Usage guidelines with samples especially for ai_context field](https://github.com/open-semantic-interchange/OSI/discussions/9) -- [Information about data modelling](https://github.com/open-semantic-interchange/OSI/discussions/8) -- [Markdown support](https://github.com/open-semantic-interchange/OSI/discussions/38) +- [Usage guidelines with samples especially for ai_context field](https://github.com/apache/ossie/discussions/9) +- [Information about data modelling](https://github.com/apache/ossie/discussions/8) +- [Markdown support](https://github.com/apache/ossie/discussions/38) **Existing Artifacts:** -- [Core Specification (spec.md)](core-spec/spec.md) — the current OSI spec document +- [Core Specification (spec.md)](core-spec/spec.md) — the current Ossie spec document - [TPC-DS Example Model](examples/tpcds_semantic_model.yaml) — reference semantic model using the TPC-DS benchmark -- [Converter Guide (converters/index.md)](converters/index.md) — hub-and-spoke converter architecture and authoring guide +- [Converter Guide (converters/README.md)](converters/README.md) — hub-and-spoke converter architecture and authoring guide --- ### Specialized Capabilities -**Goal:** Extend OSI to support domain-specific data types, audience definitions, and patterns that go beyond traditional tabular analytics. +**Goal:** Extend Ossie to support domain-specific data types, audience definitions, and patterns that go beyond traditional tabular analytics. **Motivation:** Geospatial analytics, time-series modeling, and audience segmentation have unique requirements that benefit from first-class spec support rather than ad-hoc workarounds. @@ -388,36 +407,36 @@ Geospatial analytics, time-series modeling, and audience segmentation have uniqu **Key Discussions:** -- [Geospatial data support: spatial field types, spatial relationships, and geographic hierarchies](https://github.com/open-semantic-interchange/OSI/discussions/69) -- [Date Spine models](https://github.com/open-semantic-interchange/OSI/discussions/47) -- [Add Support for Audiences](https://github.com/open-semantic-interchange/OSI/discussions/51) -- [Spatial dimension type: extending dimension with a spatial descriptor for geometry/geography and spatial index data](https://github.com/open-semantic-interchange/OSI/discussions/114) +- [Geospatial data support: spatial field types, spatial relationships, and geographic hierarchies](https://github.com/apache/ossie/discussions/69) +- [Date Spine models](https://github.com/apache/ossie/discussions/47) +- [Add Support for Audiences](https://github.com/apache/ossie/discussions/51) +- [Spatial dimension type: extending dimension with a spatial descriptor for geometry/geography and spatial index data](https://github.com/apache/ossie/discussions/114) --- ### Tooling & Ecosystem Support -**Goal:** Provide reference tooling that makes it easy to validate, convert, and adopt OSI models. +**Goal:** Provide reference tooling that makes it easy to validate, convert, and adopt Ossie models. **Motivation:** -Broad ecosystem adoption depends on practical tools that let teams validate their models against the spec and convert between OSI and existing vendor formats without manual effort. +Broad ecosystem adoption depends on practical tools that let teams validate their models against the spec and convert between Ossie and existing vendor formats without manual effort. **Roadmap Deliverables:** - Validator code (schema validation, linting, conformance checks) -- Participant ↔ OSI converter code (read/write interoperability with existing tools) +- Participant ↔ Ossie converter code (read/write interoperability with existing tools) **Existing Artifacts:** - [JSON Schema (osi-schema.json)](core-spec/osi-schema.json) — schema for structural validation -- [Validation Script (validate.py)](validation/validate.py) — validates OSI YAML against JSON Schema, unique names, references, and SQL syntax -- [Snowflake Converter](converters/snowflake/) — OSI → Snowflake Cortex Analyst YAML converter -- [GoodData Converter](converters/gooddata/) — bidirectional OSI ↔ GoodData LDM converter -- [Salesforce Converter](converters/salesforce/) — OSI ↔ Salesforce converter -- [Apache Polaris Converter](converters/polaris/) — OSI → Apache Polaris converter +- [Validation Script (validate.py)](validation/validate.py) — validates Ossie YAML against JSON Schema, unique names, references, and SQL syntax +- [Snowflake Converter](converters/snowflake/) — Ossie → Snowflake Cortex Analyst YAML converter +- [GoodData Converter](converters/gooddata/) — bidirectional Ossie ↔ GoodData LDM converter +- [Salesforce Converter](converters/salesforce/) — Ossie ↔ Salesforce converter +- [Apache Polaris Converter](converters/polaris/) — Ossie → Apache Polaris converter +- [OrionBelt Converter](converters/orionbelt/) — bidirectional Ossie ↔ OrionBelt OBML converter **Related Issues:** -- [Issue #121 — Create converter/common module (for Java binding)](https://github.com/open-semantic-interchange/OSI/issues/121) -- [Issue #111 — Follow up on OSI ai_context and custom_extensions mapping in Snowflake YAML](https://github.com/open-semantic-interchange/OSI/issues/111) - +- [Issue #121 — Create converter/common module (for Java binding)](https://github.com/apache/ossie/issues/121) +- [Issue #111 — Follow up on Ossie ai_context and custom_extensions mapping in Snowflake YAML](https://github.com/apache/ossie/issues/111) diff --git a/cli/.goreleaser.yaml b/cli/.goreleaser.yaml new file mode 100644 index 0000000..3a0a17b --- /dev/null +++ b/cli/.goreleaser.yaml @@ -0,0 +1,48 @@ +version: 2 + +project_name: ossie + +before: + hooks: + - go mod tidy + +builds: + - id: ossie + main: . + binary: ossie + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - id: ossie + format: tar.gz + format_overrides: + - goos: windows + format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/cli/Makefile b/cli/Makefile new file mode 100644 index 0000000..637d8f1 --- /dev/null +++ b/cli/Makefile @@ -0,0 +1,22 @@ +BINARY_NAME := ossie +BUILD_DIR := dist + +.PHONY: build test lint format release-dry-run clean + +build: + go build -o $(BUILD_DIR)/$(BINARY_NAME) . + +test: + go test ./... + +lint: + go vet ./... + +format: + gofmt -w . + +release-dry-run: + goreleaser release --snapshot --clean --config .goreleaser.yaml + +clean: + rm -rf $(BUILD_DIR) diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go new file mode 100644 index 0000000..dd7d360 --- /dev/null +++ b/cli/cmd/convert.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var convertCmd = &cobra.Command{ + Use: "convert --from --input | --to --input ", + Short: "Convert a semantic model between Ossie and a platform format", + RunE: runConvert, +} + +func init() { + convertCmd.Flags().String("from", "", "Source platform — converts platform → Ossie") + convertCmd.Flags().String("to", "", "Target platform — converts Ossie → platform") + convertCmd.Flags().StringP("input", "i", "", "Input file or directory path (required)") + convertCmd.Flags().StringP("output", "o", "", "Output directory path (default: ./ossie-output//)") + convertCmd.Flags().String("plugin", "", "Path to plugin directory (bypasses name-based discovery)") + convertCmd.Flags().Int("timeout", 60, "Plugin invocation timeout in seconds") + convertCmd.Flags().String("max-input-size", "100MB", "Maximum total input size (e.g. 500MB)") + + _ = convertCmd.MarkFlagRequired("input") + convertCmd.MarkFlagsMutuallyExclusive("from", "to") + convertCmd.MarkFlagsOneRequired("from", "to") +} + +func runConvert(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/install.go b/cli/cmd/plugin/install.go new file mode 100644 index 0000000..aabde2d --- /dev/null +++ b/cli/cmd/plugin/install.go @@ -0,0 +1,22 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install [name[@version] | url]", + Short: "Install a plugin from the registry or a URL", + RunE: runPluginInstall, +} + +func init() { + installCmd.Flags().Bool("all", false, "Install the latest version of all registry plugins") +} + +func runPluginInstall(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go new file mode 100644 index 0000000..f69e8bb --- /dev/null +++ b/cli/cmd/plugin/list.go @@ -0,0 +1,18 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List installed and available plugins", + RunE: runPluginList, +} + +func runPluginList(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/plugin.go b/cli/cmd/plugin/plugin.go new file mode 100644 index 0000000..c19e37d --- /dev/null +++ b/cli/cmd/plugin/plugin.go @@ -0,0 +1,16 @@ +package plugin + +import "github.com/spf13/cobra" + +// Cmd is the parent "ossie plugin" command. It is exported so cmd/root.go can +// register it. Invoking it bare prints help. +var Cmd = &cobra.Command{ + Use: "plugin", + Short: "Manage Ossie plugins", +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(installCmd) + Cmd.AddCommand(removeCmd) +} diff --git a/cli/cmd/plugin/remove.go b/cli/cmd/plugin/remove.go new file mode 100644 index 0000000..1b88669 --- /dev/null +++ b/cli/cmd/plugin/remove.go @@ -0,0 +1,19 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an installed plugin", + Args: cobra.ExactArgs(1), + RunE: runPluginRemove, +} + +func runPluginRemove(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 0000000..fe213a5 --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "github.com/apache/ossie/cli/cmd/plugin" + "github.com/apache/ossie/cli/internal/ossiedir" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "ossie", + Short: "Apache Ossie (incubating) CLI", + Long: `ossie is the command-line tool for the Apache Ossie (incubating) project.`, + // NOTE: Cobra does NOT automatically chain PersistentPreRunE from parent to + // child. If any subcommand defines its own PersistentPreRunE or PreRunE, this + // function will not run for that subcommand. Future subcommands that define + // their own must call ossiedir.EnsurePluginDir() explicitly. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return ossiedir.EnsurePluginDir() + }, +} + +// Execute runs the root command. Called by main. +func Execute() error { + return rootCmd.Execute() +} + +// SetVersion sets the version string reported by `ossie --version`. +func SetVersion(v string) { + rootCmd.Version = v +} + +func init() { + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output (shows plugin stderr)") + + rootCmd.AddCommand(convertCmd) + rootCmd.AddCommand(validateCmd) + rootCmd.AddCommand(plugin.Cmd) +} diff --git a/cli/cmd/validate.go b/cli/cmd/validate.go new file mode 100644 index 0000000..b11d00a --- /dev/null +++ b/cli/cmd/validate.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var validateCmd = &cobra.Command{ + Use: "validate [flags] [...]", + Short: "Validate one or more OSSIE YAML or JSON files", + Args: cobra.MinimumNArgs(1), + RunE: runValidate, +} + +func init() { + validateCmd.Flags().Bool("strict", false, "Promote warnings to errors") + validateCmd.Flags().String("output", "text", "Output format: text or json") +} + +func runValidate(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..c57048e --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,10 @@ +module github.com/apache/ossie/cli + +go 1.26.2 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cli/internal/ossiedir/ossiedir.go b/cli/internal/ossiedir/ossiedir.go new file mode 100644 index 0000000..323c41a --- /dev/null +++ b/cli/internal/ossiedir/ossiedir.go @@ -0,0 +1,40 @@ +package ossiedir + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + defaultOssieDir = ".ossie" + pluginsSubdir = "plugins" + envVar = "OSSIE_PLUGIN_DIR" +) + +// PluginDir returns the resolved plugin directory path. +// It respects $OSSIE_PLUGIN_DIR if set, otherwise defaults to ~/.ossie/plugins/. +func PluginDir() (string, error) { + if override := os.Getenv(envVar); override != "" { + return override, nil + } + // Use os.UserHomeDir rather than $HOME for Windows portability. + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, defaultOssieDir, pluginsSubdir), nil +} + +// EnsurePluginDir ensures the plugin directory exists, creating it if needed. +// It is safe to call multiple times — os.MkdirAll is idempotent. +func EnsurePluginDir() error { + dir, err := PluginDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("could not create plugin directory %s: %w", dir, err) + } + return nil +} diff --git a/cli/internal/ossiedir/ossiedir_test.go b/cli/internal/ossiedir/ossiedir_test.go new file mode 100644 index 0000000..76a8981 --- /dev/null +++ b/cli/internal/ossiedir/ossiedir_test.go @@ -0,0 +1,65 @@ +package ossiedir + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPluginDir_envOverride(t *testing.T) { + want := "/custom/plugin/dir" + t.Setenv(envVar, want) + + got, err := PluginDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginDir_default(t *testing.T) { + t.Setenv(envVar, "") + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("could not determine home dir: %v", err) + } + want := filepath.Join(home, defaultOssieDir, pluginsSubdir) + + got, err := PluginDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEnsurePluginDir_createsDirectory(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "plugins") + t.Setenv(envVar, target) + + if err := EnsurePluginDir(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(target); os.IsNotExist(err) { + t.Errorf("expected directory %q to exist, but it does not", target) + } +} + +func TestEnsurePluginDir_idempotent(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "plugins") + t.Setenv(envVar, target) + + if err := EnsurePluginDir(); err != nil { + t.Fatalf("first call failed: %v", err) + } + if err := EnsurePluginDir(); err != nil { + t.Fatalf("second call failed: %v", err) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..305d92d --- /dev/null +++ b/cli/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/apache/ossie/cli/cmd" +) + +// version, commit, and date are set at build time by GoReleaser via ldflags. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + cmd.SetVersion(version) + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/compliance/README.md b/compliance/README.md new file mode 100644 index 0000000..7a59b04 --- /dev/null +++ b/compliance/README.md @@ -0,0 +1 @@ +This is for compliance tests diff --git a/converters/index.md b/converters/README.md similarity index 73% rename from converters/index.md rename to converters/README.md index 6477291..48bc065 100644 --- a/converters/index.md +++ b/converters/README.md @@ -1,14 +1,33 @@ -# OSI Converters + + +# Apache Ossie Converters ## Overview -An OSI Converter translates between the OSI semantic model format and a specific vendor's semantic implementation. This enables teams to author a semantic model once in the OSI standard and then generate the corresponding vendor-specific representation automatically. +An Ossie Converter translates between the Ossie semantic model format and a specific vendor's semantic implementation. This enables teams to author a semantic model once in the Ossie standard and then generate the corresponding vendor-specific representation automatically. ## Hub-and-Spoke Model -OSI converters follow a **hub-and-spoke** architecture: +Ossie converters follow a **hub-and-spoke** architecture: -- **Hub**: The OSI core specification acts as the central, vendor-neutral format. +- **Hub**: The Ossie core specification acts as the central, vendor-neutral format. - **Spokes**: Each converter handles translation to or from a specific vendor format. ``` @@ -17,7 +36,7 @@ OSI converters follow a **hub-and-spoke** architecture: └──────┬──────┘ │ ┌─────────────┐ ┌─────┴─────┐ ┌─────────────┐ -│ dbt ├────┤ OSI ├────┤ Salesforce │ +│ dbt ├────┤ Ossie ├────┤ Salesforce │ └─────────────┘ └─────┬─────┘ └─────────────┘ │ ┌──────┴──────┐ @@ -25,28 +44,28 @@ OSI converters follow a **hub-and-spoke** architecture: └─────────────┘ ``` -This approach avoids the need for point-to-point converters between every pair of vendors. With N vendors, a point-to-point strategy would require N*(N-1) converters. With OSI as the hub, only 2*N converters are needed (one import and one export per vendor), and interoperability with all other vendors comes for free. +This approach avoids the need for point-to-point converters between every pair of vendors. With N vendors, a point-to-point strategy would require N*(N-1) converters. With Ossie as the hub, only 2*N converters are needed (one import and one export per vendor), and interoperability with all other vendors comes for free. ## Converter Responsibilities A converter handles two directions of translation: -### Export (OSI → Vendor) +### Export (Apache Ossie → Vendor) -Read an OSI semantic model and produce the equivalent vendor-specific representation. For example: +Read an Ossie semantic model and produce the equivalent vendor-specific representation. For example: -- OSI → Snowflake semantic model definition -- OSI → dbt `semantic_models` YAML -- OSI → Tableau data source / Salesforce semantic layer -- OSI → Databricks semantic layer definition +- Ossie → Snowflake semantic model definition +- Ossie → dbt `semantic_models` YAML +- Ossie → Tableau data source / Salesforce semantic layer +- Ossie → Databricks semantic layer definition -### Import (Vendor → OSI) +### Import (Vendor → Apache Ossie) -Read a vendor-specific semantic model and produce a valid OSI model, including mapping vendor-specific metadata into `custom_extensions`. +Read a vendor-specific semantic model and produce a valid Ossie model, including mapping vendor-specific metadata into `custom_extensions`. ## Supported Vendors -The OSI specification currently defines extensions for the following vendors: +The Ossie specification currently defines extensions for the following vendors: | Vendor | Description | |--------|-------------| @@ -54,18 +73,20 @@ The OSI specification currently defines extensions for the following vendors: | `SALESFORCE` | Salesforce / Tableau semantic layer | | `DBT` | dbt semantic models | | `DATABRICKS` | Databricks semantic layer | +| `OMNI` | Omni semantic model | +| `WISDOM` | WisdomAI domain | -Each vendor may define custom extensions (via the `custom_extensions` field in the OSI spec) to carry vendor-specific metadata that does not have an equivalent in the core specification. +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. ## Mapping Core Constructs -The following table shows how each OSI construct maps conceptually to vendor equivalents. A converter must handle each of these: +The following table shows how each Ossie construct maps conceptually to vendor equivalents. A converter must handle each of these: ### Semantic Model The top-level container. Maps to the root object in each vendor's format. -| OSI Field | Description | Converter Consideration | +| Ossie Field | Description | Converter Consideration | |-----------|-------------|------------------------| | `name` | Model identifier | Map to vendor's model/project name | | `description` | Human-readable description | Most vendors support a description field | @@ -79,7 +100,7 @@ The top-level container. Maps to the root object in each vendor's format. Datasets represent logical tables (fact or dimension tables). They contain fields and define the structure of the data. -| OSI Field | Description | Converter Consideration | +| Ossie Field | Description | Converter Consideration | |-----------|-------------|------------------------| | `name` | Dataset identifier | Map to table/entity/model name | | `source` | Physical table reference (e.g., `database.schema.table`) | Parse into vendor-specific catalog/schema/table components | @@ -93,7 +114,7 @@ Datasets represent logical tables (fact or dimension tables). They contain field Fields represent row-level attributes. They can be simple column references or computed expressions. -| OSI Field | Description | Converter Consideration | +| Ossie Field | Description | Converter Consideration | |-----------|-------------|------------------------| | `name` | Field identifier | Map to column/attribute name | | `expression.dialects` | Multi-dialect SQL expressions | Select the dialect matching the target vendor; fall back to `ANSI_SQL` | @@ -119,7 +140,7 @@ A Snowflake converter should pick the `SNOWFLAKE` dialect when available, and fa Relationships define foreign key connections between datasets. They support both simple and composite keys. -| OSI Field | Description | Converter Consideration | +| Ossie Field | Description | Converter Consideration | |-----------|-------------|------------------------| | `name` | Relationship identifier | Map to vendor's join/relationship name | | `from` | Many-side dataset | Map to the referencing table | @@ -140,7 +161,7 @@ means `from.product_id = to.id AND from.variant_id = to.variant_id`. The convert Metrics are aggregate measures defined at the semantic model level. They can span multiple datasets via relationships. -| OSI Field | Description | Converter Consideration | +| Ossie Field | Description | Converter Consideration | |-----------|-------------|------------------------| | `name` | Metric identifier | Map to vendor's measure/KPI name | | `expression.dialects` | Multi-dialect aggregate expressions | Select the appropriate dialect; fall back to `ANSI_SQL` | @@ -163,8 +184,8 @@ The converter must ensure that the dataset references (`store_sales`, `customer` Custom extensions carry vendor-specific metadata as a JSON string. A converter should: -1. **On export (OSI → Vendor)**: Extract extensions where `vendor_name` matches the target vendor, parse the `data` JSON, and apply the vendor-specific settings. -2. **On import (Vendor → OSI)**: Capture vendor-specific settings that have no OSI core equivalent and store them as a `custom_extension` entry. +1. **On export (Ossie → Vendor)**: Extract extensions where `vendor_name` matches the target vendor, parse the `data` JSON, and apply the vendor-specific settings. +2. **On import (Vendor → Ossie)**: Capture vendor-specific settings that have no Ossie core equivalent and store them as a `custom_extension` entry. **Example**: A Snowflake export converter would extract: @@ -200,9 +221,9 @@ A converter should map `ai_context` when the target vendor supports equivalent c ### Step-by-Step Guide -1. **Validate input**: Use the [OSI JSON Schema](../core-spec/osi-schema.json) and the [validation script](../validation/validate.py) to ensure the source OSI model is valid before conversion. +1. **Validate input**: Use the [Ossie JSON Schema](../core-spec/osi-schema.json) and the [validation script](../validation/validate.py) to ensure the source Ossie model is valid before conversion. -2. **Parse the OSI model**: Load the YAML file and iterate over the top-level `semantic_model` entries. +2. **Parse the Ossie model**: Load the YAML file and iterate over the top-level `semantic_model` entries. 3. **Map datasets**: For each dataset, translate the `name`, `source`, `primary_key`, `unique_keys`, and `fields` to the vendor's format. Parse the `source` string (typically `database.schema.table`) into the vendor's catalog structure. @@ -226,7 +247,7 @@ A converter should map `ai_context` when the target vendor supports equivalent c | Scenario | Recommended Approach | |----------|---------------------| | Missing vendor-specific dialect for a field/metric | Fall back to `ANSI_SQL`; log a warning | -| Computed fields with vendor-specific SQL syntax | Require the vendor dialect in the source OSI model; error if neither vendor nor ANSI dialect is available | +| Computed fields with vendor-specific SQL syntax | Require the vendor dialect in the source Ossie model; error if neither vendor nor ANSI dialect is available | | Composite primary keys | Ensure the vendor format supports composite keys; if not, flatten or document the limitation | | Cross-dataset metrics referencing multiple tables | Ensure all referenced datasets exist and relationships are defined; resolve qualified column names | | Custom extensions with unknown vendor | Ignore (do not discard) — preserve for round-tripping | @@ -237,14 +258,14 @@ A converter should map `ai_context` when the target vendor supports equivalent c A well-implemented converter pair (import + export) should preserve as much information as possible during round-tripping: ``` -Vendor A model → [Import] → OSI model → [Export] → Vendor A model +Vendor A model → [Import] → Ossie model → [Export] → Vendor A model ``` To achieve this: -- **Never discard information silently**. If a vendor-specific attribute has no OSI core equivalent, store it in `custom_extensions`. +- **Never discard information silently**. If a vendor-specific attribute has no Ossie core equivalent, store it in `custom_extensions`. - **Preserve field ordering** where possible, as some vendors are sensitive to declaration order. -- **Preserve `custom_extensions`** for all vendors, not just the target vendor. This allows a model to carry metadata for multiple vendors simultaneously (e.g., a single OSI model with both Snowflake and dbt extensions). +- **Preserve `custom_extensions`** for all vendors, not just the target vendor. This allows a model to carry metadata for multiple vendors simultaneously (e.g., a single Ossie model with both Snowflake and dbt extensions). ## Example: Conceptual Conversion Flow @@ -256,15 +277,15 @@ Given the [TPC-DS example](../examples/tpcds_semantic_model.yaml) included in th 4. For each field, select the `ANSI_SQL` dialect expression (since no `SNOWFLAKE` dialect is provided in this example). 5. Translate relationships into Snowflake join definitions, mapping `from_columns` and `to_columns` pairs. 6. Translate metrics like `total_sales` (`SUM(store_sales.ss_ext_sales_price)`) into Snowflake metric definitions. -7. Extract the `SALESFORCE` and `DBT` custom extensions — these would not be applied to the Snowflake output but should be preserved if the model is later imported back to OSI. +7. Extract the `SALESFORCE` and `DBT` custom extensions — these would not be applied to the Snowflake output but should be preserved if the model is later imported back to Ossie. ## Contributing a New Converter To add support for a new vendor: -1. Add the vendor to the `vendors` enum in the [core specification](../core-spec/spec.md) if not already present. +1. Use a stable `vendor_name` string in each custom extension emitted by the converter. 2. Define the custom extension schema for the vendor (what vendor-specific metadata fields are supported in the `data` JSON). -3. Implement the export converter (OSI → Vendor). -4. Implement the import converter (Vendor → OSI). +3. Implement the export converter (Ossie → Vendor). +4. Implement the import converter (Vendor → Ossie). 5. Add tests using the [TPC-DS example model](../examples/tpcds_semantic_model.yaml) as a baseline. 6. Document any limitations or unsupported constructs. diff --git a/converters/databricks/README.md b/converters/databricks/README.md new file mode 100644 index 0000000..e2fe6cc --- /dev/null +++ b/converters/databricks/README.md @@ -0,0 +1,124 @@ + + +# Apache Ossie Databricks Converter + +Bidirectional, offline conversion between an [Apache Ossie](https://github.com/apache/ossie) +semantic model and a Databricks +[Unity Catalog Metric View](https://docs.databricks.com/aws/en/metric-views/) (YAML +`1.1`). No Databricks connection required. + +- **Export** (`ossie-databricks export`): Apache Ossie -> Metric View (one fact + `source` with a nested `joins` tree and a flat `dimensions` list). +- **Import** (`ossie-databricks import`): Metric View -> Apache Ossie. Metric View features Apache Ossie has + no native field for are preserved in `custom_extensions[DATABRICKS]`, so + `MV -> Apache Ossie -> MV` is lossless. + +On **export** (Apache Ossie -> Metric View), Apache Ossie features with no Metric View slot -- relationship +`ai_context`, `dimension.is_time`, non-`DATABRICKS`/`ANSI_SQL` dialects, foreign-vendor +`custom_extensions` -- are **dropped with a warning**. On **import** (Metric View -> Apache Ossie), +Metric View only features (filter, window, format, rely, ...) are instead **preserved** in +`custom_extensions[DATABRICKS]`, so `MV -> Apache Ossie -> MV` is lossless. Any input that breaks a +[requirement](#requirements) **raises a `ConversionError`** -- the converter never +silently drops a field or produces an invalid result. + +## Installation + +```bash +pip install apache-ossie-databricks # once published to PyPI +# or, from a checkout of this directory: +pip install -e . +``` + +The only runtime dependency is `PyYAML`. Python 3.11+. + +## Usage + +### Command line + +```bash +ossie-databricks export -i model.yaml -o view.yaml [--source orders] # Apache Ossie -> Metric View +ossie-databricks import -i view.yaml -o model.yaml [--name my_model] # Metric View -> Apache Ossie +``` + +With no `-o`, output goes to stdout. `--source` (export) picks the fact/grain (default: +the FK-sink dataset; naming a coarser-grain dataset produces `one_to_many` joins); +`--name` (import) sets the Apache Ossie model name (default: the source's last identifier). + +### Python API + +```python +from ossie_databricks import convert_ossie_to_metric_view, convert_metric_view_to_ossie + +metric_view_yaml = convert_ossie_to_metric_view(ossie_yaml_str) # optionally choose the fact/grain, e.g. (ossie_yaml_str, source="orders") +ossie_yaml = convert_metric_view_to_ossie(metric_view_yaml_str, model_name="sales") +``` + +## Mapping + +Each row maps in both directions; the **Notes** flag where a behavior is specific to +**export** (Apache Ossie -> Metric View) or **import** (Metric View -> Apache Ossie). + +| Apache Ossie | Metric View (v1.1) | Notes | +|---|---|---| +| `semantic_model.description` | `comment` | Model-level description only. | +| root dataset | `source` | The fact/grain. | +| other `datasets` | nested `joins[]` | Export: the relationship graph is reassembled into the join tree; a dataset reached by two paths (a diamond) fans out into one aliased join per path. | +| `relationship` `from_columns`/`to_columns` | join `on` (differing names) / `using` (shared names) | Decomposed into columns on import; rebuilt into `on`/`using` on export. | +| `relationship.from`/`to` direction | join `cardinality` | Export: source on the many (`from`) side -> `many_to_one`; on the one (`to`) side -> `one_to_many`. | +| `dataset.primary_key` / `unique_keys` | join `rely.at_most_one_match` | Both directions: export sets `at_most_one_match` when a key covers the join columns; import recovers a `unique_keys` from it. | +| `dataset.fields[]` | `dimensions[]` | Export: fields flatten into one list and a joined column is qualified by its full join path (`customer.c_name`; `customer.region.r_name` when nested). | +| `field.expression.dialects[]` | `expr` | Export: prefer the `DATABRICKS` dialect, else `ANSI_SQL`. | +| `metrics[]` | `measures[]` | Export: fact columns are referenced bare (`SUM(amount)`). | +| `field.label` | `display_name` | | +| `field` / `metric` `description` | `comment` | | +| `ai_context.synonyms` | `synonyms` | | +| `custom_extensions[DATABRICKS]` | `filter`, `window`, `format`, `rely`, `materialization` | Import stashes Metric View only features here; export restores them -- keeping `MV -> Apache Ossie -> MV` lossless. | + +## Requirements + +Conversion raises a `ConversionError` (rather than guessing or emitting something +invalid) when an input breaks one of these: + +- the Metric View `version` is not `1.1`; +- a `source` is not a 3-part `catalog.schema.table` name or a `SELECT`/`WITH` subquery; +- the relationship graph is not acyclic and resolvable to a single fact -- a cycle, or + multiple candidate facts without `--source`, is rejected (a diamond is allowed and + fanned out); +- a join has no condition (a cross join has no Apache Ossie relationship form); +- a join condition is non-equi or otherwise can't be decomposed into equi-join columns + (Apache Ossie relationships are equi-joins, so the join has no Apache Ossie representation); +- the input YAML is malformed. + +## Development + +```bash +pip install -e ".[dev]" +python3 -m pytest tests/ +``` + +Example-based unit tests plus Hypothesis property-based round-trip tests +(`test_roundtrip_properties.py`, which skip if `hypothesis` is not installed). + +## Future effort + +Both the Apache Ossie specification and the Databricks Unity Catalog Metric View YAML are still +evolving. As either side adds or changes fields, this converter will be updated to track +them -- extending the mapping and coverage in both directions to keep the conversion +current and to support as much as each format allows over time. diff --git a/converters/databricks/pyproject.toml b/converters/databricks/pyproject.toml new file mode 100644 index 0000000..d4ed351 --- /dev/null +++ b/converters/databricks/pyproject.toml @@ -0,0 +1,52 @@ +# 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" + +[project] +name = "apache-ossie-databricks" +version = "0.2.0.dev0" +description = "Databricks Unity Catalog Metric View <> Apache Ossie converter" +requires-python = ">=3.11" +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", +] +dependencies = [ + "PyYAML>=6.0", +] + +[project.license] +text = "Apache-2.0" + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "hypothesis>=6.0", +] + +[project.scripts] +ossie-databricks = "ossie_databricks.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_databricks"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/converters/databricks/src/ossie_databricks/__init__.py b/converters/databricks/src/ossie_databricks/__init__.py new file mode 100644 index 0000000..379a2ae --- /dev/null +++ b/converters/databricks/src/ossie_databricks/__init__.py @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Bidirectional converter between Apache Ossie semantic models and Databricks Unity Catalog +Metric Views (YAML v1.1). Pure offline string-in / string-out transforms. + + from ossie_databricks import convert_ossie_to_metric_view, convert_metric_view_to_ossie +""" + +from ._common import ConversionError +from .metric_view_to_ossie import convert_metric_view_to_ossie +from .ossie_to_metric_view import convert_ossie_to_metric_view + +__all__ = [ + "ConversionError", + "convert_metric_view_to_ossie", + "convert_ossie_to_metric_view", +] diff --git a/converters/databricks/src/ossie_databricks/_common.py b/converters/databricks/src/ossie_databricks/_common.py new file mode 100644 index 0000000..4f17a5b --- /dev/null +++ b/converters/databricks/src/ossie_databricks/_common.py @@ -0,0 +1,288 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared helpers for the Apache Ossie <-> Databricks Metric View converters. + +Both directions are pure offline YAML transforms. The only cross-cutting concerns +live here: version constants, the dialect preference order, the `custom_extensions` +stash protocol, and small SQL-string helpers. +""" + +import json +import re + +import yaml + +# Apache Ossie semantic model spec version this converter targets (see core-spec). +# +# NOTE: this is an exact-match check (see convert_ossie_to_metric_view). Unlike the +# dbt converter, this spoke intentionally has no `apache-ossie` package dependency, so +# nothing updates this automatically -- it MUST be bumped in lockstep with the +# `version` in `core-spec/` whenever the spec version moves, or the converter will +# reject otherwise-valid Apache Ossie files. +OSSIE_VERSION = "0.2.0.dev0" + +# Databricks Unity Catalog Metric View YAML version. Only 1.1 supports joins, +# per-column comments, synonyms, and the format/window/parameters surface. +MV_VERSION = "1.1" + +# Vendor id used for the `custom_extensions` stash and for dialect selection. +VENDOR = "DATABRICKS" + +# Expression dialects this converter understands, in preference order. +DIALECT_DATABRICKS = "DATABRICKS" +DIALECT_ANSI = "ANSI_SQL" + +# Metric Views cap the number of synonyms per column. +SYNONYM_LIMIT = 10 + +# Bump when the shape of a stashed `data` blob changes. +STASH_VERSION = 1 + +# Metric View join cardinalities (the only two values v1.1 defines). Apache Ossie has no +# cardinality field; the value is implied by relationship direction -- `from` is the +# many side, `to` is the one side -- so the converter derives it from / writes it +# into the from/to orientation rather than relying on a dedicated field. +CARD_MANY_TO_ONE = "many_to_one" +CARD_ONE_TO_MANY = "one_to_many" + +# Model-level stash key recording which dataset was the Metric View `source` (its +# grain). Needed only when a one_to_many join puts the source on a relationship's +# `to` side, where the natural FK-sink heuristic would otherwise pick the wrong +# fact on re-export. Absent for plain many-to-one stars, so they stay clean. +STASH_SOURCE_KEY = "source_dataset" + +# A bare SQL identifier (single column reference), e.g. `c_name`. Used to decide +# whether an expression can be safely alias-prefixed on export / de-prefixed on +# import. +_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class ConversionError(Exception): + """Raised when an input cannot be converted.""" + + +def require(obj, key, what): + """Return `obj[key]`, or raise a clean ConversionError if it's missing/empty -- so + malformed input surfaces as an error message rather than a raw KeyError traceback. + + Presence is tested by key (not truthiness), so a legitimately falsy value such as + `0` or `False` is returned; a missing key, a null, or an empty/whitespace string is + rejected. + """ + if not isinstance(obj, dict) or key not in obj or obj[key] is None: + raise ConversionError(f"{what} is missing required '{key}'") + value = obj[key] + if isinstance(value, str) and not value.strip(): + raise ConversionError(f"{what} has an empty '{key}'") + return value + + +def require_str(obj, key, what): + """Like require(), but also enforce the value is a string -- so a non-string scalar + (e.g. a YAML number for a name or expression) raises a clean ConversionError instead + of crashing later in a string operation.""" + value = require(obj, key, what) + if not isinstance(value, str): + raise ConversionError( + f"{what}: '{key}' must be a string, got {type(value).__name__}") + return value + + +# YAML 1.1 (PyYAML's default) treats bare on/off/yes/no/y/n as booleans, so a metric +# view join's `on:` key would parse as the boolean True and silently lose the join +# condition. Databricks (Jackson) uses YAML 1.2 booleans (only true/false). The Loader +# below uses 1.2 semantics, so it reads DBR's bare `on:` (and any "on"/"off" value) as a +# string. The Dumper additionally force-quotes those tokens on output (see below), so the +# YAML it emits round-trips the same way through a YAML 1.1 reader too (e.g. stock +# yaml.safe_load) rather than turning an "on"/"off" synonym/label into a boolean. +class _Yaml12Loader(yaml.SafeLoader): + """SafeLoader with YAML 1.2 boolean semantics.""" + + +class _Yaml12Dumper(yaml.SafeDumper): + """SafeDumper with YAML 1.2 boolean semantics.""" + + +_YAML12_BOOL = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$") +for _cls in (_Yaml12Loader, _Yaml12Dumper): + # Drop the YAML 1.1 bool resolver (yes/no/on/off/y/n) and re-add a 1.2 one. + _cls.yaml_implicit_resolvers = { + ch: [(tag, rx) for (tag, rx) in resolvers if tag != "tag:yaml.org,2002:bool"] + for ch, resolvers in _cls.yaml_implicit_resolvers.items() + } + _cls.add_implicit_resolver("tag:yaml.org,2002:bool", _YAML12_BOOL, list("tTfF")) + + +# Force-quote string scalars that a YAML 1.1 reader would otherwise interpret as booleans +# (yes/no/on/off/y/n/true/false, any case). Number- and null-like strings are already +# quoted by PyYAML's surviving resolvers; only these bool tokens need it. Without this, a +# synonym/label/comment like "on" emits bare and a 1.1 consumer reads it back as `True`. +_YAML11_BOOL_STRS = frozenset( + variant + for word in ("y", "n", "yes", "no", "on", "off", "true", "false") + for variant in (word, word.capitalize(), word.upper()) +) + + +def _represent_str(dumper, data): + style = "'" if data in _YAML11_BOOL_STRS else None + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style) + + +_Yaml12Dumper.add_representer(str, _represent_str) + + +def load_yaml(text): + """Parse YAML with 1.2 boolean semantics, so a join `on:` key stays the string + `on` rather than becoming the boolean True. A syntax error is surfaced as a + ConversionError so callers (and the CLI) get a clean message, not a raw traceback.""" + try: + return yaml.load(text, Loader=_Yaml12Loader) + except yaml.YAMLError as e: + raise ConversionError(f"Invalid YAML: {e}") from e + + +def dump_yaml(obj): + """Serialize to YAML with 1.2 boolean semantics. The bool-like token `on` -- whether + a join condition key or an "on"/"off"/"yes"/... string value -- is force-quoted as + `'on'` by the str representer (see `_represent_str`), so a YAML 1.1 reader of this + output reads it as the string, not the boolean True. Databricks' Jackson (1.2) parser + reads the quoted key/value correctly too.""" + return yaml.dump(obj, Dumper=_Yaml12Dumper, sort_keys=False, default_flow_style=False) + + +def is_simple_identifier(expr): + """True if `expr` is a single bare column reference (no operators/functions). + + A non-string input is simply not an identifier (returns False) rather than raising.""" + return isinstance(expr, str) and bool(_IDENTIFIER_RE.match(expr.strip())) + + +def read_stash(obj): + """Return the DATABRICKS stash dict on an Apache Ossie object, or {} if absent. + + The `_v` version marker is stripped from the returned dict. + """ + for ext in (obj or {}).get("custom_extensions") or []: + if ext.get("vendor_name") == VENDOR: + try: + data = json.loads(ext.get("data") or "{}") + except json.JSONDecodeError as e: + raise ConversionError( + f"DATABRICKS custom_extensions data is not valid JSON: {e}") from e + data.pop("_v", None) + return data + return {} + + +def write_stash(obj, data): + """Attach a DATABRICKS `custom_extensions` entry holding `data` (a dict). + + No-op when `data` is empty, so hand-authored Apache Ossie stays clean. Merges into an + existing DATABRICKS entry if one is already present. + """ + if not data: + return + payload = {"_v": STASH_VERSION} + payload.update(data) + blob = json.dumps(payload) + exts = obj.setdefault("custom_extensions", []) + for ext in exts: + if ext.get("vendor_name") == VENDOR: + ext["data"] = blob + return + exts.append({"vendor_name": VENDOR, "data": blob}) + + +def foreign_vendor_extensions(obj): + """Return non-DATABRICKS custom_extensions (dropped on export, with a warning).""" + return [ + ext + for ext in (obj or {}).get("custom_extensions") or [] + if ext.get("vendor_name") != VENDOR + ] + + +def pick_expression(ossie_expression): + """Choose the SQL string for an Apache Ossie expression: DATABRICKS, else ANSI_SQL. + + Returns None if neither dialect is present (the caller warns and skips). Does + not warn about other dialects here -- only the absence of a usable one matters. + """ + dialects = { + d.get("dialect"): d.get("expression") + for d in (ossie_expression or {}).get("dialects") or [] + } + expr = dialects.get(DIALECT_DATABRICKS) or dialects.get(DIALECT_ANSI) + if expr is not None and not isinstance(expr, str): + raise ConversionError( + f"expression must be a string, got {type(expr).__name__}") + return expr + + +def synonyms_of(ai_context): + """Extract the synonyms list from an Apache Ossie ai_context (object form only).""" + if isinstance(ai_context, dict): + return list(ai_context.get("synonyms") or []) + return [] + + +def merge_description(description, ai_context): + """Fold a string-form ai_context into a description. + + The Apache Ossie schema allows ai_context to be either a string or an object. A string + has no Metric View home of its own, so it is appended to the description + (which maps to `comment`). Object-form ai_context is handled separately + (synonyms map natively; instructions/examples are dropped). + """ + if isinstance(ai_context, str) and ai_context.strip(): + return f"{description}\n{ai_context}" if description else ai_context + return description + + +def validate_source(source, dataset_name): + """Validate and normalize a dataset source for a Metric View. + + Accepts a 3-part `catalog.schema.table` identifier or a `SELECT`/`WITH` + subquery. Raises ConversionError otherwise. + """ + if not source or not str(source).strip(): + raise ConversionError(f"Dataset '{dataset_name}': missing/empty 'source'") + s = str(source).strip() + # A SELECT/WITH subquery source. `\b` after the keyword matches `WITH(...)` (no + # space) too, but not an identifier like `WITHHELD`. + if re.match(r"(?i)(select|with)\b", s): + return s + # Exactly 3 parts, each a non-empty token with no whitespace -- so `.sch.tbl`, + # `cat..tbl`, `cat.sch.`, and `cat . sch . tbl` are all rejected (an empty or + # space-laden part is not a valid catalog/schema/table identifier). + parts = s.split(".") + if len(parts) == 3 and all(p and not any(ch.isspace() for ch in p) for p in parts): + return s + raise ConversionError( + f"Dataset '{dataset_name}': source '{source}' must be a 3-part " + f"catalog.schema.table identifier or a SELECT/WITH subquery" + ) + + +def last_identifier(source): + """Last dotted part of a table reference, e.g. `samples.tpch.lineitem` -> `lineitem`. + + Coerces to str so a malformed (non-string) source doesn't crash here -- it gets a + clean error from validate_source instead.""" + return str(source).strip().split(".")[-1].strip("`") if source else source diff --git a/converters/databricks/src/ossie_databricks/cli.py b/converters/databricks/src/ossie_databricks/cli.py new file mode 100644 index 0000000..a3751ba --- /dev/null +++ b/converters/databricks/src/ossie_databricks/cli.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. + +"""Command-line interface for the Apache Ossie <-> Databricks Metric View converter. + + ossie-databricks export -i model.yaml [-o view.yaml] [--source orders] + ossie-databricks import -i view.yaml [-o model.yaml] [--name my_model] + +`export` converts an Apache Ossie semantic model to a Databricks Metric View; `import` does the +reverse. With no `-o`, the result is written to stdout. Conversions that drop +information emit warnings to stderr. +""" + +import argparse +import sys + +from ._common import ConversionError +from .metric_view_to_ossie import convert_metric_view_to_ossie +from .ossie_to_metric_view import convert_ossie_to_metric_view + + +def _build_parser(): + parser = argparse.ArgumentParser(prog="ossie-databricks", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command") + sub.required = True # set as attribute (the add_subparsers kwarg is 3.7+) + + exp = sub.add_parser("export", help="Apache Ossie semantic model -> Databricks Metric View YAML") + exp.add_argument("-i", "--input", required=True, help="Apache Ossie YAML file") + exp.add_argument("-o", "--output", help="output Metric View YAML (default: stdout)") + exp.add_argument("-s", "--source", + help="dataset to use as the fact/grain (default: the FK-sink dataset); " + "naming a coarser-grain dataset unlocks one_to_many joins") + + imp = sub.add_parser("import", help="Databricks Metric View YAML -> Apache Ossie semantic model") + imp.add_argument("-i", "--input", required=True, help="Metric View YAML file") + imp.add_argument("-o", "--output", help="output Apache Ossie YAML (default: stdout)") + imp.add_argument("--name", help="Apache Ossie model name (default: derived from the source)") + return parser + + +def main(argv=None): + args = _build_parser().parse_args(argv) + try: + with open(args.input) as fh: + text = fh.read() + if args.command == "export": + out = convert_ossie_to_metric_view(text, source=args.source) + else: + out = convert_metric_view_to_ossie(text, model_name=args.name) + except (ConversionError, OSError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + if args.output: + with open(args.output, "w") as fh: + fh.write(out) + else: + sys.stdout.write(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/converters/databricks/src/ossie_databricks/metric_view_to_ossie.py b/converters/databricks/src/ossie_databricks/metric_view_to_ossie.py new file mode 100644 index 0000000..75cdb75 --- /dev/null +++ b/converters/databricks/src/ossie_databricks/metric_view_to_ossie.py @@ -0,0 +1,366 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert a Databricks Unity Catalog Metric View (v1.1) to an Apache Ossie semantic model. + +Pure offline conversion. Accepts a Metric View (one `source` with a nested `joins` +tree). Metric View features Apache Ossie has no native field for -- filter, window, format, +rely, cardinality, parameters, materialization -- are preserved in +`custom_extensions[DATABRICKS]` so that converting back reproduces the original view. +A join condition an Apache Ossie relationship cannot represent (a non-equi or cross join) is +rejected, not stashed. See README.md. + +Usage (CLI): + ossie-databricks import -i view.yaml [-o model.yaml] [--name NAME] +""" + +import re +import warnings + +from ._common import ( + CARD_MANY_TO_ONE, + CARD_ONE_TO_MANY, + ConversionError, + DIALECT_DATABRICKS, + MV_VERSION, + OSSIE_VERSION, + STASH_SOURCE_KEY, + dump_yaml, + is_simple_identifier, + last_identifier, + load_yaml, + require, + require_str, + validate_source, + write_stash, +) + +# Metric View fields with no native Apache Ossie home -> stashed verbatim. +_MODEL_STASH_KEYS = ("filter", "parameters", "materialization") +_JOIN_STASH_KEYS = ("rely", "cardinality") +_COLUMN_STASH_KEYS = ("format", "window") + + +def _warn(scope, msg): + warnings.warn(f"[{scope}] {msg}") + + +# Operators that mean a join condition is NOT a simple equi-join (so it cannot be +# expressed as from_columns/to_columns, and the join is rejected on import). +_NON_EQUI_RE = re.compile(r"[<>!]=|<>|[<>]") + + +def _is_wildcard(col): + """A wildcard column (`expr: source.*`) is projected without a `name`; Apache Ossie has + no representation for it. Detected by the absence of a `name` key (a named column, + even one whose name is falsy like `0` or whose expression contains `*` as + multiplication, is not a wildcard).""" + return "name" not in col + + +def convert_metric_view_to_ossie(mv_yaml_str, model_name=None): + """Parse Metric View v1.1 YAML and return Apache Ossie semantic model YAML (string).""" + # load_yaml uses YAML 1.2 booleans, so a join `on:` key stays the string "on" + # (PyYAML's default 1.1 would parse it as the boolean True and drop the condition). + view = load_yaml(mv_yaml_str) + if not isinstance(view, dict): + raise ConversionError("Invalid Metric View YAML: expected a mapping at the root") + + version = str(view.get("version", "")) + if version != MV_VERSION: + raise ConversionError( + f"Unsupported Metric View version '{version}'. This converter targets " + f"v{MV_VERSION} only." + ) + + model = _convert_view(view, model_name) + return dump_yaml({"version": OSSIE_VERSION, "semantic_model": [model]}) + + +def _convert_view(view, model_name): + source = view.get("source") + if not source: + raise ConversionError("Metric View is missing required 'source'") + + # Derive the model/fact name from a table source's last identifier. A SELECT/WITH + # subquery source has no meaningful table name, so use a stable default instead of + # slicing a token out of the SQL text (override with --name). + is_sql = str(source).strip().split(None, 1)[0].upper() in ("SELECT", "WITH") + last_id = last_identifier(source) + fact_name = model_name or ( + last_id if (not is_sql and last_id and is_simple_identifier(last_id)) + else "metric_view" + ) + # Validate the source shape up front (3-part table or SELECT/WITH), mirroring the + # exporter -- so a malformed source fails here with a clean error instead of passing + # silently through to Apache Ossie and only erroring on a later re-export. + validate_source(source, fact_name) + + datasets = [{"name": fact_name, "source": source}] + relationships = [] + alias_to_dataset = {"source": fact_name, fact_name: fact_name} + # Names are compared case-insensitively (DBR identifiers are case-insensitive), so a + # `Fact`/`fact` or `dim`/`Dim` collision is caught here instead of producing two + # datasets DBR would reject on re-export. + seen_names = {fact_name.strip().lower()} + + # Walk the join tree, emitting one dataset + one relationship per join. + def walk(parent_name, parent_alias, joins): + for join in joins or []: + child = require_str(join, "name", "join") + # `source` is the reserved fact qualifier; reject any casing. + if child.strip().lower() == "source": + raise ConversionError( + "Join name 'source' is reserved for the fact source; rename the join." + ) + if child.strip().lower() in seen_names: + raise ConversionError( + f"Duplicate dataset/join name '{child}'; Metric View join names " + f"and the source must be distinct (case-insensitively)." + ) + seen_names.add(child.strip().lower()) + child_ds = {"name": child, "source": require_str(join, "source", f"join '{child}'")} + datasets.append(child_ds) + alias_to_dataset[child] = child + rel = _convert_join(join, parent_name, parent_alias, child) + relationships.append(rel) + # rely.at_most_one_match asserts the join key is unique on the joined + # (one) side, so record those columns as a unique key on the child dataset + # -- recovering key info Apache Ossie would otherwise lack. Only a many_to_one join + # has the child on the `to` side (one_to_many flips it), so this naturally + # skips one_to_many joins. + if (rel["to"] == child and rel.get("to_columns") + and (join.get("rely") or {}).get("at_most_one_match")): + child_ds["unique_keys"] = [list(rel["to_columns"])] + walk(child, child, join.get("joins")) + + walk(fact_name, "source", view.get("joins")) + + # Dimensions -> fields, grouped onto the dataset their alias points at. + # `fields` is a v1.1 alias for `dimensions` (and the form the DBR docs use), + # so accept either key. + if view.get("dimensions") and view.get("fields"): + _warn("view", "both 'dimensions' and 'fields' are set; 'fields' is a v1.1 alias " + "for 'dimensions', so the 'fields' list is ignored") + fields_by_dataset = {d["name"]: [] for d in datasets} + for dim in (view.get("dimensions") or view.get("fields") or []): + if _is_wildcard(dim): + _warn("dimension", f"wildcard column '{dim.get('expr')}' has no Apache Ossie field " + f"representation; skipped") + continue + ds_name, field = _convert_dimension(dim, alias_to_dataset, fact_name) + fields_by_dataset[ds_name].append(field) + for d in datasets: + flds = fields_by_dataset[d["name"]] + if flds: + d["fields"] = flds + + metrics = [] + for m in view.get("measures", []) or []: + if _is_wildcard(m): + _warn("measure", f"wildcard measure '{m.get('expr')}' has no Apache Ossie metric " + f"representation; skipped") + continue + metrics.append(_convert_measure(m, fact_name)) + + model = {"name": fact_name} + if view.get("comment"): + model["description"] = view["comment"] + model["datasets"] = datasets + if relationships: + model["relationships"] = relationships + if metrics: + model["metrics"] = metrics + + # Model-level stash: filter / parameters / materialization, plus the source + # dataset's identity when a one_to_many join is present -- without it the + # exporter's FK-sink heuristic would re-root at the wrong (many-side) dataset. + model_stash = {k: view[k] for k in _MODEL_STASH_KEYS if k in view} + if _has_otm(view.get("joins")): + model_stash[STASH_SOURCE_KEY] = fact_name + write_stash(model, model_stash) + return model + + +def _has_otm(joins): + """True if any join in the (nested) tree is one_to_many.""" + for j in joins or []: + if str(j.get("cardinality") or "").lower() == CARD_ONE_TO_MANY: + return True + if _has_otm(j.get("joins")): + return True + return False + + +def _convert_join(join, parent_name, parent_alias, child): + if not join.get("using") and not join.get("on"): + raise ConversionError( + f"Join '{child}' has no join condition (empty or absent 'on'/'using'); " + f"condition-less (cross) joins have no Apache Ossie relationship representation." + ) + # _decompose_on returns (parent-side columns, child-side columns). + parent_cols, child_cols, raw_on = _decompose_on(join, parent_alias, parent_name, child) + if raw_on is not None: + raise ConversionError( + f"Join '{child}' uses a non-equi or unsupported join condition ('on: {raw_on}') " + f"that an Apache Ossie relationship cannot represent. Apache Ossie joins are equi-joins of simple " + f"`alias.column` pairs (the fact side may be qualified with `source`, the source " + f"table name, or left bare). Cannot import." + ) + if "using" in join and not parent_cols: + # `using: [cols]` -> equal lists on both sides. Two distinct list objects, so the + # emitted YAML doesn't serialize one as an anchor/alias of the other. + parent_cols, child_cols = list(join["using"]), list(join["using"]) + + # Cardinality (default many_to_one) decides the Apache Ossie direction, since `from` is + # always the many side. many_to_one -> parent is many (from=parent); one_to_many + # -> the joined child is many (from=child, to=parent). Compared case-insensitively. + cardinality = join.get("cardinality") or CARD_MANY_TO_ONE + if str(cardinality).lower() == CARD_ONE_TO_MANY: + rel = {"name": f"{child}_to_{parent_name}", "from": child, "to": parent_name, + "from_columns": child_cols, "to_columns": parent_cols} + else: + rel = {"name": f"{parent_name}_to_{child}", "from": parent_name, "to": child, + "from_columns": parent_cols, "to_columns": child_cols} + + stash = {k: join[k] for k in _JOIN_STASH_KEYS if k in join} + write_stash(rel, stash) + return rel + + +def _decompose_on(join, parent_alias, parent_name, child_alias): + """Return (from_columns, to_columns, raw_on). + + raw_on is None when `on` decomposes cleanly into equi-join column pairs; it + holds the original string otherwise (a non-equi/complex condition the caller + rejects). `using` short-circuits to empty columns here and is handled by the caller. + + The child side of a clause is always referenced by its join name. The parent side + may be referenced by its alias (`source` at the top level, else the parent join + name) or by the parent dataset's own name. A bare (unqualified) operand is read as + the fact only at the top level; inside a nested join it is ambiguous (parent vs. + fact) and is rejected rather than guessed. + """ + if "using" in join: + return [], [], None + on = join.get("on") + if not on: + return [], [], None + + parent_aliases = {parent_alias, parent_name} + from_cols, to_cols = [], [] + for clause in re.split(r"\s+AND\s+", on, flags=re.IGNORECASE): + if _NON_EQUI_RE.search(clause): # >=, <=, !=, <>, <, > -> not an equi-join + return [], [], on + m = re.match(r"^\s*(.+?)\s*=\s*(.+?)\s*$", clause) + if not m: + return [], [], on + la, lc = _split_alias(m.group(1)) + ra, rc = _split_alias(m.group(2)) + # Both sides must be `.` (or a bare fact column). If an + # operand is a SQL fragment (e.g. `dim.b + 1`, or the trailing half of an + # OR/`=`-laden clause), `_split_alias` yields a non-identifier "column" -- + # that can't be an FK column pair, so stash the whole condition verbatim. + if not (is_simple_identifier(lc) and is_simple_identifier(rc)): + return [], [], on + # The parent side: its alias or the source table name. A *bare* (unqualified) + # operand is read as the fact only at the top level (`source`); inside a nested + # join an unqualified column is ambiguous (parent vs. fact), so don't guess -- + # leave it for rejection rather than silently attributing it to the parent. + allow_bare = parent_alias == "source" + l_parent = la in parent_aliases or (la is None and allow_bare) + r_parent = ra in parent_aliases or (ra is None and allow_bare) + if la == child_alias and r_parent: + from_cols.append(rc) + to_cols.append(lc) + elif ra == child_alias and l_parent: + from_cols.append(lc) + to_cols.append(rc) + else: + return [], [], on + return from_cols, to_cols, None + + +def _split_alias(operand): + """`customer.c_custkey` -> ('customer', 'c_custkey'); `x` -> (None, 'x').""" + operand = operand.strip() + if "." in operand: + alias, col = operand.split(".", 1) + return alias.strip(), col.strip() + return None, operand + + +def _convert_dimension(dim, alias_to_dataset, fact_name): + name = require_str(dim, "name", "dimension") + expr = require_str(dim, "expr", f"dimension '{name}'") + ds_name, ossie_expr = _resolve_column(expr, alias_to_dataset, fact_name) + + field = { + "name": name, + "expression": {"dialects": [{"dialect": DIALECT_DATABRICKS, "expression": ossie_expr}]}, + } + if dim.get("comment"): + field["description"] = dim["comment"] + if dim.get("display_name"): + field["label"] = dim["display_name"] + if dim.get("synonyms"): + field["ai_context"] = {"synonyms": list(dim["synonyms"])} + write_stash(field, {k: dim[k] for k in _COLUMN_STASH_KEYS if k in dim}) + return ds_name, field + + +def _resolve_column(expr, alias_to_dataset, fact_name): + """Map a dimension expression to (dataset_name, de-aliased_expression). + + A leading join path of known aliases files the field under the **deepest** one and + de-qualifies a bare column -- mirroring the exporter's nested-join qualification: + `partsupp.supplier.nation.n_name` -> `n_name` on `nation`; `customer.c_name` -> + `c_name` on `customer`; `source.x` -> `x` on the fact. A complex expression is filed + under that dataset but kept verbatim. A bare column (no leading alias) is a fact column. + """ + segments = [s.strip() for s in expr.split(".")] + ds = None + i = 0 + # Consume leading segments that are known join/source aliases (but never the last + # segment -- that is the column). The deepest alias is the owning dataset. + while i < len(segments) - 1 and segments[i] in alias_to_dataset: + ds = alias_to_dataset[segments[i]] + i += 1 + if ds is None: + return fact_name, expr + rest = ".".join(segments[i:]) + return (ds, rest) if is_simple_identifier(rest) else (ds, expr) + + +def _convert_measure(measure, fact_name): + name = require_str(measure, "name", "measure") + # Mirror the exporter's word-boundary handling: a `source.` fact qualifier (if + # an author used one) maps back to the fact dataset name. The replacement is a + # lambda so `fact_name` is inserted literally (a `--name` containing backslashes + # is not interpreted as a regex backreference). + raw_expr = require_str(measure, "expr", f"measure '{name}'") + expr = re.sub(r"\bsource\.", lambda _m: f"{fact_name}.", raw_expr) + metric = { + "name": name, + "expression": {"dialects": [{"dialect": DIALECT_DATABRICKS, "expression": expr}]}, + } + if measure.get("comment"): + metric["description"] = measure["comment"] + if measure.get("synonyms"): + metric["ai_context"] = {"synonyms": list(measure["synonyms"])} + write_stash(metric, {k: measure[k] for k in _COLUMN_STASH_KEYS if k in measure}) + return metric diff --git a/converters/databricks/src/ossie_databricks/ossie_to_metric_view.py b/converters/databricks/src/ossie_databricks/ossie_to_metric_view.py new file mode 100644 index 0000000..3c535ed --- /dev/null +++ b/converters/databricks/src/ossie_databricks/ossie_to_metric_view.py @@ -0,0 +1,660 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert an Apache Ossie semantic model to a Databricks Unity Catalog Metric View (v1.1). + +Pure offline conversion -- no Databricks connection required. Produces the +Metric View: one fact `source` with a nested `joins` tree and +all fields flattened into one `dimensions` list. See README.md for the +capability summary and limitations. + +Usage (CLI): + ossie-databricks export -i model.yaml [-o view.yaml] [--source orders] +""" + +import re +import warnings + +from ._common import ( + CARD_ONE_TO_MANY, + ConversionError, + MV_VERSION, + OSSIE_VERSION, + STASH_SOURCE_KEY, + SYNONYM_LIMIT, + dump_yaml, + foreign_vendor_extensions, + is_simple_identifier, + load_yaml, + merge_description, + pick_expression, + read_stash, + require, + require_str, + synonyms_of, + validate_source, +) + + +def _warn(scope, msg): + warnings.warn(f"[{scope}] {msg}") + + +# Fanning a diamond out into per-path joins can expand exponentially on a pathological +# lattice; real snowflakes are tiny, so cap total joins to catch runaway inputs. +_MAX_JOIN_NODES = 200 + + +def convert_ossie_to_metric_view(ossie_yaml_str, source=None): + """Parse Apache Ossie YAML and return Databricks Metric View v1.1 YAML (string). + + `source` names the dataset to use as the view's fact/grain. When omitted, the + fact is the dataset that is never a relationship `to` (the FK sink of a plain + many-to-one star). Naming a coarser-grain dataset as the source is what unlocks + `one_to_many` joins (the joined detail tables sit on the `from`/many side). + """ + root = load_yaml(ossie_yaml_str) + if not isinstance(root, dict): + raise ConversionError("Invalid Apache Ossie YAML: expected a mapping at the root") + + version = str(root.get("version", "")) + if version != OSSIE_VERSION: + raise ConversionError( + f"Unsupported Apache Ossie version '{version}'. Supported: {OSSIE_VERSION}" + ) + + models = root.get("semantic_model") + if not isinstance(models, list) or not models: + raise ConversionError("'semantic_model' must be a non-empty list") + if len(models) > 1: + _warn("model", "multiple semantic models found; converting only the first") + + view = _convert_model(models[0], explicit_source=source) + return dump_yaml(view) + + +def _convert_model(model, explicit_source=None): + name = model.get("name", "") + dataset_list = model.get("datasets", []) or [] + if not dataset_list: + raise ConversionError(f"Model '{name}' has no datasets") + + seen = set() + for d in dataset_list: + ds_name = require_str(d, "name", f"Model '{name}': dataset") + if ds_name.strip().lower() in seen: # case-insensitive: DBR identifiers are + raise ConversionError(f"Model '{name}': duplicate dataset name '{ds_name}'") + seen.add(ds_name.strip().lower()) + datasets = {d["name"]: d for d in dataset_list} + relationships = model.get("relationships", []) or [] + + # Model-level stash: filter / parameters / materialization, plus an optional + # `source_dataset` recording the original grain (written on import only when a + # one_to_many join made the fact ambiguous). An explicit `source` arg wins. + model_stash = read_stash(model) + fact_hint = explicit_source or model_stash.get(STASH_SOURCE_KEY) + root, fact = _build_join_tree(name, datasets, relationships, fact_hint) + counts = _assign_aliases(root, fact) + + # Mark one_to_many nodes (parent on the `to`/one side); their columns can't be + # dimensions. Also validates one_to_many subtree uniformity. + _mark_otm(name, root) + + fact_ds = datasets[fact] + view = {"version": MV_VERSION, "source": validate_source(fact_ds.get("source"), fact)} + + # The view comment is simply the model's top-level description -- the closest + # match. Model ai_context and dataset descriptions are not merged in (dropped; + # see _warn_dropped_model), which keeps model.description round-trippable. + comment = model.get("description") + if comment: + view["comment"] = comment + + if "filter" in model_stash: + view["filter"] = model_stash["filter"] + + joins = [_build_join(child, "source", datasets) for child in root["children"]] + if joins: + view["joins"] = joins + + # Dimensions: every field across every join instance, fact first then join order. + # A dataset joined under more than one alias (fanned out) is emitted once per + # instance with alias-prefixed names. Track dropped names so we can cascade-drop + # anything that references them. + dropped_dims, dropped_measures = set(), set() + dimensions = [] + seen_dims = set() + for node, join_path in _node_order(root): + is_fact = node is root + prefix = node["alias"] if counts[node["dataset"]] > 1 else None + for field in datasets[node["dataset"]].get("fields", []) or []: + fname = require_str(field, "name", f"dataset '{node['dataset']}': field") + if node["is_otm"]: + _warn( + f"field '{fname}'", + "column on a one-to-many-joined table cannot be a dimension " + "(must resolve to one value per source row); dropped", + ) + dropped_dims.add(fname) + continue + dim = _convert_field(field, fname, ".".join(join_path), is_fact, prefix) + if dim is None: + dropped_dims.add(fname) + continue + if dim["name"].lower() in seen_dims: # case-insensitive + raise ConversionError( + f"dataset '{node['dataset']}': dimension name '{dim['name']}' " + f"collides with another dimension/measure; Metric Views require " + f"unique dimension/measure names -- rename before use" + ) + seen_dims.add(dim["name"].lower()) + dimensions.append(dim) + + measures = [] + for metric in model.get("metrics", []) or []: + measure = _convert_metric(metric, fact, seen_dims) + if measure is None: + dropped_measures.add(metric.get("name")) + continue + measures.append(measure) + + # Cascade: drop any dimension/measure whose expression references a dropped + # name (transitively), so we never emit a dangling reference. + _cascade_drop(dimensions, measures, dropped_dims, dropped_measures) + + if dimensions: + view["dimensions"] = dimensions + if measures: + view["measures"] = measures + + if "parameters" in model_stash: + view["parameters"] = model_stash["parameters"] + if "materialization" in model_stash: + view["materialization"] = model_stash["materialization"] + + _warn_dropped_model(model) + return view + + +def _build_join_tree(model_name, datasets, relationships, fact_hint=None): + """Build the Metric View join tree from the Apache Ossie relationship graph; return + (root_node, fact_name). + + Each node is a dict: {alias, dataset, rel, parent_is_from, children, is_otm}. + Edges are oriented away from the fact (the nearer endpoint is the parent), so a + dataset reachable by more than one path -- a diamond, e.g. two facts sharing a + dimension, or a dimension reached via two parents -- is fanned out into one node + per path. Each instance is later given a unique alias, mirroring how a Metric View + joins the same table more than once. Non-tree (cyclic) shapes are rejected. + """ + for rel in relationships: + scope = f"Model '{model_name}': relationship '{rel.get('name', '')}'" + if require(rel, "from", scope) not in datasets or require(rel, "to", scope) not in datasets: + raise ConversionError( + f"Model '{model_name}': relationship '{rel.get('name')}' references " + f"an unknown dataset" + ) + + # Re-orient any relationship whose declared keys show `from`/`to` is mislabeled + # (the `from` columns are a unique key, the `to` columns are not). Done before + # fact selection so cardinality, columns, and fact choice all use the key-derived + # orientation. The join condition is unchanged (it is symmetric). + relationships = [_orient_by_key(model_name, rel, datasets) for rel in relationships] + + fact = _pick_fact(model_name, datasets, relationships, fact_hint) + + # BFS (undirected) measures each dataset's distance from the fact; that distance + # orients every edge away from the fact (parent = the nearer endpoint). + adj = {name: [] for name in datasets} + for rel in relationships: + adj[rel["from"]].append(rel["to"]) + adj[rel["to"]].append(rel["from"]) + dist = {fact: 0} + queue = [fact] + while queue: + cur = queue.pop(0) + for neighbor in adj[cur]: + if neighbor not in dist: + dist[neighbor] = dist[cur] + 1 + queue.append(neighbor) + + unreachable = set(datasets) - set(dist) + if unreachable: + raise ConversionError( + f"Model '{model_name}': datasets {sorted(unreachable)} are not reachable " + f"from fact '{fact}' via relationships." + ) + + # Orient each edge nearer->farther. An edge between two equidistant datasets has + # no fact-ward direction -- that only happens in a cyclic / non-tree graph. + children_of = {name: [] for name in datasets} + for rel in relationships: + a, b = rel["from"], rel["to"] + if dist[a] == dist[b]: + raise ConversionError( + f"Model '{model_name}': relationship '{rel.get('name')}' joins two " + f"datasets equidistant from the fact; the graph is not tree-shaped " + f"(it contains a cycle)." + ) + parent, child = (a, b) if dist[a] < dist[b] else (b, a) + children_of[parent].append((child, rel, parent == rel["from"])) + + counter = [0] + + def build(dataset, rel, parent_is_from): + counter[0] += 1 + if counter[0] > _MAX_JOIN_NODES: + raise ConversionError( + f"Model '{model_name}': join graph fans out to more than " + f"{_MAX_JOIN_NODES} joins; check for an unintended diamond explosion." + ) + node = {"alias": None, "dataset": dataset, "rel": rel, + "parent_is_from": parent_is_from, "children": [], "is_otm": False} + for child, crel, cfrom in children_of[dataset]: + node["children"].append(build(child, crel, cfrom)) + return node + + return build(fact, None, None), fact + + +def _assign_aliases(root, fact): + """Give every node a unique join alias and return per-dataset instance counts. + + A dataset with a single instance keeps its bare name (so non-diamond graphs are + unchanged); a fanned-out dataset's instances are disambiguated by parent alias + (e.g. `customers_regions` / `suppliers_regions`). The fact's alias is `source`. + """ + counts = {} + + def count(node): + counts[node["dataset"]] = counts.get(node["dataset"], 0) + 1 + for c in node["children"]: + count(c) + + count(root) + + used = {"source"} # reserved for the fact, so a dataset named `source` gets renamed + + def assign(node, parent_alias): + if node["dataset"] == fact: + alias = "source" + else: + # Single-instance datasets keep their bare name; fanned-out ones are + # qualified by the parent alias. Either way the result is deduped against + # `used` (which reserves `source`), so no two joins ever share an alias. + if counts[node["dataset"]] == 1: + base = node["dataset"] + else: + base = (f"{parent_alias}_{node['dataset']}" + if parent_alias and parent_alias != "source" else node["dataset"]) + alias, n = base, 2 + while alias in used: + alias, n = f"{base}_{n}", n + 1 + node["alias"] = alias + used.add(alias) + for c in node["children"]: + assign(c, alias) + + assign(root, None) + return counts + + +def _pick_fact(model_name, datasets, relationships, fact_hint): + """Choose the fact/root: an explicit hint if given, else the dataset that is + never a relationship `to` (the FK sink of a plain many-to-one star).""" + if fact_hint is not None: + if fact_hint not in datasets: + raise ConversionError( + f"Model '{model_name}': requested source '{fact_hint}' is not a dataset" + ) + return fact_hint + if len(datasets) > 1 and not relationships: + raise ConversionError( + f"Model '{model_name}': {len(datasets)} datasets but no relationships; " + f"cannot determine the fact table." + ) + incoming = {name: 0 for name in datasets} + for rel in relationships: + incoming[rel["to"]] += 1 + roots = [n for n, c in incoming.items() if c == 0] + if not roots: + raise ConversionError( + f"Model '{model_name}': join graph contains a cycle (no root dataset). " + f"A Metric View requires an acyclic, tree-shaped graph." + ) + if len(roots) > 1: + raise ConversionError( + f"Model '{model_name}': multiple candidate fact datasets {sorted(roots)}. " + f"Name the grain with --source -- e.g. for multiple facts sharing a " + f"dimension, name that dimension so each fact becomes a one_to_many join." + ) + return roots[0] + + +def _mark_otm(model_name, root): + """Mark each node `is_otm` (reached through a one_to_many join -- a parent on the + `to`/one side). Their columns can't be dimensions. Enforces the DBR rule that + every descendant of a one_to_many join is itself one_to_many.""" + + def visit(node, under_otm): + for child in node["children"]: + is_otm = not child["parent_is_from"] # parent on the `to` (one) side + if under_otm and not is_otm: + raise ConversionError( + f"Model '{model_name}': join '{child['alias']}' is many-to-one but " + f"descends from a one-to-many join; all descendants of a one-to-many " + f"join must also be one-to-many (Databricks Metric View rule)." + ) + child["is_otm"] = under_otm or is_otm + visit(child, child["is_otm"]) + + visit(root, False) + + +def _node_order(root): + """Fact first, then a stable depth-first walk of the join tree (one node per join + instance, so a fanned-out dataset appears once per path). Yields (node, join_path): + `join_path` is the tuple of join aliases from the source down to and including the + node (empty for the fact). A joined column is qualified in a dimension/measure by this + full path (`parent.child.col`) -- the Databricks nested-join rule -- which for a + depth-1 join is just the join's own name.""" + order = [] + + def visit(node, path): + order.append((node, path)) + for child in node["children"]: + visit(child, path + (child["alias"],)) + + visit(root, ()) + return order + + +def _build_join(node, parent_alias, datasets): + """Build one Metric View join entry from a tree node (recursively for nested joins). + + `node['parent_is_from']` is True when the parent is the relationship's `from` + (many) side -> a many_to_one join (the default, left implicit). When the parent is + the `to` (one) side the join is one_to_many and the column roles flip. + """ + rel, alias = node["rel"], node["alias"] + join = {"name": alias, + "source": validate_source(datasets[node["dataset"]].get("source"), node["dataset"])} + + stash = read_stash(rel) + from_cols = rel.get("from_columns") or [] + to_cols = rel.get("to_columns") or [] + # Apache Ossie relationships are equi-joins; a relationship without usable equi columns + # (e.g. a non-equi join the importer would have rejected) is rejected here too. + _validate_join_columns(rel, from_cols, to_cols) + # Write parent-side = child-side: the parent uses whichever list belongs to + # it -- from_columns when it is the `from`, to_columns when it is the `to`. + parent_cols, child_cols = ( + (from_cols, to_cols) if node["parent_is_from"] else (to_cols, from_cols)) + if parent_cols == child_cols: + # Equal column lists are an equi-join on shared names -> `using`, which + # round-trips faithfully (the importer maps `using` to equal lists). + join["using"] = list(parent_cols) + else: + join["on"] = " AND ".join( + f"{parent_alias}.{pc} = {alias}.{cc}" for pc, cc in zip(parent_cols, child_cols) + ) + # rely.at_most_one_match: a stashed value round-trips verbatim; otherwise derive it + # for a many_to_one join whose `to_columns` cover a declared primary/unique key of + # the joined dataset (joining on a key matches at most one row -- no fan-out). + if "rely" in stash: + join["rely"] = stash["rely"] + elif node["parent_is_from"] and _covers_unique_key(datasets[node["dataset"]], to_cols): + join["rely"] = {"at_most_one_match": True} + # Cardinality: an explicit stashed value round-trips verbatim; otherwise derive + # from orientation -- parent on the `to` (one) side means one_to_many. The + # many_to_one default is left implicit. + if "cardinality" in stash: + join["cardinality"] = stash["cardinality"] + elif not node["parent_is_from"]: + join["cardinality"] = CARD_ONE_TO_MANY + + nested = [_build_join(c, alias, datasets) for c in node["children"]] + if nested: + join["joins"] = nested + return join + + +def _covers_unique_key(dataset, join_cols): + """True if `join_cols` include a declared `primary_key` or one of `unique_keys` of + `dataset` -- i.e. joining on them matches at most one target row, so a many_to_one + join can assert `rely.at_most_one_match`.""" + cols = set(join_cols) + keys = [dataset.get("primary_key")] if dataset.get("primary_key") else [] + keys += dataset.get("unique_keys") or [] + return any(key and set(key) <= cols for key in keys) + + +def _orient_by_key(model_name, rel, datasets): + """`to` should be the unique 'one' side (per spec `to_columns` are key columns). If + the declared keys say otherwise -- the `from` columns are a unique key while the + `to` side declares keys its `to_columns` don't cover -- `from`/`to` is mislabeled. + Return a copy with `from`/`to` (and their columns) swapped, and warn. The swap is + symmetric, so the join condition is unchanged; only the orientation is corrected. + + When the `from` columns cover a unique key but the `to` side declares no key at all, + the orientation can't be verified either way (the `to` side may or may not be + unique); leave it as-is but warn, since the resulting cardinality may be inverted.""" + from_cols = rel.get("from_columns") or [] + to_cols = rel.get("to_columns") or [] + if not from_cols or not to_cols: + return rel # non-equi / column-less: nothing to deduce from + to_ds = datasets[rel["to"]] + to_has_keys = bool(to_ds.get("primary_key") or to_ds.get("unique_keys")) + from_covers = _covers_unique_key(datasets[rel["from"]], from_cols) + if from_covers and to_has_keys and not _covers_unique_key(to_ds, to_cols): + _warn( + f"relationship '{rel.get('name')}'", + "from/to looks mislabeled (the `from` columns are a declared key, the `to` " + "columns are not); re-orienting so the key side is the `to`/one side", + ) + return {**rel, "from": rel["to"], "to": rel["from"], + "from_columns": to_cols, "to_columns": from_cols} + if from_covers and not to_has_keys: + _warn( + f"relationship '{rel.get('name')}'", + "the `from` columns are a declared key but the `to` side declares none, so " + "from/to orientation can't be verified; using it as-is -- check the join " + "direction if the resulting cardinality looks inverted", + ) + return rel + + +def _validate_join_columns(rel, from_cols, to_cols): + if not from_cols or not to_cols: + raise ConversionError( + f"Relationship '{rel.get('name')}': from_columns and to_columns are required" + ) + if not isinstance(from_cols, list) or not isinstance(to_cols, list): + raise ConversionError( + f"Relationship '{rel.get('name')}': from_columns and to_columns must be lists" + ) + if len(from_cols) != len(to_cols): + raise ConversionError( + f"Relationship '{rel.get('name')}': from_columns ({len(from_cols)}) and " + f"to_columns ({len(to_cols)}) must have the same length" + ) + + +def _convert_field(field, name, qualifier, is_fact, prefix=None): + scope = f"field '{name}'" + expr = pick_expression(field.get("expression")) + if expr is None: + _warn(scope, "no DATABRICKS/ANSI_SQL dialect; dropping field") + return None + + # Requalify a joined-table column with its full join-name path from the source + # (`parent.child.col`); a depth-1 join is just its own name. Only safe for bare + # columns. A complex expression on a single join is emitted as-is (likely resolves; + # warned). On a fanned-out (diamond) dataset it cannot be attributed to one of the + # instances, so it is dropped rather than emitted as an ambiguous dimension. + if not is_fact: + if is_simple_identifier(expr): + expr = f"{qualifier}.{expr}" + elif prefix: + _warn(scope, "complex expression on a fanned-out (diamond) join cannot be " + "unambiguously qualified; dropped") + return None + else: + _warn(scope, "complex expression on a joined table; emitted as-is, verify qualification") + + # A fanned-out dataset (joined under more than one alias) needs unique dimension + # names, so prefix with the instance alias (e.g. customer_region's r_name -> + # customer_region_r_name). Single-instance datasets keep the bare field name. + if prefix: + name = f"{prefix}_{name}" + + dim = {"name": name, "expr": expr} + comment = merge_description(field.get("description"), field.get("ai_context")) + if comment: + dim["comment"] = comment + if field.get("label"): + dim["display_name"] = field["label"] + syns = synonyms_of(field.get("ai_context")) + if syns: + dim["synonyms"] = _truncate_synonyms(syns, scope) + + stash = read_stash(field) + if "format" in stash: + dim["format"] = stash["format"] + _warn_dropped_field(field, scope) + return dim + + +def _convert_metric(metric, fact, seen_names): + name = require_str(metric, "name", "metric") + scope = f"metric '{name}'" + if name.lower() in seen_names: # case-insensitive (shares seen_dims with dimensions) + raise ConversionError( + f"metric '{name}' collides with another dimension/measure; Metric Views " + f"require unique dimension/measure names -- rename before use") + seen_names.add(name.lower()) + expr = pick_expression(metric.get("expression")) + if expr is None: + _warn(scope, "no DATABRICKS/ANSI_SQL dialect; dropping metric") + return None + + # Fact-table columns are referenced by bare name in measure expressions (DBR + # idiom: `SUM(amount)`, not `SUM(source.amount)`); strip a `.` qualifier. + # Joined-table columns keep their alias. Word boundary avoids touching a table + # whose name merely ends with the fact name (e.g. fact 'sales' vs 'store_sales'). + expr = re.sub(r"\b" + re.escape(fact) + r"\.", "", expr) + + measure = {"name": name, "expr": expr} + comment = merge_description(metric.get("description"), metric.get("ai_context")) + if comment: + measure["comment"] = comment + syns = synonyms_of(metric.get("ai_context")) + if syns: + measure["synonyms"] = _truncate_synonyms(syns, scope) + + stash = read_stash(metric) + if "format" in stash: + measure["format"] = stash["format"] + if "window" in stash: + measure["window"] = stash["window"] + return measure + + +def _references_dropped(expr, self_name, dropped_dims, dropped_measures): + """Return a dropped name referenced by `expr`, or None. + + Measures are only referenceable via `measure()` (exact). Dimensions are + referenced by their bare, *unqualified* name: a name that is part of a qualified + path (`alias.name` or `name.col`) is ignored, so a join alias or joined column + that merely shares a dropped dimension's name is not over-dropped. The one + ambiguity the regex can't resolve without a SQL parser is a bare, unqualified + *source column* sharing a dropped dimension's name -- there it errs on dropping. + """ + for m in dropped_measures: + if re.search(r"measure\(\s*" + re.escape(m) + r"\s*\)", expr): + return m + for d in dropped_dims: + # Match only a bare, unqualified token: the negative look-behind/ahead for a + # word char or `.` excludes both substrings of a larger identifier and + # qualified paths (`alias.name` / `name.col`), so a join alias or joined + # column sharing a dropped name is not falsely cascade-dropped. + if d != self_name and re.search( + r"(? SYNONYM_LIMIT: + _warn(scope, f"{len(syns)} synonyms exceeds Metric View limit; keeping first {SYNONYM_LIMIT}") + return syns[:SYNONYM_LIMIT] + return syns + + +def _warn_dropped_model(model): + if foreign_vendor_extensions(model): + _warn("model", "foreign-vendor custom_extensions dropped") + if model.get("ai_context"): # string or object -- only the description maps to comment + _warn("model", "model-level ai_context dropped (only the description maps to the view comment)") + for ds in model.get("datasets", []) or []: + scope = f"dataset '{ds['name']}'" + if ds.get("primary_key") or ds.get("unique_keys"): + _warn(scope, "primary_key/unique_keys not stored as columns; used to set " + "rely.at_most_one_match on a matching many_to_one join where applicable") + if isinstance(ds.get("ai_context"), dict) and ds["ai_context"]: + _warn(scope, "dataset-level ai_context (object) dropped") + # Dataset descriptions are not merged into the view comment (a Metric View + # has no per-source comment); only the model description is used. + if ds.get("description"): + _warn(scope, "dataset-level description dropped (no per-source comment field)") + if foreign_vendor_extensions(ds): + _warn(scope, "foreign-vendor custom_extensions dropped") + for rel in model.get("relationships", []) or []: + if rel.get("ai_context"): + _warn(f"relationship '{rel.get('name', '')}'", "relationship ai_context dropped") + + +def _warn_dropped_field(field, scope): + dim = field.get("dimension") + if isinstance(dim, dict) and "is_time" in dim: + _warn(scope, "dimension.is_time has no Metric View counterpart; dropped") + if foreign_vendor_extensions(field): + _warn(scope, "foreign-vendor custom_extensions dropped") diff --git a/converters/databricks/tests/_roundtrip_helpers.py b/converters/databricks/tests/_roundtrip_helpers.py new file mode 100644 index 0000000..6c4ddfb --- /dev/null +++ b/converters/databricks/tests/_roundtrip_helpers.py @@ -0,0 +1,355 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared model builders and round-trip assertions for property-based tests. + +This module is deliberately free of any third-party test dependency (no hypothesis, +no pytest) so the generation + assertion logic can run two ways: + + - driven by Hypothesis strategies (see test_roundtrip_properties.py), and + - driven by a plain seeded `random.Random` (RandomRnd below), which is how the + logic is exercised in environments where hypothesis is not installed. + +Both drivers implement the small `Rnd` interface (chance/count/pick/text/colname); +the builders below depend only on that interface, so the generated model space is +identical regardless of driver. + +The builders intentionally generate within the *round-trippable subset* of models - +the shapes the converter reproduces exactly. Known normalizations are avoided by +construction (documented inline) rather than asserted around, e.g.: + - join `on` conditions use distinct parent/child column names, so an equi-join on + shared names is never silently rewritten to `using`; + - measure expressions are emitted without a `source.` qualifier, which the exporter + would otherwise strip; + - in the Apache Ossie direction, joined-dataset fields are bare identifiers, so they survive + the alias requalify/de-qualify trip on the same dataset. +Name fuzzing (reserved words, collisions) is left to the targeted unit tests, which +assert the converter *rejects* those inputs. +""" + +import random +import re +import string +import warnings + +from ossie_databricks import metric_view_to_ossie as importer +from ossie_databricks import ossie_to_metric_view as exporter +from ossie_databricks._common import MV_VERSION, OSSIE_VERSION, dump_yaml, load_yaml + +_AGGS = ["SUM", "COUNT", "AVG", "MIN", "MAX"] + + +# --- Rnd backend for offline (no hypothesis) runs -------------------------------- + +class RandomRnd: + """The `Rnd` interface backed by a seeded `random.Random`.""" + + def __init__(self, seed): + self.r = random.Random(seed) + + def chance(self, p=0.5): + return self.r.random() < p + + def count(self, lo, hi): + return self.r.randint(lo, hi) + + def pick(self, seq): + return self.r.choice(list(seq)) + + def text(self): + # Alphanumeric with optional interior spaces; no leading/trailing space and + # no YAML-special characters, so the value is preserved verbatim through a + # dump/load cycle (any failure then reflects the converter, not PyYAML). + alnum = string.ascii_letters + string.digits + n = self.r.randint(0, 10) + body = "".join(self.r.choice(alnum + " ") for _ in range(n)) + return (self.r.choice(alnum) + body).strip() or "x" + + def colname(self): + first = self.r.choice(string.ascii_lowercase + "_") + rest = "".join( + self.r.choice(string.ascii_lowercase + string.digits + "_") + for _ in range(self.r.randint(0, 7)) + ) + return first + rest + + +# --- Small generation helpers ---------------------------------------------------- + +class _Names: + """Hands out globally-unique names with a given prefix.""" + + def __init__(self): + self._n = {} + + def next(self, prefix): + i = self._n.get(prefix, 0) + self._n[prefix] = i + 1 + return f"{prefix}{i}" + + +def _maybe_meta(rnd, target): + """Attach optional comment/display_name/synonyms/format to a dim/measure dict.""" + if rnd.chance(0.4): + target["comment"] = rnd.text() + if rnd.chance(0.3): + target["display_name"] = rnd.text() + if rnd.chance(0.3): + target["synonyms"] = [rnd.text() for _ in range(rnd.count(1, 3))] + if rnd.chance(0.25): + fmt = {"type": rnd.pick(["number", "currency", "date"])} + if fmt["type"] == "currency": + fmt["currency_code"] = "USD" + target["format"] = fmt + + +# --- Metric View builder (for MV -> Apache Ossie -> MV) ----------------------------------- + +def _build_join(rnd, names, parent_alias, depth, ancestor_path): + name = names.next("j") + # Full join-name path from the source -> how dimensions/measures qualify this join's + # columns (DBR nested-join rule). `on:` conditions instead use the immediate names. + qual = ".".join(ancestor_path + [name]) + join = {"name": name, "source": _three_part(rnd)} + if rnd.chance(0.5): + ncols = rnd.count(1, 2) + join["using"] = [f"u{i}_{rnd.colname()}" for i in range(ncols)] + else: + ncols = rnd.count(1, 2) + # distinct parent/child names so the equi-join stays `on`, not `using` + pairs = [(f"fk{i}_{rnd.colname()}", f"pk{i}_{rnd.colname()}") for i in range(ncols)] + join["on"] = " AND ".join(f"{parent_alias}.{pc} = {name}.{cc}" for pc, cc in pairs) + if rnd.chance(0.4): + join["cardinality"] = "many_to_one" # only the lossless cardinality (see module doc) + if rnd.chance(0.3): + join["rely"] = {"at_most_one_match": True} + # dimensions on this join (qualified by the full join path) + dims = [] + for _ in range(rnd.count(0, 2)): + col = rnd.colname() + expr = (f"{qual}.{col}" if rnd.chance(0.7) + else f"{qual}.{col} + {qual}.{rnd.colname()}") + dim = {"name": names.next("c"), "expr": expr} + _maybe_meta(rnd, dim) + dims.append(dim) + if depth < 2 and rnd.chance(0.35): + child, child_dims = _build_join(rnd, names, name, depth + 1, ancestor_path + [name]) + join["joins"] = [child] + dims.extend(child_dims) + return join, dims + + +def build_metric_view(rnd): + """Generate a Metric View YAML dict in the round-trippable subset.""" + names = _Names() + mv = {"version": MV_VERSION, "source": _three_part(rnd)} + if rnd.chance(0.4): + mv["comment"] = rnd.text() + if rnd.chance(0.3): + mv["filter"] = f"{rnd.colname()} > 0" + + fields, joins = [], [] + for _ in range(rnd.count(0, 3)): # source dimensions (bare or function exprs) + col = rnd.colname() + expr = col if rnd.chance(0.7) else f"UPPER({col})" + dim = {"name": names.next("c"), "expr": expr} + _maybe_meta(rnd, dim) + fields.append(dim) + for _ in range(rnd.count(0, 2)): # join subtrees + join, jdims = _build_join(rnd, names, "source", 0, []) + joins.append(join) + fields.extend(jdims) + + measures = [] + for _ in range(rnd.count(0, 2)): + m = {"name": names.next("c"), "expr": f"{rnd.pick(_AGGS)}({rnd.colname()})"} + if rnd.chance(0.4): + m["comment"] = rnd.text() + if rnd.chance(0.3): + m["synonyms"] = [rnd.text() for _ in range(rnd.count(1, 3))] + if rnd.chance(0.3): + m["window"] = [{"order": rnd.colname(), "range": "trailing 7 day"}] + measures.append(m) + + if joins: + mv["joins"] = joins + if fields: + mv["fields"] = fields + if measures: + mv["measures"] = measures + if rnd.chance(0.2): + mv["materialization"] = {"schedule": "every 6 hours", + "mode": rnd.pick(["relaxed", "strict"])} + return mv + + +# --- Apache Ossie builder (for Apache Ossie -> MV -> Apache Ossie) ------------------------------------------ + +def _ossie_field(name, expr): + return {"name": name, + "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": expr}]}} + + +def build_ossie(rnd): + """Generate an Apache Ossie semantic model dict in the round-trippable subset.""" + names = _Names() + fact = "fact" # fact name must equal its source's last identifier to round-trip + datasets = [{"name": fact, "source": f"c.s.{fact}"}] + relationships = [] + + n_dims = rnd.count(0, 3) + dim_names = [names.next("dim") for _ in range(n_dims)] + reachable = [fact] + for i, dname in enumerate(dim_names): + parent = rnd.pick(reachable) # star, or snowflake off an earlier node + ds = {"name": dname, "source": f"c.s.{rnd.colname()}{i}"} + datasets.append(ds) + reachable.append(dname) + if rnd.chance(0.5): # equal column names -> `using`; else distinct -> `on` + cols = [rnd.colname() for _ in range(rnd.count(1, 2))] + relationships.append({"name": names.next("r"), "from": parent, "to": dname, + "from_columns": list(cols), "to_columns": list(cols)}) + else: + n = rnd.count(1, 2) + fcols = [f"fk{j}_{rnd.colname()}" for j in range(n)] + tcols = [f"pk{j}_{rnd.colname()}" for j in range(n)] + relationships.append({"name": names.next("r"), "from": parent, "to": dname, + "from_columns": fcols, "to_columns": tcols}) + + # fields, bare identifiers, filed onto a random dataset (globally unique names) + for ds in datasets: + flds = [_ossie_field(names.next("c"), rnd.colname()) for _ in range(rnd.count(0, 3))] + if flds: + ds["fields"] = flds + + metrics = [{"name": names.next("c"), + "expression": {"dialects": [{"dialect": "DATABRICKS", + "expression": f"{rnd.pick(_AGGS)}({rnd.colname()})"}]}} + for _ in range(rnd.count(0, 2))] + + model = {"name": names.next("m")} + if rnd.chance(0.4): + model["description"] = rnd.text() + model["datasets"] = datasets + if relationships: + model["relationships"] = relationships + if metrics: + model["metrics"] = metrics + return {"version": OSSIE_VERSION, "semantic_model": [model]} + + +def _three_part(rnd): + return f"{rnd.colname()}.{rnd.colname()}.{rnd.colname()}" + + +# --- Round-trip assertions ------------------------------------------------------- + +def _convert(fn, text): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(text) + + +def _cond_canon(join): + if join.get("using"): + return ("using", tuple(sorted(join["using"]))) + on = join.get("on") + if not on: + return (None, None) + pairs = set() + for clause in re.split(r"\s+AND\s+", on, flags=re.IGNORECASE): + left, right = clause.split("=", 1) + pairs.add((left.strip(), right.strip())) + return ("on", frozenset(pairs)) + + +def _flatten_joins(joins, parent="source", acc=None, edges=None): + acc = {} if acc is None else acc + edges = set() if edges is None else edges + for j in joins or []: + acc[j["name"]] = {"source": j["source"], "cond": _cond_canon(j), + "cardinality": j.get("cardinality"), "rely": j.get("rely")} + edges.add((parent, j["name"])) + _flatten_joins(j.get("joins"), j["name"], acc, edges) + return acc, edges + + +def _dims(mv): + # The exporter emits the canonical `dimensions:` key; the importer also accepts the + # `fields:` alias. Read either so the comparison is key-name agnostic. + return mv.get("dimensions") or mv.get("fields") or [] + + +def _dim_norm(d): + return (d["expr"], d.get("comment"), d.get("display_name"), + d.get("synonyms"), d.get("format")) + + +def _meas_norm(m): + return (m["expr"], m.get("comment"), m.get("synonyms"), m.get("format"), m.get("window")) + + +def assert_mv_roundtrip(mv): + """A Metric View dict survives MV -> Apache Ossie -> MV with content preserved.""" + ossie_yaml = _convert(importer.convert_metric_view_to_ossie, dump_yaml(mv)) + mv2 = load_yaml(_convert(exporter.convert_ossie_to_metric_view, ossie_yaml)) + + assert mv2["source"] == mv["source"], "source" + assert mv2.get("comment") == mv.get("comment"), "comment" + assert mv2.get("filter") == mv.get("filter"), "filter" + assert mv2.get("materialization") == mv.get("materialization"), "materialization" + + assert ({d["name"]: _dim_norm(d) for d in _dims(mv)} + == {d["name"]: _dim_norm(d) for d in _dims(mv2)}), "fields" + assert ({m["name"]: _meas_norm(m) for m in mv.get("measures", [])} + == {m["name"]: _meas_norm(m) for m in mv2.get("measures", [])}), "measures" + + a1, e1 = _flatten_joins(mv.get("joins")) + a2, e2 = _flatten_joins(mv2.get("joins")) + assert a1 == a2, "joins" + assert e1 == e2, "join nesting" + + +def _expr_of(obj): + for d in obj["expression"]["dialects"]: + if d["dialect"] == "DATABRICKS": + return d["expression"] + return None + + +def _fields_map(ds): + return {f["name"]: _expr_of(f) for f in ds.get("fields", [])} + + +def _rel_set(model): + return {(r["from"], r["to"], tuple(r.get("from_columns") or []), + tuple(r.get("to_columns") or [])) + for r in model.get("relationships", [])} + + +def assert_ossie_roundtrip(ossie): + """An Apache Ossie model dict survives Apache Ossie -> MV -> Apache Ossie with content preserved.""" + mv_yaml = _convert(exporter.convert_ossie_to_metric_view, dump_yaml(ossie)) + ossie2 = load_yaml(_convert(importer.convert_metric_view_to_ossie, mv_yaml)) + + m1, m2 = ossie["semantic_model"][0], ossie2["semantic_model"][0] + assert ({d["name"]: (d["source"], _fields_map(d)) for d in m1["datasets"]} + == {d["name"]: (d["source"], _fields_map(d)) for d in m2["datasets"]}), "datasets" + assert _rel_set(m1) == _rel_set(m2), "relationships" + assert ({x["name"]: _expr_of(x) for x in m1.get("metrics", [])} + == {x["name"]: _expr_of(x) for x in m2.get("metrics", [])}), "metrics" + assert m1.get("description") == m2.get("description"), "description" diff --git a/converters/databricks/tests/_util.py b/converters/databricks/tests/_util.py new file mode 100644 index 0000000..e82a1d0 --- /dev/null +++ b/converters/databricks/tests/_util.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. + +"""Shared test helpers: fixture loading and structural normalization.""" + +import copy +import json +import pathlib + +from ossie_databricks._common import load_yaml # src is on sys.path via conftest.py + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" + + +def load_fixture(name): + with open(FIXTURES / name) as fh: + return fh.read() + + +def parse(yaml_str): + # YAML 1.2 booleans so a join `on:` key parses as the string "on" (matching the + # converter's own load/dump), not the YAML-1.1 boolean True. + return load_yaml(yaml_str) + + +def canon(obj): + """Deep-copy with every `custom_extensions[].data` JSON string parsed into a + dict, so comparisons are insensitive to JSON key order / whitespace.""" + obj = copy.deepcopy(obj) + + def walk(node): + if isinstance(node, dict): + for ext in node.get("custom_extensions") or []: + if isinstance(ext.get("data"), str): + ext["data"] = json.loads(ext["data"]) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for v in node: + walk(v) + + walk(obj) + return obj + + +def strip_dropped(ossie): + """Normalize away what the Apache Ossie -> MV -> Apache Ossie trip changes, so a round-trip + comparison reflects the documented limitations. Besides outright losses (model + name, descriptions), a declared key transforms across the trip: `primary_key` -> + `rely.at_most_one_match` (MV) -> `unique_keys` + a relationship rely-stash. We drop + both key forms and the relationship stash so the key info is compared as 'gone'.""" + ossie = copy.deepcopy(ossie) + for model in ossie.get("semantic_model", []): + model.pop("name", None) # MV carries no model name + model.pop("description", None) # model + fact descriptions merge into one comment + for ds in model.get("datasets", []): + ds.pop("primary_key", None) + ds.pop("unique_keys", None) + ds.pop("description", None) # no per-source comment in single-source MV + for rel in model.get("relationships", []): + rel.pop("custom_extensions", None) # derived rely-stash from a declared key + return ossie diff --git a/converters/databricks/tests/conftest.py b/converters/databricks/tests/conftest.py new file mode 100644 index 0000000..0bcde53 --- /dev/null +++ b/converters/databricks/tests/conftest.py @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pathlib +import sys + +# Make the converter modules in ../src importable from the tests. +_SRC = pathlib.Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(_SRC)) diff --git a/converters/databricks/tests/fixtures/fixtureA_metric_view.yaml b/converters/databricks/tests/fixtures/fixtureA_metric_view.yaml new file mode 100644 index 0000000..4ffe681 --- /dev/null +++ b/converters/databricks/tests/fixtures/fixtureA_metric_view.yaml @@ -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. + +# Fixture A -- expected UC Metric View (v1.1, single-source) produced from +# fixtureA_ossie.yaml. Must parse under the v1.1 strict schema. + +version: '1.1' +source: samples.tpch.orders +comment: Sales orders with customer attributes +joins: +- name: customer + source: samples.tpch.customer + on: source.o_custkey = customer.c_custkey + rely: + at_most_one_match: true +dimensions: +- name: o_orderkey + expr: o_orderkey + comment: Order identifier +- name: o_orderdate + expr: o_orderdate + display_name: Order Date + synonyms: + - order date + - date +- name: c_name + expr: customer.c_name + comment: Customer name +measures: +- name: total_revenue + expr: SUM(o_totalprice) + comment: Total order revenue + synonyms: + - revenue + - total revenue + - sales +- name: order_count + expr: COUNT(*) + comment: Number of orders diff --git a/converters/databricks/tests/fixtures/fixtureA_ossie.yaml b/converters/databricks/tests/fixtures/fixtureA_ossie.yaml new file mode 100644 index 0000000..a53942f --- /dev/null +++ b/converters/databricks/tests/fixtures/fixtureA_ossie.yaml @@ -0,0 +1,79 @@ +# 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. + +# yaml-language-server: $schema=../../../../core-spec/osi-schema.json +# +# Fixture A -- all-native round-trip (Apache Ossie -> MV -> Apache Ossie). +# Star schema; every field maps to a native MV field. Documented losses on the +# Apache Ossie -> MV -> Apache Ossie trip: the model name (MV carries none) and primary_key. + +version: "0.2.0.dev0" + +semantic_model: + - name: sales + description: Sales orders with customer attributes + datasets: + - name: orders # fact: no incoming relationship -> becomes `source` + source: samples.tpch.orders + primary_key: [o_orderkey] # dropped on export (Apache Ossie-only) + description: One row per order + fields: + - name: o_orderkey + expression: + dialects: + - dialect: DATABRICKS + expression: o_orderkey + description: Order identifier + - name: o_orderdate + expression: + dialects: + - dialect: DATABRICKS + expression: o_orderdate + label: Order Date + ai_context: + synonyms: [order date, date] + - name: customer + source: samples.tpch.customer + primary_key: [c_custkey] + fields: + - name: c_name + expression: + dialects: + - dialect: DATABRICKS + expression: c_name + description: Customer name + relationships: + - name: orders_to_customer + from: orders + to: customer + from_columns: [o_custkey] + to_columns: [c_custkey] + metrics: + - name: total_revenue + expression: + dialects: + - dialect: DATABRICKS + expression: SUM(o_totalprice) # fact columns are bare in measures + description: Total order revenue + ai_context: + synonyms: [revenue, total revenue, sales] + - name: order_count + expression: + dialects: + - dialect: DATABRICKS + expression: COUNT(*) + description: Number of orders diff --git a/converters/databricks/tests/fixtures/fixtureB_metric_view.yaml b/converters/databricks/tests/fixtures/fixtureB_metric_view.yaml new file mode 100644 index 0000000..bbbbabc --- /dev/null +++ b/converters/databricks/tests/fixtures/fixtureB_metric_view.yaml @@ -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. + +# Fixture B -- stash round-trip (MV -> Apache Ossie -> MV, lossless). +# Exercises the stash at every placement level: model (filter), relationship +# (rely), dimension (format), measure (format). Must parse under v1.1. + +version: '1.1' +source: samples.tpch.lineitem +filter: l_returnflag = 'N' +comment: Line item shipping metrics +joins: +- name: orders + source: samples.tpch.orders + on: source.l_orderkey = orders.o_orderkey + rely: + at_most_one_match: true +dimensions: +- name: line_number + expr: l_linenumber + format: + type: number + decimal_places: + type: exact + places: 0 +measures: +- name: revenue + expr: SUM(l_extendedprice * (1 - l_discount)) + comment: Net revenue + format: + type: currency + currency_code: USD + decimal_places: + type: exact + places: 2 +- name: order_count + expr: COUNT(DISTINCT l_orderkey) diff --git a/converters/databricks/tests/fixtures/fixtureB_ossie.yaml b/converters/databricks/tests/fixtures/fixtureB_ossie.yaml new file mode 100644 index 0000000..f2c11d9 --- /dev/null +++ b/converters/databricks/tests/fixtures/fixtureB_ossie.yaml @@ -0,0 +1,72 @@ +# 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. + +# yaml-language-server: $schema=../../../../core-spec/osi-schema.json +# +# Fixture B -- expected Apache Ossie produced from fixtureB_metric_view.yaml. MV-only +# features are stashed in custom_extensions[DATABRICKS], keyed by their exact v1.1 +# field name. Exporting this back must reproduce fixtureB_metric_view.yaml. +# Model name is derived from the fact table (`lineitem`). + +version: "0.2.0.dev0" + +semantic_model: + - name: lineitem + description: Line item shipping metrics + datasets: + - name: lineitem + source: samples.tpch.lineitem + fields: + - name: line_number + expression: + dialects: + - dialect: DATABRICKS + expression: l_linenumber + custom_extensions: + - vendor_name: DATABRICKS + data: '{"_v": 1, "format": {"type": "number", "decimal_places": {"type": "exact", "places": 0}}}' + - name: orders + source: samples.tpch.orders + unique_keys: + - [o_orderkey] + relationships: + - name: lineitem_to_orders + from: lineitem + to: orders + from_columns: [l_orderkey] + to_columns: [o_orderkey] + custom_extensions: + - vendor_name: DATABRICKS + data: '{"_v": 1, "rely": {"at_most_one_match": true}}' + metrics: + - name: revenue + expression: + dialects: + - dialect: DATABRICKS + expression: SUM(l_extendedprice * (1 - l_discount)) + description: Net revenue + custom_extensions: + - vendor_name: DATABRICKS + data: '{"_v": 1, "format": {"type": "currency", "currency_code": "USD", "decimal_places": {"type": "exact", "places": 2}}}' + - name: order_count + expression: + dialects: + - dialect: DATABRICKS + expression: COUNT(DISTINCT l_orderkey) + custom_extensions: + - vendor_name: DATABRICKS + data: '{"_v": 1, "filter": "l_returnflag = ''N''"}' diff --git a/converters/databricks/tests/fixtures/tpcds_metric_view.yaml b/converters/databricks/tests/fixtures/tpcds_metric_view.yaml new file mode 100644 index 0000000..b22fc69 --- /dev/null +++ b/converters/databricks/tests/fixtures/tpcds_metric_view.yaml @@ -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. + +version: '1.1' +source: tpcds.public.store_sales +comment: Store sales enriched with date, item, and customer dimensions +filter: ss_net_profit > 0 +joins: +- name: date_dim + source: tpcds.public.date_dim + on: source.ss_sold_date_sk = date_dim.d_date_sk + rely: + at_most_one_match: true +- name: item + source: tpcds.public.item + on: source.ss_item_sk = item.i_item_sk + rely: + at_most_one_match: true +- name: customer + source: tpcds.public.customer + on: source.ss_customer_sk = customer.c_customer_sk + rely: + at_most_one_match: true +dimensions: +- name: ticket_number + expr: ss_ticket_number +- name: sold_year + expr: date_dim.d_year + display_name: Year + synonyms: + - year + - yr +- name: sold_date + expr: date_dim.d_date +- name: item_category + expr: item.i_category + synonyms: + - category + - product type +- name: item_brand + expr: item.i_brand +- name: birth_country + expr: customer.c_birth_country +measures: +- name: total_sales + expr: SUM(ss_ext_sales_price) + comment: Total sales revenue + format: + type: currency + currency_code: USD +- name: total_quantity + expr: SUM(ss_quantity) + comment: Total units sold diff --git a/converters/databricks/tests/fixtures/tpcds_ossie.yaml b/converters/databricks/tests/fixtures/tpcds_ossie.yaml new file mode 100644 index 0000000..e055eab --- /dev/null +++ b/converters/databricks/tests/fixtures/tpcds_ossie.yaml @@ -0,0 +1,89 @@ +# 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. + +version: "0.2.0.dev0" +semantic_model: + - name: tpcds_store_sales + description: Store sales enriched with date, item, and customer dimensions + datasets: + - name: store_sales + source: tpcds.public.store_sales + fields: + - name: ticket_number + expression: + dialects: [{dialect: DATABRICKS, expression: ss_ticket_number}] + - name: date_dim + source: tpcds.public.date_dim + primary_key: [d_date_sk] + fields: + - name: sold_year + expression: + dialects: [{dialect: DATABRICKS, expression: d_year}] + label: Year + ai_context: {synonyms: [year, yr]} + - name: sold_date + expression: + dialects: [{dialect: DATABRICKS, expression: d_date}] + - name: item + source: tpcds.public.item + primary_key: [i_item_sk] + fields: + - name: item_category + expression: + dialects: [{dialect: DATABRICKS, expression: i_category}] + ai_context: {synonyms: [category, product type]} + - name: item_brand + expression: + dialects: [{dialect: DATABRICKS, expression: i_brand}] + - name: customer + source: tpcds.public.customer + primary_key: [c_customer_sk] + fields: + - name: birth_country + expression: + dialects: [{dialect: DATABRICKS, expression: c_birth_country}] + relationships: + - name: store_sales_to_date_dim + from: store_sales + to: date_dim + from_columns: [ss_sold_date_sk] + to_columns: [d_date_sk] + - name: store_sales_to_item + from: store_sales + to: item + from_columns: [ss_item_sk] + to_columns: [i_item_sk] + - name: store_sales_to_customer + from: store_sales + to: customer + from_columns: [ss_customer_sk] + to_columns: [c_customer_sk] + metrics: + - name: total_sales + expression: + dialects: [{dialect: DATABRICKS, expression: SUM(ss_ext_sales_price)}] + description: Total sales revenue + custom_extensions: + - vendor_name: DATABRICKS + data: '{"_v": 1, "format": {"type": "currency", "currency_code": "USD"}}' + - name: total_quantity + expression: + dialects: [{dialect: DATABRICKS, expression: SUM(ss_quantity)}] + description: Total units sold + custom_extensions: + - vendor_name: DATABRICKS + data: '{"_v": 1, "filter": "ss_net_profit > 0"}' diff --git a/converters/databricks/tests/test_metric_view_to_ossie.py b/converters/databricks/tests/test_metric_view_to_ossie.py new file mode 100644 index 0000000..0289003 --- /dev/null +++ b/converters/databricks/tests/test_metric_view_to_ossie.py @@ -0,0 +1,347 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Databricks Metric View -> Apache Ossie importer.""" + +import pytest + +from ossie_databricks import ConversionError +from ossie_databricks import metric_view_to_ossie as importer +from _util import canon, load_fixture, parse + + +def test_fixtureB_import_matches_expected(): + out = importer.convert_metric_view_to_ossie(load_fixture("fixtureB_metric_view.yaml")) + assert canon(parse(out)) == canon(parse(load_fixture("fixtureB_ossie.yaml"))) + + +def test_fields_is_accepted_as_alias_for_dimensions(): + """`fields:` is a v1.1 alias for `dimensions:` (the form the DBR docs use); the + importer must read it, not silently drop the columns.""" + mv = ( + "version: '1.1'\nsource: c.s.orders\n" + "fields:\n- {name: region, expr: region}\n" + ) + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + fields = ossie["semantic_model"][0]["datasets"][0].get("fields", []) + assert [f["name"] for f in fields] == ["region"] + + +def test_unsupported_version_rejected(): + with pytest.raises(ConversionError): + importer.convert_metric_view_to_ossie("version: '0.1'\nsource: c.s.t\n") + + +def test_both_dimensions_and_fields_present_warns_and_uses_dimensions(): + """`fields` is a v1.1 alias for `dimensions`; if a (malformed) view sets both, the + importer uses `dimensions` and warns that the `fields` list is ignored.""" + import warnings + mv = ( + "version: '1.1'\nsource: c.s.orders\n" + "dimensions:\n- {name: kept, expr: kept}\n" + "fields:\n- {name: ignored, expr: ignored}\n" + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + names = [f["name"] for f in ossie["semantic_model"][0]["datasets"][0].get("fields", [])] + assert names == ["kept"] + assert any("fields" in str(w.message) and "ignored" in str(w.message) for w in caught) + + +def test_stash_written_at_each_level(): + ossie = parse(importer.convert_metric_view_to_ossie(load_fixture("fixtureB_metric_view.yaml"))) + model = ossie["semantic_model"][0] + + # model-level filter + assert any(e["vendor_name"] == "DATABRICKS" and "filter" in e["data"] + for e in model["custom_extensions"]) + # relationship-level rely + rel = model["relationships"][0] + assert any("rely" in e["data"] for e in rel["custom_extensions"]) + # metric-level format + revenue = next(m for m in model["metrics"] if m["name"] == "revenue") + assert any("format" in e["data"] for e in revenue["custom_extensions"]) + + +def test_name_override(): + ossie = parse(importer.convert_metric_view_to_ossie( + load_fixture("fixtureB_metric_view.yaml"), model_name="custom")) + assert ossie["semantic_model"][0]["name"] == "custom" + + +def test_cross_join_rejected(): + mv = "version: '1.1'\nsource: c.s.fact\njoins:\n- name: dim\n source: c.s.dim\n" + with pytest.raises(ConversionError, match="cross"): + importer.convert_metric_view_to_ossie(mv) + + +def test_duplicate_join_name_rejected(): + # a join named like the fact (derived from the source's last identifier) collides + mv = "version: '1.1'\nsource: c.s.fact\njoins:\n- name: fact\n source: c.s.other\n using: [id]\n" + with pytest.raises(ConversionError, match="Duplicate"): + importer.convert_metric_view_to_ossie(mv) + + +def test_complex_joined_dimension_filed_under_join_dataset(): + mv = ( + "version: '1.1'\nsource: c.s.fact\n" + "joins:\n- name: cust\n source: c.s.cust\n on: source.cid = cust.id\n" + "dimensions:\n- name: full\n expr: cust.a || cust.b\n" + ) + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + cust = next(d for d in ossie["semantic_model"][0]["datasets"] if d["name"] == "cust") + assert any(f["name"] == "full" for f in cust.get("fields", [])) + + +def test_non_equi_on_rejected(): + """Apache Ossie relationships are equi-joins (from_columns/to_columns required, minItems 1), so a + non-equi `on` has no Apache Ossie representation and is rejected on import (rather than emitting + a relationship with empty column lists, which is invalid per the Apache Ossie schema).""" + mv = ( + "version: '1.1'\n" + "source: c.s.fact\n" + "joins:\n" + "- name: dim\n" + " source: c.s.dim\n" + " on: source.a >= dim.b\n" + ) + with pytest.raises(ConversionError, match="non-equi"): + importer.convert_metric_view_to_ossie(mv) + + +def test_complex_equi_on_rejected(): + """An equi `on` whose operand is a SQL fragment (OR, computed) can't be decomposed + into from/to columns, so it's rejected rather than producing schema-invalid Apache Ossie with + empty column lists.""" + for cond in ("source.a = dim.b OR source.c = dim.d", "source.a = dim.b + 1"): + mv = ( + "version: '1.1'\nsource: c.s.fact\n" + f"joins:\n- name: dim\n source: c.s.dim\n on: {cond}\n" + ) + with pytest.raises(ConversionError, match="non-equi"): + importer.convert_metric_view_to_ossie(mv) + + +def test_one_to_many_join_flips_from_to_and_stashes_source(): + """A one_to_many MV join becomes an Apache Ossie relationship with the MANY side as `from` + (the joined table), the source/grain on the `to` side, and the grain recorded in + the model-level stash so re-export re-roots correctly.""" + mv = ( + "version: '1.1'\nsource: c.s.orders\n" + "joins:\n- name: line_items\n source: c.s.line_items\n" + " on: source.order_id = line_items.l_order_id\n" + " cardinality: one_to_many\n" + "measures:\n- {name: order_count, expr: COUNT(*)}\n" + ) + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + model = ossie["semantic_model"][0] + rel = model["relationships"][0] + assert rel["from"] == "line_items" # many side (holds the FK) + assert rel["to"] == "orders" # one side (holds the PK) + assert rel["from_columns"] == ["l_order_id"] + assert rel["to_columns"] == ["order_id"] + assert any(e["vendor_name"] == "DATABRICKS" and "source_dataset" in e["data"] + for e in model["custom_extensions"]) + + +def test_at_most_one_match_recovers_unique_key(): + """A many_to_one join with rely.at_most_one_match records the join key as a + unique_keys entry on the joined dataset (recovering key info Apache Ossie would lack).""" + mv = ( + "version: '1.1'\nsource: c.s.orders\n" + "joins:\n- name: customer\n source: c.s.customer\n" + " on: source.cid = customer.id\n" + " rely: {at_most_one_match: true}\n" + ) + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + cust = next(d for d in ossie["semantic_model"][0]["datasets"] if d["name"] == "customer") + assert cust.get("unique_keys") == [["id"]] + + +def test_join_named_source_rejected(): + """`source` is reserved for the fact; a join named `source` is rejected (DBR + forbids it too) rather than silently overwriting the fact alias.""" + mv = ("version: '1.1'\nsource: c.s.fact\n" + "joins:\n- name: source\n source: c.s.dim\n using: [id]\n") + with pytest.raises(ConversionError, match="reserved"): + importer.convert_metric_view_to_ossie(mv) + + +def test_sql_source_name_defaults_to_metric_view(): + """A SELECT/WITH source has no table name, so the model name defaults to + `metric_view` (not a token sliced out of the SQL).""" + mv = "version: '1.1'\nsource: SELECT a, b FROM main.sales.orders\n" + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + assert ossie["semantic_model"][0]["name"] == "metric_view" + + +def test_join_missing_source_raises(): + """Missing required keys surface as ConversionError, not a raw KeyError.""" + mv = "version: '1.1'\nsource: c.s.fact\njoins:\n- name: dim\n using: [id]\n" + with pytest.raises(ConversionError, match="missing required 'source'"): + importer.convert_metric_view_to_ossie(mv) + + +def test_measure_rewrite_with_regex_special_name(): + r"""The `source.` -> fact-name rewrite in measures inserts the name literally, so a + --name containing regex backreference syntax (e.g. \1) does not raise a re.error.""" + mv = "version: '1.1'\nsource: c.s.fact\nmeasures:\n- {name: rev, expr: SUM(source.amount)}\n" + ossie = parse(importer.convert_metric_view_to_ossie(mv, model_name=r"a\1b")) + expr = ossie["semantic_model"][0]["metrics"][0]["expression"]["dialects"][0]["expression"] + assert expr == r"SUM(a\1b.amount)" + + +def test_invalid_source_rejected(): + """A malformed source (not 3-part / not SELECT) is rejected on import, matching the + exporter -- rather than passing through and only failing on a later re-export.""" + mv = "version: '1.1'\nsource: a.b\n" # 2-part, invalid + with pytest.raises(ConversionError, match="3-part"): + importer.convert_metric_view_to_ossie(mv) + + +def test_malformed_yaml_raises_conversion_error(): + """Invalid YAML surfaces as ConversionError, not a raw yaml.YAMLError traceback.""" + with pytest.raises(ConversionError, match="Invalid YAML"): + importer.convert_metric_view_to_ossie("source: c.s.t\njoins: [oops\n") + + +def test_empty_using_rejected(): + """An empty `using: []` is a condition-less join -- rejected at import rather than + silently producing a relationship with empty key columns (which would then fail on + re-export).""" + mv = "version: '1.1'\nsource: c.s.fact\njoins:\n- name: dim\n source: c.s.dim\n using: []\n" + with pytest.raises(ConversionError, match="cross"): + importer.convert_metric_view_to_ossie(mv) + + +def test_boollike_string_values_stay_strings_for_a_yaml_1_1_reader(): + """Bool-like string scalars (e.g. the synonyms `on`/`off`) must be emitted quoted so a + stock YAML 1.1 reader reads them back as strings, not booleans.""" + import yaml + mv = ("version: '1.1'\nsource: c.s.t\n" + "dimensions:\n- {name: status, expr: status, synonyms: [on, off]}\n") + ossie_out = importer.convert_metric_view_to_ossie(mv) + field = yaml.safe_load(ossie_out)["semantic_model"][0]["datasets"][0]["fields"][0] + assert field["ai_context"]["synonyms"] == ["on", "off"] + + +def test_fact_qualifier_variants_in_on_decompose(): + """The fact side of an `on` is valid MV YAML whether qualified with `source`, the + source table name, or left bare; all decompose to the same equi-join columns rather + than being wrongly rejected as a non-equi condition (bug-bash finding).""" + base = ("version: '1.1'\nsource: c.s.orders\n" + "joins:\n- name: customer\n source: c.s.customer\n on: {cond}\n") + for cond in ( + "source.o_custkey = customer.c_custkey", # `source` qualifier + "orders.o_custkey = customer.c_custkey", # source table name + "o_custkey = customer.c_custkey", # bare fact column + "customer.c_custkey = o_custkey", # reversed operand order, bare fact + ): + rel = parse(importer.convert_metric_view_to_ossie( + base.format(cond=cond)))["semantic_model"][0]["relationships"][0] + assert rel["from"] == "orders" and rel["to"] == "customer" + assert rel["from_columns"] == ["o_custkey"] + assert rel["to_columns"] == ["c_custkey"] + + +def test_multi_column_on_with_bare_and_tablename_fact(): + """Composite keys decompose with bare / source-table-name fact qualifiers too.""" + mv = ("version: '1.1'\nsource: c.s.orders\n" + "joins:\n- name: customer\n source: c.s.customer\n" + " on: o_a = customer.c_a AND orders.o_b = customer.c_b\n") + rel = parse(importer.convert_metric_view_to_ossie(mv))["semantic_model"][0]["relationships"][0] + assert rel["from_columns"] == ["o_a", "o_b"] + assert rel["to_columns"] == ["c_a", "c_b"] + + +def test_join_named_source_rejected_any_case(): + """`source` is reserved case-insensitively (DBR identifiers are case-insensitive), + so `Source`/`SOURCE` are rejected too (bug-bash finding).""" + for name in ("Source", "SOURCE", "SoUrCe"): + mv = (f"version: '1.1'\nsource: c.s.fact\n" + f"joins:\n- name: {name}\n source: c.s.dim\n using: [id]\n") + with pytest.raises(ConversionError, match="reserved"): + importer.convert_metric_view_to_ossie(mv) + + +def test_empty_source_part_rejected(): + """A 3-dot source with an empty part (`.s.t`, `c..t`, `c.s.`) is not a valid 3-part + identifier and is rejected -- the dot count alone is not enough (bug-bash finding).""" + for src in (".s.t", "c..t", "c.s."): + mv = f"version: '1.1'\nsource: {src}\n" + with pytest.raises(ConversionError, match="3-part"): + importer.convert_metric_view_to_ossie(mv) + + +def test_whitespace_source_part_rejected(): + """A 3-dot source with a whitespace-laden part (`cat . sch . tbl`) is rejected -- + a space is not part of a valid identifier (review finding).""" + with pytest.raises(ConversionError, match="3-part"): + importer.convert_metric_view_to_ossie("version: '1.1'\nsource: cat . sch . tbl\n") + + +def test_with_paren_subquery_source_accepted(): + """A `WITH(...)` subquery with no space after the keyword is recognized as SQL, + not mistaken for a (non-3-part) identifier (review finding).""" + mv = "version: '1.1'\nsource: WITH(t AS (SELECT 1 AS a)) SELECT a FROM t\n" + ossie = parse(importer.convert_metric_view_to_ossie(mv)) + assert ossie["semantic_model"][0]["datasets"][0]["source"].startswith("WITH(") + + +def test_nested_join_bare_column_rejected(): + """A bare (unqualified) operand in a NESTED join's `on` is ambiguous (parent vs. + fact), so it is rejected rather than silently attributed to the immediate parent. + A bare fact column is still accepted at the top level (review finding).""" + mv = ("version: '1.1'\nsource: c.s.orders\n" + "joins:\n- name: customer\n source: c.s.customer\n on: source.ckey = customer.c_key\n" + " joins:\n - name: nation\n source: c.s.nation\n on: o_nkey = nation.n_key\n") + with pytest.raises(ConversionError, match="non-equi or unsupported"): + importer.convert_metric_view_to_ossie(mv) + + +def test_case_variant_duplicate_name_rejected(): + """DBR identifiers are case-insensitive, so `dim`/`Dim` collide and must be rejected + (consistent with the case-insensitive reserved-`source` check) (review finding).""" + mv = ("version: '1.1'\nsource: c.s.x\n" + "joins:\n- {name: dim, source: c.s.a, using: [id]}\n- {name: Dim, source: c.s.b, using: [id]}\n") + with pytest.raises(ConversionError, match="[Dd]uplicate"): + importer.convert_metric_view_to_ossie(mv) + + +def test_falsy_dimension_name_is_not_a_wildcard(): + """A present-but-falsy name (`0`) is a malformed column, not a wildcard projection; + it raises a clean error rather than being silently dropped (review finding).""" + with pytest.raises(ConversionError): + importer.convert_metric_view_to_ossie("version: '1.1'\nsource: c.s.o\ndimensions:\n- {name: 0, expr: x}\n") + + +def test_non_string_scalars_raise_clean_error(): + """Non-string scalars where a string is required (join name, measure expr) raise a + ConversionError, not a raw AttributeError/TypeError (review finding).""" + for mv in ("version: '1.1'\nsource: c.s.f\njoins:\n- {name: 5, source: c.s.d, using: [id]}\n", + "version: '1.1'\nsource: c.s.o\nmeasures:\n- {name: rev, expr: 5}\n"): + with pytest.raises(ConversionError): + importer.convert_metric_view_to_ossie(mv) + + +def test_using_join_emits_no_yaml_anchor(): + """`from_columns`/`to_columns` of a `using` join are distinct list objects, so the + output has no YAML anchor/alias (`&id`/`*id`) -- safer for other Apache Ossie consumers.""" + out = importer.convert_metric_view_to_ossie( + "version: '1.1'\nsource: c.s.a\njoins:\n- {name: b, source: c.s.b, using: [id]}\n") + assert "&id" not in out and "*id" not in out diff --git a/converters/databricks/tests/test_ossie_to_metric_view.py b/converters/databricks/tests/test_ossie_to_metric_view.py new file mode 100644 index 0000000..8b8808b --- /dev/null +++ b/converters/databricks/tests/test_ossie_to_metric_view.py @@ -0,0 +1,670 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Apache Ossie -> Databricks Metric View exporter.""" + +import json + +import pytest + +from ossie_databricks import ConversionError +from ossie_databricks import ossie_to_metric_view as exporter +from _util import canon, load_fixture, parse + + +def test_fixtureA_export_matches_expected(): + out = exporter.convert_ossie_to_metric_view(load_fixture("fixtureA_ossie.yaml")) + assert parse(out) == parse(load_fixture("fixtureA_metric_view.yaml")) + + +def test_tpcds_export_matches_expected(): + """A normalized TPC-DS star (fact + date/item/customer dims) exports to the expected + Metric View: a join tree, primary keys bridged to rely.at_most_one_match, joined + columns alias-qualified, and the filter/format stash carried through.""" + out = exporter.convert_ossie_to_metric_view(load_fixture("tpcds_ossie.yaml")) + assert parse(out) == parse(load_fixture("tpcds_metric_view.yaml")) + + +def test_unsupported_version_rejected(): + ossie = "version: '9.9.9'\nsemantic_model:\n - name: m\n datasets:\n - {name: d, source: c.s.t}\n" + with pytest.raises(ConversionError): + exporter.convert_ossie_to_metric_view(ossie) + + +def _model(rels): + return { + "version": exporter.OSSIE_VERSION, + "semantic_model": [ + { + "name": "m", + "datasets": [ + {"name": "a", "source": "c.s.a"}, + {"name": "b", "source": "c.s.b"}, + {"name": "x", "source": "c.s.x"}, + ], + "relationships": rels, + } + ], + } + + +def _rel(name, frm, to): + return {"name": name, "from": frm, "to": to, + "from_columns": ["k"], "to_columns": ["k"]} + + +def test_multiple_roots_raises(): + import yaml + # a->b leaves x as a second root. + ossie = yaml.safe_dump(_model([_rel("r1", "a", "b")])) + with pytest.raises(ConversionError, match="multiple candidate fact"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_triangle_is_rejected_as_cycle(): + import yaml + # a->b, a->x, b->x : b and x are equidistant from a (a triangle) -> not a tree. + ossie = yaml.safe_dump(_model([_rel("r1", "a", "b"), _rel("r2", "a", "x"), + _rel("r3", "b", "x")])) + with pytest.raises(ConversionError, match="cycle"): + exporter.convert_ossie_to_metric_view(ossie) + + +def _field(name, col): + return {"name": name, "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": col}]}} + + +def test_mto_diamond_fans_out(): + """A shared dimension reached by two parents (orders->customers->regions and + orders->suppliers->regions) is fanned out into two aliased joins, not rejected.""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", "fields": [_field("amt", "amount")]}, + {"name": "customers", "source": "c.s.customers"}, + {"name": "suppliers", "source": "c.s.suppliers"}, + {"name": "regions", "source": "c.s.regions", "fields": [_field("rname", "r_name")]}, + ], + "relationships": [ + {"name": "r1", "from": "orders", "to": "customers", "from_columns": ["cid"], "to_columns": ["id"]}, + {"name": "r2", "from": "orders", "to": "suppliers", "from_columns": ["sid"], "to_columns": ["id"]}, + {"name": "r3", "from": "customers", "to": "regions", "from_columns": ["rid"], "to_columns": ["id"]}, + {"name": "r4", "from": "suppliers", "to": "regions", "from_columns": ["rid"], "to_columns": ["id"]}, + ], + }]}) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + region_joins = [j for top in out["joins"] for j in top.get("joins", []) if j["source"] == "c.s.regions"] + assert {j["name"] for j in region_joins} == {"customers_regions", "suppliers_regions"} + dims = {d["name"]: d["expr"] for d in out["dimensions"]} + # the fanned `regions` is a depth-2 join, so its column is qualified by the full path + assert dims["customers_regions_rname"] == "customers.customers_regions.r_name" + assert dims["suppliers_regions_rname"] == "suppliers.suppliers_regions.r_name" + + +def test_otm_diamond_fans_out(): + """customers (fact) -> past_orders/future_orders -> line_items: the shared + line_items is fanned out, and every join is one_to_many.""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "customers", "source": "c.s.customers"}, + {"name": "past_orders", "source": "c.s.past_orders"}, + {"name": "future_orders", "source": "c.s.future_orders"}, + {"name": "line_items", "source": "c.s.line_items"}, + ], + "relationships": [ + {"name": "r1", "from": "past_orders", "to": "customers", "from_columns": ["cid"], "to_columns": ["id"]}, + {"name": "r2", "from": "future_orders", "to": "customers", "from_columns": ["cid"], "to_columns": ["id"]}, + {"name": "r3", "from": "line_items", "to": "past_orders", "from_columns": ["oid"], "to_columns": ["id"]}, + {"name": "r4", "from": "line_items", "to": "future_orders", "from_columns": ["oid"], "to_columns": ["id"]}, + ], + "metrics": [{"name": "cnt", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "COUNT(*)"}]}}], + }]}) + out = parse(exporter.convert_ossie_to_metric_view(ossie, source="customers")) + leaf_names = {j["name"] for top in out["joins"] for j in top.get("joins", [])} + assert leaf_names == {"past_orders_line_items", "future_orders_line_items"} + + def all_otm(joins): + return all(j.get("cardinality") == "one_to_many" and all_otm(j.get("joins", [])) + for j in joins) + assert all_otm(out["joins"]) + + +def test_cycle_raises(): + import yaml + # a->b->x->a : cycle, and no root. + ossie = yaml.safe_dump(_model([_rel("r1", "a", "b"), _rel("r2", "b", "x"), + _rel("r3", "x", "a")])) + with pytest.raises(ConversionError, match="cycle"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_no_unknown_keys_leak(): + """Exporter output must contain no key outside the v1.1 schema (the strict-parse + guard: no `custom_extensions`, no `sql_on`).""" + out = exporter.convert_ossie_to_metric_view(load_fixture("fixtureA_ossie.yaml")) + assert "custom_extensions" not in out + assert "sql_on" not in out + + +def test_primary_key_is_dropped(): + out = parse(exporter.convert_ossie_to_metric_view(load_fixture("fixtureA_ossie.yaml"))) + assert "primary_key" not in json.dumps(out) + + +def _single_fact_model(metric_expr): + import yaml + return yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [{"name": "orders", "source": "c.s.orders", + "fields": [{"name": "k", "expression": {"dialects": [ + {"dialect": "DATABRICKS", "expression": "k"}]}}]}], + "metrics": [{"name": "rev", "expression": {"dialects": [ + {"dialect": "DATABRICKS", "expression": metric_expr}]}}], + }], + }) + + +def test_measure_strips_fact_prefix(): + """Fact columns are bare in measure expressions (DBR idiom), not `source.`-qualified.""" + out = parse(exporter.convert_ossie_to_metric_view(_single_fact_model("SUM(orders.amount)"))) + assert out["measures"][0]["expr"] == "SUM(amount)" + + +def test_measure_keeps_lookalike_table_prefix(): + """A table whose name merely ends with the fact name is not stripped.""" + out = parse(exporter.convert_ossie_to_metric_view(_single_fact_model("SUM(store_orders.amount)"))) + assert out["measures"][0]["expr"] == "SUM(store_orders.amount)" + + +def test_invalid_source_rejected(): + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{"name": "m", "datasets": [{"name": "d", "source": "justatable"}]}], + }) + with pytest.raises(ConversionError, match="source"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_duplicate_dimension_name_rejected(): + # Two datasets each contributing a field named `id` collide in the single flat + # `dimensions` list. Metric Views require unique dimension/measure names, so this + # is rejected (rather than emitting a duplicate the user would hand to Databricks). + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", + "fields": [{"name": "id", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "id"}]}}]}, + {"name": "customer", "source": "c.s.customer", + "fields": [{"name": "id", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "id"}]}}]}, + ], + "relationships": [{"name": "r", "from": "orders", "to": "customer", + "from_columns": ["cid"], "to_columns": ["id"]}], + }], + }) + with pytest.raises(ConversionError, match="collides"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_measure_name_collides_with_dimension_rejected(): + # A measure whose name matches an existing dimension (case-insensitively) collides + # in the shared name space; Metric Views require uniqueness, so this is rejected. + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", + "fields": [{"name": "total", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "total"}]}}]}, + ], + "metrics": [ + {"name": "Total", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "SUM(total)"}]}}, + ], + }], + }) + with pytest.raises(ConversionError, match="collides"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_cascade_drop_downstream_measure_reference(): + """A measure that references a dropped measure via measure() is itself dropped + (transitively), so no dangling reference is emitted.""" + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [{"name": "f", "source": "c.s.f"}], + "metrics": [ + {"name": "base", "expression": {"dialects": [{"dialect": "SNOWFLAKE", "expression": "SUM(x)"}]}}, # dropped (no DBX/ANSI) + {"name": "derived", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "measure(base) * 2"}]}}, + {"name": "derived2", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "measure(derived) + 1"}]}}, # transitive + {"name": "ok", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "COUNT(*)"}]}}, + ], + }], + }) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + names = [m["name"] for m in out.get("measures", [])] + assert names == ["ok"] # base dropped; derived + derived2 cascade-dropped; ok survives + + +def test_cascade_drop_downstream_dimension_reference(): + """A field/measure referencing a dropped dimension by name is also dropped.""" + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [{"name": "f", "source": "c.s.f", "fields": [ + {"name": "region", "expression": {"dialects": [{"dialect": "SNOWFLAKE", "expression": "r"}]}}, # dropped dim + {"name": "label", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "upper(region)"}]}}, # references region + {"name": "keep", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "id"}]}}, + ]}], + }], + }) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + dims = [d["name"] for d in out.get("dimensions", [])] + assert dims == ["keep"] # region dropped; label cascade-dropped; keep survives + + +def test_orientation_unverifiable_when_to_side_has_no_key_warns(): + """If the `from` columns are a declared key but the `to` side declares no key, the + from/to orientation can't be verified; the converter leaves it as-is (no reorient) + and warns, rather than silently producing a possibly-inverted cardinality.""" + import warnings + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "a", "source": "c.s.a", "primary_key": ["a_id"], "fields": [ + {"name": "a_name", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "a_name"}]}}]}, + {"name": "b", "source": "c.s.b", "fields": [ + {"name": "b_name", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "b_name"}]}}]}, + ], + # from columns cover a's PK, but b (the `to` side) declares no key + "relationships": [{"name": "a_to_b", "from": "a", "to": "b", + "from_columns": ["a_id"], "to_columns": ["b_x"]}], + }], + }) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + exporter.convert_ossie_to_metric_view(ossie) + assert any("orientation can't be verified" in str(w.message) for w in caught) + + +def test_cascade_drop_skips_qualified_join_alias_collision(): + """A dropped field whose name collides with a join alias must NOT cascade-drop a + *qualified* `alias.col` reference. A genuine dimension reference is unqualified; + `region.r_name` points at the join `region`, a different thing than a dropped bare + `region`, so the cascade must leave it (and the joined column) alone.""" + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", "fields": [ + # dropped (no DBX/ANSI dialect); its name collides with the `region` join + {"name": "region", "expression": {"dialects": [{"dialect": "SNOWFLAKE", "expression": "r"}]}}, + # references the join alias `region`, not the dropped field -> must survive + {"name": "summary", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "region.r_name"}]}}, + ]}, + {"name": "region", "source": "c.s.region", "primary_key": ["r_key"], "fields": [ + {"name": "r_name", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "r_name"}]}}, + ]}, + ], + "relationships": [{"name": "orr", "from": "orders", "to": "region", + "from_columns": ["o_rkey"], "to_columns": ["r_key"]}], + }], + }) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + dims = [d["name"] for d in out.get("dimensions", [])] + # `region` field dropped; `summary` (refs alias region.r_name) and the joined + # `r_name` (qualified to region.r_name on export) both survive -- no false cascade. + assert "region" not in dims + assert "summary" in dims and "r_name" in dims + + +def _orders_lineitems_ossie(): + """Apache Ossie: line_items (many, FK l_order_id) -> orders (one, PK order_id).""" + import yaml + return yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "sales", + "datasets": [ + {"name": "orders", "source": "c.s.orders", "primary_key": ["order_id"], + "fields": [{"name": "order_date", "expression": {"dialects": [ + {"dialect": "DATABRICKS", "expression": "o_order_date"}]}}]}, + {"name": "line_items", "source": "c.s.line_items", + "fields": [{"name": "product_sk", "expression": {"dialects": [ + {"dialect": "DATABRICKS", "expression": "l_product_sk"}]}}]}, + ], + "relationships": [{"name": "li_to_order", "from": "line_items", "to": "orders", + "from_columns": ["l_order_id"], "to_columns": ["order_id"]}], + "metrics": [{"name": "order_count", "expression": {"dialects": [ + {"dialect": "DATABRICKS", "expression": "COUNT(*)"}]}}], + }], + }) + + +def test_source_on_to_side_derives_one_to_many(): + """Naming the PK/one-side dataset as the source makes its join one_to_many, and + the many-side table's columns drop (a field must resolve to one value/source row).""" + out = parse(exporter.convert_ossie_to_metric_view(_orders_lineitems_ossie(), source="orders")) + assert out["source"] == "c.s.orders" + join = out["joins"][0] + assert join["name"] == "line_items" + assert join["cardinality"] == "one_to_many" + assert join["on"] == "source.order_id = line_items.l_order_id" + assert [d["name"] for d in out.get("dimensions", [])] == ["order_date"] # product_sk dropped + + +def test_default_fact_is_fk_sink_and_many_to_one(): + """Without an explicit source the fact is the FK-sink (line_items) and the join to + orders is the default many_to_one (no explicit cardinality).""" + out = parse(exporter.convert_ossie_to_metric_view(_orders_lineitems_ossie())) + assert out["source"] == "c.s.line_items" + join = out["joins"][0] + assert join["name"] == "orders" + assert "cardinality" not in join + assert join["on"] == "source.l_order_id = orders.order_id" + + +def test_unknown_source_rejected(): + with pytest.raises(ConversionError, match="not a dataset"): + exporter.convert_ossie_to_metric_view(_orders_lineitems_ossie(), source="nope") + + +def test_one_to_many_subtree_must_stay_one_to_many(): + """A many_to_one join descending from a one_to_many join is rejected (DBR rule).""" + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders"}, + {"name": "line_items", "source": "c.s.line_items"}, + {"name": "product", "source": "c.s.product"}, + ], + "relationships": [ + {"name": "li_to_order", "from": "line_items", "to": "orders", # orders->li : OTM + "from_columns": ["l_order_id"], "to_columns": ["order_id"]}, + {"name": "li_to_product", "from": "line_items", "to": "product", # li->product : MTO + "from_columns": ["l_product_sk"], "to_columns": ["p_sk"]}, + ], + }], + }) + with pytest.raises(ConversionError, match="one-to-many"): + exporter.convert_ossie_to_metric_view(ossie, source="orders") + + +def test_primary_key_deduces_at_most_one_match(): + """A many_to_one join whose to_columns cover the target's declared primary_key + gets rely.at_most_one_match; a join to a key-less dataset does not.""" + import yaml + + def model(dim_extra): + dim = {"name": "customer", "source": "c.s.customer"} + dim.update(dim_extra) + return yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [{"name": "orders", "source": "c.s.orders"}, dim], + "relationships": [{"name": "r", "from": "orders", "to": "customer", + "from_columns": ["cid"], "to_columns": ["id"]}], + }]}) + + join = parse(exporter.convert_ossie_to_metric_view(model({"primary_key": ["id"]})))["joins"][0] + assert join.get("rely") == {"at_most_one_match": True} + join2 = parse(exporter.convert_ossie_to_metric_view(model({})))["joins"][0] + assert "rely" not in join2 + + +def test_mislabeled_from_to_reoriented_by_key(): + """When from/to is swapped but the declared keys show the real one-side, the + converter re-orients to the key side (warns) -- so fact selection and cardinality + come out identical to the well-formed model.""" + import warnings + import yaml + + def model(frm, to, from_cols, to_cols): + return yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", "primary_key": ["order_id"], + "fields": [_field("amt", "amount")]}, + {"name": "customer", "source": "c.s.customer", "primary_key": ["c_id"], + "fields": [_field("cname", "c_name")]}, + ], + "relationships": [{"name": "r", "from": frm, "to": to, + "from_columns": from_cols, "to_columns": to_cols}], + }]}) + + well = parse(exporter.convert_ossie_to_metric_view( + model("orders", "customer", ["cust_id"], ["c_id"]))) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + swapped = parse(exporter.convert_ossie_to_metric_view( + model("customer", "orders", ["c_id"], ["cust_id"]))) + assert any("mislabeled" in str(w.message) for w in caught) + assert swapped["source"] == "c.s.orders" # fact selection corrected to the FK holder + assert swapped == well # identical to the well-formed model + + +def test_dataset_named_source_is_renamed(): + """A dataset literally named `source` must not collide with the fact's reserved + `source` alias (would otherwise emit an ambiguous join).""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders"}, + {"name": "source", "source": "c.s.dim", "fields": [_field("x", "xcol")]}, + ], + "relationships": [{"name": "r", "from": "orders", "to": "source", + "from_columns": ["sid"], "to_columns": ["id"]}], + }]}) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + join = out["joins"][0] + assert join["name"] != "source" + assert join["on"] == f"source.sid = {join['name']}.id" + assert out["dimensions"][0]["expr"] == f"{join['name']}.xcol" + + +def test_fanout_alias_collision_deduped(): + """A real dataset whose name equals a synthesized fan-out alias still gets a + distinct alias -- no two joins share a name.""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders"}, + {"name": "customers", "source": "c.s.customers"}, + {"name": "suppliers", "source": "c.s.suppliers"}, + {"name": "regions", "source": "c.s.regions"}, + {"name": "customers_regions", "source": "c.s.cr"}, # collides with fan-out alias + ], + "relationships": [ + {"name": "r1", "from": "orders", "to": "customers", "from_columns": ["cid"], "to_columns": ["id"]}, + {"name": "r2", "from": "orders", "to": "suppliers", "from_columns": ["sid"], "to_columns": ["id"]}, + {"name": "r3", "from": "customers", "to": "regions", "from_columns": ["rid"], "to_columns": ["id"]}, + {"name": "r4", "from": "suppliers", "to": "regions", "from_columns": ["rid"], "to_columns": ["id"]}, + {"name": "r5", "from": "orders", "to": "customers_regions", "from_columns": ["xid"], "to_columns": ["id"]}, + ], + }]}) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + names = [] + + def collect(joins): + for j in joins: + names.append(j["name"]) + collect(j.get("joins", [])) + + collect(out["joins"]) + assert len(names) == len(set(names)), names # all join names unique + + +def test_malformed_input_raises_conversion_error(): + """Missing required keys surface as ConversionError, not a raw KeyError traceback.""" + import yaml + bad = yaml.safe_dump({"version": exporter.OSSIE_VERSION, + "semantic_model": [{"name": "m", "datasets": [{"source": "c.s.t"}]}]}) + with pytest.raises(ConversionError, match="missing required 'name'"): + exporter.convert_ossie_to_metric_view(bad) + + +def test_nameless_relationship_with_ai_context_does_not_crash(): + """A relationship may omit `name`; the dropped-ai_context warning must not raise a + raw KeyError when it has ai_context but no name.""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", "fields": [_field("amt", "amt")]}, + {"name": "customers", "source": "c.s.customers"}, + ], + "relationships": [ + {"from": "orders", "to": "customers", "from_columns": ["cid"], + "to_columns": ["id"], "ai_context": "joins orders to customers"}, + ], + }]}) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) # must not raise + assert out["joins"][0]["name"] == "customers" + + +def _cfield(name, expr): + return {"name": name, "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": expr}]}} + + +def test_fanout_complex_expr_dropped_not_emitted_ambiguously(): + """On a fanned-out (diamond) dataset, a simple column fans out into one aliased + dimension per instance, but a complex expression -- which cannot be attributed to a + single instance -- is dropped rather than emitted ambiguously.""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders"}, + {"name": "customers", "source": "c.s.customers"}, + {"name": "suppliers", "source": "c.s.suppliers"}, + {"name": "regions", "source": "c.s.regions", + "fields": [_cfield("r_name", "r_name"), _cfield("rfull", "r_a || r_b")]}, + ], + "relationships": [ + {"name": "r1", "from": "orders", "to": "customers", "from_columns": ["cid"], "to_columns": ["id"]}, + {"name": "r2", "from": "orders", "to": "suppliers", "from_columns": ["sid"], "to_columns": ["id"]}, + {"name": "r3", "from": "customers", "to": "regions", "from_columns": ["rid"], "to_columns": ["id"]}, + {"name": "r4", "from": "suppliers", "to": "regions", "from_columns": ["rid"], "to_columns": ["id"]}, + ], + }]}) + dims = parse(exporter.convert_ossie_to_metric_view(ossie)).get("dimensions", []) + # the simple column fans out into two unambiguous, alias-qualified dimensions ... + assert sum(1 for d in dims if d["name"].endswith("_r_name")) == 2 + # ... while the ambiguous complex expression is dropped (never emitted unqualified) + assert not any("||" in d["expr"] for d in dims) + + +def test_malformed_yaml_raises_conversion_error(): + """Invalid YAML surfaces as ConversionError, not a raw yaml.YAMLError traceback.""" + with pytest.raises(ConversionError, match="Invalid YAML"): + exporter.convert_ossie_to_metric_view("semantic_model: [oops\n") + + +def test_nested_join_uses_full_path_qualification(): + """A snowflake (orders -> customer -> nation) qualifies a column from the nested + `nation` join by its full join path from the source (`customer.nation.n_name`) -- the + Databricks nested-join rule -- not the single-level `nation.n_name`. A depth-1 join + stays single-name.""" + import yaml + ossie = yaml.safe_dump({"version": exporter.OSSIE_VERSION, "semantic_model": [{ + "name": "m", + "datasets": [ + {"name": "orders", "source": "c.s.orders", "fields": [_field("amt", "amount")]}, + {"name": "customer", "source": "c.s.customer", "fields": [_field("cname", "c_name")]}, + {"name": "nation", "source": "c.s.nation", "fields": [_field("nname", "n_name")]}, + ], + "relationships": [ + {"name": "r1", "from": "orders", "to": "customer", "from_columns": ["ckey"], "to_columns": ["c_key"]}, + {"name": "r2", "from": "customer", "to": "nation", "from_columns": ["nkey"], "to_columns": ["n_key"]}, + ], + }]}) + out = parse(exporter.convert_ossie_to_metric_view(ossie)) + exprs = {d["name"]: d["expr"] for d in out["dimensions"]} + assert exprs["cname"] == "customer.c_name" # depth-1: the join's own name + assert exprs["nname"] == "customer.nation.n_name" # depth-2: full path from source + # the nested join's `on:` still uses immediate names (single-level) + nation_join = out["joins"][0]["joins"][0] + assert nation_join["on"] == "customer.nkey = nation.n_key" + + +def test_case_variant_dataset_name_rejected(): + """DBR identifiers are case-insensitive, so two datasets differing only in case + (`customer`/`Customer`) collide and are rejected (review finding).""" + ossie = ("version: 0.2.0.dev0\nsemantic_model:\n- name: m\n datasets:\n" + " - {name: customer, source: c.s.c}\n - {name: Customer, source: c.s.c2}\n") + with pytest.raises(ConversionError, match="duplicate"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_non_string_field_expression_raises_clean_error(): + """A non-string dialect expression raises a ConversionError, not a raw crash + (review finding).""" + ossie = ("version: 0.2.0.dev0\nsemantic_model:\n- name: m\n datasets:\n" + " - name: o\n source: c.s.o\n fields:\n - name: d\n expression:\n" + " dialects:\n - {dialect: DATABRICKS, expression: 123}\n") + with pytest.raises(ConversionError, match="must be a string"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_scalar_join_columns_rejected(): + """`from_columns`/`to_columns` given as a scalar string (not a list) raise a clear + 'must be lists' error rather than a misleading character-count length error.""" + ossie = ("version: 0.2.0.dev0\nsemantic_model:\n- name: m\n datasets:\n" + " - {name: a, source: c.s.a}\n - {name: b, source: c.s.b}\n relationships:\n" + " - {name: ab, from: a, to: b, from_columns: cid, to_columns: id}\n") + with pytest.raises(ConversionError, match="must be lists"): + exporter.convert_ossie_to_metric_view(ossie) + + +def test_malformed_stash_json_raises_conversion_error(): + # A hand-edited DATABRICKS custom_extensions with invalid JSON in `data` must + # surface as a clean ConversionError, not a raw json.JSONDecodeError traceback. + import yaml + ossie = yaml.safe_dump({ + "version": exporter.OSSIE_VERSION, + "semantic_model": [{ + "name": "m", + "custom_extensions": [{"vendor_name": "DATABRICKS", "data": "{not valid json"}], + "datasets": [{"name": "f", "source": "c.s.f", + "fields": [{"name": "x", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "x"}]}}]}], + "metrics": [{"name": "n", "expression": {"dialects": [{"dialect": "DATABRICKS", "expression": "COUNT(*)"}]}}], + }], + }) + with pytest.raises(ConversionError, match="not valid JSON"): + exporter.convert_ossie_to_metric_view(ossie) diff --git a/converters/databricks/tests/test_roundtrip.py b/converters/databricks/tests/test_roundtrip.py new file mode 100644 index 0000000..c9d5629 --- /dev/null +++ b/converters/databricks/tests/test_roundtrip.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. + +"""Round-trip tests in both directions.""" + +from ossie_databricks import metric_view_to_ossie as importer +from ossie_databricks import ossie_to_metric_view as exporter +from _util import canon, load_fixture, parse, strip_dropped + + +def test_ossie_to_mv_to_ossie(): + """Apache Ossie -> MV -> Apache Ossie preserves everything except the documented drops + (model name, primary_key/unique_keys).""" + ossie_in = load_fixture("fixtureA_ossie.yaml") + mv = exporter.convert_ossie_to_metric_view(ossie_in) + ossie_out = importer.convert_metric_view_to_ossie(mv) + assert strip_dropped(parse(ossie_out)) == strip_dropped(parse(ossie_in)) + + +def test_using_clause_round_trips(): + """A `using` join survives MV -> Apache Ossie -> MV (equal column lists re-emit as `using`).""" + mv_in = ( + "version: '1.1'\nsource: c.s.fact\n" + "joins:\n- name: dim\n source: c.s.dim\n using: [id]\n" + "measures:\n- name: n\n expr: count(*)\n" + ) + ossie = importer.convert_metric_view_to_ossie(mv_in) + join = parse(exporter.convert_ossie_to_metric_view(ossie))["joins"][0] + assert join.get("using") == ["id"] + assert "on" not in join + + +def test_mv_to_ossie_to_mv_is_lossless(): + """MV -> Apache Ossie -> MV is byte-faithful (structurally): the stash carries every + MV-only feature through Apache Ossie and back.""" + mv_in = load_fixture("fixtureB_metric_view.yaml") + ossie = importer.convert_metric_view_to_ossie(mv_in) + mv_out = exporter.convert_ossie_to_metric_view(ossie) + assert parse(mv_out) == parse(mv_in) + + +def test_tpcds_mv_round_trips(): + """The TPC-DS Metric View (multi-join star with rely/filter/format) survives + MV -> Apache Ossie -> MV unchanged.""" + mv_in = load_fixture("tpcds_metric_view.yaml") + ossie = importer.convert_metric_view_to_ossie(mv_in) + mv_out = exporter.convert_ossie_to_metric_view(ossie) + assert parse(mv_out) == parse(mv_in) + + +def test_one_to_many_round_trips_mv_ossie_mv(): + """A one_to_many Metric View survives MV -> Apache Ossie -> MV: cardinality rides the + relationship direction (+ stash), and the source/grain rides the model stash, so + the exporter re-roots at `orders` rather than the FK-sink `line_items`.""" + mv_in = ( + "version: '1.1'\nsource: c.s.orders\ncomment: Orders\n" + "joins:\n- name: line_items\n source: c.s.line_items\n" + " on: source.order_id = line_items.l_order_id\n" + " cardinality: one_to_many\n" + "dimensions:\n- {name: order_date, expr: o_order_date}\n" + "measures:\n- {name: order_count, expr: COUNT(*)}\n" + ) + ossie = importer.convert_metric_view_to_ossie(mv_in) + mv_out = exporter.convert_ossie_to_metric_view(ossie) + assert parse(mv_out) == parse(mv_in) + + +# Property-based round-trip coverage. The Hypothesis driver lives in +# test_roundtrip_properties.py; these run the same generators/assertions under a plain +# seeded RNG so the property coverage also holds where Hypothesis is not installed. + +def test_property_mv_to_ossie_to_mv_seeded(): + from _roundtrip_helpers import RandomRnd, assert_mv_roundtrip, build_metric_view + for seed in range(250): + assert_mv_roundtrip(build_metric_view(RandomRnd(seed))) + + +def test_property_ossie_to_mv_to_ossie_seeded(): + from _roundtrip_helpers import RandomRnd, assert_ossie_roundtrip, build_ossie + for seed in range(250): + assert_ossie_roundtrip(build_ossie(RandomRnd(1_000_000 + seed))) diff --git a/converters/databricks/tests/test_roundtrip_properties.py b/converters/databricks/tests/test_roundtrip_properties.py new file mode 100644 index 0000000..d022ebb --- /dev/null +++ b/converters/databricks/tests/test_roundtrip_properties.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. + +"""Property-based round-trip tests (Hypothesis). + +For any generated model in the round-trippable subset, converting one direction and +back preserves content: + + - MV -> Apache Ossie -> MV : source, every dimension/measure name+expression+metadata, every + join (name, source, condition, cardinality, rely) and the join nesting, and the + model-level filter/comment/materialization. + - Apache Ossie -> MV -> Apache Ossie : dataset names+sources+fields, relationship from/to/columns, + metric name+expression, and the model description. + +The model generation and the assertions live in `_roundtrip_helpers` (no test-framework +dependency) so the exact same logic also runs under a plain seeded RNG where Hypothesis +is unavailable (see the `*_seeded` tests in test_roundtrip.py). This file is the thin +Hypothesis driver: it maps draws into the shared `Rnd` interface and runs the properties. + +Run: `pytest test_roundtrip_properties.py` (needs `hypothesis`). +""" + +import pytest + +pytest.importorskip("hypothesis") # skip cleanly if hypothesis is not installed + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from _roundtrip_helpers import ( + assert_mv_roundtrip, + assert_ossie_roundtrip, + build_metric_view, + build_ossie, +) + +# Alphanumeric metadata text with optional interior spaces (no leading/trailing space, +# no YAML-special characters), so values survive a dump/load cycle verbatim. +_safe_text = st.from_regex(r"[A-Za-z0-9]([A-Za-z0-9 ]{0,18}[A-Za-z0-9])?", fullmatch=True) +# A SQL column-style identifier. +_colident = st.from_regex(r"[a-z_][a-z0-9_]{0,7}", fullmatch=True) + +_SETTINGS = settings( + max_examples=300, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +class _HypothesisRnd: + """The `Rnd` interface backed by a Hypothesis `draw`. `chance(p)` ignores `p` + (Hypothesis explores both branches regardless).""" + + def __init__(self, draw): + self._draw = draw + + def chance(self, p=0.5): + return self._draw(st.booleans()) + + def count(self, lo, hi): + return self._draw(st.integers(min_value=lo, max_value=hi)) + + def pick(self, seq): + return self._draw(st.sampled_from(list(seq))) + + def text(self): + return self._draw(_safe_text) + + def colname(self): + return self._draw(_colident) + + +@st.composite +def metric_views(draw): + return build_metric_view(_HypothesisRnd(draw)) + + +@st.composite +def ossie_models(draw): + return build_ossie(_HypothesisRnd(draw)) + + +class TestMetricViewRoundTrip: + @given(mv=metric_views()) + @_SETTINGS + def test_mv_to_ossie_to_mv(self, mv): + assert_mv_roundtrip(mv) + + +class TestOSIRoundTrip: + @given(ossie=ossie_models()) + @_SETTINGS + def test_ossie_to_mv_to_ossie(self, ossie): + assert_ossie_roundtrip(ossie) diff --git a/converters/dbt/README.md b/converters/dbt/README.md index ae31a01..c39090f 100644 --- a/converters/dbt/README.md +++ b/converters/dbt/README.md @@ -1,11 +1,30 @@ -# osi-dbt + + +# apache-ossie-dbt + +Converts between dbt's [MetricFlow Semantic Interface](https://docs.getdbt.com/docs/build/about-metricflow) (MSI) and the [Apache Ossie](https://github.com/apache/ossie) format. Both conversion directions are supported: -- `msi-to-osi` — `semantic_manifest.json` (dbt output) → OSI YAML -- `osi-to-msi` — OSI YAML → `semantic_manifest.json` +- `msi-to-osi` — `semantic_manifest.json` (dbt output) → Ossie YAML +- `osi-to-msi` — Ossie YAML → `semantic_manifest.json` ## Requirements @@ -15,18 +34,18 @@ Both conversion directions are supported: ## Installation ```bash -pip install osi-dbt +pip install apache-ossie-dbt ``` Or with uv: ```bash -uv add osi-dbt +uv add apache-ossie-dbt ``` ## CLI usage -### dbt → OSI +### dbt → Apache Ossie Generate `semantic_manifest.json` from your dbt project first: @@ -35,24 +54,24 @@ dbt parse # output: target/semantic_manifest.json ``` -Then convert to OSI YAML: +Then convert to Ossie YAML: ```bash -osi-dbt msi-to-osi -i target/semantic_manifest.json -o semantic_model.yaml +ossie-dbt msi-to-osi -i target/semantic_manifest.json -o semantic_model.yaml ``` -By default the OSI semantic model is named `semantic_model`. Override it with `--model-name`: +By default the Ossie semantic model is named `semantic_model`. Override it with `--model-name`: ```bash -osi-dbt msi-to-osi -i target/semantic_manifest.json -o semantic_model.yaml --model-name my_project +ossie-dbt msi-to-osi -i target/semantic_manifest.json -o semantic_model.yaml --model-name my_project ``` Conversion issues (e.g. dropped CONVERSION or PRIVATE metrics) are printed as warnings to stderr. The output file is still written. -### OSI → dbt +### Apache Ossie → dbt ```bash -osi-dbt osi-to-msi -i semantic_model.yaml -o semantic_manifest.json +ossie-dbt osi-to-msi -i semantic_model.yaml -o semantic_manifest.json ``` Produces a `semantic_manifest.json` that metricflow can load. @@ -60,18 +79,18 @@ Produces a `semantic_manifest.json` that metricflow can load. ### Help ```bash -osi-dbt --help -osi-dbt msi-to-osi --help -osi-dbt osi-to-msi --help +ossie-dbt --help +ossie-dbt msi-to-osi --help +ossie-dbt osi-to-msi --help ``` ## Python API ```python -from osi_dbt import MSIToOSIConverter, OSIToMSIConverter +from ossie_dbt import MSIToOSIConverter, OSIToMSIConverter from metricflow_semantics.model.dbt_manifest_parser import parse_manifest_from_dbt_generated_manifest -# dbt → OSI +# dbt → Apache Ossie manifest = parse_manifest_from_dbt_generated_manifest(Path("target/semantic_manifest.json").read_text()) result = MSIToOSIConverter().convert(manifest, osi_model_name="my_project") @@ -80,9 +99,9 @@ for issue in result.issues: osi_yaml = result.output.to_osi_yaml() -# OSI → dbt +# Apache Ossie → dbt import yaml -from osi import OSIDocument +from ossie import OSIDocument document = OSIDocument.model_validate(yaml.safe_load(Path("semantic_model.yaml").read_text())) result = OSIToMSIConverter().convert(document) @@ -91,21 +110,21 @@ manifest_json = result.output.model_dump_json(by_alias=True, exclude_none=True, ### Conversion notes -**MSI → OSI** is lossy in the following ways, each recorded as a `ConverterIssue` in the result: +**MSI → Ossie** is lossy in the following ways, each recorded as a `ConverterIssue` in the result: | Issue type | Reason | |---|---| -| `CONVERSION_METRIC_DROPPED` | OSI has no conversion-funnel metric type | -| `PRIVATE_METRIC_DROPPED` | OSI has no visibility modifiers | -| `NATURAL_ENTITY_DROPPED` | OSI has no natural-key entity type | -| `CUMULATIVE_SEMANTICS_LOSS` | Window/grain semantics cannot be expressed in an OSI expression string; the base aggregation is preserved | +| `CONVERSION_METRIC_DROPPED` | Ossie has no conversion-funnel metric type | +| `PRIVATE_METRIC_DROPPED` | Ossie has no visibility modifiers | +| `NATURAL_ENTITY_DROPPED` | Ossie has no natural-key entity type | +| `CUMULATIVE_SEMANTICS_LOSS` | Window/grain semantics cannot be expressed in an Ossie expression string; the base aggregation is preserved | -**OSI → MSI** reconstructs a best-effort MSI manifest from OSI's simpler schema. Nothing is dropped, but OSI carries less structural information than MSI, so the converter makes the following choices: +**Ossie → MSI** reconstructs a best-effort MSI manifest from Ossie's simpler schema. Nothing is dropped, but Ossie carries less structural information than MSI, so the converter makes the following choices: - Single aggregations (`SUM(col)`, `COUNT(DISTINCT col)`, etc.) → SIMPLE metric with `metric_aggregation_params` - `(expr_a) / (expr_b)` → RATIO metric with auto-generated sub-metrics - Anything else → SIMPLE metric with the raw expression stored verbatim -- Time dimensions always receive `TimeGranularity.DAY` (OSI carries no granularity field) +- Time dimensions always receive `TimeGranularity.DAY` (Ossie carries no granularity field) ## Development @@ -115,8 +134,7 @@ uv sync uv run pytest ``` -Generate initial syrupy snapshots on first run: - +Generate new Syrupy snapshots: ```bash uv run pytest --snapshot-update ``` diff --git a/converters/dbt/pyproject.toml b/converters/dbt/pyproject.toml index f1bb3b0..5dea258 100644 --- a/converters/dbt/pyproject.toml +++ b/converters/dbt/pyproject.toml @@ -1,34 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" +[dependency-groups] +dev = [ + "pytest>=8.0", + "syrupy>=4.0", +] + [project] -name = "osi-dbt" +name = "apache-ossie-dbt" version = "0.2.0.dev0" -description = "dbt (MetricFlow Semantic Interface) <> OSI converter" +description = "dbt (MetricFlow Semantic Interface) <> Apache Ossie converter" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] requires-python = ">=3.11" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", + "dbt" +] dependencies = [ - "osi-python>=0.2.0.dev0", - "metricflow>=0.200", + "apache-ossie>=0.2.0.dev0", + "metricflow>=0.209.0", "sqlglot>=20.0", "jinja2>=3.0", "PyYAML>=6.0", ] -[project.license] -text = "Apache-2.0" - [project.scripts] -osi-dbt = "osi_dbt.cli:main" +ossie-dbt = "ossie_dbt.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" [tool.hatch.build.targets.wheel] -packages = ["src/osi_dbt"] +packages = ["src/ossie_dbt"] + +[tool.pytest.ini_options] +testpaths = ["tests"] [tool.uv] -dev-dependencies = [ - "pytest>=8.0", - "syrupy>=4.0", +required-version = ">=0.9.0" +default-groups = [ + "dev" ] -[tool.pytest.ini_options] -testpaths = ["tests"] +# apache-ossie is not yet published to PyPI; resolve it from the in-repo +# package for now. Remove this block once apache-ossie published to PyPI. +[tool.uv.sources] +apache-ossie = { path = "../../python", editable = true} \ No newline at end of file diff --git a/converters/dbt/src/osi_dbt/__init__.py b/converters/dbt/src/osi_dbt/__init__.py deleted file mode 100644 index 5cc35cb..0000000 --- a/converters/dbt/src/osi_dbt/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from osi_dbt.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult -from osi_dbt.msi_to_osi import MSIToOSIConverter -from osi_dbt.osi_to_msi import OSIToMSIConverter - -__all__ = [ - "ConverterIssue", - "ConverterIssueType", - "ConverterResult", - "MSIToOSIConverter", - "OSIToMSIConverter", -] diff --git a/converters/dbt/src/ossie_dbt/__init__.py b/converters/dbt/src/ossie_dbt/__init__.py new file mode 100644 index 0000000..f3f2492 --- /dev/null +++ b/converters/dbt/src/ossie_dbt/__init__.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 ossie_dbt.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult +from ossie_dbt.msi_to_osi import MSIToOSIConverter +from ossie_dbt.osi_to_msi import OSIToMSIConverter + +__all__ = [ + "ConverterIssue", + "ConverterIssueType", + "ConverterResult", + "MSIToOSIConverter", + "OSIToMSIConverter", +] diff --git a/converters/dbt/src/osi_dbt/cli.py b/converters/dbt/src/ossie_dbt/cli.py similarity index 58% rename from converters/dbt/src/osi_dbt/cli.py rename to converters/dbt/src/ossie_dbt/cli.py index 5dd8a29..8d71faa 100644 --- a/converters/dbt/src/osi_dbt/cli.py +++ b/converters/dbt/src/ossie_dbt/cli.py @@ -1,8 +1,25 @@ -"""CLI entry point for the osi-dbt converter. +# 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. + +"""CLI entry point for the ossie-dbt converter. Usage: - osi-dbt msi-to-osi -i semantic_manifest.json -o output.yaml - osi-dbt osi-to-msi -i input.yaml -o semantic_manifest.json + ossie-dbt msi-to-osi -i semantic_manifest.json -o output.yaml + ossie-dbt osi-to-msi -i input.yaml -o semantic_manifest.json """ import argparse @@ -12,18 +29,18 @@ import yaml -from osi import OSIDocument -from osi_dbt.converter_issues import ConverterIssueType -from osi_dbt.msi_to_osi import MSIToOSIConverter -from osi_dbt.osi_to_msi import OSIToMSIConverter +from ossie import OSIDocument +from ossie_dbt.converter_issues import ConverterIssueType +from ossie_dbt.msi_to_osi import MSIToOSIConverter +from ossie_dbt.osi_to_msi import OSIToMSIConverter from metricflow_semantics.model.dbt_manifest_parser import parse_manifest_from_dbt_generated_manifest _ISSUE_REASON: dict[ConverterIssueType, str] = { - ConverterIssueType.CONVERSION_METRIC_DROPPED: "OSI has no conversion-funnel metric type", - ConverterIssueType.PRIVATE_METRIC_DROPPED: "OSI has no visibility modifiers", - ConverterIssueType.NATURAL_ENTITY_DROPPED: "OSI has no natural-key entity type", - ConverterIssueType.CUMULATIVE_SEMANTICS_LOSS: "OSI expressions cannot represent window or grain semantics; the base aggregation was preserved", + ConverterIssueType.CONVERSION_METRIC_DROPPED: "Ossie has no conversion-funnel metric type", + ConverterIssueType.PRIVATE_METRIC_DROPPED: "Ossie has no visibility modifiers", + ConverterIssueType.NATURAL_ENTITY_DROPPED: "Ossie has no natural-key entity type", + ConverterIssueType.CUMULATIVE_SEMANTICS_LOSS: "Ossie expressions cannot represent window or grain semantics; the base aggregation was preserved", } _DROPPED_ISSUE_TYPES = { @@ -64,20 +81,20 @@ def _cmd_osi_to_msi(args: argparse.Namespace) -> None: def main() -> None: parser = argparse.ArgumentParser( - prog="osi-dbt", - description="Convert between dbt semantic_manifest.json and OSI YAML.", + prog="ossie-dbt", + description="Convert between dbt semantic_manifest.json and Ossie YAML.", ) subparsers = parser.add_subparsers(dest="command", required=True) - msi_to_osi = subparsers.add_parser("msi-to-osi", help="Convert semantic_manifest.json → OSI YAML") + msi_to_osi = subparsers.add_parser("msi-to-osi", help="Convert semantic_manifest.json → Ossie YAML") msi_to_osi.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to semantic_manifest.json") - msi_to_osi.add_argument("-o", "--output", required=True, metavar="FILE", help="Path for output OSI YAML") + msi_to_osi.add_argument("-o", "--output", required=True, metavar="FILE", help="Path for output Ossie YAML") msi_to_osi.add_argument( - "--model-name", default="semantic_model", metavar="NAME", help="OSI semantic model name (default: semantic_model)" + "--model-name", default="semantic_model", metavar="NAME", help="Ossie semantic model name (default: semantic_model)" ) - osi_to_msi = subparsers.add_parser("osi-to-msi", help="Convert OSI YAML → semantic_manifest.json") - osi_to_msi.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to OSI YAML") + osi_to_msi = subparsers.add_parser("osi-to-msi", help="Convert Ossie YAML → semantic_manifest.json") + osi_to_msi.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to Ossie YAML") osi_to_msi.add_argument("-o", "--output", required=True, metavar="FILE", help="Path for output semantic_manifest.json") args = parser.parse_args() diff --git a/converters/dbt/src/osi_dbt/converter_issues.py b/converters/dbt/src/ossie_dbt/converter_issues.py similarity index 52% rename from converters/dbt/src/osi_dbt/converter_issues.py rename to converters/dbt/src/ossie_dbt/converter_issues.py index 7c87862..e1606fa 100644 --- a/converters/dbt/src/osi_dbt/converter_issues.py +++ b/converters/dbt/src/ossie_dbt/converter_issues.py @@ -1,3 +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 dataclasses import dataclass from enum import Enum from typing import Generic, List, TypeVar diff --git a/converters/dbt/src/osi_dbt/expression_utils.py b/converters/dbt/src/ossie_dbt/expression_utils.py similarity index 85% rename from converters/dbt/src/osi_dbt/expression_utils.py rename to converters/dbt/src/ossie_dbt/expression_utils.py index a9dfbd2..8e481df 100644 --- a/converters/dbt/src/osi_dbt/expression_utils.py +++ b/converters/dbt/src/ossie_dbt/expression_utils.py @@ -1,3 +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 typing import Optional, Tuple import sqlglot diff --git a/converters/dbt/src/osi_dbt/filter_utils.py b/converters/dbt/src/ossie_dbt/filter_utils.py similarity index 83% rename from converters/dbt/src/osi_dbt/filter_utils.py rename to converters/dbt/src/ossie_dbt/filter_utils.py index a10642f..9875999 100644 --- a/converters/dbt/src/osi_dbt/filter_utils.py +++ b/converters/dbt/src/ossie_dbt/filter_utils.py @@ -1,3 +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 typing import List, Optional, Sequence import jinja2 @@ -97,7 +114,7 @@ def _render_filter_template(template: str) -> str: `{{ TimeDimension('order__ds', 'day') }}`, `{{ Entity('user') }}`, and `{{ Metric('revenue') }}` are resolved to their column-name equivalents using lightweight stubs. The output is a best-effort SQL - string suitable for embedding in an OSI expression. + string suitable for embedding in an Ossie expression. """ return jinja2.Template(template, undefined=jinja2.StrictUndefined).render( Dimension=_DimensionStub, @@ -115,7 +132,7 @@ def _collect_filter_sql(*filters: Optional[WhereFilterIntersection]) -> Optional as `order__status`. These are *not* fully resolved SQL column aliases — resolving to actual table column names would require `WhereFilterSpecFactory` and `ColumnAssociationResolver` from `metricflow_semantics`, which is out - of scope here. OSI consumers are expected to perform their own column + of scope here. Ossie consumers are expected to perform their own column resolution against the source data. """ parts: List[str] = [] diff --git a/converters/dbt/src/osi_dbt/msi_to_osi.py b/converters/dbt/src/ossie_dbt/msi_to_osi.py similarity index 93% rename from converters/dbt/src/osi_dbt/msi_to_osi.py rename to converters/dbt/src/ossie_dbt/msi_to_osi.py index 6a7dbec..db9e131 100644 --- a/converters/dbt/src/osi_dbt/msi_to_osi.py +++ b/converters/dbt/src/ossie_dbt/msi_to_osi.py @@ -1,10 +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 re from collections import defaultdict from dataclasses import dataclass from itertools import combinations from typing import Dict, List, Optional, Sequence, Tuple -from osi import ( +from ossie import ( OSIDataset, OSIDialect, OSIDialectExpression, @@ -16,8 +33,8 @@ OSIRelationship, OSISemanticModel, ) -from osi_dbt.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult -from osi_dbt.filter_utils import _collect_filter_sql, _merge_filter_sqls +from ossie_dbt.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult +from ossie_dbt.filter_utils import _collect_filter_sql, _merge_filter_sqls from metricflow_semantic_interfaces.enum_extension import assert_values_exhausted from metricflow_semantic_interfaces.implementations.semantic_manifest import PydanticSemanticManifest @@ -57,7 +74,7 @@ class _RelationshipDirection: class MSIToOSIConverter: - """Converts an MSI SemanticManifest into an OSI Document.""" + """Converts an MSI SemanticManifest into an Ossie Document.""" def __init__(self, dialect: OSIDialect = OSIDialect.ANSI_SQL) -> None: self._dialect = dialect @@ -265,7 +282,7 @@ def _resolve_cumulative( ) -> str: """Resolve a CUMULATIVE metric to its base aggregation expression. - Window/grain semantics are not representable in an OSI expression string. + Window/grain semantics are not representable in an Ossie expression string. """ cumulative_params = metric.type_params.cumulative_type_params if cumulative_params is None or cumulative_params.metric is None: @@ -355,9 +372,9 @@ def _build_entity_index( def _relationship_direction( ds_a: str, col_a: str, type_a: EntityType, ds_b: str, col_b: str, type_b: EntityType ) -> _RelationshipDirection: - """Return a _RelationshipDirection obeying OSI directionality. + """Return a _RelationshipDirection obeying Ossie directionality. - OSI spec: `from` is the many-side (FK holder), `to` is the one-side (PK holder). + Ossie spec: `from` is the many-side (FK holder), `to` is the one-side (PK holder). FOREIGN entities are always the many-side; PRIMARY/UNIQUE are the one-side. When both sides share the same cardinality tier, break ties alphabetically by dataset name. """ @@ -378,7 +395,7 @@ def _relationship_direction( def _build_relationships( entity_index: Dict[str, List[_EntityEntry]], ) -> List[OSIRelationship]: - """Resolve implicit MSI entity links into explicit OSI relationships. + """Resolve implicit MSI entity links into explicit Ossie relationships. Every pair of datasets sharing an entity name is a valid join path. """ diff --git a/converters/dbt/src/osi_dbt/osi_to_msi.py b/converters/dbt/src/ossie_dbt/osi_to_msi.py similarity index 92% rename from converters/dbt/src/osi_dbt/osi_to_msi.py rename to converters/dbt/src/ossie_dbt/osi_to_msi.py index 5eb9e49..1da337d 100644 --- a/converters/dbt/src/osi_dbt/osi_to_msi.py +++ b/converters/dbt/src/ossie_dbt/osi_to_msi.py @@ -1,7 +1,24 @@ +# 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 typing import List, Optional, Set -from osi import ( +from ossie import ( OSIDataset, OSIDialect, OSIDocument, @@ -9,8 +26,8 @@ OSIField, OSISemanticModel, ) -from osi_dbt.converter_issues import ConverterResult -from osi_dbt.expression_utils import ( +from ossie_dbt.converter_issues import ConverterResult +from ossie_dbt.expression_utils import ( _extract_agg_info, _get_raw_inner_col, _strip_qualifier, @@ -58,9 +75,9 @@ class _KeySets: class OSIToMSIConverter: - """Converts an OSI Document into a PydanticSemanticManifest. + """Converts an Ossie Document into a PydanticSemanticManifest. - The conversion is inherently lossy: OSI stores metrics as raw SQL expressions + The conversion is inherently lossy: Ossie stores metrics as raw SQL expressions and carries no metric-type metadata (SIMPLE / RATIO / CUMULATIVE / …). The converter reconstructs a best-effort MSI manifest using the following rules: @@ -68,7 +85,7 @@ class OSIToMSIConverter: * Fields are classified as entities or dimensions using key and relationship metadata. Aggregation info now lives directly on metrics (via `metric_aggregation_params`), not on semantic model measures. - * Time dimensions always receive `TimeGranularity.DAY` — OSI has no + * Time dimensions always receive `TimeGranularity.DAY` — Ossie has no granularity field. * Metric expressions are parsed with sqlglot: - single-agg patterns (`SUM(col)`, `COUNT(DISTINCT col)`, …) → SIMPLE @@ -159,7 +176,7 @@ def _classify_field( entities: List[PydanticEntity], dimensions: List[PydanticDimension], ) -> None: - """Classify a single OSI field and append it to the appropriate list. + """Classify a single Ossie field and append it to the appropriate list. Classification order (first match wins): 1. primary_key → PRIMARY entity @@ -211,7 +228,7 @@ def _classify_field( ) return if field.dimension is not None and field.dimension.is_time: - # OSI carries no granularity metadata; default to DAY. + # Ossie carries no granularity metadata; default to DAY. dimensions.append( PydanticDimension( name=field.name, @@ -254,7 +271,7 @@ def _convert_metric( description: Optional[str], datasets: List[OSIDataset], ) -> List[PydanticMetric]: - """Return one or more PydanticMetric objects for the given OSI expression. + """Return one or more PydanticMetric objects for the given Ossie expression. Simple metrics use `metric_aggregation_params` to store aggregation type and column expression directly — no intermediate measure is created. diff --git a/converters/dbt/tests/__init__.py b/converters/dbt/tests/__init__.py index e69de29..2456923 100644 --- a/converters/dbt/tests/__init__.py +++ b/converters/dbt/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/dbt/tests/__snapshots__/test_msi_to_osi.ambr b/converters/dbt/tests/__snapshots__/test_msi_to_osi.ambr new file mode 100644 index 0000000..facd135 --- /dev/null +++ b/converters/dbt/tests/__snapshots__/test_msi_to_osi.ambr @@ -0,0 +1,200 @@ +# serializer version: 1 +# 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: TestMetricConversion.test_derived_metric_nested + ''' + version: 0.2.0.dev0 + dialects: + - ANSI_SQL + semantic_model: + - name: semantic_model + datasets: + - name: orders + source: schema.table + fields: + - name: revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: amount + - name: cost + expression: + dialects: + - dialect: ANSI_SQL + expression: cost_amount + - name: expenses + expression: + dialects: + - dialect: ANSI_SQL + expression: expense_amount + metrics: + - name: revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) + - name: cost + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.cost_amount) + - name: expenses + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.expense_amount) + - name: gross_profit + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) - SUM(orders.cost_amount) + - name: net_profit + expression: + dialects: + - dialect: ANSI_SQL + expression: (SUM(orders.amount) - SUM(orders.cost_amount)) - SUM(orders.expense_amount) + + ''' +# --- +# name: TestMetricConversion.test_ratio_metric_inlines_sub_expressions + ''' + version: 0.2.0.dev0 + dialects: + - ANSI_SQL + semantic_model: + - name: semantic_model + datasets: + - name: orders + source: schema.table + fields: + - name: revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: amount + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: CASE WHEN order_id IS NOT NULL THEN 1 ELSE 0 END + metrics: + - name: revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(CASE WHEN orders.order_id IS NOT NULL THEN 1 ELSE 0 END) + - name: arpu + expression: + dialects: + - dialect: ANSI_SQL + expression: (SUM(orders.amount)) / (SUM(CASE WHEN orders.order_id IS NOT NULL + THEN 1 ELSE 0 END)) + + ''' +# --- +# name: TestMetricFilterFlattening.test_metric_and_measure_filters_combined_with_and + ''' + version: 0.2.0.dev0 + dialects: + - ANSI_SQL + semantic_model: + - name: semantic_model + datasets: + - name: orders + source: schema.table + fields: + - name: revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: amount + metrics: + - name: paid_intl_revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(CASE WHEN (status = 'paid') AND (region = 'intl') THEN orders.amount + END) + + ''' +# --- +# name: TestRelationshipConversion.test_three_datasets_produce_all_pairs + ''' + version: 0.2.0.dev0 + dialects: + - ANSI_SQL + semantic_model: + - name: semantic_model + datasets: + - name: users_a + source: schema.table + primary_key: + - user_id + fields: + - name: user + expression: + dialects: + - dialect: ANSI_SQL + expression: user_id + - name: users_b + source: schema.table + unique_keys: + - - user_id + fields: + - name: user + expression: + dialects: + - dialect: ANSI_SQL + expression: user_id + - name: orders + source: schema.table + fields: + - name: user + expression: + dialects: + - dialect: ANSI_SQL + expression: user_id + relationships: + - name: users_a__users_b__user + from: users_a + to: users_b + from_columns: + - user_id + to_columns: + - user_id + - name: orders__users_a__user + from: orders + to: users_a + from_columns: + - user_id + to_columns: + - user_id + - name: orders__users_b__user + from: orders + to: users_b + from_columns: + - user_id + to_columns: + - user_id + + ''' +# --- diff --git a/converters/dbt/tests/__snapshots__/test_osi_to_msi.ambr b/converters/dbt/tests/__snapshots__/test_osi_to_msi.ambr new file mode 100644 index 0000000..e886e0b --- /dev/null +++ b/converters/dbt/tests/__snapshots__/test_osi_to_msi.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# 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: TestOSIToMSIRoundTrip.test_osi_to_msi_to_osi_preserves_structure + ''' + version: 0.2.0.dev0 + dialects: + - ANSI_SQL + semantic_model: + - name: semantic_model + datasets: + - name: orders + source: analytics.orders + primary_key: + - order_id + fields: + - name: order_id + expression: + dialects: + - dialect: ANSI_SQL + expression: order_id + - name: status + expression: + dialects: + - dialect: ANSI_SQL + expression: status + dimension: + is_time: false + - name: created_at + expression: + dialects: + - dialect: ANSI_SQL + expression: created_at + dimension: + is_time: true + - name: amount + expression: + dialects: + - dialect: ANSI_SQL + expression: amount + dimension: + is_time: false + metrics: + - name: revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) + + ''' +# --- diff --git a/converters/dbt/tests/helpers.py b/converters/dbt/tests/helpers.py index 534b458..550a493 100644 --- a/converters/dbt/tests/helpers.py +++ b/converters/dbt/tests/helpers.py @@ -1,6 +1,23 @@ -"""Shared test helpers for OSI converter tests.""" - -from osi import ( +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared test helpers for Ossie converter tests.""" + +from ossie import ( OSIDataset, OSIDialect, OSIDialectExpression, @@ -136,7 +153,7 @@ def _filter(sql: str) -> PydanticWhereFilterIntersection: # --------------------------------------------------------------------------- -# OSI builders +# Ossie builders # --------------------------------------------------------------------------- diff --git a/converters/dbt/tests/test_msi_to_osi.py b/converters/dbt/tests/test_msi_to_osi.py index 686106b..a0aa9f8 100644 --- a/converters/dbt/tests/test_msi_to_osi.py +++ b/converters/dbt/tests/test_msi_to_osi.py @@ -1,13 +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. + import json from typing import List, Optional import pytest from syrupy.assertion import SnapshotAssertion -from osi_dbt.converter_issues import ConverterIssueType -from osi_dbt.filter_utils import _render_filter_template -from osi import OSIDialect, OSIDocument -from osi_dbt.msi_to_osi import MSIToOSIConverter +from ossie_dbt.converter_issues import ConverterIssueType +from ossie_dbt.filter_utils import _render_filter_template +from ossie import OSIDialect, OSIDocument +from ossie_dbt.msi_to_osi import MSIToOSIConverter from metricflow_semantic_interfaces.implementations.metric import ( PydanticConversionTypeParams, PydanticCumulativeTypeParams, @@ -57,7 +74,7 @@ def _field_expr(result: OSIDocument, field_idx: int = 0) -> str: def _osi_metrics(result: OSIDocument) -> list: - """Return OSI metrics for the first semantic model, asserting they exist.""" + """Return Ossie metrics for the first semantic model, asserting they exist.""" metrics = result.semantic_model[0].metrics assert metrics is not None return metrics diff --git a/converters/dbt/tests/test_osi_to_msi.py b/converters/dbt/tests/test_osi_to_msi.py index afb3589..9a2bf86 100644 --- a/converters/dbt/tests/test_osi_to_msi.py +++ b/converters/dbt/tests/test_osi_to_msi.py @@ -1,10 +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. + """Tests for OSIToMSIConverter.""" import pytest from syrupy.assertion import SnapshotAssertion -from osi_dbt.msi_to_osi import MSIToOSIConverter -from osi_dbt.osi_to_msi import OSIToMSIConverter +from ossie_dbt.msi_to_osi import MSIToOSIConverter +from ossie_dbt.osi_to_msi import OSIToMSIConverter from metricflow_semantic_interfaces.type_enums import ( AggregationType, DimensionType, @@ -331,7 +348,7 @@ def test_percentile_cont_non_median_carries_percentile_param(self) -> None: class TestOSIToMSIRoundTrip: def test_osi_to_msi_to_osi_preserves_structure(self, snapshot: SnapshotAssertion) -> None: - """OSI → MSI → OSI preserves dataset names, fields, and metric expressions.""" + """Ossie → MSI → Ossie preserves dataset names, fields, and metric expressions.""" original = _osi_doc( datasets=[ _osi_dataset( diff --git a/converters/dbt/uv.lock b/converters/dbt/uv.lock new file mode 100644 index 0000000..37eac1d --- /dev/null +++ b/converters/dbt/uv.lock @@ -0,0 +1,756 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "apache-ossie" +version = "0.2.0.dev0" +source = { editable = "../../python" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "apache-ossie-dbt" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "apache-ossie" }, + { name = "jinja2" }, + { name = "metricflow" }, + { name = "pyyaml" }, + { name = "sqlglot" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "syrupy" }, +] + +[package.metadata] +requires-dist = [ + { name = "apache-ossie", editable = "../../python" }, + { name = "jinja2", specifier = ">=3.0" }, + { name = "metricflow", specifier = ">=0.209.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "sqlglot", specifier = ">=20.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "syrupy", specifier = ">=4.0" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "metricflow" +version = "0.211.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "more-itertools" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "rapidfuzz" }, + { name = "referencing" }, + { name = "sqlglot" }, + { name = "tabulate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/1c/2303b109a1e02216615d5c01cccf61a12d133f326388b9c881d32d00d182/metricflow-0.211.0.tar.gz", hash = "sha256:e15d0c24647f4498fa0926e87c61dd23b15205c9577566bcf848460d58f48bec", size = 1549648, upload-time = "2026-05-11T21:53:51.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/b7/de51d5d04ac43aeecbfea7741bf29da84fd5a04995e25a2b85abaa6ee807/metricflow-0.211.0-py3-none-any.whl", hash = "sha256:9cb83ccaa0c47363482fcc5cb57944a3f1af17ca948d90805524161f4e03d600", size = 1914356, upload-time = "2026-05-11T21:53:49.225Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[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 = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[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 = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlglot" +version = "30.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ed/a6c45aec29353b6392ea34548c40af3ac6ffd6bc5572cf23b2ce250876fc/sqlglot-30.12.0.tar.gz", hash = "sha256:6b8369704662d4f654bc934cea4dd31c916c2a571b389210cb9e951a275e5fd9", size = 5905110, upload-time = "2026-06-26T14:09:40.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/9e/82a390ecc85f066ff80affa01d195f744e3de60ad4d695b8de31c9a66da3/sqlglot-30.12.0-py3-none-any.whl", hash = "sha256:86cccc610073c645c03e72b55b60ae0518aa3253a7fc3bd56551370d003c6554", size = 707583, upload-time = "2026-06-26T14:09:38.525Z" }, +] + +[[package]] +name = "syrupy" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/39/17dde9f0c76cc5abcdeef1b2243791bb850d498f784147b8e460dd23abe8/syrupy-5.5.3.tar.gz", hash = "sha256:fa21e4ae77c89ec5abfca513338d8a7eb916da6618ca0f8db301476e0768e57a", size = 91614, upload-time = "2026-07-11T15:54:31.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f9/617a194c1a4203279998e1859426cf2635042533a99a93d63d3ee1fb967e/syrupy-5.5.3-py3-none-any.whl", hash = "sha256:0b260a0c9dad55e1fb83818973dc36fbc1aea3fd5381592f442a986686c4171a", size = 54959, upload-time = "2026-07-11T15:54:29.836Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[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" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/converters/gooddata/README.md b/converters/gooddata/README.md index 318e9e6..8262b1c 100644 --- a/converters/gooddata/README.md +++ b/converters/gooddata/README.md @@ -1,14 +1,33 @@ -# GoodData OSI Converter + + +# GoodData Apache Ossie Converter Bidirectional converter between GoodData's declarative Logical Data Model (LDM) -and the [OSI (Open Semantic Interchange)](https://github.com/open-semantic-interchange/OSI) +and the [Apache Ossie](https://github.com/apache/ossie) semantic model specification. ## Features -- **GoodData → OSI**: Convert a GoodData declarative LDM JSON to OSI semantic model YAML -- **OSI → GoodData**: Convert an OSI semantic model YAML to GoodData declarative LDM JSON -- Preserves GoodData-specific metadata (labels, date granularities, geo types) via OSI custom_extensions +- **GoodData → Ossie**: Convert a GoodData declarative LDM JSON to Ossie semantic model YAML +- **Ossie → GoodData**: Convert an Ossie semantic model YAML to GoodData declarative LDM JSON +- Preserves GoodData-specific metadata (labels, date granularities, geo types) via Ossie custom_extensions - Generates dual-dialect expressions (ANSI_SQL + MAQL) for fields ## Usage @@ -16,17 +35,17 @@ semantic model specification. ```python import json import yaml -from gooddata_osi import gooddata_to_osi, osi_to_gooddata -from gooddata_osi.models import gd_model_from_dict, gd_model_to_dict +from ossie_gooddata import gooddata_to_osi, osi_to_gooddata +from ossie_gooddata.models import gd_model_from_dict, gd_model_to_dict -# GoodData → OSI +# GoodData → Apache Ossie with open("gooddata_ldm.json") as f: gd_model = gd_model_from_dict(json.load(f)) osi_model = gooddata_to_osi(gd_model, model_name="my_model") with open("osi_model.yaml", "w") as f: yaml.dump(osi_model, f, default_flow_style=False) -# OSI → GoodData +# Apache Ossie → GoodData with open("osi_model.yaml") as f: osi_data = yaml.safe_load(f) gd_model = osi_to_gooddata(osi_data, data_source_id="my_datasource") @@ -43,7 +62,7 @@ uv run pytest ## Concept Mapping -| GoodData LDM | OSI Semantic Model | +| GoodData LDM | Ossie Semantic Model | |---|---| | Dataset | Dataset | | Attribute (+ Labels) | Field with `dimension` metadata | @@ -55,7 +74,7 @@ uv run pytest ## Limitations - **Metrics are not converted.** GoodData metrics use MAQL, a context-aware metric language - where dimensionality and filters are applied at report time. The current OSI metric model + where dimensionality and filters are applied at report time. The current Ossie metric model is SQL-expression-based and cannot represent this paradigm. - AggregatedFacts are not yet supported. - Workspace data filters are not mapped. diff --git a/converters/gooddata/pyproject.toml b/converters/gooddata/pyproject.toml index 7cc6e38..98a56e7 100644 --- a/converters/gooddata/pyproject.toml +++ b/converters/gooddata/pyproject.toml @@ -1,14 +1,38 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + [project] -name = "gooddata-osi" +name = "apache-ossie-gooddata" version = "0.2.0.dev0" -description = "Bidirectional converter between GoodData LDM and OSI semantic model" +description = "Bidirectional converter between GoodData LDM and Apache Ossie semantic model" authors = [{name = "GoodData Corporation", email = "support@gooddata.com"}] readme = "README.md" requires-python = ">=3.12" +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", +] dependencies = [ "pyyaml>=6.0.1", ] +[project.license] +text = "Apache-2.0" + [dependency-groups] dev = [ "ruff>=0.11.5", @@ -21,11 +45,11 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build] -include = ["src/gooddata_osi/**/*"] +include = ["src/ossie_gooddata/**/*"] ignore-vcs = true [tool.hatch.build.targets.wheel] -packages = ["src/gooddata_osi"] +packages = ["src/ossie_gooddata"] [tool.ruff] line-length = 120 diff --git a/converters/gooddata/src/gooddata_osi/__init__.py b/converters/gooddata/src/gooddata_osi/__init__.py deleted file mode 100644 index a1f5091..0000000 --- a/converters/gooddata/src/gooddata_osi/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Bidirectional converter between GoodData LDM and OSI semantic model.""" - -from gooddata_osi.gooddata_to_osi import gooddata_to_osi -from gooddata_osi.osi_to_gooddata import osi_to_gooddata - -__all__ = ["gooddata_to_osi", "osi_to_gooddata"] diff --git a/converters/gooddata/src/ossie_gooddata/__init__.py b/converters/gooddata/src/ossie_gooddata/__init__.py new file mode 100644 index 0000000..6074ef5 --- /dev/null +++ b/converters/gooddata/src/ossie_gooddata/__init__.py @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Bidirectional converter between GoodData LDM and Ossie semantic model.""" + +from ossie_gooddata.gooddata_to_osi import gooddata_to_osi +from ossie_gooddata.osi_to_gooddata import osi_to_gooddata + +__all__ = ["gooddata_to_osi", "osi_to_gooddata"] diff --git a/converters/gooddata/src/gooddata_osi/gooddata_to_osi.py b/converters/gooddata/src/ossie_gooddata/gooddata_to_osi.py similarity index 81% rename from converters/gooddata/src/gooddata_osi/gooddata_to_osi.py rename to converters/gooddata/src/ossie_gooddata/gooddata_to_osi.py index 1fc43a5..db68133 100644 --- a/converters/gooddata/src/gooddata_osi/gooddata_to_osi.py +++ b/converters/gooddata/src/ossie_gooddata/gooddata_to_osi.py @@ -1,11 +1,28 @@ -"""Convert GoodData declarative LDM to OSI semantic model.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert GoodData declarative LDM to Ossie semantic model.""" from __future__ import annotations import json from typing import Any -from gooddata_osi.models import ( +from ossie_gooddata.models import ( GdAttribute, GdDataset, GdDateInstance, @@ -24,22 +41,22 @@ def gooddata_to_osi( model_description: str = "", data_source_id: str | None = None, ) -> dict[str, Any]: - """Convert a GoodData declarative model to an OSI semantic model dict. + """Convert a GoodData declarative model to an Ossie semantic model dict. Args: model: Parsed GoodData declarative model. - model_name: Name for the OSI semantic model. - model_description: Description for the OSI semantic model. + model_name: Name for the Ossie semantic model. + model_description: Description for the Ossie semantic model. data_source_id: Data source ID to include in custom_extensions. Returns: - OSI semantic model as a dict (ready to serialize to YAML). + Ossie semantic model as a dict (ready to serialize to YAML). """ osi_datasets = [] osi_relationships = [] # Map dataset id -> {attribute id -> source column}. Reference targets point - # at a grain attribute id; OSI relationships need the physical column name. + # at a grain attribute id; Ossie relationships need the physical column name. attr_source_col_map: dict[str, dict[str, str]] = {} for ds in model.ldm.datasets: attr_source_col_map[ds.id] = {a.id: a.source_column for a in ds.attributes} @@ -85,7 +102,7 @@ def _convert_dataset( ds: GdDataset, attr_source_col_map: dict[str, dict[str, str]], ) -> tuple[dict[str, Any], list[dict[str, Any]]]: - """Convert a GoodData dataset to an OSI dataset + relationships.""" + """Convert a GoodData dataset to an Ossie dataset + relationships.""" # Build source from dataSourceTableId source = _build_source(ds) @@ -111,7 +128,7 @@ def _convert_dataset( if fields: osi_ds["fields"] = fields - # Convert references to OSI relationships + # Convert references to Ossie relationships relationships = [] for ref in ds.references: rel = _convert_reference(ds.id, ref, attr_source_col_map) @@ -121,7 +138,7 @@ def _convert_dataset( def _convert_date_instance(di: GdDateInstance) -> dict[str, Any]: - """Convert a GoodData date instance to an OSI dataset.""" + """Convert a GoodData date instance to an Ossie dataset.""" osi_ds: dict[str, Any] = { "name": di.id, "source": di.id, @@ -144,7 +161,7 @@ def _convert_date_instance(di: GdDateInstance) -> dict[str, Any]: def _convert_attribute(attr: GdAttribute, dataset_id: str) -> dict[str, Any]: - """Convert a GoodData attribute to an OSI field.""" + """Convert a GoodData attribute to an Ossie field.""" osi_field: dict[str, Any] = { "name": attr.source_column, "expression": { @@ -169,7 +186,7 @@ def _convert_attribute(attr: GdAttribute, dataset_id: str) -> dict[str, Any]: def _convert_fact(fact: GdFact, dataset_id: str) -> dict[str, Any]: - """Convert a GoodData fact to an OSI field.""" + """Convert a GoodData fact to an Ossie field.""" osi_field: dict[str, Any] = { "name": fact.source_column, "expression": { @@ -192,7 +209,7 @@ def _convert_reference( ref: GdReference, attr_source_col_map: dict[str, dict[str, str]], ) -> dict[str, Any]: - """Convert a GoodData reference to an OSI relationship. + """Convert a GoodData reference to an Ossie relationship. Each source carries an explicit `target` grain identifier. For attribute targets the target column is resolved via the target dataset's attribute @@ -232,7 +249,7 @@ def _convert_reference( def _build_source(ds: GdDataset) -> str: - """Build an OSI source string from a GoodData dataset's data source table.""" + """Build an Ossie source string from a GoodData dataset's data source table.""" if ds.data_source_table_id: t = ds.data_source_table_id if t.path: diff --git a/converters/gooddata/src/gooddata_osi/models.py b/converters/gooddata/src/ossie_gooddata/models.py similarity index 93% rename from converters/gooddata/src/gooddata_osi/models.py rename to converters/gooddata/src/ossie_gooddata/models.py index 623981e..2ef93b0 100644 --- a/converters/gooddata/src/gooddata_osi/models.py +++ b/converters/gooddata/src/ossie_gooddata/models.py @@ -1,4 +1,21 @@ -"""Data models for GoodData declarative LDM and OSI semantic model. +# 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. + +"""Data models for GoodData declarative LDM and Ossie semantic model. GoodData LDM structure (from declarative API): DeclarativeModel @@ -18,7 +35,7 @@ ├── granularities[] └── granularitiesFormatting {titleBase, titlePattern} -OSI semantic model structure: +Ossie semantic model structure: version, semantic_model[] ├── name, description, ai_context, custom_extensions[] ├── datasets[] {name, source, primary_key[], fields[], custom_extensions[]} diff --git a/converters/gooddata/src/gooddata_osi/osi_to_gooddata.py b/converters/gooddata/src/ossie_gooddata/osi_to_gooddata.py similarity index 86% rename from converters/gooddata/src/gooddata_osi/osi_to_gooddata.py rename to converters/gooddata/src/ossie_gooddata/osi_to_gooddata.py index 41990f4..f7f05c8 100644 --- a/converters/gooddata/src/gooddata_osi/osi_to_gooddata.py +++ b/converters/gooddata/src/ossie_gooddata/osi_to_gooddata.py @@ -1,4 +1,21 @@ -"""Convert OSI semantic model to GoodData declarative LDM.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert Ossie semantic model to GoodData declarative LDM.""" from __future__ import annotations @@ -6,7 +23,7 @@ import re from typing import Any -from gooddata_osi.models import ( +from ossie_gooddata.models import ( GdAttribute, GdDataset, GdDataSourceTableId, @@ -32,10 +49,10 @@ def osi_to_gooddata( osi_model: dict[str, Any], data_source_id: str = "default", ) -> GdDeclarativeModel: - """Convert an OSI semantic model dict to a GoodData declarative model. + """Convert an Ossie semantic model dict to a GoodData declarative model. Args: - osi_model: Parsed OSI YAML as a dict. + osi_model: Parsed Ossie YAML as a dict. data_source_id: GoodData data source ID to use for table references. Returns: @@ -46,7 +63,7 @@ def osi_to_gooddata( for sm in osi_model.get("semantic_model", []): relationship_map = _build_relationship_map(sm) - # Pre-pass: for each OSI dataset, record whether it is a date instance + # Pre-pass: for each Ossie dataset, record whether it is a date instance # and map its physical source columns to the attribute ids that will # be generated. Reference target columns resolve via this map. target_info = _build_target_info(sm) @@ -103,7 +120,7 @@ def _convert_osi_dataset( target_info: dict[str, dict[str, Any]], data_source_id: str, ) -> tuple[GdDataset, GdDateInstance | None]: - """Convert an OSI dataset to a GoodData dataset or date instance.""" + """Convert an Ossie dataset to a GoodData dataset or date instance.""" ds_name = ds["name"] source = ds.get("source", ds_name) @@ -171,7 +188,7 @@ def _convert_osi_dataset( def _convert_to_date_instance(ds: dict[str, Any], gd_ext: dict[str, Any]) -> GdDateInstance: - """Convert an OSI dataset with date_dimension extension to a GoodData date instance.""" + """Convert an Ossie dataset with date_dimension extension to a GoodData date instance.""" return GdDateInstance( id=ds["name"], title=_get_title(ds), @@ -182,7 +199,7 @@ def _convert_to_date_instance(ds: dict[str, Any], gd_ext: dict[str, Any]) -> GdD def _convert_to_date_instance_from_fields(ds: dict[str, Any]) -> GdDateInstance: - """Convert an all-time-dimension OSI dataset to a GoodData date instance.""" + """Convert an all-time-dimension Ossie dataset to a GoodData date instance.""" return GdDateInstance( id=ds["name"], title=_get_title(ds), @@ -193,7 +210,7 @@ def _convert_to_date_instance_from_fields(ds: dict[str, Any]) -> GdDateInstance: def _convert_to_attribute(field_def: dict[str, Any], dataset_id: str) -> GdAttribute: - """Convert an OSI field (dimension) to a GoodData attribute.""" + """Convert an Ossie field (dimension) to a GoodData attribute.""" field_name = field_def["name"] source_col = _get_source_column(field_def) attr_id = f"attr.{dataset_id}.{field_name}" @@ -224,7 +241,7 @@ def _convert_to_attribute(field_def: dict[str, Any], dataset_id: str) -> GdAttri def _convert_to_fact(field_def: dict[str, Any], dataset_id: str) -> GdFact: - """Convert an OSI field (non-dimension) to a GoodData fact.""" + """Convert an Ossie field (non-dimension) to a GoodData fact.""" field_name = field_def["name"] source_col = _get_source_column(field_def) @@ -237,7 +254,7 @@ def _convert_to_fact(field_def: dict[str, Any], dataset_id: str) -> GdFact: def _get_source_column(field_def: dict[str, Any]) -> str: - """Extract the source column name from an OSI field's ANSI_SQL expression.""" + """Extract the source column name from an Ossie field's ANSI_SQL expression.""" for dialect_expr in field_def.get("expression", {}).get("dialects", []): if dialect_expr.get("dialect") == "ANSI_SQL": return dialect_expr["expression"] @@ -257,7 +274,7 @@ def _detect_type_from_maql(field_def: dict[str, Any]) -> str: def _get_gooddata_extension(obj: dict[str, Any]) -> dict[str, Any] | None: - """Extract the GOODDATA custom extension data from an OSI object.""" + """Extract the GOODDATA custom extension data from an Ossie object.""" for ext in obj.get("custom_extensions", []): if ext.get("vendor_name") == "GOODDATA": data = ext.get("data", "{}") @@ -268,7 +285,7 @@ def _get_gooddata_extension(obj: dict[str, Any]) -> dict[str, Any] | None: def _get_title(obj: dict[str, Any], fallback: str = "") -> str: - """Get a title from an OSI object, preferring ai_context synonyms.""" + """Get a title from an Ossie object, preferring ai_context synonyms.""" ctx = obj.get("ai_context") if isinstance(ctx, dict): synonyms = ctx.get("synonyms", []) @@ -318,7 +335,7 @@ def _is_multivalue(rel: dict[str, Any]) -> bool: def _parse_source_to_table_id(source: str, data_source_id: str) -> GdDataSourceTableId: - """Parse an OSI source string into a GoodData DataSourceTableId.""" + """Parse an Ossie source string into a GoodData DataSourceTableId.""" parts = source.split(".") if len(parts) >= 3: # source_id.schema.table or more diff --git a/converters/gooddata/tests/__init__.py b/converters/gooddata/tests/__init__.py index e69de29..2456923 100644 --- a/converters/gooddata/tests/__init__.py +++ b/converters/gooddata/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/gooddata/tests/conftest.py b/converters/gooddata/tests/conftest.py index f06cffe..bb8c8c8 100644 --- a/converters/gooddata/tests/conftest.py +++ b/converters/gooddata/tests/conftest.py @@ -1,4 +1,21 @@ -"""Shared pytest fixtures for gooddata-osi converter tests.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared pytest fixtures for Apache Ossie GoodData converter tests.""" from __future__ import annotations @@ -8,7 +25,7 @@ import pytest import yaml -from gooddata_osi.models import GdDeclarativeModel, gd_model_from_dict +from ossie_gooddata.models import GdDeclarativeModel, gd_model_from_dict FIXTURES_DIR = Path(__file__).parent / "fixtures" @@ -28,6 +45,6 @@ def gooddata_tpcds_model(gooddata_tpcds_dict: dict) -> GdDeclarativeModel: @pytest.fixture() def osi_tpcds_dict() -> dict: - """Load the OSI TPC-DS YAML fixture.""" + """Load the Ossie TPC-DS YAML fixture.""" with open(FIXTURES_DIR / "osi_tpcds.yaml") as f: return yaml.safe_load(f) diff --git a/converters/gooddata/tests/fixtures/osi_tpcds.yaml b/converters/gooddata/tests/fixtures/osi_tpcds.yaml index 531a4dd..af14a2f 100644 --- a/converters/gooddata/tests/fixtures/osi_tpcds.yaml +++ b/converters/gooddata/tests/fixtures/osi_tpcds.yaml @@ -1,3 +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. + version: "0.2.0.dev0" semantic_model: diff --git a/converters/gooddata/tests/test_gooddata_to_osi.py b/converters/gooddata/tests/test_gooddata_to_osi.py index f4c4ff6..637f77d 100644 --- a/converters/gooddata/tests/test_gooddata_to_osi.py +++ b/converters/gooddata/tests/test_gooddata_to_osi.py @@ -1,13 +1,30 @@ -"""Tests for GoodData → OSI conversion.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for GoodData → Ossie conversion.""" from __future__ import annotations -from gooddata_osi.gooddata_to_osi import gooddata_to_osi -from gooddata_osi.models import GdDeclarativeModel +from ossie_gooddata.gooddata_to_osi import gooddata_to_osi +from ossie_gooddata.models import GdDeclarativeModel def test_basic_conversion(gooddata_tpcds_model: GdDeclarativeModel): - """Verify basic structure of GoodData → OSI conversion.""" + """Verify basic structure of GoodData → Ossie conversion.""" result = gooddata_to_osi(gooddata_tpcds_model, model_name="tpcds_test") assert result["version"] == "0.2.0.dev0" @@ -52,7 +69,7 @@ def test_primary_key_from_grain(gooddata_tpcds_model: GdDeclarativeModel): def test_attributes_become_dimension_fields(gooddata_tpcds_model: GdDeclarativeModel): - """Verify GoodData attributes become OSI fields with dimension metadata.""" + """Verify GoodData attributes become Ossie fields with dimension metadata.""" result = gooddata_to_osi(gooddata_tpcds_model) sm = result["semantic_model"][0] @@ -69,7 +86,7 @@ def test_attributes_become_dimension_fields(gooddata_tpcds_model: GdDeclarativeM def test_facts_become_plain_fields(gooddata_tpcds_model: GdDeclarativeModel): - """Verify GoodData facts become OSI fields without dimension metadata.""" + """Verify GoodData facts become Ossie fields without dimension metadata.""" result = gooddata_to_osi(gooddata_tpcds_model) sm = result["semantic_model"][0] @@ -102,7 +119,7 @@ def test_maql_expressions(gooddata_tpcds_model: GdDeclarativeModel): def test_references_become_relationships(gooddata_tpcds_model: GdDeclarativeModel): - """Verify GoodData references become OSI relationships.""" + """Verify GoodData references become Ossie relationships.""" result = gooddata_to_osi(gooddata_tpcds_model) sm = result["semantic_model"][0] @@ -115,7 +132,7 @@ def test_references_become_relationships(gooddata_tpcds_model: GdDeclarativeMode def test_date_instance_converted(gooddata_tpcds_model: GdDeclarativeModel): - """Verify date instances become OSI datasets with custom_extensions.""" + """Verify date instances become Ossie datasets with custom_extensions.""" result = gooddata_to_osi(gooddata_tpcds_model) sm = result["semantic_model"][0] diff --git a/converters/gooddata/tests/test_models.py b/converters/gooddata/tests/test_models.py index ec2d3ea..64b67d7 100644 --- a/converters/gooddata/tests/test_models.py +++ b/converters/gooddata/tests/test_models.py @@ -1,8 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Tests for GoodData declarative model serialization/deserialization.""" from __future__ import annotations -from gooddata_osi.models import GdDeclarativeModel, gd_model_from_dict, gd_model_to_dict +from ossie_gooddata.models import GdDeclarativeModel, gd_model_from_dict, gd_model_to_dict def test_parse_gooddata_model(gooddata_tpcds_dict: dict, gooddata_tpcds_model: GdDeclarativeModel): diff --git a/converters/gooddata/tests/test_osi_to_gooddata.py b/converters/gooddata/tests/test_osi_to_gooddata.py index a112c5c..abd4091 100644 --- a/converters/gooddata/tests/test_osi_to_gooddata.py +++ b/converters/gooddata/tests/test_osi_to_gooddata.py @@ -1,12 +1,29 @@ -"""Tests for OSI → GoodData conversion.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for Ossie → GoodData conversion.""" from __future__ import annotations -from gooddata_osi.osi_to_gooddata import osi_to_gooddata +from ossie_gooddata.osi_to_gooddata import osi_to_gooddata def test_basic_conversion(osi_tpcds_dict: dict): - """Verify basic structure of OSI → GoodData conversion.""" + """Verify basic structure of Ossie → GoodData conversion.""" result = osi_to_gooddata(osi_tpcds_dict, data_source_id="tpcds") # 4 regular datasets (date_dim becomes a date instance) @@ -25,7 +42,7 @@ def test_dataset_ids(osi_tpcds_dict: dict): def test_date_dimension_detected(osi_tpcds_dict: dict): - """Verify OSI dataset with date_dimension extension becomes a GoodData date instance.""" + """Verify Ossie dataset with date_dimension extension becomes a GoodData date instance.""" result = osi_to_gooddata(osi_tpcds_dict) assert len(result.ldm.date_instances) == 1 @@ -36,7 +53,7 @@ def test_date_dimension_detected(osi_tpcds_dict: dict): def test_dimension_fields_become_attributes(osi_tpcds_dict: dict): - """Verify OSI fields with dimension metadata become GoodData attributes.""" + """Verify Ossie fields with dimension metadata become GoodData attributes.""" result = osi_to_gooddata(osi_tpcds_dict) customer = next(ds for ds in result.ldm.datasets if ds.id == "customer") @@ -46,7 +63,7 @@ def test_dimension_fields_become_attributes(osi_tpcds_dict: dict): def test_non_dimension_fields_become_facts(osi_tpcds_dict: dict): - """Verify OSI fields without dimension become GoodData facts.""" + """Verify Ossie fields without dimension become GoodData facts.""" result = osi_to_gooddata(osi_tpcds_dict) store_sales = next(ds for ds in result.ldm.datasets if ds.id == "store_sales") @@ -76,7 +93,7 @@ def test_grain_from_primary_key(osi_tpcds_dict: dict): def test_relationships_become_references(osi_tpcds_dict: dict): - """Verify OSI relationships become GoodData references.""" + """Verify Ossie relationships become GoodData references.""" result = osi_to_gooddata(osi_tpcds_dict) store_sales = next(ds for ds in result.ldm.datasets if ds.id == "store_sales") diff --git a/converters/gooddata/tests/test_roundtrip.py b/converters/gooddata/tests/test_roundtrip.py index 50544f6..89f4fe0 100644 --- a/converters/gooddata/tests/test_roundtrip.py +++ b/converters/gooddata/tests/test_roundtrip.py @@ -1,19 +1,36 @@ -"""Round-trip conversion tests: GoodData → OSI → GoodData.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Round-trip conversion tests: GoodData → Ossie → GoodData.""" from __future__ import annotations -from gooddata_osi.gooddata_to_osi import gooddata_to_osi -from gooddata_osi.models import GdDeclarativeModel -from gooddata_osi.osi_to_gooddata import osi_to_gooddata +from ossie_gooddata.gooddata_to_osi import gooddata_to_osi +from ossie_gooddata.models import GdDeclarativeModel +from ossie_gooddata.osi_to_gooddata import osi_to_gooddata def test_roundtrip_preserves_datasets(gooddata_tpcds_model: GdDeclarativeModel): - """Verify GoodData → OSI → GoodData preserves dataset count and IDs.""" - # GoodData -> OSI - osi = gooddata_to_osi(gooddata_tpcds_model, model_name="roundtrip_test") + """Verify GoodData → Ossie → GoodData preserves dataset count and IDs.""" + # GoodData -> Ossie + ossie = gooddata_to_osi(gooddata_tpcds_model, model_name="roundtrip_test") - # OSI -> GoodData - result = osi_to_gooddata(osi) + # Ossie -> GoodData + result = osi_to_gooddata(ossie) original_ds_ids = {ds.id for ds in gooddata_tpcds_model.ldm.datasets} result_ds_ids = {ds.id for ds in result.ldm.datasets} @@ -25,8 +42,8 @@ def test_roundtrip_preserves_datasets(gooddata_tpcds_model: GdDeclarativeModel): def test_roundtrip_preserves_date_instances(gooddata_tpcds_model: GdDeclarativeModel): """Verify date instances survive the round trip.""" - osi = gooddata_to_osi(gooddata_tpcds_model) - result = osi_to_gooddata(osi) + ossie = gooddata_to_osi(gooddata_tpcds_model) + result = osi_to_gooddata(ossie) assert len(result.ldm.date_instances) == len(gooddata_tpcds_model.ldm.date_instances) @@ -38,8 +55,8 @@ def test_roundtrip_preserves_date_instances(gooddata_tpcds_model: GdDeclarativeM def test_roundtrip_preserves_references(gooddata_tpcds_model: GdDeclarativeModel): """Verify references/relationships survive the round trip.""" - osi = gooddata_to_osi(gooddata_tpcds_model) - result = osi_to_gooddata(osi) + ossie = gooddata_to_osi(gooddata_tpcds_model) + result = osi_to_gooddata(ossie) original_ss = next(ds for ds in gooddata_tpcds_model.ldm.datasets if ds.id == "store_sales") result_ss = next(ds for ds in result.ldm.datasets if ds.id == "store_sales") @@ -51,8 +68,8 @@ def test_roundtrip_preserves_references(gooddata_tpcds_model: GdDeclarativeModel def test_roundtrip_preserves_attribute_count(gooddata_tpcds_model: GdDeclarativeModel): """Verify attribute counts survive the round trip.""" - osi = gooddata_to_osi(gooddata_tpcds_model) - result = osi_to_gooddata(osi) + ossie = gooddata_to_osi(gooddata_tpcds_model) + result = osi_to_gooddata(ossie) for orig_ds in gooddata_tpcds_model.ldm.datasets: result_ds = next((ds for ds in result.ldm.datasets if ds.id == orig_ds.id), None) @@ -64,8 +81,8 @@ def test_roundtrip_preserves_attribute_count(gooddata_tpcds_model: GdDeclarative def test_roundtrip_preserves_fact_count(gooddata_tpcds_model: GdDeclarativeModel): """Verify fact counts survive the round trip.""" - osi = gooddata_to_osi(gooddata_tpcds_model) - result = osi_to_gooddata(osi) + ossie = gooddata_to_osi(gooddata_tpcds_model) + result = osi_to_gooddata(ossie) for orig_ds in gooddata_tpcds_model.ldm.datasets: result_ds = next((ds for ds in result.ldm.datasets if ds.id == orig_ds.id), None) diff --git a/converters/gooddata/uv.lock b/converters/gooddata/uv.lock index b74fb18..5434a5a 100644 --- a/converters/gooddata/uv.lock +++ b/converters/gooddata/uv.lock @@ -3,101 +3,8 @@ revision = 3 requires-python = ">=3.12" [[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 = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, -] - -[[package]] -name = "gooddata-osi" -version = "0.1.0" +name = "apache-ossie-gooddata" +version = "0.2.0.dev0" source = { editable = "." } dependencies = [ { name = "pyyaml" }, @@ -120,6 +27,99 @@ dev = [ { name = "ruff", specifier = ">=0.11.5" }, ] +[[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 = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -131,11 +131,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { 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]] @@ -149,16 +149,16 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { 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.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -167,9 +167,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -234,25 +234,25 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" +version = "0.15.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] diff --git a/converters/honeydew/.gitignore b/converters/honeydew/.gitignore new file mode 100644 index 0000000..b5fed1f --- /dev/null +++ b/converters/honeydew/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +dist/ +build/ +.venv/ +venv/ diff --git a/converters/honeydew/README.md b/converters/honeydew/README.md new file mode 100644 index 0000000..03f1bab --- /dev/null +++ b/converters/honeydew/README.md @@ -0,0 +1,74 @@ +# OSI ↔ Honeydew Converter + +Bidirectional converter between [OSI](../../core-spec/spec.md) semantic models and [Honeydew](https://honeydew.ai/docs) workspace YAML. + +## Overview + +| Direction | Input | Output | +|-----------|-------|--------| +| `osi-to-honeydew` | Single OSI YAML file | Honeydew workspace directory | +| `honeydew-to-osi` | Honeydew workspace directory | Single OSI YAML file | + +### OSI → Honeydew mapping + +| OSI concept | Honeydew concept | +|-------------|-----------------| +| `semantic_model.name` | `workspace.yml name` | +| `dataset` | Entity + dataset files under `schema//` | +| `dataset.source` | `dataset.sql` | +| `dataset.primary_key` | `entity.keys` | +| Simple column field | Source Attribute (`dataset.attributes` entry) | +| Computed field expression | Calculated Attribute (`calculated_attribute` YAML) | +| `field.ai_context` | AI Metadata on the attribute or entity | +| `relationship` (from → to) | `entity.relations` on the "from" entity (`rel_type: many-to-one`) | +| `metric` | `metric` YAML (assigned to entity by expression parse) | + +### Honeydew → OSI mapping + +| Honeydew concept | OSI concept | +|-----------------|-------------| +| `workspace.name` | `semantic_model.name` | +| Entity + primary dataset | `dataset` | +| `entity.keys` | `dataset.primary_key` (and `dataset.unique_keys`) | +| `dataset.attributes` (columns) | `fields` with `ANSI_SQL` expression = column name | +| `calculated_attribute` SQL | `fields` with `ANSI_SQL` expression + `HONEYDEW` custom extension | +| `entity.relations` (`many-to-one`) | `relationship` with `from` = this entity | +| `entity.relations` (`one-to-many`) | `relationship` with `from` = target entity | +| `metric.sql` | `metric` expression in `ANSI_SQL` dialect | + +## Requirements + +- Python 3.12+ +- PyYAML 6.0+ + +## Setup + +```bash +uv sync +``` + +## Usage + +```bash +# OSI YAML → Honeydew workspace directory +uv run honeydew-osi osi-to-honeydew -i input.yaml -o output_dir/ + +# Honeydew workspace directory → OSI YAML +uv run honeydew-osi honeydew-to-osi -i workspace_dir/ -o output.yaml +``` + +## Tests + +```bash +uv run pytest +``` + +## Limitations + +- **One source dataset per entity**: Honeydew entities can have multiple source dataset files; the converter always generates exactly one, because an OSI `dataset` block describes a single table or SQL query. +- **Datatype inference**: OSI fields have no explicit datatype; the converter infers Honeydew datatypes from the `dimension.is_time` flag (`timestamp`) and the presence/absence of the `dimension` key (`string` vs `number`). +- **Honeydew SQL expressions**: Calculated attributes and metrics use Honeydew's `entity.attribute` reference syntax. These are exported as `ANSI_SQL` dialect expressions in OSI; they remain valid for round-tripping but may not run on other databases without adaptation. +- **Perspectives and domains**: Not converted (no OSI equivalent). +- **Connection expressions** (`connection_expr`): Preserved in `HONEYDEW` custom extensions on the OSI relationship and restored on the return trip. +- **`ai_context`**: OSI `ai_context` fields (synonyms, instructions) are stored in Honeydew `metadata` for round-trip recovery. Instructions are also merged into `description` for human readability. +- **`unique_keys`**: A Honeydew entity's `keys` uniquely identify its rows — Honeydew enforces this and validates that relations join to those keys — so they are emitted as the OSI dataset's `primary_key` *and* a `unique_keys` entry. This surfaces the join-target cardinality for OSI consumers (e.g. a many-to-one relation's `to_columns` are always covered by the target's `unique_keys`). A unique key identical to the primary key is not stored back in Honeydew `metadata` on the return trip, so `Honeydew → OSI → Honeydew` stays clean; `OSI → Honeydew → OSI` normalizes by surfacing the primary key as a unique key. diff --git a/converters/honeydew/pyproject.toml b/converters/honeydew/pyproject.toml new file mode 100644 index 0000000..7c94c33 --- /dev/null +++ b/converters/honeydew/pyproject.toml @@ -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. + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[project] +name = "honeydew-osi" +version = "0.2.0.dev0" +description = "Bidirectional converter between Honeydew workspace YAML and OSI semantic model" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] +requires-python = ">=3.12" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", + "Honeydew" +] +dependencies = [ + "pyyaml>=6.0", +] + +[project.scripts] +honeydew-osi = "honeydew_osi.converter:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/honeydew_osi"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] \ No newline at end of file diff --git a/converters/honeydew/src/honeydew_osi/__init__.py b/converters/honeydew/src/honeydew_osi/__init__.py new file mode 100644 index 0000000..d216be4 --- /dev/null +++ b/converters/honeydew/src/honeydew_osi/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. \ No newline at end of file diff --git a/converters/honeydew/src/honeydew_osi/converter.py b/converters/honeydew/src/honeydew_osi/converter.py new file mode 100644 index 0000000..e3f78f4 --- /dev/null +++ b/converters/honeydew/src/honeydew_osi/converter.py @@ -0,0 +1,1068 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Bidirectional converter between OSI and Honeydew semantic model formats. + +OSI → Honeydew: Converts a single OSI YAML file into a Honeydew workspace + directory (multiple YAML files per entity). + +Honeydew → OSI: Reads a Honeydew workspace directory and produces an OSI YAML. + +Usage: + python converter.py osi-to-honeydew -i input.yaml -o output_dir/ + python converter.py honeydew-to-osi -i workspace_dir/ -o output.yaml +""" + +import argparse +import json +import os +import re +import sys +import warnings +from typing import Any + +import yaml + +SUPPORTED_OSI_VERSION = "0.2.0.dev0" +HONEYDEW_VENDOR = "HONEYDEW" +_OSI_METADATA_SECTION = "osi" +_HD_ATTR_KEYS = ("display_name", "hidden", "folder", "format_string", "timegrain") + + +class HoneydewConversionError(Exception): + """Raised when conversion between OSI and Honeydew fails.""" + + +# ───────────────────────────────────────────────────────────────────────────── +# OSI → Honeydew +# ───────────────────────────────────────────────────────────────────────────── + + +def convert_osi_to_honeydew(osi_yaml_str: str) -> dict[str, str]: + """Convert an OSI YAML string to a Honeydew workspace file tree. + + Returns a dict mapping relative file paths to their YAML content strings. + The caller writes these to disk under the desired output directory. + + Honeydew workspace structure produced:: + + workspace.yml + schema// + .yml + datasets/.yml + attributes/.yml (computed fields only) + metrics/.yml + + ``ai_context`` is mapped to Honeydew's native fields (``description``, + ``labels``, and the AI metadata section); the structured form is also + stored in ``metadata`` for lossless round-tripping. ``unique_keys`` and + non-Honeydew ``custom_extensions`` have no direct Honeydew equivalent and + are stored in the Honeydew ``metadata`` section under a section named + ``"osi"`` so they can be recovered on the return trip. + + Args: + osi_yaml_str: OSI YAML document as a string. + + Returns: + Dict of {relative_path: yaml_content}. + + Raises: + HoneydewConversionError: On invalid or unsupported input. + """ + root = yaml.safe_load(osi_yaml_str) + if not isinstance(root, dict): + raise HoneydewConversionError("Invalid OSI YAML: expected a mapping at the root") + + version_str = str(root.get("version", "")) + if version_str != SUPPORTED_OSI_VERSION: + raise HoneydewConversionError( + f"Unsupported OSI version '{version_str}'. Supported: {SUPPORTED_OSI_VERSION}" + ) + + semantic_models = root.get("semantic_model") + if not isinstance(semantic_models, list) or not semantic_models: + raise HoneydewConversionError("'semantic_model' must be a non-empty list") + + if len(semantic_models) > 1: + warnings.warn( + f"OSI YAML contains {len(semantic_models)} semantic models; " + "only the first will be converted" + ) + + vendors = [v for v in (root.get("vendors") or []) if v != HONEYDEW_VENDOR] + return _model_to_files(semantic_models[0], extra_vendors=vendors) + + +def _model_to_files(sm: dict[str, Any], *, extra_vendors: list[str] | None = None) -> dict[str, str]: + name = sm.get("name") + if not name: + raise HoneydewConversionError("Missing 'name' in semantic model") + + files: dict[str, str] = {} + + workspace: dict[str, Any] = {"type": "workspace", "name": name} + if sm.get("description"): + workspace["description"] = sm["description"] + + # Preserve model-level ai_context, non-HONEYDEW custom_extensions, and extra vendors + model_ai_ctx = sm.get("ai_context") + model_ext = [e for e in (sm.get("custom_extensions") or []) if e.get("vendor_name") != HONEYDEW_VENDOR] + ws_meta = _build_osi_metadata( + ai_context=model_ai_ctx, + custom_extensions=model_ext or None, + extra_vendors=extra_vendors or None, + ) + if ws_meta: + workspace["metadata"] = [ws_meta] + + files["workspace.yml"] = _dump(workspace) + + datasets = sm.get("datasets") or [] + metrics = sm.get("metrics") or [] + relationships = sm.get("relationships") or [] + + entity_names = [ds["name"] for ds in datasets if ds.get("name")] + + # Group OSI relationships by from-entity + rel_by_entity: dict[str, list[dict[str, Any]]] = {} + for rel in relationships: + from_ds = rel.get("from") + if from_ds: + rel_by_entity.setdefault(from_ds, []).append(rel) + + # Assign OSI metrics to entities (honours HONEYDEW entity hint for round-trips) + metric_by_entity = _assign_metrics_to_entities(metrics, entity_names) + + for ds in datasets: + entity_name = ds.get("name") + if not entity_name: + raise HoneydewConversionError("Dataset missing 'name'") + files.update( + _dataset_to_files( + ds, + rel_by_entity.get(entity_name, []), + metric_by_entity.get(entity_name, []), + ) + ) + + return files + + +def _fields_to_honeydew( + fields: list[dict[str, Any]], + entity_name: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Classify OSI fields into Honeydew dataset attributes and calculated attributes.""" + dataset_attrs: list[dict[str, Any]] = [] + calc_attrs: list[dict[str, Any]] = [] + + for field in fields: + field_name = field.get("name") + if not field_name: + raise HoneydewConversionError(f"Field missing 'name' in dataset '{entity_name}'") + + expr = _pick_ansi_expression(field.get("expression"), field_name) + if not expr or not expr.strip(): + continue + + datatype = _osi_field_to_honeydew_datatype(field) + field_desc = field.get("description") + field_label = field.get("label") + field_ai_ctx = field.get("ai_context") + field_ext = [e for e in (field.get("custom_extensions") or []) if e.get("vendor_name") != HONEYDEW_VENDOR] + + # Merge ai_context instructions into description; keep full object in metadata + effective_desc = field_desc + if isinstance(field_ai_ctx, str) and field_ai_ctx: + effective_desc = f"{field_desc}\n{field_ai_ctx}" if field_desc else field_ai_ctx + elif isinstance(field_ai_ctx, dict) and field_ai_ctx.get("instructions"): + instr = field_ai_ctx["instructions"] + effective_desc = f"{field_desc}\n{instr}" if field_desc else instr + + # Build labels: OSI label + ai_context synonyms + labels: list[str] = [] + if field_label: + labels.append(field_label) + if isinstance(field_ai_ctx, dict): + for syn in (field_ai_ctx.get("synonyms") or []): + if syn not in labels: + labels.append(syn) + + field_meta = _build_osi_metadata( + ai_context=field_ai_ctx if isinstance(field_ai_ctx, dict) else None, + label=field_label if field_label and not isinstance(field_ai_ctx, dict) else None, + custom_extensions=field_ext or None, + ) + + hd_hint = _get_honeydew_extension(field) + force_calc = hd_hint.get("type") == "calculated_attribute" + + if _is_simple_identifier(expr) and not force_calc: + attr: dict[str, Any] = {"column": expr, "name": field_name, "datatype": datatype} + if effective_desc: + attr["description"] = effective_desc + if labels: + attr["labels"] = labels + if field_meta: + attr["metadata"] = [field_meta] + for k in _HD_ATTR_KEYS: + if k in hd_hint: + attr[k] = hd_hint[k] + dataset_attrs.append(attr) + else: + calc: dict[str, Any] = { + "type": "calculated_attribute", + "entity": entity_name, + "name": field_name, + "datatype": datatype, + "sql": expr, + } + if effective_desc: + calc["description"] = effective_desc + if labels: + calc["labels"] = labels + if field_meta: + calc["metadata"] = [field_meta] + for k in _HD_ATTR_KEYS: + if k in hd_hint: + calc[k] = hd_hint[k] + calc_attrs.append(calc) + + return dataset_attrs, calc_attrs + + +def _dataset_to_files( + ds: dict[str, Any], + relations: list[dict[str, Any]], + metrics: list[dict[str, Any]], +) -> dict[str, str]: + entity_name = ds["name"] + base = f"schema/{entity_name}" + files: dict[str, str] = {} + + primary_key = ds.get("primary_key") or [] + unique_keys = ds.get("unique_keys") + # A unique key identical to the primary key is re-derived from the entity's + # keys on the Honeydew → OSI path, so don't persist it as metadata — that + # would inject a redundant metadata block on a Honeydew → OSI → Honeydew round-trip. + if unique_keys: + pk_tuple = tuple(primary_key) + unique_keys = [uk for uk in unique_keys if tuple(uk) != pk_tuple] or None + description = ds.get("description") + ai_context = ds.get("ai_context") + fields = ds.get("fields") or [] + ds_ext = [e for e in (ds.get("custom_extensions") or []) if e.get("vendor_name") != HONEYDEW_VENDOR] + + # ── entity YAML ──────────────────────────────────────────────────────────── + entity_dict: dict[str, Any] = {"type": "entity", "name": entity_name} + if description: + entity_dict["description"] = description + if primary_key: + entity_dict["keys"] = list(primary_key) + entity_dict["key_dataset"] = entity_name + + # Restore Honeydew-specific entity fields from HONEYDEW custom_extension + entity_hd_hint = _get_honeydew_extension(ds) + for key in ("owner", "display_name", "hidden", "folder"): + if key in entity_hd_hint: + entity_dict[key] = entity_hd_hint[key] + if "labels" in entity_hd_hint: + entity_dict["labels"] = entity_hd_hint["labels"] + + honeydew_relations = [] + for rel in relations: + hr = _osi_relation_to_honeydew(rel) + if hr is not None: + honeydew_relations.append(hr) + entity_dict["relations"] = honeydew_relations + + # Preserve OSI fields that have no Honeydew native equivalent + entity_meta = _build_osi_metadata( + ai_context=ai_context, + unique_keys=unique_keys, + custom_extensions=ds_ext or None, + ) + if entity_meta: + entity_dict["metadata"] = [entity_meta] + + files[f"{base}/{entity_name}.yml"] = _dump(entity_dict) + + # ── classify fields into dataset attributes vs calculated attributes ──────── + dataset_attrs, calc_attrs = _fields_to_honeydew(fields, entity_name) + + # ── dataset YAML ─────────────────────────────────────────────────────────── + source_sql, dataset_type = _parse_osi_source(ds.get("source", "")) + dataset_dict: dict[str, Any] = { + "type": "dataset", + "entity": entity_name, + "name": entity_name, + "sql": source_sql, + "dataset_type": dataset_type, + "attributes": dataset_attrs, + } + if description: + dataset_dict["description"] = description + + files[f"{base}/datasets/{entity_name}.yml"] = _dump(dataset_dict) + + # ── calculated_attribute YAMLs ───────────────────────────────────────────── + for calc in calc_attrs: + files[f"{base}/attributes/{calc['name']}.yml"] = _dump(calc) + + # ── metric YAMLs ──────────────────────────────────────────────────────────── + for metric in metrics: + mname = metric.get("name") + if not mname: + continue + mexpr = _pick_ansi_expression(metric.get("expression"), mname) + if not mexpr or not mexpr.strip(): + continue + + metric_dict: dict[str, Any] = { + "type": "metric", + "entity": entity_name, + "name": mname, + "datatype": "number", + "sql": mexpr, + } + if metric.get("description"): + metric_dict["description"] = metric["description"] + + metric_ai_ctx = metric.get("ai_context") + if isinstance(metric_ai_ctx, str) and metric_ai_ctx: + existing = metric_dict.get("description", "") + metric_dict["description"] = f"{existing}\n{metric_ai_ctx}".strip() if existing else metric_ai_ctx + + metric_ext = [e for e in (metric.get("custom_extensions") or []) if e.get("vendor_name") != HONEYDEW_VENDOR] + metric_meta = _build_osi_metadata( + ai_context=metric_ai_ctx, + custom_extensions=metric_ext or None, + ) + if metric_meta: + metric_dict["metadata"] = [metric_meta] + + metric_path = f"{base}/metrics/{mname}.yml" + if metric_path in files: + warnings.warn( + f"Metric '{mname}' in entity '{entity_name}' is defined more than once; later definition wins" + ) + files[metric_path] = _dump(metric_dict) + + return files + + +def _osi_relation_to_honeydew(rel: dict[str, Any]) -> dict[str, Any] | None: + rel_name = rel.get("name", "") + to_ds = rel.get("to") + if not to_ds: + warnings.warn(f"Relationship '{rel_name}' missing 'to', skipping") + return None + + from_cols = rel.get("from_columns") or [] + to_cols = rel.get("to_columns") or [] + + if len(from_cols) != len(to_cols): + raise HoneydewConversionError( + f"Relationship '{rel_name}': from_columns and to_columns length mismatch " + f"({len(from_cols)} vs {len(to_cols)})" + ) + + honeydew_rel: dict[str, Any] = { + "target_entity": to_ds, + "rel_type": "many-to-one", + } + if rel.get("name"): + honeydew_rel["name"] = rel["name"] + if from_cols: + honeydew_rel["connection"] = [ + {"src_field": fc, "target_field": tc} + for fc, tc in zip(from_cols, to_cols) + ] + else: + hd_ext = _get_honeydew_extension(rel) + if hd_ext.get("connection_expr"): + honeydew_rel["connection_expr"] = {"sql": hd_ext["connection_expr"]} + else: + warnings.warn( + f"Relationship '{rel_name}' has no from_columns and no connection_expr; " + "Honeydew will not be able to resolve the join" + ) + return honeydew_rel + + +def _pick_ansi_expression(expression: Any, field_name: str) -> str | None: + """Select the ANSI_SQL expression; fall back to first available dialect.""" + if expression is None: + return None + if not isinstance(expression, dict): + warnings.warn(f"'{field_name}': 'expression' must be a mapping; field will be skipped") + return None + dialects = expression.get("dialects") or [] + if not dialects: + return None + + ansi_expr = None + first_expr = None + + for d in dialects: + dialect = (d.get("dialect") or "").upper() + expr = d.get("expression") + if first_expr is None: + first_expr = expr + if dialect == "ANSI_SQL" and ansi_expr is None: + ansi_expr = expr + + if ansi_expr is not None: + return ansi_expr + + if first_expr is not None: + warnings.warn(f"'{field_name}': no ANSI_SQL dialect found; using first available") + return first_expr + + return None + + +def _osi_field_to_honeydew_datatype(field: dict[str, Any]) -> str: + hd_ext = _get_honeydew_extension(field) + if hd_ext.get("datatype"): + return hd_ext["datatype"] + dimension = field.get("dimension") + if isinstance(dimension, dict) and dimension.get("is_time"): + return "timestamp" + if dimension is not None: + return "string" + return "number" + + +def _is_simple_identifier(expr: str) -> bool: + return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", expr.strip())) + + +def _parse_osi_source(source: str) -> tuple[str, str]: + source = (source or "").strip() + if not source: + return ("", "table") + upper = source.upper() + if upper.startswith(("SELECT ", "SELECT\n", "SELECT\t", "WITH ", "WITH\n", "WITH\t")): + return (source, "sql") + return (source, "table") + + +def _assign_metrics_to_entities( + metrics: list[dict[str, Any]], + entity_names: list[str], +) -> dict[str, list[dict[str, Any]]]: + """Assign each OSI metric to the most appropriate Honeydew entity. + + Priority: + 1. HONEYDEW ``custom_extension`` entity hint (preserves round-trip placement) + 2. First ``entity.column`` pattern in the ANSI_SQL expression + 3. First entity in the model (with a warning) + """ + entity_set = set(entity_names) + result: dict[str, list[dict[str, Any]]] = {} + + for metric in metrics: + mname = metric.get("name", "") + + # Priority 1: HONEYDEW entity hint (set during Honeydew → OSI) + hinted = _get_honeydew_extension(metric).get("entity") + if hinted and hinted in entity_set: + result.setdefault(hinted, []).append(metric) + continue + + # Priority 2: expression scan + expr_dict = metric.get("expression") or {} + dialects = expr_dict.get("dialects") or [] if isinstance(expr_dict, dict) else [] + expr_str = "" + for d in dialects: + if (d.get("dialect") or "").upper() == "ANSI_SQL": + expr_str = d.get("expression") or "" + break + if not expr_str and dialects: + expr_str = dialects[0].get("expression") or "" + + assigned = _find_entity_in_expression(expr_str, entity_set) + + # Priority 3: fallback + if assigned is None: + if entity_names: + assigned = entity_names[0] + warnings.warn( + f"Metric '{mname}': no entity reference found in expression; " + f"assigning to '{assigned}'" + ) + else: + warnings.warn(f"Metric '{mname}': no entities to assign to, skipping") + continue + + result.setdefault(assigned, []).append(metric) + + return result + + +def _find_entity_in_expression(expr: str, entity_names: set[str]) -> str | None: + for match in re.finditer(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b", expr): + if match.group(1) in entity_names: + return match.group(1) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# Honeydew → OSI +# ───────────────────────────────────────────────────────────────────────────── + + +def convert_honeydew_to_osi(workspace_dir: str) -> str: + """Convert a Honeydew workspace directory to an OSI YAML string. + + Reads workspace.yml and all entity subdirectories under schema/. Honeydew + fields with no OSI equivalent (``owner``, ``display_name``, ``hidden``, + ``format_string``, ``timegrain``, attribute ``labels``) are preserved in a + HONEYDEW ``custom_extension`` so they survive a round-trip back to Honeydew. + + Args: + workspace_dir: Path to the Honeydew workspace root. + + Returns: + OSI YAML document string. + + Raises: + HoneydewConversionError: On missing workspace.yml. + """ + workspace_path = os.path.join(workspace_dir, "workspace.yml") + if not os.path.exists(workspace_path): + raise HoneydewConversionError(f"workspace.yml not found in '{workspace_dir}'") + + with open(workspace_path) as f: + workspace = yaml.safe_load(f) or {} + + model_name = workspace.get("name") or os.path.basename(workspace_dir.rstrip("/\\")) + model_description = workspace.get("description") + ws_osi_meta = _read_osi_metadata(workspace) + + schema_dir = os.path.join(workspace_dir, "schema") + entity_dirs: list[str] = [] + if os.path.isdir(schema_dir): + entity_dirs = sorted( + d for d in os.listdir(schema_dir) + if os.path.isdir(os.path.join(schema_dir, d)) + ) + + osi_datasets: list[dict[str, Any]] = [] + osi_relationships: list[dict[str, Any]] = [] + osi_metrics: list[dict[str, Any]] = [] + seen_relationships: set[tuple] = set() + + for entity_name in entity_dirs: + entity_dir = os.path.join(schema_dir, entity_name) + entity_data = _read_entity_dir(entity_dir, entity_name) + + osi_datasets.append(_entity_to_osi_dataset(entity_data)) + + for rel in entity_data["relations"]: + osi_rel = _honeydew_relation_to_osi( + rel, entity_name, seen_relationships + ) + if osi_rel is not None: + osi_relationships.append(osi_rel) + + for metric in entity_data["metrics"]: + osi_m = _honeydew_metric_to_osi(metric, entity_name) + if osi_m is not None: + osi_metrics.append(osi_m) + + sm: dict[str, Any] = {"name": model_name, "datasets": osi_datasets} + if model_description: + sm["description"] = str(model_description).strip() + if ws_osi_meta.get("ai_context"): + sm["ai_context"] = ws_osi_meta["ai_context"] + + restored_ws_ext = ws_osi_meta.get("custom_extensions") or [] + if restored_ws_ext: + sm["custom_extensions"] = restored_ws_ext + + if osi_relationships: + sm["relationships"] = osi_relationships + if osi_metrics: + sm["metrics"] = osi_metrics + + extra_vendors = ws_osi_meta.get("vendors") or [] + vendors = [HONEYDEW_VENDOR] + [v for v in extra_vendors if v != HONEYDEW_VENDOR] + root: dict[str, Any] = { + "version": SUPPORTED_OSI_VERSION, + "vendors": vendors, + "semantic_model": [sm], + } + return _dump(root) + + +def _read_entity_dir(entity_dir: str, entity_name: str) -> dict[str, Any]: + data: dict[str, Any] = { + "name": entity_name, + "description": None, + "keys": [], + "key_dataset": None, + "relations": [], + "primary_dataset": None, + "calculated_attributes": [], + "metrics": [], + "osi_meta": {}, + "honeydew_extra": {}, + } + + entity_yml = os.path.join(entity_dir, f"{entity_name}.yml") + if os.path.exists(entity_yml): + with open(entity_yml) as f: + ey = yaml.safe_load(f) or {} + data["keys"] = _coerce_list(ey.get("keys")) + data["description"] = ey.get("description") + data["key_dataset"] = ey.get("key_dataset") + data["relations"] = ey.get("relations") or [] + data["osi_meta"] = _read_osi_metadata(ey) + data["honeydew_extra"] = { + k: ey[k] for k in ("owner", "display_name", "hidden", "folder", "labels") + if k in ey + } + + datasets_dir = os.path.join(entity_dir, "datasets") + if os.path.isdir(datasets_dir): + all_ds: list[dict[str, Any]] = [] + for fn in sorted(os.listdir(datasets_dir)): + if fn.endswith((".yml", ".yaml")): + with open(os.path.join(datasets_dir, fn)) as f: + all_ds.append(yaml.safe_load(f) or {}) + if len(all_ds) > 1: + warnings.warn( + f"Entity '{entity_name}' has {len(all_ds)} dataset files; " + "only the primary dataset will be converted", + stacklevel=2, + ) + for ds in all_ds: + if ds.get("name") == data["key_dataset"] or data["primary_dataset"] is None: + data["primary_dataset"] = ds + if ds.get("name") == data["key_dataset"]: + break + + attrs_dir = os.path.join(entity_dir, "attributes") + if os.path.isdir(attrs_dir): + for fn in sorted(os.listdir(attrs_dir)): + if fn.endswith((".yml", ".yaml")): + with open(os.path.join(attrs_dir, fn)) as f: + data["calculated_attributes"].append(yaml.safe_load(f) or {}) + + metrics_dir = os.path.join(entity_dir, "metrics") + if os.path.isdir(metrics_dir): + for fn in sorted(os.listdir(metrics_dir)): + if fn.endswith((".yml", ".yaml")): + with open(os.path.join(metrics_dir, fn)) as f: + data["metrics"].append(yaml.safe_load(f) or {}) + + return data + + +def _entity_to_osi_dataset(entity_data: dict[str, Any]) -> dict[str, Any]: + entity_name = entity_data["name"] + ds: dict[str, Any] = {"name": entity_name} + + if entity_data.get("description"): + ds["description"] = str(entity_data["description"]).strip() + + primary_ds = entity_data.get("primary_dataset") + ds["source"] = (primary_ds.get("sql") or "").strip() if primary_ds else entity_name + + keys = entity_data.get("keys") or [] + if keys: + ds["primary_key"] = list(keys) + + # Restore OSI-only fields preserved in Honeydew metadata + osi_meta = entity_data.get("osi_meta") or {} + if osi_meta.get("ai_context"): + ds["ai_context"] = osi_meta["ai_context"] + + # A Honeydew entity's keys uniquely identify its rows — Honeydew enforces this + # and validates that relationships join to those keys — so surface them as a + # unique key as well as the primary key. Union with any unique keys preserved + # from an OSI source, de-duplicated. + unique_keys = [list(uk) for uk in (osi_meta.get("unique_keys") or [])] + if keys and tuple(keys) not in {tuple(uk) for uk in unique_keys}: + unique_keys.append(list(keys)) + if unique_keys: + ds["unique_keys"] = unique_keys + + restored_ext = list(osi_meta.get("custom_extensions") or []) + honeydew_extra = entity_data.get("honeydew_extra") or {} + if honeydew_extra: + restored_ext.append({"vendor_name": HONEYDEW_VENDOR, "data": json.dumps(honeydew_extra)}) + if restored_ext: + ds["custom_extensions"] = restored_ext + + # Build fields + fields: list[dict[str, Any]] = [] + seen: set[str] = set() + + if primary_ds: + for attr in primary_ds.get("attributes") or []: + col = attr.get("column") or attr.get("name") or "" + aname = attr.get("name") or col + if not aname or aname in seen: + continue + seen.add(aname) + + field: dict[str, Any] = { + "name": aname, + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": col}]}, + } + datatype = attr.get("datatype") or "string" + dim = _honeydew_datatype_to_osi_dimension(datatype) + if dim is not None: + field["dimension"] = dim + if attr.get("description"): + field["description"] = str(attr["description"]).strip() + + attr_osi_meta = _read_osi_metadata(attr) + attr_labels = attr.get("labels") or [] + + # Restore ai_context (structured form takes priority) + # Only synthesise synonyms from labels when they are native Honeydew labels + # (i.e. osi_meta has no 'label' key, meaning the label didn't come from OSI) + if attr_osi_meta.get("ai_context"): + field["ai_context"] = attr_osi_meta["ai_context"] + elif attr_labels and "label" not in attr_osi_meta: + field["ai_context"] = {"synonyms": list(attr_labels)} + + # Restore label: prefer osi_meta (exact round-trip), else first Honeydew label + # Don't set label when labels came from ai_context.synonyms (osi_meta has ai_context) + if "label" in attr_osi_meta: + field["label"] = attr_osi_meta["label"] + elif attr_labels and not attr_osi_meta.get("ai_context"): + field["label"] = attr_labels[0] + + # Honeydew-specific metadata → HONEYDEW custom_extension + attr_honeydew_extra = { + k: attr[k] for k in ("display_name", "hidden", "folder", "format_string", "timegrain") + if k in attr + } + if datatype == "bool": + attr_honeydew_extra["datatype"] = datatype + if len(attr_labels) > 1: + attr_honeydew_extra["labels"] = attr_labels + + all_ext = list(attr_osi_meta.get("custom_extensions") or []) + if attr_honeydew_extra: + all_ext.append({"vendor_name": HONEYDEW_VENDOR, "data": json.dumps(attr_honeydew_extra)}) + if all_ext: + field["custom_extensions"] = all_ext + + fields.append(field) + + for calc in entity_data.get("calculated_attributes") or []: + aname = calc.get("name") or "" + if not aname or aname in seen: + continue + seen.add(aname) + + sql = (calc.get("sql") or "").strip() + datatype = calc.get("datatype") or "string" + + field = { + "name": aname, + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": sql}]}, + } + dim = _honeydew_datatype_to_osi_dimension(datatype) + if dim is not None: + field["dimension"] = dim + if calc.get("description"): + cleaned = str(calc["description"]).strip() + if cleaned: + field["description"] = cleaned + + calc_osi_meta = _read_osi_metadata(calc) + calc_labels = calc.get("labels") or [] + + if calc_osi_meta.get("ai_context"): + field["ai_context"] = calc_osi_meta["ai_context"] + elif calc_labels and "label" not in calc_osi_meta: + field["ai_context"] = {"synonyms": list(calc_labels)} + + if "label" in calc_osi_meta: + field["label"] = calc_osi_meta["label"] + elif calc_labels and not calc_osi_meta.get("ai_context"): + field["label"] = calc_labels[0] + + calc_honeydew_extra = { + k: calc[k] for k in ("display_name", "hidden", "folder", "format_string", "timegrain") + if k in calc + } + if datatype == "bool": + calc_honeydew_extra["datatype"] = datatype + + all_calc_ext = list(calc_osi_meta.get("custom_extensions") or []) + # Always mark as calculated_attribute so OSI → Honeydew routes it correctly + all_calc_ext.append({ + "vendor_name": HONEYDEW_VENDOR, + "data": json.dumps(dict({"type": "calculated_attribute", "entity": entity_name}, **calc_honeydew_extra)), + }) + field["custom_extensions"] = all_calc_ext + + fields.append(field) + + if fields: + ds["fields"] = fields + + return ds + + +def _honeydew_datatype_to_osi_dimension(datatype: str) -> dict[str, Any] | None: + dt = (datatype or "").lower() + if dt in ("date", "timestamp", "time"): + return {"is_time": True} + if dt in ("bool", "string"): + return {"is_time": False} + return None # number / float → OSI fact (no dimension key) + + +def _honeydew_relation_to_osi( + rel: dict[str, Any], + entity_name: str, + seen: set[tuple], +) -> dict[str, Any] | None: + target = rel.get("target_entity") + if not target: + warnings.warn(f"Entity '{entity_name}': relation missing target_entity, skipping") + return None + + rel_type = (rel.get("rel_type") or "many-to-one").lower() + connection = rel.get("connection") or [] + connection_expr = rel.get("connection_expr") + + if rel_type == "many-to-one": + from_entity, to_entity = entity_name, target + from_cols = [c.get("src_field", "") for c in connection] + to_cols = [c.get("target_field", "") for c in connection] + elif rel_type == "one-to-many": + from_entity, to_entity = target, entity_name + from_cols = [c.get("target_field", "") for c in connection] + to_cols = [c.get("src_field", "") for c in connection] + else: + from_entity, to_entity = entity_name, target + from_cols = [c.get("src_field", "") for c in connection] + to_cols = [c.get("target_field", "") for c in connection] + + dedup_key = (from_entity, to_entity, tuple(from_cols), tuple(to_cols)) + if dedup_key in seen: + return None + seen.add(dedup_key) + + rel_name = rel.get("name") or f"{from_entity}_to_{to_entity}" + + osi_rel: dict[str, Any] = {"name": rel_name, "from": from_entity, "to": to_entity} + if from_cols: + osi_rel["from_columns"] = from_cols + osi_rel["to_columns"] = to_cols + + if connection_expr and not connection: + sql_expr = (connection_expr.get("sql") or "") if isinstance(connection_expr, dict) else str(connection_expr) + osi_rel["custom_extensions"] = [ + {"vendor_name": HONEYDEW_VENDOR, "data": json.dumps({"connection_expr": sql_expr})} + ] + + return osi_rel + + +def _honeydew_metric_to_osi(metric: dict[str, Any], entity_name: str) -> dict[str, Any] | None: + mname = metric.get("name") or "" + if not mname: + return None + + sql = (metric.get("sql") or "").strip() + if not sql: + warnings.warn(f"Metric '{mname}' in entity '{entity_name}' has no SQL, skipping") + return None + + osi_m: dict[str, Any] = { + "name": mname, + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": sql}]}, + "custom_extensions": [ + {"vendor_name": HONEYDEW_VENDOR, "data": json.dumps({"entity": entity_name})} + ], + } + + if metric.get("description"): + cleaned = str(metric["description"]).strip() + if cleaned: + osi_m["description"] = cleaned + + metric_osi_meta = _read_osi_metadata(metric) + if metric_osi_meta.get("ai_context"): + osi_m["ai_context"] = metric_osi_meta["ai_context"] + + restored_ext = metric_osi_meta.get("custom_extensions") or [] + if restored_ext: + osi_m["custom_extensions"] = osi_m["custom_extensions"] + list(restored_ext) + + return osi_m + + +# ───────────────────────────────────────────────────────────────────────────── +# OSI metadata helpers — store/restore OSI fields in Honeydew metadata sections +# ───────────────────────────────────────────────────────────────────────────── + + +def _build_osi_metadata( + *, + ai_context: Any = None, + label: str | None = None, + unique_keys: Any = None, + custom_extensions: list | None = None, + extra_vendors: list[str] | None = None, +) -> dict[str, Any] | None: + """Build a Honeydew metadata entry that stores OSI-only fields for round-tripping.""" + items: list[dict[str, Any]] = [] + + if ai_context is not None: + val = ai_context if isinstance(ai_context, str) else json.dumps(ai_context) + items.append({"name": "ai_context", "value": val}) + if label is not None: + items.append({"name": "label", "value": label}) + if unique_keys: + items.append({"name": "unique_keys", "value": json.dumps(unique_keys)}) + if custom_extensions: + items.append({"name": "custom_extensions", "value": json.dumps(custom_extensions)}) + if extra_vendors: + items.append({"name": "vendors", "value": json.dumps(extra_vendors)}) + + if not items: + return None + return {"name": _OSI_METADATA_SECTION, "metadata": items} + + +def _read_osi_metadata(obj: dict[str, Any]) -> dict[str, Any]: + """Read OSI-preserved fields from a Honeydew object's 'osi' metadata section.""" + for section in (obj.get("metadata") or []): + if (section.get("name") or "") != _OSI_METADATA_SECTION: + continue + result: dict[str, Any] = {} + for item in (section.get("metadata") or []): + key = item.get("name") or "" + raw = item.get("value") + if key == "ai_context": + try: + result[key] = json.loads(raw) + except (json.JSONDecodeError, TypeError): + result[key] = raw + elif key == "label": + result[key] = raw + elif key in ("unique_keys", "custom_extensions", "vendors"): + try: + result[key] = json.loads(raw) + except (json.JSONDecodeError, TypeError): + warnings.warn(f"Could not parse OSI metadata field '{key}': {raw!r}") + return result + return {} + + +def _get_honeydew_extension(obj: dict[str, Any]) -> dict[str, Any]: + """Extract the HONEYDEW custom_extension data from an OSI object.""" + for ext in (obj.get("custom_extensions") or []): + if ext.get("vendor_name") == HONEYDEW_VENDOR: + try: + return json.loads(ext.get("data") or "{}") + except (json.JSONDecodeError, TypeError): + return {} + return {} + + +# ───────────────────────────────────────────────────────────────────────────── +# Utilities +# ───────────────────────────────────────────────────────────────────────────── + + +def _coerce_list(value: Any) -> list: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def _dump(data: Any) -> str: + return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +def _check_safe_path(output_abs: str, rel_path: str) -> bool: + """Return True iff the resolved path stays inside output_abs.""" + full = os.path.normpath(os.path.join(output_abs, rel_path)) + return full.startswith(output_abs + os.sep) + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Bidirectional OSI ↔ Honeydew semantic model converter" + ) + sub = parser.add_subparsers(dest="command", required=True) + + p1 = sub.add_parser("osi-to-honeydew", help="Convert OSI YAML → Honeydew workspace") + p1.add_argument("-i", "--input", required=True, help="OSI YAML input file") + p1.add_argument("-o", "--output", required=True, help="Output directory for Honeydew workspace") + + p2 = sub.add_parser("honeydew-to-osi", help="Convert Honeydew workspace → OSI YAML") + p2.add_argument("-i", "--input", required=True, help="Honeydew workspace directory") + p2.add_argument("-o", "--output", required=True, help="OSI YAML output file") + + args = parser.parse_args() + + if args.command == "osi-to-honeydew": + with open(args.input) as f: + osi_yaml = f.read() + try: + files = convert_osi_to_honeydew(osi_yaml) + except HoneydewConversionError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + output_abs = os.path.abspath(args.output) + for rel_path, content in files.items(): + if not _check_safe_path(output_abs, rel_path): + print(f"Error: refusing to write outside output directory: {rel_path}", file=sys.stderr) + sys.exit(1) + full_path = os.path.normpath(os.path.join(output_abs, rel_path)) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + print(f"Wrote {len(files)} file(s) to {args.output}") + + elif args.command == "honeydew-to-osi": + try: + osi_yaml = convert_honeydew_to_osi(args.input) + except HoneydewConversionError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + with open(args.output, "w") as f: + f.write(osi_yaml) + print(f"Converted {args.input} → {args.output}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/converters/honeydew/tests/test_honeydew_osi_converter.py b/converters/honeydew/tests/test_honeydew_osi_converter.py new file mode 100644 index 0000000..c3aaa75 --- /dev/null +++ b/converters/honeydew/tests/test_honeydew_osi_converter.py @@ -0,0 +1,1496 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the bidirectional OSI ↔ Honeydew converter.""" + +import json +import os +import sys +import warnings +from pathlib import Path + +import pytest +import yaml + +from honeydew_osi.converter import ( + HoneydewConversionError, + _assign_metrics_to_entities, + _build_osi_metadata, + _check_safe_path, + _fields_to_honeydew, + _find_entity_in_expression, + _honeydew_datatype_to_osi_dimension, + _is_simple_identifier, + _osi_field_to_honeydew_datatype, + _parse_osi_source, + _pick_ansi_expression, + _read_osi_metadata, + convert_honeydew_to_osi, + convert_osi_to_honeydew, +) + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +OSI_VERSION = "0.2.0.dev0" + + +def _osi(model_dict): + return yaml.dump( + {"version": OSI_VERSION, "semantic_model": [model_dict]}, + default_flow_style=False, + sort_keys=False, + ) + + +def _minimal_osi_field(name, expr, is_dimension=True, is_time=False): + field = { + "name": name, + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": expr}]}, + } + if is_dimension: + field["dimension"] = {"is_time": is_time} + return field + + +def _minimal_model(): + return { + "name": "test_model", + "datasets": [ + { + "name": "orders", + "source": "db.schema.orders", + "primary_key": ["order_id"], + "fields": [ + _minimal_osi_field("order_id", "order_id"), + _minimal_osi_field("order_date", "order_date", is_time=True), + _minimal_osi_field("total", "total_amount", is_dimension=False), + ], + } + ], + } + + +def _write_workspace(tmp_dir, workspace_name, entities): + """Write a minimal Honeydew workspace to tmp_dir.""" + workspace_path = os.path.join(tmp_dir, "workspace.yml") + with open(workspace_path, "w") as f: + yaml.dump({"type": "workspace", "name": workspace_name}, f) + + for e in entities: + ename = e["name"] + base = os.path.join(tmp_dir, "schema", ename) + os.makedirs(os.path.join(base, "datasets"), exist_ok=True) + os.makedirs(os.path.join(base, "attributes"), exist_ok=True) + os.makedirs(os.path.join(base, "metrics"), exist_ok=True) + + entity_dict = { + "type": "entity", + "name": ename, + "keys": e.get("keys", []), + "key_dataset": e.get("key_dataset", ename), + "relations": e.get("relations", []), + } + for k in ("owner", "display_name", "hidden", "folder"): + if k in e: + entity_dict[k] = e[k] + with open(os.path.join(base, f"{ename}.yml"), "w") as f: + yaml.dump(entity_dict, f) + + ds_name = e.get("key_dataset", ename) + ds_dict = { + "type": "dataset", + "entity": ename, + "name": ds_name, + "sql": e.get("sql", "DB.SCHEMA." + ename.upper()), + "dataset_type": "table", + "attributes": e.get("dataset_attrs", []), + } + with open(os.path.join(base, "datasets", f"{ds_name}.yml"), "w") as f: + yaml.dump(ds_dict, f) + + for attr in e.get("calc_attrs", []): + with open(os.path.join(base, "attributes", f"{attr['name']}.yml"), "w") as f: + yaml.dump(attr, f) + + for m in e.get("metrics", []): + with open(os.path.join(base, "metrics", f"{m['name']}.yml"), "w") as f: + yaml.dump(m, f) + + +def _osi_roundtrip(model_dict, tmp_path): + """OSI → Honeydew → OSI; returns the semantic model dict.""" + files = convert_osi_to_honeydew(_osi(model_dict)) + for rel_path, content in files.items(): + p = tmp_path / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + return yaml.safe_load(convert_honeydew_to_osi(str(tmp_path)))["semantic_model"][0] + + +def _honeydew_roundtrip(entities, tmp_path): + """Honeydew → OSI → Honeydew; returns Path to the output workspace directory.""" + _write_workspace(str(tmp_path), "ws", entities) + osi_yaml = convert_honeydew_to_osi(str(tmp_path)) + files = convert_osi_to_honeydew(osi_yaml) + out_dir = tmp_path / "out" + for rel_path, content in files.items(): + p = out_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + return out_dir + + +# ───────────────────────────────────────────────────────────────────────────── +# Unit tests – helpers +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("expr,expected", [ + ("order_id", True), + ("SUM(x)", False), + ("orders.id", False), + ("1col", False), + ("_hidden", True), +]) +def test_is_simple_identifier(expr, expected): + assert _is_simple_identifier(expr) is expected + + +@pytest.mark.parametrize("source,expected_sql,expected_type", [ + ("db.schema.table", "db.schema.table", "table"), + ("SELECT id FROM foo", "SELECT id FROM foo", "sql"), + ("WITH cte AS (SELECT 1) SELECT * FROM cte", "WITH cte AS (SELECT 1) SELECT * FROM cte", "sql"), + ("", "", "table"), +]) +def test_parse_osi_source(source, expected_sql, expected_type): + sql, dtype = _parse_osi_source(source) + assert sql == expected_sql and dtype == expected_type + + +@pytest.mark.parametrize("field,expected_dt", [ + ({"dimension": {"is_time": True}}, "timestamp"), + ({"dimension": {"is_time": False}}, "string"), + ({}, "number"), +]) +def test_osi_field_to_honeydew_datatype(field, expected_dt): + assert _osi_field_to_honeydew_datatype(field) == expected_dt + + +@pytest.mark.parametrize("datatype,expected_dim", [ + ("date", {"is_time": True}), + ("timestamp", {"is_time": True}), + ("string", {"is_time": False}), + ("bool", {"is_time": False}), + ("number", None), + ("float", None), +]) +def test_honeydew_datatype_to_osi_dimension(datatype, expected_dim): + assert _honeydew_datatype_to_osi_dimension(datatype) == expected_dim + + +@pytest.mark.parametrize("expr,entities,expected", [ + ("SUM(orders.total)", {"orders", "customers"}, "orders"), + ("orders.a / customers.b", {"orders", "customers"}, "orders"), + ("COUNT(*)", {"orders"}, None), + ("SUM(foo.col)", {"orders"}, None), +]) +def test_find_entity_in_expression(expr, entities, expected): + assert _find_entity_in_expression(expr, entities) == expected + + +def test_pick_ansi_expression_ansi_preferred(): + expr = {"dialects": [ + {"dialect": "SNOWFLAKE", "expression": "col::VARCHAR"}, + {"dialect": "ANSI_SQL", "expression": "col"}, + ]} + assert _pick_ansi_expression(expr, "f") == "col" + + +def test_pick_ansi_expression_fallback_warns(): + expr = {"dialects": [{"dialect": "SNOWFLAKE", "expression": "col::VARCHAR"}]} + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = _pick_ansi_expression(expr, "f") + assert result == "col::VARCHAR" + assert any("ANSI_SQL" in str(x.message) for x in w) + + +@pytest.mark.parametrize("expression", [None, {"dialects": []}]) +def test_pick_ansi_expression_returns_none(expression): + assert _pick_ansi_expression(expression, "f") is None + + +def test_pick_ansi_expression_non_dict_warns(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = _pick_ansi_expression("just_a_string", "f") + assert result is None + assert any("must be a mapping" in str(x.message) for x in w) + + +# ───────────────────────────────────────────────────────────────────────────── +# OSI metadata helpers +# ───────────────────────────────────────────────────────────────────────────── + +def test_build_and_read_ai_context_string(): + section = _build_osi_metadata(ai_context="orders, purchases") + result = _read_osi_metadata({"metadata": [section]}) + assert result["ai_context"] == "orders, purchases" + + +def test_build_and_read_ai_context_dict(): + ctx = {"instructions": "Use for sales", "synonyms": ["orders", "purchases"]} + section = _build_osi_metadata(ai_context=ctx) + result = _read_osi_metadata({"metadata": [section]}) + assert result["ai_context"] == ctx + + +def test_build_and_read_unique_keys(): + uks = [["col1", "col2"], ["col3"]] + section = _build_osi_metadata(unique_keys=uks) + result = _read_osi_metadata({"metadata": [section]}) + assert result["unique_keys"] == uks + + +def test_build_and_read_custom_extensions(): + exts = [{"vendor_name": "SNOWFLAKE", "data": '{"warehouse": "WH"}'}] + section = _build_osi_metadata(custom_extensions=exts) + result = _read_osi_metadata({"metadata": [section]}) + assert result["custom_extensions"] == exts + + +def test_read_osi_metadata_no_osi_section(): + assert _read_osi_metadata({"metadata": [{"name": "other", "metadata": []}]}) == {} + + +def test_read_osi_metadata_no_metadata(): + assert _read_osi_metadata({}) == {} + + +def test_build_osi_metadata_nothing_to_store(): + assert _build_osi_metadata() is None + + +# ───────────────────────────────────────────────────────────────────────────── +# Assign metrics to entities +# ───────────────────────────────────────────────────────────────────────────── + +def test_assign_metrics_by_expression(): + metrics = [{"name": "total", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "SUM(orders.total)"}]}}] + result = _assign_metrics_to_entities(metrics, ["orders", "customers"]) + assert "total" in [m["name"] for m in result.get("orders", [])] + + +def test_assign_metrics_honeydew_hint_takes_priority(): + metrics = [{ + "name": "cnt", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "SUM(orders.x)"}]}, + "custom_extensions": [{"vendor_name": "HONEYDEW", "data": '{"entity": "customers"}'}], + }] + result = _assign_metrics_to_entities(metrics, ["orders", "customers"]) + assert "cnt" in [m["name"] for m in result.get("customers", [])] + assert "orders" not in result + + +def test_assign_metrics_fallback_to_first_entity(): + metrics = [{"name": "cnt", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "COUNT(*)"}]}}] + with warnings.catch_warnings(record=True): + result = _assign_metrics_to_entities(metrics, ["orders"]) + assert "cnt" in [m["name"] for m in result.get("orders", [])] + + +def test_assign_metrics_no_entities(): + metrics = [{"name": "m", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "COUNT(*)"}]}}] + with warnings.catch_warnings(record=True): + result = _assign_metrics_to_entities(metrics, []) + assert result == {} + + +# ───────────────────────────────────────────────────────────────────────────── +# Path traversal guard +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("rel_path,expected", [ + ("workspace.yml", True), + ("schema/orders/orders.yml", True), + ("schema/orders/datasets/orders.yml", True), + ("../evil.yml", False), + ("../../etc/passwd", False), + ("schema/../../../evil", False), +]) +def test_check_safe_path(rel_path, expected): + output_abs = os.path.abspath("/tmp/test_output") + assert _check_safe_path(output_abs, rel_path) is expected + + +# ───────────────────────────────────────────────────────────────────────────── +# OSI → Honeydew: file content +# ───────────────────────────────────────────────────────────────────────────── + +_REL_MODEL = { + "name": "m", + "datasets": [ + {"name": "orders", "source": "db.s.orders", "fields": []}, + {"name": "customers", "source": "db.s.customers", "fields": []}, + ], + "relationships": [{"name": "orders_to_customers", "from": "orders", "to": "customers", + "from_columns": ["cid"], "to_columns": ["id"]}], +} + +@pytest.mark.parametrize("model,path,expected", [ + # ── minimal model ────────────────────────────────────────────────────────── + pytest.param( + _minimal_model(), + "workspace.yml", + {"type": "workspace", "name": "test_model"}, + id="minimal-workspace", + ), + pytest.param( + _minimal_model(), + "schema/orders/orders.yml", + {"type": "entity", "name": "orders", "keys": ["order_id"], + "key_dataset": "orders", "relations": []}, + id="minimal-entity", + ), + pytest.param( + _minimal_model(), + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.schema.orders", "dataset_type": "table", + "attributes": [ + {"column": "order_id", "name": "order_id", "datatype": "string"}, + {"column": "order_date", "name": "order_date", "datatype": "timestamp"}, + {"column": "total_amount", "name": "total", "datatype": "number"}, + ], + }, + id="minimal-dataset", + ), + # ── complex expression → calculated attribute ────────────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "disc_price", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "price * (1 - discount)"}]}, + "dimension": {"is_time": False}, + }]}]}, + "schema/orders/attributes/disc_price.yml", + {"type": "calculated_attribute", "entity": "orders", "name": "disc_price", + "datatype": "string", "sql": "price * (1 - discount)"}, + id="calc-attr-file", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "disc_price", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "price * (1 - discount)"}]}, + "dimension": {"is_time": False}, + }]}]}, + "schema/orders/datasets/orders.yml", + {"type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.s.orders", "dataset_type": "table", "attributes": []}, + id="calc-attr-dataset-empty", + ), + # ── label → labels in attr ──────────────────────────────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "status", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "status"}]}, + "dimension": {"is_time": False}, + "label": "sales", + }]}]}, + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.s.orders", "dataset_type": "table", + "attributes": [{"column": "status", "name": "status", + "datatype": "string", "labels": ["sales"], + "metadata": [{"name": "osi", "metadata": [ + {"name": "label", "value": "sales"} + ]}]}], + }, + id="label-in-attr", + ), + # ── ai_context string → description merged ──────────────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "total", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "total"}]}, + "description": "Base desc", + "ai_context": "revenue, earnings", + }]}]}, + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.s.orders", "dataset_type": "table", + "attributes": [{"column": "total", "name": "total", "datatype": "number", + "description": "Base desc\nrevenue, earnings"}], + }, + id="ai-context-string", + ), + # ── ai_context dict → labels + description + metadata ──────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "total", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "total"}]}, + "ai_context": {"instructions": "Use for revenue", "synonyms": ["rev", "earnings"]}, + }]}]}, + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.s.orders", "dataset_type": "table", + "attributes": [{ + "column": "total", "name": "total", "datatype": "number", + "description": "Use for revenue", + "labels": ["rev", "earnings"], + "metadata": [{"name": "osi", "metadata": [ + {"name": "ai_context", + "value": '{"instructions": "Use for revenue", "synonyms": ["rev", "earnings"]}'}, + ]}], + }], + }, + id="ai-context-dict", + ), + # ── unique_keys → entity metadata ───────────────────────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "items", "source": "db.s.items", + "primary_key": ["item_id"], + "unique_keys": [["sku"], ["item_id", "variant"]], + "fields": []}]}, + "schema/items/items.yml", + { + "type": "entity", "name": "items", "keys": ["item_id"], + "key_dataset": "items", "relations": [], + "metadata": [{"name": "osi", "metadata": [ + {"name": "unique_keys", "value": '[["sku"], ["item_id", "variant"]]'}, + ]}], + }, + id="unique-keys", + ), + # ── non-HONEYDEW custom_extensions → entity metadata ────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "custom_extensions": [{"vendor_name": "SNOWFLAKE", "data": '{"warehouse": "WH"}'}], + "fields": []}]}, + "schema/orders/orders.yml", + { + "type": "entity", "name": "orders", "key_dataset": "orders", "relations": [], + "metadata": [{"name": "osi", "metadata": [ + {"name": "custom_extensions", + "value": '[{"vendor_name": "SNOWFLAKE", "data": "{\\"warehouse\\": \\"WH\\"}"}]'}, + ]}], + }, + id="custom-ext", + ), + # ── relationship on from-entity; nothing on to-entity ──────────────────── + pytest.param( + _REL_MODEL, + "schema/orders/orders.yml", + { + "type": "entity", "name": "orders", "key_dataset": "orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "name": "orders_to_customers", + "connection": [{"src_field": "cid", "target_field": "id"}]}], + }, + id="relation-from-entity", + ), + pytest.param( + _REL_MODEL, + "schema/customers/customers.yml", + {"type": "entity", "name": "customers", "key_dataset": "customers", "relations": []}, + id="relation-to-entity-empty", + ), + # ── model-level ai_context → workspace metadata ─────────────────────────── + pytest.param( + {"name": "m", "datasets": [], + "ai_context": {"instructions": "Use for retail analytics", "synonyms": ["store"]}}, + "workspace.yml", + { + "type": "workspace", "name": "m", + "metadata": [{"name": "osi", "metadata": [ + {"name": "ai_context", + "value": '{"instructions": "Use for retail analytics", "synonyms": ["store"]}'}, + ]}], + }, + id="model-ai-context", + ), + # ── metric ──────────────────────────────────────────────────────────────── + pytest.param( + {"name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders", "fields": []}], + "metrics": [{"name": "total_rev", "description": "Sum of sales", + "expression": {"dialects": [{"dialect": "ANSI_SQL", + "expression": "SUM(orders.total)"}]}}]}, + "schema/orders/metrics/total_rev.yml", + {"type": "metric", "entity": "orders", "name": "total_rev", + "datatype": "number", "sql": "SUM(orders.total)", "description": "Sum of sales"}, + id="metric", + ), + # ── subquery source → dataset_type sql ─────────────────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "orders", + "source": "SELECT * FROM raw.orders WHERE active = true", "fields": []}]}, + "schema/orders/datasets/orders.yml", + {"type": "dataset", "entity": "orders", "name": "orders", + "sql": "SELECT * FROM raw.orders WHERE active = true", + "dataset_type": "sql", "attributes": []}, + id="subquery-source", + ), + # ── composite primary key ───────────────────────────────────────────────── + pytest.param( + {"name": "m", "datasets": [{"name": "li", "source": "db.s.li", + "primary_key": ["order_id", "line_number"], "fields": []}]}, + "schema/li/li.yml", + {"type": "entity", "name": "li", "keys": ["order_id", "line_number"], + "key_dataset": "li", "relations": []}, + id="composite-pk", + ), +]) +def test_osi_to_honeydew_file_content(model, path, expected): + files = convert_osi_to_honeydew(_osi(model)) + assert path in files + assert yaml.safe_load(files[path]) == expected + + +def test_osi_to_honeydew_metric_entity_hint_overrides_expression(): + model = {"name": "m", + "datasets": [ + {"name": "orders", "source": "db.s.orders", "fields": []}, + {"name": "customers", "source": "db.s.customers", "fields": []}, + ], + "metrics": [{ + "name": "cnt", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "SUM(orders.x)"}]}, + "custom_extensions": [{"vendor_name": "HONEYDEW", "data": '{"entity": "customers"}'}], + }]} + files = convert_osi_to_honeydew(_osi(model)) + assert "schema/customers/metrics/cnt.yml" in files + assert "schema/orders/metrics/cnt.yml" not in files + + +def test_osi_to_honeydew_invalid_version_raises(): + with pytest.raises(HoneydewConversionError, match="Unsupported"): + convert_osi_to_honeydew("version: '9.9.9'\nsemantic_model:\n - name: m\n") + + +def test_osi_to_honeydew_missing_semantic_model_raises(): + with pytest.raises(HoneydewConversionError): + convert_osi_to_honeydew(f"version: '{OSI_VERSION}'\n") + + +def test_osi_to_honeydew_multiple_models_warns(): + doc = yaml.dump({"version": OSI_VERSION, "semantic_model": [ + {"name": "m1", "datasets": []}, + {"name": "m2", "datasets": []}, + ]}) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + files = convert_osi_to_honeydew(doc) + assert any("only the first" in str(x.message) for x in w) + assert yaml.safe_load(files["workspace.yml"]) == {"type": "workspace", "name": "m1"} + + +# ───────────────────────────────────────────────────────────────────────────── +# Honeydew → OSI: full document +# ───────────────────────────────────────────────────────────────────────────── + +def _hd_root(sm): + return {"version": OSI_VERSION, "vendors": ["HONEYDEW"], "semantic_model": [sm]} + + +def _ansi(expr): + return {"dialects": [{"dialect": "ANSI_SQL", "expression": expr}]} + + +@pytest.mark.parametrize("ws_name,entities,expected_root", [ + # ── basic entity with two dataset attributes ────────────────────────────── + pytest.param( + "tpch", + [{"name": "orders", "keys": ["orderkey"], "key_dataset": "tpch_orders", + "sql": "SNOWFLAKE_SAMPLE_DATA.TPCH_SF1.ORDERS", + "dataset_attrs": [ + {"column": "o_orderkey", "name": "orderkey", "datatype": "number"}, + {"column": "o_orderdate", "name": "orderdate", "datatype": "date"}, + ]}], + _hd_root({ + "name": "tpch", + "datasets": [{ + "name": "orders", + "source": "SNOWFLAKE_SAMPLE_DATA.TPCH_SF1.ORDERS", + "primary_key": ["orderkey"], + "unique_keys": [["orderkey"]], + "fields": [ + {"name": "orderkey", "expression": _ansi("o_orderkey")}, + {"name": "orderdate", "expression": _ansi("o_orderdate"), + "dimension": {"is_time": True}}, + ], + }], + }), + id="basic", + ), + # ── field types ─────────────────────────────────────────────────────────── + pytest.param( + "ws", + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "dataset_attrs": [{"column": "id", "name": "id", "datatype": "number"}]}], + _hd_root({"name": "ws", "datasets": [{ + "name": "orders", "source": "db.s.orders", "primary_key": ["id"], + "unique_keys": [["id"]], + "fields": [{"name": "id", "expression": _ansi("id")}], + }]}), + id="field-number", + ), + pytest.param( + "ws", + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "dataset_attrs": [{"column": "status", "name": "status", "datatype": "string"}]}], + _hd_root({"name": "ws", "datasets": [{ + "name": "orders", "source": "db.s.orders", "primary_key": ["id"], + "unique_keys": [["id"]], + "fields": [{"name": "status", "expression": _ansi("status"), + "dimension": {"is_time": False}}], + }]}), + id="field-string", + ), + pytest.param( + "ws", + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "dataset_attrs": [{"column": "created_at", "name": "created_at", "datatype": "timestamp"}]}], + _hd_root({"name": "ws", "datasets": [{ + "name": "orders", "source": "db.s.orders", "primary_key": ["id"], + "unique_keys": [["id"]], + "fields": [{"name": "created_at", "expression": _ansi("created_at"), + "dimension": {"is_time": True}}], + }]}), + id="field-timestamp", + ), + # ── labels → label + ai_context + custom_extension ─────────────────────── + pytest.param( + "ws", + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "dataset_attrs": [{"column": "status", "name": "status", "datatype": "string", + "labels": ["sales", "reporting"]}]}], + _hd_root({"name": "ws", "datasets": [{ + "name": "orders", "source": "db.s.orders", "primary_key": ["id"], + "unique_keys": [["id"]], + "fields": [{ + "name": "status", "expression": _ansi("status"), + "dimension": {"is_time": False}, + "ai_context": {"synonyms": ["sales", "reporting"]}, + "label": "sales", + "custom_extensions": [ + {"vendor_name": "HONEYDEW", "data": '{"labels": ["sales", "reporting"]}'}, + ], + }], + }]}), + id="labels", + ), + # ── many-to-one relationship ────────────────────────────────────────────── + pytest.param( + "ws", + [ + {"name": "orders", "keys": ["order_id"], "key_dataset": "orders", "sql": "db.s.orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "connection": [{"src_field": "customer_id", "target_field": "id"}]}], + "dataset_attrs": []}, + {"name": "customers", "keys": ["id"], "key_dataset": "customers", + "sql": "db.s.customers", "dataset_attrs": []}, + ], + _hd_root({ + "name": "ws", + "datasets": [ + {"name": "customers", "source": "db.s.customers", "primary_key": ["id"], + "unique_keys": [["id"]]}, + {"name": "orders", "source": "db.s.orders", "primary_key": ["order_id"], + "unique_keys": [["order_id"]]}, + ], + "relationships": [{"name": "orders_to_customers", "from": "orders", "to": "customers", + "from_columns": ["customer_id"], "to_columns": ["id"]}], + }), + id="many-to-one", + ), + # ── one-to-many (direction flipped) ────────────────────────────────────── + pytest.param( + "ws", + [ + {"name": "customers", "keys": ["id"], "key_dataset": "customers", "sql": "db.s.customers", + "relations": [{"target_entity": "orders", "rel_type": "one-to-many", + "connection": [{"src_field": "id", "target_field": "customer_id"}]}], + "dataset_attrs": []}, + {"name": "orders", "keys": ["order_id"], "key_dataset": "orders", + "sql": "db.s.orders", "dataset_attrs": []}, + ], + _hd_root({ + "name": "ws", + "datasets": [ + {"name": "customers", "source": "db.s.customers", "primary_key": ["id"], + "unique_keys": [["id"]]}, + {"name": "orders", "source": "db.s.orders", "primary_key": ["order_id"], + "unique_keys": [["order_id"]]}, + ], + "relationships": [{"name": "orders_to_customers", "from": "orders", "to": "customers", + "from_columns": ["customer_id"], "to_columns": ["id"]}], + }), + id="one-to-many-flipped", + ), + # ── metric ──────────────────────────────────────────────────────────────── + pytest.param( + "ws", + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "dataset_attrs": [], + "metrics": [{"type": "metric", "entity": "orders", "name": "count", + "datatype": "number", "sql": "COUNT(*)"}]}], + _hd_root({"name": "ws", "datasets": [ + {"name": "orders", "source": "db.s.orders", "primary_key": ["id"], + "unique_keys": [["id"]]}, + ], "metrics": [{ + "name": "count", + "expression": _ansi("COUNT(*)"), + "custom_extensions": [{"vendor_name": "HONEYDEW", "data": '{"entity": "orders"}'}], + }]}), + id="metric", + ), + # ── calculated attribute → OSI field with HONEYDEW extension ───────────── + pytest.param( + "ws", + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "dataset_attrs": [], + "calc_attrs": [{"type": "calculated_attribute", "entity": "orders", + "name": "discounted", "datatype": "number", + "sql": "orders.price * (1 - orders.discount)"}]}], + _hd_root({"name": "ws", "datasets": [{ + "name": "orders", "source": "db.s.orders", "primary_key": ["id"], + "unique_keys": [["id"]], + "fields": [{ + "name": "discounted", + "expression": _ansi("orders.price * (1 - orders.discount)"), + "custom_extensions": [ + {"vendor_name": "HONEYDEW", + "data": '{"type": "calculated_attribute", "entity": "orders"}'}, + ], + }], + }]}), + id="calc-attr", + ), +]) +def test_honeydew_to_osi_output(tmp_path, ws_name, entities, expected_root): + _write_workspace(str(tmp_path), ws_name, entities) + result = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path))) + assert result == expected_root + + +def test_honeydew_to_osi_missing_workspace_raises(tmp_path): + with pytest.raises(HoneydewConversionError, match="workspace.yml"): + convert_honeydew_to_osi(str(tmp_path)) + + +def test_honeydew_to_osi_missing_schema_dir_empty_model(tmp_path): + (tmp_path / "workspace.yml").write_text(yaml.dump({"type": "workspace", "name": "ws"})) + result = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path))) + assert result == {"version": OSI_VERSION, "vendors": ["HONEYDEW"], + "semantic_model": [{"name": "ws", "datasets": []}]} + + +def test_honeydew_to_osi_empty_metric_sql_skipped(tmp_path): + _write_workspace(str(tmp_path), "ws", [{"name": "orders", "keys": ["id"], + "key_dataset": "orders", "sql": "db.s.orders", "dataset_attrs": [], + "metrics": [{"type": "metric", "entity": "orders", "name": "bad", + "datatype": "number", "sql": ""}]}]) + with warnings.catch_warnings(record=True): + result = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path))) + assert "metrics" not in result["semantic_model"][0] + + +def test_honeydew_to_osi_duplicate_relations_deduplicated(tmp_path): + _write_workspace(str(tmp_path), "ws", [ + {"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "db.s.orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "connection": [{"src_field": "cid", "target_field": "id"}]}], + "dataset_attrs": []}, + {"name": "customers", "keys": ["id"], "key_dataset": "customers", "sql": "db.s.customers", + "relations": [{"target_entity": "orders", "rel_type": "one-to-many", + "connection": [{"src_field": "id", "target_field": "cid"}]}], + "dataset_attrs": []}, + ]) + result = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path))) + assert len(result["semantic_model"][0].get("relationships", [])) == 1 + + +def test_honeydew_to_osi_relation_target_columns_are_unique_keys(tmp_path): + # Honeydew validates that a many-to-one relation joins to the target entity's + # keys, so the relation's to_columns are always covered by the target dataset's + # unique_keys (derived from those keys). This preserves the cardinality metadata + # for OSI consumers (e.g. Snowflake) without inspecting the relation itself. + _write_workspace(str(tmp_path), "ws", [ + {"name": "orders", "keys": ["order_id"], "key_dataset": "orders", "sql": "db.s.orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "connection": [{"src_field": "customer_id", "target_field": "id"}]}], + "dataset_attrs": []}, + {"name": "customers", "keys": ["id"], "key_dataset": "customers", + "sql": "db.s.customers", "dataset_attrs": []}, + ]) + sm = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path)))["semantic_model"][0] + datasets = {ds["name"]: ds for ds in sm["datasets"]} + rel = sm["relationships"][0] + target_ds = datasets[rel["to"]] + assert rel["to_columns"] in target_ds["unique_keys"] + + +# ───────────────────────────────────────────────────────────────────────────── +# OSI → Honeydew → OSI round-trip: full semantic model +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("model,expected_sm", [ + pytest.param( + {"name": "retail", "description": "Retail model", "datasets": []}, + {"name": "retail", "datasets": [], "description": "Retail model"}, + id="name-desc", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "primary_key": ["order_id"], "fields": []}]}, + # OSI → Honeydew → OSI normalizes: the primary key surfaces as a unique key, + # because Honeydew expresses uniqueness through entity keys. + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "primary_key": ["order_id"], "unique_keys": [["order_id"]]}]}, + id="pk-single", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "primary_key": ["order_id", "line_no"], "fields": []}]}, + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "primary_key": ["order_id", "line_no"], + "unique_keys": [["order_id", "line_no"]]}]}, + id="pk-composite", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "items", "source": "db.s.items", + "primary_key": ["id"], + "unique_keys": [["sku"], ["id", "variant"]], + "fields": []}]}, + # The primary key is appended as a unique key on the way back out. + {"name": "m", "datasets": [{"name": "items", "source": "db.s.items", + "primary_key": ["id"], + "unique_keys": [["sku"], ["id", "variant"], ["id"]]}]}, + id="unique-keys", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "fields": [{"name": "status", "label": "sales", + "expression": _ansi("status"), + "dimension": {"is_time": False}}]}]}, + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "fields": [{"name": "status", "expression": _ansi("status"), + "dimension": {"is_time": False}, + "label": "sales"}]}]}, + id="field-label", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "fields": [{"name": "total", + "expression": _ansi("total"), + "ai_context": {"instructions": "Use for revenue analysis", + "synonyms": ["revenue", "sales"]}}]}]}, + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "fields": [{"name": "total", "expression": _ansi("total"), + "description": "Use for revenue analysis", + "ai_context": {"instructions": "Use for revenue analysis", + "synonyms": ["revenue", "sales"]}, + "custom_extensions": [ + {"vendor_name": "HONEYDEW", + "data": '{"labels": ["revenue", "sales"]}'}, + ]}]}]}, + id="ai-context-dict", + ), + pytest.param( + {"name": "m", "ai_context": {"instructions": "Retail analytics", "synonyms": ["store"]}, + "datasets": []}, + {"name": "m", "datasets": [], + "ai_context": {"instructions": "Retail analytics", "synonyms": ["store"]}}, + id="model-ai-context", + ), + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "custom_extensions": [{"vendor_name": "SNOWFLAKE", "data": '{"warehouse": "WH"}'}], + "fields": []}]}, + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "custom_extensions": [{"vendor_name": "SNOWFLAKE", "data": '{"warehouse": "WH"}'}]}]}, + id="custom-ext", + ), + pytest.param( + {"name": "m", "datasets": [ + {"name": "orders", "source": "db.s.orders", "fields": []}, + {"name": "customers", "source": "db.s.customers", "fields": []}, + ], "relationships": [{"name": "orders_to_customers", "from": "orders", "to": "customers", + "from_columns": ["cid"], "to_columns": ["id"]}]}, + {"name": "m", "datasets": [ + {"name": "customers", "source": "db.s.customers"}, + {"name": "orders", "source": "db.s.orders"}, + ], "relationships": [{"name": "orders_to_customers", "from": "orders", "to": "customers", + "from_columns": ["cid"], "to_columns": ["id"]}]}, + id="relationship", + ), + pytest.param( + {"name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders", "fields": []}], + "metrics": [{"name": "total_revenue", "description": "Sum of sales", + "expression": _ansi("SUM(orders.total)")}]}, + {"name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders"}], + "metrics": [{"name": "total_revenue", + "expression": _ansi("SUM(orders.total)"), + "custom_extensions": [ + {"vendor_name": "HONEYDEW", "data": '{"entity": "orders"}'}, + ], + "description": "Sum of sales"}]}, + id="metric", + ), + # ai_context string is merged into description and not stored in metadata for fields + pytest.param( + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "fields": [{"name": "status", + "expression": _ansi("status"), + "ai_context": "order status, order state", + "dimension": {"is_time": False}}]}]}, + {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", + "fields": [{"name": "status", "expression": _ansi("status"), + "dimension": {"is_time": False}, + "description": "order status, order state"}]}]}, + id="ai-context-string-becomes-desc", + ), +]) +def test_osi_roundtrip_sm(tmp_path, model, expected_sm): + assert _osi_roundtrip(model, tmp_path) == expected_sm + + +def test_osi_roundtrip_tpcds_example(tmp_path): + tpcds_path = ( + Path(__file__).resolve().parent.parent.parent.parent + / "examples" / "tpcds_semantic_model.yaml" + ) + if not tpcds_path.exists(): + pytest.skip("TPC-DS example not found") + osi_yaml = tpcds_path.read_text() + files = convert_osi_to_honeydew(osi_yaml) + for rel_path, content in files.items(): + p = tmp_path / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + result = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path))) + sm = result["semantic_model"][0] + assert sm["name"] == "tpcds_retail_model" + ds_names = {ds["name"] for ds in sm["datasets"]} + assert "store_sales" in ds_names and "customer" in ds_names + + +# ───────────────────────────────────────────────────────────────────────────── +# Honeydew → OSI → Honeydew round-trip: full file content +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("entities,path,expected", [ + # ── entity name + keys ──────────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["order_id"], "key_dataset": "orders", + "sql": "DB.S.ORDERS", "dataset_attrs": []}], + "schema/orders/orders.yml", + {"type": "entity", "name": "orders", "keys": ["order_id"], + "key_dataset": "orders", "relations": []}, + id="entity-keys", + ), + # ── dataset source ──────────────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", + "sql": "DB.SCHEMA.ORDERS", "dataset_attrs": []}], + "schema/orders/datasets/orders.yml", + {"type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.SCHEMA.ORDERS", "dataset_type": "table", "attributes": []}, + id="dataset-source", + ), + # ── column attributes ───────────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [ + {"column": "o_id", "name": "id", "datatype": "number"}, + {"column": "o_status", "name": "status", "datatype": "string"}, + ]}], + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", + "attributes": [ + {"column": "o_id", "name": "id", "datatype": "number"}, + {"column": "o_status", "name": "status", "datatype": "string"}, + ], + }, + id="column-attrs", + ), + # ── labels on column ────────────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [{"column": "status", "name": "status", "datatype": "string", + "labels": ["sales"]}]}], + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", + "attributes": [{ + "column": "status", "name": "status", "datatype": "string", + "labels": ["sales"], + "metadata": [{"name": "osi", "metadata": [ + {"name": "ai_context", "value": '{"synonyms": ["sales"]}'}, + ]}], + }], + }, + id="labels-on-column", + ), + # ── calculated attribute sql ─────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], + "calc_attrs": [{"type": "calculated_attribute", "entity": "orders", + "name": "disc", "datatype": "number", + "sql": "orders.price * (1 - orders.discount)"}]}], + "schema/orders/attributes/disc.yml", + {"type": "calculated_attribute", "entity": "orders", "name": "disc", + "datatype": "number", "sql": "orders.price * (1 - orders.discount)"}, + id="calc-attr-sql", + ), + # ── calc attr with simple identifier stays as calc_attr ────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], + "calc_attrs": [{"type": "calculated_attribute", "entity": "orders", + "name": "revenue", "datatype": "number", "sql": "revenue"}]}], + "schema/orders/attributes/revenue.yml", + {"type": "calculated_attribute", "entity": "orders", "name": "revenue", + "datatype": "number", "sql": "revenue"}, + id="calc-simple-stays-calc", + ), + # ── metric entity assignment ────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], + "metrics": [{"type": "metric", "entity": "orders", "name": "cnt", + "datatype": "number", "sql": "COUNT(*)"}]}], + "schema/orders/metrics/cnt.yml", + {"type": "metric", "entity": "orders", "name": "cnt", + "datatype": "number", "sql": "COUNT(*)"}, + id="metric", + ), + # ── many-to-one relation ────────────────────────────────────────────────── + pytest.param( + [ + {"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "connection": [{"src_field": "cid", "target_field": "id"}]}], + "dataset_attrs": []}, + {"name": "customers", "keys": ["id"], "key_dataset": "customers", + "sql": "DB.S.CUSTOMERS", "dataset_attrs": []}, + ], + "schema/orders/orders.yml", + { + "type": "entity", "name": "orders", "keys": ["id"], + "key_dataset": "orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "name": "orders_to_customers", + "connection": [{"src_field": "cid", "target_field": "id"}]}], + }, + id="relation", + ), + # ── connection_expr round-trip ──────────────────────────────────────────── + pytest.param( + [ + {"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "connection_expr": {"sql": "orders.cid = customers.id AND orders.region = customers.region"}}], + "dataset_attrs": []}, + {"name": "customers", "keys": ["id"], "key_dataset": "customers", + "sql": "DB.S.CUSTOMERS", "dataset_attrs": []}, + ], + "schema/orders/orders.yml", + { + "type": "entity", "name": "orders", "keys": ["id"], + "key_dataset": "orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", + "name": "orders_to_customers", + "connection_expr": {"sql": "orders.cid = customers.id AND orders.region = customers.region"}}], + }, + id="connection-expr", + ), + # ── bool datatype ───────────────────────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [{"column": "is_active", "name": "is_active", "datatype": "bool"}]}], + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", + "attributes": [{"column": "is_active", "name": "is_active", "datatype": "bool"}], + }, + id="bool-datatype", + ), + # ── Honeydew-specific attribute fields ─────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [{"column": "status", "name": "status", "datatype": "string", + "display_name": "Order Status"}]}], + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", + "attributes": [{"column": "status", "name": "status", "datatype": "string", + "display_name": "Order Status"}], + }, + id="attr-display-name", + ), + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [{"column": "status", "name": "status", "datatype": "string", + "hidden": True}]}], + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", + "attributes": [{"column": "status", "name": "status", "datatype": "string", + "hidden": True}], + }, + id="attr-hidden", + ), + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [{"column": "status", "name": "status", "datatype": "string", + "format_string": "##,###"}]}], + "schema/orders/datasets/orders.yml", + { + "type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", + "attributes": [{"column": "status", "name": "status", "datatype": "string", + "format_string": "##,###"}], + }, + id="attr-format-string", + ), + # ── Honeydew-specific calc attr fields ──────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], + "calc_attrs": [{"type": "calculated_attribute", "entity": "orders", + "name": "disc", "datatype": "number", + "sql": "orders.price * 0.9", "display_name": "Discounted Price"}]}], + "schema/orders/attributes/disc.yml", + {"type": "calculated_attribute", "entity": "orders", "name": "disc", + "datatype": "number", "sql": "orders.price * 0.9", "display_name": "Discounted Price"}, + id="calc-display-name", + ), + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], + "calc_attrs": [{"type": "calculated_attribute", "entity": "orders", + "name": "disc", "datatype": "number", + "sql": "orders.price * 0.9", "timegrain": "day"}]}], + "schema/orders/attributes/disc.yml", + {"type": "calculated_attribute", "entity": "orders", "name": "disc", + "datatype": "number", "sql": "orders.price * 0.9", "timegrain": "day"}, + id="calc-timegrain", + ), + # ── Honeydew-specific entity fields ────────────────────────────────────── + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], "owner": "analytics_team"}], + "schema/orders/orders.yml", + {"type": "entity", "name": "orders", "keys": ["id"], "key_dataset": "orders", + "owner": "analytics_team", "relations": []}, + id="entity-owner", + ), + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], "display_name": "Orders Table"}], + "schema/orders/orders.yml", + {"type": "entity", "name": "orders", "keys": ["id"], "key_dataset": "orders", + "display_name": "Orders Table", "relations": []}, + id="entity-display-name", + ), + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], "hidden": True}], + "schema/orders/orders.yml", + {"type": "entity", "name": "orders", "keys": ["id"], "key_dataset": "orders", + "hidden": True, "relations": []}, + id="entity-hidden", + ), + pytest.param( + [{"name": "orders", "keys": ["id"], "key_dataset": "orders", "sql": "DB.S.ORDERS", + "dataset_attrs": [], "folder": "finance"}], + "schema/orders/orders.yml", + {"type": "entity", "name": "orders", "keys": ["id"], "key_dataset": "orders", + "folder": "finance", "relations": []}, + id="entity-folder", + ), +]) +def test_honeydew_roundtrip_file(tmp_path, entities, path, expected): + out_dir = _honeydew_roundtrip(entities, tmp_path) + p = out_dir / path + assert p.exists(), f"Expected file {path!r} was not generated" + assert yaml.safe_load(p.read_text()) == expected + + +# ───────────────────────────────────────────────────────────────────────────── +# Bug-fix regression tests +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("expression", [ + {"dialects": [{"dialect": "ANSI_SQL", "expression": ""}]}, + {"dialects": [{"dialect": "ANSI_SQL", "expression": " "}]}, +]) +def test_empty_or_whitespace_field_expression_skipped(expression): + model = {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "bad", + "expression": expression, + "dimension": {"is_time": False}, + }]}]} + files = convert_osi_to_honeydew(_osi(model)) + ds = yaml.safe_load(files["schema/orders/datasets/orders.yml"]) + assert ds == {"type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.s.orders", "dataset_type": "table", "attributes": []} + assert "schema/orders/attributes/bad.yml" not in files + + +@pytest.mark.parametrize("expression", [ + {"dialects": [{"dialect": "ANSI_SQL", "expression": ""}]}, + {"dialects": [{"dialect": "ANSI_SQL", "expression": " "}]}, +]) +def test_empty_or_whitespace_metric_expression_skipped(expression): + model = {"name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders", "fields": []}], + "metrics": [{"name": "bad_m", "expression": expression}]} + files = convert_osi_to_honeydew(_osi(model)) + assert "schema/orders/metrics/bad_m.yml" not in files + + +def test_non_dict_expression_warns(): + model = {"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [{ + "name": "bad", + "expression": "just_a_string", + "dimension": {"is_time": False}, + }]}]} + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + files = convert_osi_to_honeydew(_osi(model)) + assert any("must be a mapping" in str(x.message) for x in w) + ds = yaml.safe_load(files["schema/orders/datasets/orders.yml"]) + assert ds == {"type": "dataset", "entity": "orders", "name": "orders", + "sql": "db.s.orders", "dataset_type": "table", "attributes": []} + + +def test_duplicate_metric_name_warns(): + model = {"name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders", "fields": []}], + "metrics": [ + {"name": "total", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "SUM(orders.a)"}]}}, + {"name": "total", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "SUM(orders.b)"}]}}, + ]} + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + files = convert_osi_to_honeydew(_osi(model)) + assert any("total" in str(x.message) for x in w) + assert yaml.safe_load(files["schema/orders/metrics/total.yml"]) == { + "type": "metric", "entity": "orders", "name": "total", + "datatype": "number", "sql": "SUM(orders.b)", + } + + +def test_metric_string_ai_context_preserved_in_roundtrip(tmp_path): + model = {"name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders", "fields": []}], + "metrics": [{"name": "rev", "ai_context": "Use for revenue analysis", + "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "SUM(orders.total)"}]}}]} + sm = _osi_roundtrip(model, tmp_path) + assert sm == { + "name": "m", + "datasets": [{"name": "orders", "source": "db.s.orders"}], + "metrics": [{ + "name": "rev", + "expression": _ansi("SUM(orders.total)"), + "custom_extensions": [{"vendor_name": "HONEYDEW", "data": '{"entity": "orders"}'}], + "description": "Use for revenue analysis", + "ai_context": "Use for revenue analysis", + }], + } + + +def test_malformed_osi_metadata_json_warns(tmp_path): + ws_path = tmp_path / "workspace.yml" + ws_path.write_text(yaml.dump({"type": "workspace", "name": "ws"})) + base = tmp_path / "schema" / "orders" + (base / "datasets").mkdir(parents=True) + entity = { + "type": "entity", "name": "orders", "keys": ["id"], "key_dataset": "orders", + "relations": [], + "metadata": [{"name": "osi", "metadata": [ + {"name": "unique_keys", "value": "[broken json"}, + ]}], + } + (base / "orders.yml").write_text(yaml.dump(entity)) + (base / "datasets" / "orders.yml").write_text(yaml.dump( + {"type": "dataset", "entity": "orders", "name": "orders", + "sql": "DB.S.ORDERS", "dataset_type": "table", "attributes": []} + )) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + convert_honeydew_to_osi(str(tmp_path)) + assert any("unique_keys" in str(x.message) for x in w) + + +# ───────────────────────────────────────────────────────────────────────────── +# _fields_to_honeydew unit tests +# ───────────────────────────────────────────────────────────────────────────── + +def test_fields_to_honeydew_simple_identifier_goes_to_dataset(): + fields = [{"name": "status", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "status"}]}, + "dimension": {"is_time": False}}] + dataset_attrs, calc_attrs = _fields_to_honeydew(fields, "orders") + assert dataset_attrs == [{"column": "status", "name": "status", "datatype": "string"}] + assert calc_attrs == [] + + +def test_fields_to_honeydew_complex_sql_goes_to_calc(): + fields = [{"name": "disc", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "price * 0.9"}]}}] + dataset_attrs, calc_attrs = _fields_to_honeydew(fields, "orders") + assert dataset_attrs == [] + assert calc_attrs == [{"type": "calculated_attribute", "entity": "orders", "name": "disc", + "datatype": "number", "sql": "price * 0.9"}] + + +def test_fields_to_honeydew_missing_name_raises(): + fields = [{"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "col"}]}}] + with pytest.raises(HoneydewConversionError, match="missing 'name'"): + _fields_to_honeydew(fields, "orders") + + +def test_fields_to_honeydew_empty_expression_skipped(): + fields = [{"name": "bad", "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": ""}]}}] + dataset_attrs, calc_attrs = _fields_to_honeydew(fields, "orders") + assert dataset_attrs == [] and calc_attrs == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Connectionless relation warning +# ───────────────────────────────────────────────────────────────────────────── + +def test_connectionless_relation_warns(): + model = {"name": "m", "datasets": [ + {"name": "orders", "source": "db.s.orders", "fields": []}, + {"name": "customers", "source": "db.s.customers", "fields": []}, + ], "relationships": [{"name": "r", "from": "orders", "to": "customers"}]} + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + files = convert_osi_to_honeydew(_osi(model)) + assert any("resolve the join" in str(x.message) for x in w) + assert yaml.safe_load(files["schema/orders/orders.yml"]) == { + "type": "entity", "name": "orders", "key_dataset": "orders", + "relations": [{"target_entity": "customers", "rel_type": "many-to-one", "name": "r"}], + } + + +# ───────────────────────────────────────────────────────────────────────────── +# Vendors round-trip +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("input_vendors,expected_vendors", [ + (["SNOWFLAKE", "HONEYDEW"], ["HONEYDEW", "SNOWFLAKE"]), + (["SNOWFLAKE"], ["HONEYDEW", "SNOWFLAKE"]), + (["HONEYDEW"], ["HONEYDEW"]), +]) +def test_vendors_roundtrip(tmp_path, input_vendors, expected_vendors): + doc = yaml.dump({ + "version": OSI_VERSION, + "vendors": input_vendors, + "semantic_model": [{"name": "m", "datasets": []}], + }) + files = convert_osi_to_honeydew(doc) + for rel_path, content in files.items(): + p = tmp_path / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + result = yaml.safe_load(convert_honeydew_to_osi(str(tmp_path))) + assert result == {"version": OSI_VERSION, "vendors": expected_vendors, + "semantic_model": [{"name": "m", "datasets": []}]} + + +# ───────────────────────────────────────────────────────────────────────────── +# main() CLI smoke tests +# ───────────────────────────────────────────────────────────────────────────── + +def test_main_osi_to_honeydew(tmp_path): + import subprocess + input_file = tmp_path / "model.yaml" + input_file.write_text(yaml.dump({ + "version": OSI_VERSION, + "semantic_model": [{"name": "m", "datasets": [ + {"name": "orders", "source": "db.s.orders", "fields": []} + ]}], + })) + output_dir = tmp_path / "out" + result = subprocess.run( + [sys.executable, "-m", "honeydew_osi.converter", + "osi-to-honeydew", "-i", str(input_file), "-o", str(output_dir)], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert yaml.safe_load((output_dir / "workspace.yml").read_text()) == { + "type": "workspace", "name": "m", + } + + +def test_main_honeydew_to_osi(tmp_path): + import subprocess + _write_workspace(str(tmp_path), "ws", [{ + "name": "orders", "keys": ["id"], "key_dataset": "orders", + "sql": "DB.S.ORDERS", "dataset_attrs": [], + }]) + output_file = tmp_path / "output.yaml" + result = subprocess.run( + [sys.executable, "-m", "honeydew_osi.converter", + "honeydew-to-osi", "-i", str(tmp_path), "-o", str(output_file)], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert yaml.safe_load(output_file.read_text()) == { + "version": OSI_VERSION, + "vendors": ["HONEYDEW"], + "semantic_model": [{"name": "ws", "datasets": [ + {"name": "orders", "source": "DB.S.ORDERS", "primary_key": ["id"], + "unique_keys": [["id"]]}, + ]}], + } + + +def test_main_path_traversal_rejected(tmp_path): + import subprocess + input_file = tmp_path / "model.yaml" + input_file.write_text( + f"version: '{OSI_VERSION}'\nsemantic_model:\n" + " - name: m\n datasets:\n" + " - name: '../../evil'\n source: db.s.evil\n fields: []\n" + ) + output_dir = tmp_path / "out" + result = subprocess.run( + [sys.executable, "-m", "honeydew_osi.converter", + "osi-to-honeydew", "-i", str(input_file), "-o", str(output_dir)], + capture_output=True, text=True, + ) + assert result.returncode == 1 + assert "refusing to write" in result.stderr + assert not (tmp_path / "evil.yml").exists() diff --git a/converters/honeydew/uv.lock b/converters/honeydew/uv.lock new file mode 100644 index 0000000..1220f53 --- /dev/null +++ b/converters/honeydew/uv.lock @@ -0,0 +1,129 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[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 = "honeydew-osi" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "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/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" }, +] diff --git a/converters/omni/README.md b/converters/omni/README.md new file mode 100644 index 0000000..c1029cc --- /dev/null +++ b/converters/omni/README.md @@ -0,0 +1,168 @@ +# OSI <-> Omni converter + +Bidirectional, offline conversion between an OSI semantic model and +[Omni](https://docs.omni.co/modeling) semantic model files. No Omni connection +required. + +An Omni model is a *directory* of YAML files rather than a single document, so +this converter maps one OSI YAML document to/from the Omni model layout: + +``` +model.yaml # model-wide config (restored verbatim from a prior import) +relationships.yaml # top-level list of join definitions +views/.view.yaml # one per OSI dataset +topics/.topic.yaml # one generated topic per OSI model (or restored originals) +``` + +Import also accepts the layout Omni's API/IDE emits (`omni models yaml-get`, +git sync): view/topic files in per-schema or arbitrary folders +(`DELIGHTED/response.view`), bare `.view`/`.topic` suffixes, and +schema-qualified view names (`delighted__response`) taken from each file's +`# Reference this view as ...` header (falling back to the file's basename). +Original file paths are preserved through a round trip. + +- **Export** (`osi-omni export`): OSI -> Omni files. Datasets become views, + relationships become `relationships.yaml` joins, metrics become measures on + the view they reference, and the model becomes a topic (rooted at the + fact/FK-sink dataset, or `--base-view`). +- **Import** (`osi-omni import`): Omni files -> OSI. Omni features OSI has no + native field for are preserved in `custom_extensions[OMNI]`, so + **Omni -> OSI -> Omni is lossless**. + +On **export** (OSI -> Omni), OSI features with no Omni slot -- `unique_keys`, +relationship `ai_context`, dataset-level `ai_context` synonyms, model-level +`ai_context` synonyms/examples, foreign-vendor `custom_extensions`, fields and +metrics without a usable dialect -- are **dropped with a warning**. On +**import** (Omni -> OSI), Omni-only features (formats, timeframes, hidden/tags, +topic curation, the model file, ...) are instead **preserved** in +`custom_extensions[OMNI]`. Any input that breaks a +[requirement](#requirements) **raises a `ConversionError`** -- the converter +never silently drops a field or produces an invalid result. + +## Installation + +```bash +pip install osi-omni # once published to PyPI +# or, from a checkout of this directory: +pip install -e . +``` + +The only runtime dependency is `PyYAML`. Python 3.9+. + +## Usage + +### Command line + +```bash +osi-omni export -i model.yaml -o omni_model/ [--base-view orders] [--dialect SNOWFLAKE] +osi-omni import -i omni_model/ [-o model.yaml] [--name my_model] [--topic orders] +``` + +`export` writes the Omni files into the `-o` directory. `import` reads a model +directory (the [local-editor / git layout](https://docs.omni.co/guides/modeling/local-development), +`.yaml`-suffixed or bare `.view`/`.topic` names both work); with no `-o` the +OSI YAML goes to stdout. `--base-view` picks the dataset the generated topic is +rooted at (default: the FK-sink dataset). `--topic` picks which topic's +description/AI context map onto the OSI model when there are several. + +### Python API + +```python +from osi_omni import convert_osi_to_omni, convert_omni_to_osi + +files = convert_osi_to_omni(osi_yaml_str) # -> {relative filename: YAML str} +osi_yaml = convert_omni_to_osi(files) # {relative filename: YAML str} -> str +``` + +## Mapping + +Each row maps in both directions; the **Notes** flag where a behavior is +specific to **export** (OSI -> Omni) or **import** (Omni -> OSI). + +| OSI | Omni | Notes | +|---|---|---| +| `semantic_model.name` | topic file name | Import: the mapped topic's name (override with `--name`). | +| `model.description` / `ai_context.instructions` | topic `description` / `ai_context` | Import: taken from the sole topic, or `--topic`. | +| dataset | `views/.view.yaml` | Import: a stashed original path (`DELIGHTED/response.view`) is restored on export. | +| `dataset.source` `catalog.schema.table` / `schema.table` | view `catalog` + `schema` + `table_name` | `table_name` left implicit when it matches the file name; a part that is not a plain identifier is double-quoted (`"Omni Views".upload`). | +| `dataset.source` `SELECT ...` | view `sql:` | A SQL-defined view. | +| `dataset.description` / `ai_context.instructions` | view `description` / `ai_context` | | +| `dataset.primary_key` (single) | `primary_key: true` on the matching dimension | Export: a key column no field covers becomes a hidden dimension. | +| `dataset.primary_key` (composite) | view `custom_compound_primary_key_sql` | Import: `${view.field}` entries resolve to plain field names (original list stashed); multiple `primary_key: true` dimensions also form a composite key. | +| field | dimension | Export: an already-valid Omni identifier (incl. `_fivetran_id`, `..._day_`, camelCase) passes through; anything else sanitizes to `[a-z][a-z0-9_]*`. A case-insensitive collision is an error. | +| `field.expression` | dimension `sql` | Export: a bare column named like the field emits `{}` (the schema-layer default); import translates `${field}`/`${view.field}`/`${TABLE}.col` references and stashes the original `sql`. | +| `field.dimension.is_time` | dimension `timeframes` | Export: the Omni default list; import stashes the exact list. | +| `field.label` / `description` | `label` / `description` | | +| `field.ai_context` synonyms / instructions | `synonyms` / `ai_context` | | +| relationship | `relationships.yaml` entry | `from`(many) -> `join_from_view`, `to`(one) -> `join_to_view`, columns -> `on_sql` equi-join. Declared `join_type`/`relationship_type` (even Omni defaults) and any `on_sql` the rebuild would not reproduce (aliases, `and` casing, spacing) are stashed verbatim. | +| `relationship.name` | -- | Regenerated as `_to_` on import (suffixed `_2`, `_3`, ... when several joins share a view pair). | +| metric | measure on the view its expression references (else the base view) | `AGG(view.field)` <-> `aggregate_type` + `sql: ${field}`; `COUNT(*)` <-> `aggregate_type: count`; anything else <-> a raw-`sql` measure. | +| `metric.description` / `ai_context` | measure `description` / `synonyms` / `ai_context` | | +| `custom_extensions[OMNI]` | everything Omni-only | Import stashes; export restores -- keeping `Omni -> OSI -> Omni` lossless. | + +**Stashed on import** (and restored on export): the model file (verbatim), +topics (verbatim, minus the natively-mapped description/AI context), original +file paths, view extras (`label`, `hidden`, `tags`, view-level `filters:`, +...), dimension extras (`format`, `group_label`, exact `timeframes`, original +`sql`, ...), present-but-empty metadata (`description: ''`), join extras +(declared `join_type`/`relationship_type`, `reversible`, `where_sql`, aliases, +non-canonical `on_sql`), joins OSI cannot represent (non-equi/range joins, +joins touching a query or extends-only view -- restored at their original +positions), fields/measures whose sql uses Omni template (`{{...}}`) syntax, +non-reconstructible measures (filtered, `percentile`/`list`/`*_distinct_on`, +raw-SQL), and files with no OSI form (query views, extends-only views, +unrecognized files). + +**Expression dialects**: Omni SQL is the SQL of the model's database +connection, and the OSI dialect enum has no `OMNI` entry -- so import emits +`ANSI_SQL` expressions, and export prefers `ANSI_SQL` with `--dialect` +prepending a warehouse dialect (e.g. `SNOWFLAKE` for a Snowflake-backed Omni +model). A field/metric with neither is dropped with a warning. + +## Requirements + +Conversion raises a `ConversionError` (rather than guessing or emitting +something invalid) when an input breaks one of these: + +- a dataset `source` has no schema part (Omni views require `schema`); +- the relationship graph gives no unambiguous base view for the generated topic + (multiple FK sinks or a cycle) and `--base-view` is not given; +- two names sanitize to the same Omni identifier, case-insensitively (never + silently merged); two view files resolve to the same canonical view name; +- a measure has an unknown `aggregate_type`; an import directory has no + convertible view files; the input YAML is malformed. + +## Notes and limitations + +- Exported `on_sql` references columns as `${view.column}`. Omni resolves these + against the schema layer, which auto-generates a dimension per physical + column, so the reference is valid even when the OSI model declares no field + for the column. +- `dimension: {is_time: false}` is equivalent to omitting `dimension` and is + normalized away on a round trip. +- One generated topic per OSI model; multi-path (aliased) join fan-out and + Omni query views, extends-only views, non-equi joins, composite topics, + access grants/filters, and templated filters are stash-and-restore only (no + OSI semantics). +- A model containing *only* query/extends views (e.g. a pure GA4-export model) + has nothing to convert and is rejected. +- OSI metric order is regrouped by view on import (order is not semantic). + +## Development + +```bash +pip install -e ".[dev]" +python3 -m pytest tests/ +``` + +Example-based unit tests plus Hypothesis property-based round-trip tests +(`test_roundtrip_properties.py`, which fall back to a seeded-random sweep if +`hypothesis` is not installed). + +## Future effort + +Both the OSI specification and Omni's model YAML are still evolving. As either +side adds or changes fields, this converter will be updated to track them -- +extending the mapping and coverage in both directions (query views, composite +topics, measure filters as first-class OSI once the spec grows a slot for +them) to keep the conversion current. diff --git a/converters/omni/pyproject.toml b/converters/omni/pyproject.toml new file mode 100644 index 0000000..69bea10 --- /dev/null +++ b/converters/omni/pyproject.toml @@ -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. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "hypothesis>=6.0", +] + +[project] +name = "osi-omni" +version = "0.2.0.dev0" +description = "Bidirectional converter between Apache Ossie semantic models and Omni semantic model files" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] +requires-python = ">=3.10" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", + "Omni" +] +dependencies = [ + "PyYAML>=6.0", +] + +[project.scripts] +osi-omni = "osi_omni.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/osi_omni"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] \ No newline at end of file diff --git a/converters/omni/src/osi_omni/__init__.py b/converters/omni/src/osi_omni/__init__.py new file mode 100644 index 0000000..ca2ffdf --- /dev/null +++ b/converters/omni/src/osi_omni/__init__.py @@ -0,0 +1,33 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Bidirectional converter between OSI semantic models and Omni semantic model +files (model.yaml / relationships.yaml / views/*.view.yaml / topics/*.topic.yaml). +Pure offline transforms: OSI YAML string <-> {relative filename: YAML string}. + + from osi_omni import convert_osi_to_omni, convert_omni_to_osi +""" + +from ._common import ConversionError +from .omni_to_osi import convert_omni_to_osi +from .osi_to_omni import convert_osi_to_omni + +__all__ = [ + "ConversionError", + "convert_omni_to_osi", + "convert_osi_to_omni", +] diff --git a/converters/omni/src/osi_omni/_common.py b/converters/omni/src/osi_omni/_common.py new file mode 100644 index 0000000..ad799bc --- /dev/null +++ b/converters/omni/src/osi_omni/_common.py @@ -0,0 +1,422 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared helpers for the OSI <-> Omni converters. + +Both directions are pure offline YAML transforms. The cross-cutting concerns live +here: version constants, the dialect preference order, the `custom_extensions` +stash protocol, Omni file-name conventions, identifier sanitization, and the +`${...}` reference translation between Omni SQL and the plain column references +OSI expressions use. +""" + +import datetime +import json +import re + +import yaml + +# OSI semantic model spec version this converter targets (see core-spec). +OSI_VERSION = "0.2.0.dev0" + +# Vendor id used for the `custom_extensions` stash. +VENDOR = "OMNI" + +# Omni SQL is the SQL of the model's database connection, so there is no OMNI +# entry in the OSI dialect enum. Import emits ANSI_SQL; export prefers ANSI_SQL +# and lets the caller prepend a warehouse dialect (e.g. SNOWFLAKE) that the +# actual connection would accept. +DIALECT_ANSI = "ANSI_SQL" + +# Bump when the shape of a stashed `data` blob changes. +STASH_VERSION = 1 + +# Omni model file names (the local-editor/git layout, with `.yaml` appended). +MODEL_FILE = "model.yaml" +RELATIONSHIPS_FILE = "relationships.yaml" +VIEW_DIR = "views" +TOPIC_DIR = "topics" + +# Omni relationship defaults (left implicit on export when they hold). +DEFAULT_JOIN_TYPE = "always_left" +REL_MANY_TO_ONE = "many_to_one" +REL_ONE_TO_MANY = "one_to_many" + +# The timeframes Omni applies to a time dimension by default; used to represent +# OSI `dimension.is_time` when no exact list is stashed. +DEFAULT_TIMEFRAMES = ["raw", "date", "week", "month", "quarter", "year"] + +# A valid Omni identifier (view, dimension, measure, topic name). Broader than +# the documented lowercase convention because real Omni-generated models use +# more: leading underscores (Fivetran's `_fivetran_id`), trailing underscores +# (truncated column names), and camelCase (JSON-flattened `..._dimensionIndex`). +_OMNI_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +# A bare SQL identifier (single column reference), e.g. `c_name`. +_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +# An Omni `${...}` reference: ${field}, ${view.field}, ${view.field[timeframe]}, +# ${TABLE}.column. Group 1 is the reference body (without the braces). +_OMNI_REF_RE = re.compile(r"\$\{\s*([^}]*?)\s*\}") + + +class ConversionError(Exception): + """Raised when an input cannot be converted.""" + + +def require(obj, key, what): + """Return `obj[key]`, or raise a clean ConversionError if it's missing/empty -- + so malformed input surfaces as an error message rather than a raw KeyError. + + Presence is tested by key (not truthiness), so a legitimately falsy value such + as `0` or `False` is returned; a missing key, a null, or an empty/whitespace + string is rejected. + """ + if not isinstance(obj, dict) or key not in obj or obj[key] is None: + raise ConversionError(f"{what} is missing required '{key}'") + value = obj[key] + if isinstance(value, str) and not value.strip(): + raise ConversionError(f"{what} has an empty '{key}'") + return value + + +def require_str(obj, key, what): + """Like require(), but also enforce the value is a string -- so a non-string + scalar (e.g. a YAML number for a name or expression) raises a clean + ConversionError instead of crashing later in a string operation.""" + value = require(obj, key, what) + if not isinstance(value, str): + raise ConversionError( + f"{what}: '{key}' must be a string, got {type(value).__name__}") + return value + + +# PyYAML's default YAML 1.1 semantics turn bare on/off/yes/no into booleans, which +# would corrupt Omni string values (a label "On", a week_start_day, a synonym). +# The Loader below uses YAML 1.2 booleans (only true/false); the Dumper +# force-quotes bool-like string tokens so the output round-trips through a 1.1 +# reader too. Same approach as the osi-databricks converter. +class _Yaml12Loader(yaml.SafeLoader): + """SafeLoader with YAML 1.2 boolean semantics.""" + + +class _Yaml12Dumper(yaml.SafeDumper): + """SafeDumper with YAML 1.2 boolean semantics.""" + + +_YAML12_BOOL = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$") +for _cls in (_Yaml12Loader, _Yaml12Dumper): + # Drop the YAML 1.1 bool resolver (yes/no/on/off/y/n) and re-add a 1.2 one. + _cls.yaml_implicit_resolvers = { + ch: [(tag, rx) for (tag, rx) in resolvers if tag != "tag:yaml.org,2002:bool"] + for ch, resolvers in _cls.yaml_implicit_resolvers.items() + } + _cls.add_implicit_resolver("tag:yaml.org,2002:bool", _YAML12_BOOL, list("tTfF")) + + +_YAML11_BOOL_STRS = frozenset( + variant + for word in ("y", "n", "yes", "no", "on", "off", "true", "false") + for variant in (word, word.capitalize(), word.upper()) +) + + +def _represent_str(dumper, data): + style = "'" if data in _YAML11_BOOL_STRS else None + 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, what="input"): + """Parse YAML with 1.2 boolean semantics. A syntax error is surfaced as a + ConversionError so callers (and the CLI) get a clean message.""" + try: + return yaml.load(text, Loader=_Yaml12Loader) + except yaml.YAMLError as e: + raise ConversionError(f"Invalid YAML in {what}: {e}") from e + + +def dump_yaml(obj): + """Serialize to YAML with 1.2 boolean semantics; bool-like string tokens are + force-quoted so a YAML 1.1 reader of this output sees strings, not booleans.""" + return yaml.dump(obj, Dumper=_Yaml12Dumper, sort_keys=False, + default_flow_style=False, allow_unicode=True) + + +def is_simple_identifier(expr): + """True if `expr` is a single bare column reference (no operators/functions).""" + return isinstance(expr, str) and bool(_IDENTIFIER_RE.match(expr.strip())) + + +def is_omni_name(name): + """True if `name` is already a valid Omni identifier.""" + return isinstance(name, str) and bool(_OMNI_NAME_RE.match(name)) + + +def sanitize_name(name, what, taken): + """Coerce an OSI name into a valid Omni identifier. + + A name that is already a valid Omni identifier passes through untouched + (leading/trailing underscores and camelCase are legal and occur in real + Omni-generated models); anything else is lowercased with every invalid + character run replaced by `_`. A result colliding case-insensitively with + one already in `taken` (a set of casefolded names) is an error rather than + a silent merge; the caller adds `result.lower()` to `taken`. + """ + raw = str(name) + if _OMNI_NAME_RE.match(raw): + out = raw + else: + out = re.sub(r"[^a-z0-9_]+", "_", raw.lower()).strip("_") + if not out or not out[0].isalpha(): + out = f"v_{out}" if out else "v" + if out.lower() in taken: + raise ConversionError( + f"{what} '{name}' sanitizes to '{out}', which collides with another " + f"name; rename it in the OSI model." + ) + return out + + +def view_file(view_name): + return f"{VIEW_DIR}/{view_name}.view.yaml" + + +def topic_file(topic_name): + return f"{TOPIC_DIR}/{topic_name}.topic.yaml" + + +# YAML parses a bare `2024-01-01` (e.g. in a topic's default_filters) into a +# datetime.date, which JSON cannot hold; the stash tags such values so they come +# back as dates and re-dump unquoted, keeping the round trip lossless. +_JSON_TEMPORAL = {"date": datetime.date, "datetime": datetime.datetime} + + +def _json_default(o): + for tag, cls in _JSON_TEMPORAL.items(): + if type(o) is cls: + return {"__osi_omni__": tag, "v": o.isoformat()} + raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable") + + +def _json_object_hook(d): + cls = _JSON_TEMPORAL.get(d.get("__osi_omni__", "")) + if cls is not None and set(d) == {"__osi_omni__", "v"}: + return cls.fromisoformat(d["v"]) + return d + + +def read_stash(obj): + """Return the OMNI stash dict on an OSI object, or {} if absent. + + The `_v` version marker is stripped from the returned dict. + """ + for ext in (obj or {}).get("custom_extensions") or []: + if ext.get("vendor_name") == VENDOR: + data = json.loads(ext.get("data") or "{}", + object_hook=_json_object_hook) + data.pop("_v", None) + return data + return {} + + +def write_stash(obj, data): + """Attach an OMNI `custom_extensions` entry holding `data` (a dict). + + No-op when `data` is empty, so hand-authored OSI stays clean. Merges into an + existing OMNI entry if one is already present. + """ + if not data: + return + payload = {"_v": STASH_VERSION} + payload.update(data) + blob = json.dumps(payload, default=_json_default) + exts = obj.setdefault("custom_extensions", []) + for ext in exts: + if ext.get("vendor_name") == VENDOR: + ext["data"] = blob + return + exts.append({"vendor_name": VENDOR, "data": blob}) + + +def foreign_vendor_extensions(obj): + """Return non-OMNI custom_extensions (dropped on export, with a warning).""" + return [ + ext + for ext in (obj or {}).get("custom_extensions") or [] + if ext.get("vendor_name") != VENDOR + ] + + +def pick_expression(osi_expression, preferred=None): + """Choose the SQL string for an OSI expression. + + Preference order: the caller-chosen warehouse dialect (Omni passes SQL through + to the connection's database, so e.g. SNOWFLAKE SQL is valid on a Snowflake- + backed Omni model), then ANSI_SQL. Returns None if neither is present (the + caller warns and skips). + """ + dialects = { + d.get("dialect"): d.get("expression") + for d in (osi_expression or {}).get("dialects") or [] + } + expr = None + if preferred: + expr = dialects.get(preferred) + if expr is None: + expr = dialects.get(DIALECT_ANSI) + if expr is not None and not isinstance(expr, str): + raise ConversionError( + f"expression must be a string, got {type(expr).__name__}") + return expr + + +def synonyms_of(ai_context): + """Extract the synonyms list from an OSI ai_context (object form only).""" + if isinstance(ai_context, dict): + return list(ai_context.get("synonyms") or []) + return [] + + +def instructions_of(ai_context): + """The free-text part of an OSI ai_context: the string itself, or the + object form's `instructions`.""" + if isinstance(ai_context, str) and ai_context.strip(): + return ai_context + if isinstance(ai_context, dict): + text = ai_context.get("instructions") + if isinstance(text, str) and text.strip(): + return text + return None + + +# One part of a dotted source reference: double-quoted (may hold spaces/dots -- +# Omni's uploaded-CSV schema is literally `Omni Views`) or a bare name. +_SOURCE_PARTS_RE = re.compile(r'^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$') +_SOURCE_PART_RE = re.compile(r'"([^"]+)"|([^".]+)') + + +def quote_source_part(part): + """Quote one part of an OSI dotted source when it needs it.""" + p = str(part) + return p if re.fullmatch(r"[A-Za-z0-9_$]+", p) else f'"{p}"' + + +def parse_source(source, dataset_name): + """Split an OSI dataset `source` into Omni view placement. + + Returns ("sql", sql_text) for a SELECT/WITH subquery source, or + ("table", catalog_or_None, schema, table) for a dotted table reference + (parts may be double-quoted: `"Omni Views".channel_info`). + Omni views require a `schema`, so a bare 1-part table name is rejected. + """ + if not source or not str(source).strip(): + raise ConversionError(f"Dataset '{dataset_name}': missing/empty 'source'") + s = str(source).strip() + if re.match(r"(?i)(select|with)\b", s): + return ("sql", s) + if not _SOURCE_PARTS_RE.match(s): + raise ConversionError( + f"Dataset '{dataset_name}': source '{source}' is not a valid dotted " + f"table reference or SELECT/WITH subquery" + ) + parts = [] + for m in _SOURCE_PART_RE.finditer(s): + quoted, bare = m.group(1), m.group(2) + if bare is not None and any(ch.isspace() for ch in bare): + raise ConversionError( + f"Dataset '{dataset_name}': source '{source}' is not a valid " + f"dotted table reference or SELECT/WITH subquery" + ) + parts.append(quoted if quoted is not None else bare) + if len(parts) == 3: + return ("table", parts[0], parts[1], parts[2]) + if len(parts) == 2: + return ("table", None, parts[0], parts[1]) + raise ConversionError( + f"Dataset '{dataset_name}': source '{source}' has no schema part; Omni " + f"views require a `schema`, so use `schema.table` or `catalog.schema.table`" + ) + + +def join_source(view): + """Rebuild an OSI dataset `source` string from an Omni view dict.""" + if view.get("sql") is not None: + return str(view["sql"]).strip() + schema = view.get("schema") + table = view.get("table_name") + if not schema or not table: + return None + parts = [view["catalog"], schema, table] if view.get("catalog") else [schema, table] + return ".".join(quote_source_part(p) for p in parts) + + +def omni_sql_to_osi(sql, own_view): + """Translate Omni `${...}` references in a SQL string to the plain references + OSI expressions use. Returns (translated, changed). + + - `${TABLE}.col` -> `col` (a raw column of the owning view) + - `${field}` -> `field` (same-view field) + - `${own_view.field}` -> `field` (qualified same-view field) + - `${other.field}` -> `other.field` + - `${view.field[tf]}` -> `view.field` (timeframe access has no OSI form; + the caller warns and stashes the original) + OSI has no field-vs-column distinction, so both flavors flatten to names. + """ + changed = False + + def repl(m): + nonlocal changed + changed = True + body = m.group(1) + if body == "TABLE": + return "__OSI_TABLE__" # handled below with its trailing dot + body = re.sub(r"\[[^\]]*\]$", "", body).strip() # drop [timeframe] + if "." in body: + head, rest = body.split(".", 1) + if head == own_view: + return rest + return body + + out = _OMNI_REF_RE.sub(repl, sql) + out = out.replace("__OSI_TABLE__.", "").replace("__OSI_TABLE__", "") + return out, changed + + +def has_timeframe_ref(sql): + """True if the Omni SQL contains a `${view.field[timeframe]}` reference.""" + return bool(re.search(r"\$\{[^}]*\[[^\]]*\][^}]*\}", sql or "")) + + +def osi_expr_refs_to_omni(expr, view_names): + """Rewrite `view.column` references in an OSI expression to Omni `${view.column}` + form, for the known `view_names` only -- so a genuine schema-qualified table or + an unrelated dotted token is left alone. Bare columns stay bare (raw columns + are legal in Omni SQL).""" + if not view_names: + return expr + + pattern = re.compile( + r"(? Omni converter. + + osi-omni export -i model.yaml -o omni_model/ [--base-view orders] [--dialect SNOWFLAKE] + osi-omni import -i omni_model/ [-o model.yaml] [--name my_model] [--topic orders] + +`export` converts an OSI semantic model into an Omni model directory +(model.yaml / relationships.yaml / views/*.view.yaml / topics/*.topic.yaml); +`import` does the reverse. Import with no `-o` writes the OSI YAML to stdout; +export always needs `-o` (a directory). Conversions that drop information emit +warnings to stderr. +""" + +import argparse +import os +import sys + +from ._common import ConversionError +from .omni_to_osi import convert_omni_to_osi +from .osi_to_omni import convert_osi_to_omni + + +def _build_parser(): + parser = argparse.ArgumentParser(prog="osi-omni", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command") + sub.required = True + + exp = sub.add_parser("export", help="OSI semantic model -> Omni model directory") + exp.add_argument("-i", "--input", required=True, help="OSI YAML file") + exp.add_argument("-o", "--output", required=True, + help="output directory for the Omni model files") + exp.add_argument("-b", "--base-view", + help="dataset the generated topic is rooted at " + "(default: the FK-sink dataset)") + exp.add_argument("-d", "--dialect", + help="preferred OSI expression dialect (e.g. SNOWFLAKE); " + "ANSI_SQL is always the fallback") + + imp = sub.add_parser("import", help="Omni model directory -> OSI semantic model YAML") + imp.add_argument("-i", "--input", required=True, help="Omni model directory") + imp.add_argument("-o", "--output", help="output OSI YAML file (default: stdout)") + imp.add_argument("--name", help="OSI model name (default: the mapped topic's name)") + imp.add_argument("--topic", + help="topic whose description/AI context map onto the OSI model " + "(default: the sole topic, if there is exactly one)") + return parser + + +def _read_model_dir(path): + """Collect every YAML file under an Omni model directory as {relative path: + text}. Hidden files and non-YAML extensions (except the canonical + extensionless `model`/`relationships`/`*.view`/`*.topic` names) are skipped.""" + if not os.path.isdir(path): + raise ConversionError(f"'{path}' is not a directory") + files = {} + for dirpath, dirnames, filenames in os.walk(path): + dirnames[:] = [d for d in sorted(dirnames) if not d.startswith(".")] + for fname in sorted(filenames): + if fname.startswith("."): + continue + rel = os.path.relpath(os.path.join(dirpath, fname), path) + rel = rel.replace(os.sep, "/") + base = fname.lower() + if not (base.endswith((".yaml", ".yml", ".view", ".topic")) + or base in ("model", "relationships")): + continue + with open(os.path.join(dirpath, fname)) as fh: + files[rel] = fh.read() + return files + + +def main(argv=None): + args = _build_parser().parse_args(argv) + try: + if args.command == "export": + with open(args.input) as fh: + osi_yaml = fh.read() + files = convert_osi_to_omni(osi_yaml, base_view=args.base_view, + dialect=args.dialect) + for rel, text in files.items(): + dest = os.path.join(args.output, *rel.split("/")) + os.makedirs(os.path.dirname(dest) or ".", exist_ok=True) + with open(dest, "w") as fh: + fh.write(text) + print(f"Wrote {len(files)} file(s) to {args.output}", file=sys.stderr) + else: + files = _read_model_dir(args.input) + out = convert_omni_to_osi(files, model_name=args.name, topic=args.topic) + if args.output: + with open(args.output, "w") as fh: + fh.write(out) + else: + sys.stdout.write(out) + except (ConversionError, OSError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/converters/omni/src/osi_omni/omni_to_osi.py b/converters/omni/src/osi_omni/omni_to_osi.py new file mode 100644 index 0000000..75a1d3b --- /dev/null +++ b/converters/omni/src/osi_omni/omni_to_osi.py @@ -0,0 +1,718 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Convert Omni semantic model files to an OSI semantic model. + +Pure offline conversion. Accepts the Omni model-directory layout as a mapping of +{relative filename: YAML string} -- `model.yaml`, `relationships.yaml`, +`views/*.view.yaml`, `topics/*.topic.yaml`. Omni features OSI has no native field +for (formats, timeframes, hidden/tags, topic curation, the model file itself, +query views, extends-only views, non-equi joins, ...) are preserved in +`custom_extensions[OMNI]` so that converting back reproduces the original files. +See README.md. + +Usage (CLI): + osi-omni import -i omni_model/ [-o model.yaml] [--name NAME] [--topic TOPIC] +""" + +import re +import warnings + +from ._common import ( + ConversionError, + DIALECT_ANSI, + MODEL_FILE, + OSI_VERSION, + REL_MANY_TO_ONE, + REL_ONE_TO_MANY, + RELATIONSHIPS_FILE, + TOPIC_DIR, + dump_yaml, + has_timeframe_ref, + is_simple_identifier, + join_source, + load_yaml, + omni_sql_to_osi, + require_str, + view_file, + write_stash, +) + + +def _warn(scope, msg): + warnings.warn(f"[{scope}] {msg}") + + +_VIEW_FILE_RE = re.compile(r"(?:^|/)([^/]+)\.view(?:\.ya?ml)?$") +_QUERY_VIEW_FILE_RE = re.compile(r"(?:^|/)([^/]+)\.query\.view(?:\.ya?ml)?$") +_TOPIC_FILE_RE = re.compile(r"(?:^|/)([^/]+)\.topic(?:\.ya?ml)?$") +_MODEL_FILE_RE = re.compile(r"(?:^|/)model(?:\.ya?ml)?$") +_RELS_FILE_RE = re.compile(r"(?:^|/)relationships(?:\.ya?ml)?$") + +# Omni's IDE/API writes each view file with a header naming the identifier the +# rest of the model uses for it -- which is schema-qualified (`schema__table`) +# when the view is outside the connection's default schema, and so differs from +# the file's basename. That header is authoritative; the basename is the +# fallback for hand-laid-out directories (including this converter's exports). +_REF_COMMENT_RE = re.compile(r"^#\s*Reference this view as\s+([A-Za-z_]\w*)\s*$") + + +def _canonical_view_name(text, basename): + for line in text.splitlines()[:5]: + m = _REF_COMMENT_RE.match(line) + if m: + return m.group(1) + if line.strip() and not line.lstrip().startswith("#"): + break + return basename + +# View-file keys the converter maps natively; everything else is stashed +# verbatim in the dataset's `view_extras` (and restored on export). +_VIEW_NATIVE_KEYS = {"schema", "catalog", "table_name", "sql", "description", + "ai_context", "dimensions", "measures", + "custom_compound_primary_key_sql"} + +# Dimension keys mapped natively; the rest stash flat on the field. +_DIM_NATIVE_KEYS = {"sql", "label", "description", "synonyms", "ai_context", + "primary_key"} + +# Measure keys the OSI metric represents natively (given a reconstructible +# aggregate); any other key forces the full-measure stash. +_MEASURE_NATIVE_KEYS = {"sql", "aggregate_type", "description", "synonyms", + "ai_context"} + +# aggregate_type values whose OSI expression the exporter can rebuild exactly. +_SIMPLE_AGGS = {"sum": "SUM", "count": "COUNT", "average": "AVG", "min": "MIN", + "max": "MAX", "median": "MEDIAN", "count_distinct": None} + +# Best-effort ANSI renderings for Omni-only aggregate types. The original +# measure is stashed verbatim, so the Omni -> OSI -> Omni trip stays lossless; +# the expression is what other OSI consumers see. +_EXOTIC_AGGS = {"percentile", "list", "sum_distinct_on", "average_distinct_on", + "median_distinct_on", "percentile_distinct_on"} + +_ON_CLAUSE_RE = re.compile( + r"^\s*\$\{\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\s*\}" + r"\s*=\s*" + r"\$\{\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\s*\}\s*$" +) + + +def convert_omni_to_osi(files, model_name=None, topic=None): + """Convert Omni model files ({relative filename: YAML str}) to OSI YAML. + + `model_name` overrides the OSI model name (default: the mapped topic's name, + else 'omni_model'). `topic` names the topic whose description/AI context map + onto the OSI model when the directory holds more than one topic. + """ + if not isinstance(files, dict) or not files: + raise ConversionError("expected a non-empty mapping of {filename: YAML}") + + views, topics = {}, {} + view_meta = {} # canonical name -> (file path, file basename) + topic_paths = {} + unmapped_views = set() # view names present as files but with no OSI dataset + model_yaml = None + rel_entries = [] + extra_files = {} + for fname, text in files.items(): + qv = _QUERY_VIEW_FILE_RE.search(fname) + if qv: + # Query views are backed by a saved query, not a table; OSI has no + # dataset form for them. Preserved verbatim, restored on export. + _warn(f"file '{fname}'", "query views have no OSI dataset form; " + "preserved in custom_extensions only") + unmapped_views.add(_canonical_view_name(text, qv.group(1))) + extra_files[fname] = text + continue + mv = _VIEW_FILE_RE.search(fname) + if mv: + vname = _canonical_view_name(text, mv.group(1)) + parsed = load_yaml(text, fname) or {} + source = join_source( + dict(parsed, table_name=parsed.get("table_name", mv.group(1)))) + if source is None: + # A view with no schema/sql of its own (an `extends`-only view) + # has no standalone OSI dataset form. Preserved verbatim. + _warn(f"view '{vname}'", + "no `schema`/`sql` source (an extends-only view?); " + "preserved in custom_extensions only") + unmapped_views.add(vname) + extra_files[fname] = text + continue + if vname in views: + raise ConversionError( + f"two view files resolve to view '{vname}' " + f"('{view_meta[vname][0]}' and '{fname}')") + views[vname] = parsed + view_meta[vname] = (fname, mv.group(1)) + continue + mt = _TOPIC_FILE_RE.search(fname) + if mt: + topics[mt.group(1)] = load_yaml(text, fname) or {} + topic_paths[mt.group(1)] = fname + continue + if _MODEL_FILE_RE.search(fname): + model_yaml = load_yaml(text, fname) + continue + if _RELS_FILE_RE.search(fname): + parsed = load_yaml(text, fname) or [] + if not isinstance(parsed, list): + raise ConversionError( + f"'{fname}' must be a top-level YAML list of joins") + rel_entries = parsed + continue + _warn(f"file '{fname}'", "unrecognized file; preserved in " + "custom_extensions only") + extra_files[fname] = text + + if not views: + raise ConversionError( + "no convertible view files (*.view.yaml with a schema/sql source) " + "found; nothing to convert") + + # The mapped topic supplies the OSI model's name/description/ai_context. + mapped_name = None + if topic is not None: + if topic not in topics: + raise ConversionError( + f"requested topic '{topic}' not found; topics present: " + f"{sorted(topics) or 'none'}") + mapped_name = topic + elif len(topics) == 1: + mapped_name = next(iter(topics)) + elif len(topics) > 1: + _warn("model", f"{len(topics)} topics found and none chosen with --topic; " + f"topic metadata is preserved in custom_extensions only") + + model = {"name": model_name or mapped_name or "omni_model"} + + mapped_topic = topics.get(mapped_name, {}) + if mapped_topic.get("description"): + model["description"] = mapped_topic["description"] + ai = {} + if mapped_topic.get("ai_context"): + ai["instructions"] = mapped_topic["ai_context"] + if ai: + model["ai_context"] = ai + + datasets = [] + for vname, view in views.items(): + datasets.append(_convert_view(vname, view, view_meta[vname])) + model["datasets"] = datasets + + # A join OSI cannot represent -- one touching a view with no OSI dataset (a + # query view, an extends-only view), or a non-equi/cross join -- is stashed + # verbatim with its position, so export rebuilds relationships.yaml in the + # original order. Malformed entries still raise. + relationships, extra_rels = [], [] + rel_names = set() + for i, entry in enumerate(rel_entries): + endpoints = {entry.get("join_from_view"), entry.get("join_to_view")} + rel = None + if endpoints & unmapped_views: + _warn(f"relationship #{i + 1}", + "references a view with no OSI dataset form; preserved in " + "custom_extensions only") + else: + rel = _convert_relationship(entry, i, views) + if rel is None: + extra_rels.append({"index": i, "entry": entry}) + continue + # OSI relationship names are unique per model; several (aliased) joins + # between one view pair generate the same `_to_` -- suffix + # the repeats. Export never reads the name, so this stays lossless. + base, n, k = rel["name"], rel["name"], 2 + while n in rel_names: + n, k = f"{base}_{k}", k + 1 + rel["name"] = n + rel_names.add(n) + relationships.append(rel) + if relationships: + model["relationships"] = relationships + + base_view = mapped_topic.get("base_view") + metrics = _convert_measures(views, relationships, base_view) + if metrics: + model["metrics"] = metrics + + # Model-level stash: the model file and topics verbatim (minus natively + # mapped topic properties), the mapped topic's identity, the topic's base + # view (so export re-roots the generated join tree identically), and any + # unconvertible files. `topics` is stashed even when empty so a lossless + # re-export does not invent a topic the original model never had. + stash = {"topics": {}} + if model_yaml is not None: + stash["model_file"] = model_yaml + for tname, tdict in topics.items(): + tdict = dict(tdict) + if tname == mapped_name: + tdict.pop("description", None) + tdict.pop("ai_context", None) + stash["topics"][tname] = tdict + topic_files = {t: p for t, p in topic_paths.items() + if p != f"{TOPIC_DIR}/{t}.topic.yaml"} + if topic_files: + stash["topic_files"] = topic_files + if mapped_name is not None: + stash["mapped_topic"] = mapped_name + if base_view: + if base_view not in views: + _warn(f"topic '{mapped_name}'", + f"base_view '{base_view}' is not a view in this model") + else: + stash["base_view"] = base_view + if extra_rels: + stash["extra_relationships"] = extra_rels + if extra_files: + stash["extra_files"] = extra_files + write_stash(model, stash) + + return dump_yaml({"version": OSI_VERSION, "semantic_model": [model]}) + + +def _convert_view(vname, view, meta): + scope = f"view '{vname}'" + fname, basename = meta + ds = {"name": vname} + stash = {} + + # An implicit table_name is the *file's* name -- not the canonical view + # name, which is schema-qualified for a view outside the default schema. + source = join_source(dict(view, table_name=view.get("table_name", basename))) + ds["source"] = source + if str(view.get("table_name", "")) == basename: + # Explicit-but-redundant table_name: the exporter would normalize it + # away, so remember it was spelled out. + stash["table_name"] = view["table_name"] + + if view.get("description"): + ds["description"] = view["description"] + if view.get("ai_context"): + ds["ai_context"] = {"instructions": view["ai_context"]} + + fields = [] + pk_cols = [] + # Omni writes compound-key entries as `${view.field}`/`${field}` references; + # resolve same-view references to plain field names (the original list is + # stashed whenever this normalization changes it). + raw_compound = view.get("custom_compound_primary_key_sql") or [] + compound = [] + for c in raw_compound: + translated, _ = omni_sql_to_osi(str(c), vname) + translated = translated.strip() + compound.append(translated if is_simple_identifier(translated) else str(c)) + # Omni mustache templating ({{# field.filter }} ...) in a field's sql has + # no SQL (or OSI) form at all; such dimensions/measures are stashed whole + # and dropped from the OSI model. Popped here so the later measure pass + # sees only convertible measures. + for kind, key in (("dimensions", "extra_dimensions"), + ("measures", "extra_measures")): + entries = view.get(kind) or {} + templated = {n: e for n, e in entries.items() + if "{{" in str((e or {}).get("sql", ""))} + if templated: + for n in templated: + _warn(f"{kind[:-1]} '{vname}.{n}'", + "sql uses Omni template syntax ('{{'), which has no " + "OSI form; preserved in custom_extensions only") + entries.pop(n) + stash[key] = templated + + dims = view.get("dimensions") or {} + covered = set() # names the exporter can resolve back to a dimension + for dname, dim in dims.items(): + dim = dim or {} + field, col = _convert_dimension(vname, dname, dim) + fields.append(field) + covered.update((dname, col)) + if dim.get("primary_key"): + pk_cols.append(col) + if dname in compound: + compound = [col if c == dname else c for c in compound] + if fields: + ds["fields"] = fields + + # Stash the original compound-key list whenever the exporter could not + # rebuild it from the OSI primary_key alone (a `${view.field}` reference + # that was normalized away, or an entry no dimension covers). + if raw_compound and ( + compound != [str(c) for c in raw_compound] + or any(str(c) not in covered for c in compound)): + stash["custom_compound_primary_key_sql"] = list(raw_compound) + + if compound: + unknown = [c for c in compound if not is_simple_identifier(str(c))] + if unknown: + _warn(scope, f"custom_compound_primary_key_sql entries {unknown} are not " + f"plain field names; using them as-is in primary_key") + ds["primary_key"] = [str(c) for c in compound] + if pk_cols: + _warn(scope, "both a primary_key dimension and " + "custom_compound_primary_key_sql found; using the compound key") + elif len(pk_cols) == 1: + ds["primary_key"] = pk_cols + elif len(pk_cols) > 1: + # Multiple primary_key dimensions form a composite key in Omni. + ds["primary_key"] = pk_cols + + if fname != view_file(vname): + stash["file"] = fname + extras = {k: v for k, v in view.items() if k not in _VIEW_NATIVE_KEYS} + for key in ("description", "ai_context"): + # Present-but-empty metadata has no OSI slot; preserve it as an extra. + if key in view and not view[key]: + extras[key] = view[key] + if extras: + stash["view_extras"] = extras + write_stash(ds, stash) + return ds + + +def _convert_dimension(vname, dname, dim): + """Build one OSI field from an Omni dimension. Returns (field, column) where + `column` is the underlying column used for key resolution (the translated + expression when it is a bare column, else the dimension name).""" + scope = f"dimension '{vname}.{dname}'" + stash = {} + + sql = dim.get("sql") + if sql is None: + expr = dname # schema-layer default: the same-named physical column + else: + sql = str(sql) + expr, changed = omni_sql_to_osi(sql, vname) + if has_timeframe_ref(sql): + _warn(scope, "timeframe reference (${view.field[timeframe]}) has no OSI " + "form; flattened to the base field, original sql stashed") + if changed or sql.strip() == dname: + # Stashed when the OSI expression differs from the Omni sql, and + # also when the sql is an explicit same-named bare column -- which + # the exporter would otherwise normalize to the implicit + # schema-layer default (no `sql:` key). + stash["sql"] = sql + + field = { + "name": dname, + "expression": {"dialects": [{"dialect": DIALECT_ANSI, "expression": expr}]}, + } + if dim.get("label"): + field["label"] = dim["label"] + if dim.get("description"): + field["description"] = dim["description"] + ai = {} + if dim.get("ai_context"): + ai["instructions"] = dim["ai_context"] + if dim.get("synonyms"): + ai["synonyms"] = list(dim["synonyms"]) + if ai: + field["ai_context"] = ai + if "timeframes" in dim: + field["dimension"] = {"is_time": True} + stash["timeframes"] = dim["timeframes"] + + for key, value in dim.items(): + if key == "timeframes": + continue + if key in _DIM_NATIVE_KEYS: + # A present-but-empty native value (description: '') has no OSI + # slot -- OSI omits empty metadata -- so it rides in the stash. + if not value and not isinstance(value, bool) and key != "sql": + stash[key] = value + continue + stash[key] = value + + write_stash(field, stash) + column = expr.strip() if is_simple_identifier(expr) else dname + return field, column + + +def _convert_relationship(entry, index, views): + what = f"relationship #{index + 1}" + from_view = require_str(entry, "join_from_view", what) + to_view = require_str(entry, "join_to_view", what) + for v in (from_view, to_view): + if v not in views: + raise ConversionError(f"{what}: view '{v}' has no view file") + on_sql = require_str(entry, "on_sql", what) + + # Aliased joins reference the alias in on_sql; accept those names too. + aliases = { + entry.get("join_from_view_as") or from_view: from_view, + entry.get("join_to_view_as") or to_view: to_view, + from_view: from_view, + to_view: to_view, + } + + from_cols, to_cols = [], [] + for clause in re.split(r"\s+AND\s+", on_sql, flags=re.IGNORECASE): + m = _ON_CLAUSE_RE.match(clause) + if not m: + # A valid Omni join OSI cannot express (a range/non-equi or cross + # join). The caller stashes the entry verbatim. + _warn(what, f"('{from_view}' -> '{to_view}'): on_sql clause " + f"'{clause.strip()}' is not an equi-join of two " + f"${{view.field}} references, so it has no OSI " + f"relationship form; preserved in custom_extensions only") + return None + la, lf, ra, rf = m.groups() + if aliases.get(la) == from_view and aliases.get(ra) == to_view: + from_cols.append(_field_column(views[from_view], lf)) + to_cols.append(_field_column(views[to_view], rf)) + elif aliases.get(la) == to_view and aliases.get(ra) == from_view: + from_cols.append(_field_column(views[from_view], rf)) + to_cols.append(_field_column(views[to_view], lf)) + else: + _warn(what, f"on_sql clause '{clause.strip()}' references views other " + f"than '{from_view}'/'{to_view}' (or their aliases); " + f"preserved in custom_extensions only") + return None + + # The declared type/join_type are stashed verbatim whenever present -- even + # when they restate an Omni default -- so export reproduces the exact file. + stash = {} + # Export rebuilds on_sql from the OSI columns in canonical form; when that + # would not reproduce the original (an alias reference, reversed clause + # sides, `and` casing, spacing, a field-to-column translation), the + # original rides in the stash instead. + rebuilt = " AND ".join( + "${" + f"{from_view}.{fc}" + "} = ${" + f"{to_view}.{tc}" + "}" + for fc, tc in zip(from_cols, to_cols)) + if rebuilt != on_sql: + stash["on_sql"] = on_sql + rel_type = str(entry.get("relationship_type") or REL_MANY_TO_ONE) + if "relationship_type" in entry: + stash["relationship_type"] = rel_type + if rel_type == REL_ONE_TO_MANY: + # OSI `from` is always the many side; flip the orientation (the stashed + # type tells export to flip back to the original one_to_many join). + from_view, to_view = to_view, from_view + from_cols, to_cols = to_cols, from_cols + elif rel_type == "many_to_many": + # one_to_one / many_to_many / assumed_many_to_one keep the declared + # orientation. + _warn(what, "many_to_many has no OSI orientation (OSI `to` is the one " + "side); orientation kept as declared, type preserved in " + "custom_extensions") + + rel = {"name": f"{from_view}_to_{to_view}", "from": from_view, "to": to_view, + "from_columns": from_cols, "to_columns": to_cols} + + if entry.get("join_type"): + stash["join_type"] = entry["join_type"] + if entry.get("where_sql"): + stash["where_sql"] = entry["where_sql"] + _warn(what, "where_sql (join filter) has no OSI form; preserved in " + "custom_extensions only") + for key in ("reversible", "id", "join_from_view_as", "join_from_view_as_label", + "join_to_view_as", "join_to_view_as_label"): + if key in entry: + stash[key] = entry[key] + write_stash(rel, stash) + return rel + + +def _field_column(view, fname): + """Resolve a ${view.field} reference to the field's underlying column when the + dimension is a bare column, else keep the field name.""" + dim = (view.get("dimensions") or {}).get(fname) or {} + sql = dim.get("sql") + if sql is None: + return fname + return sql.strip() if is_simple_identifier(str(sql)) else fname + + +def _convert_measures(views, relationships, base_view): + """Turn every view's measures into OSI model-level metrics. + + A metric name is the measure name when globally unique, else + `__` (the original name is stashed either way when it + matters). The full original measure is stashed whenever the OSI expression + alone cannot reconstruct it exactly. + """ + # The exporter re-derives measure placement from the expression (a + # view-qualified aggregate lands on that view, everything else on the base + # view). Compute that default here so placement is only stashed when needed. + effective_base = base_view or _fk_sink(views, relationships) + + counts = {} + for vname, view in views.items(): + for mname in (view.get("measures") or {}): + counts[mname] = counts.get(mname, 0) + 1 + + metrics = [] + seen = set() + for vname, view in views.items(): + for mname, measure in (view.get("measures") or {}).items(): + measure = measure or {} + metric_name = mname if counts[mname] == 1 else f"{vname}__{mname}" + if metric_name in seen: + raise ConversionError( + f"metric name '{metric_name}' derived twice; rename the " + f"colliding measures in Omni") + seen.add(metric_name) + metrics.append( + _convert_measure(vname, mname, metric_name, measure, effective_base)) + return metrics + + +def _convert_measure(vname, mname, metric_name, measure, effective_base): + scope = f"measure '{vname}.{mname}'" + stash = {} + + agg = measure.get("aggregate_type") + sql = measure.get("sql") + expr = None + # Reconstructible = the exporter can rebuild this measure from the OSI + # expression alone: only natively-mapped keys, and a sql that is either free + # of `${...}` references or a lone same-view field reference (which the + # exporter re-derives from the view's dimension names). Anything else keeps + # the original measure in the stash. + reconstructible = set(measure) <= _MEASURE_NATIVE_KEYS and ( + sql is None + or "${" not in str(sql) + or re.fullmatch(r"\s*\$\{\s*[A-Za-z_]\w*\s*\}\s*", str(sql)) is not None + ) + + if agg is None and sql is not None: + # A raw-SQL measure (the aggregate is written out in the sql). OSI + # metrics are model-level, so view qualifiers are kept, not stripped + # (own_view=None below leaves `${view.field}` as `view.field`). + # Always stashed: the exporter would otherwise re-parse a lone + # aggregate call into a structured measure, changing the file. + expr, _ = omni_sql_to_osi(str(sql), own_view=None) + reconstructible = False + elif agg in _SIMPLE_AGGS: + if agg == "count" and sql is None: + expr = "COUNT(*)" + elif sql is None: + raise ConversionError(f"{scope}: aggregate_type '{agg}' requires sql") + else: + inner = _measure_operand(vname, str(sql)) + if agg == "count_distinct": + expr = f"COUNT(DISTINCT {inner})" + else: + expr = f"{_SIMPLE_AGGS[agg]}({inner})" + elif agg in _EXOTIC_AGGS: + # Best-effort ANSI so OSI consumers still see a metric; the verbatim + # measure is stashed, so re-export is exact. + inner = _measure_operand(vname, str(sql or "")) + expr = _exotic_expr(agg, inner, measure) + _warn(scope, f"aggregate_type '{agg}' has no exact ANSI form; emitted a " + f"best-effort expression, original measure preserved in " + f"custom_extensions") + reconstructible = False + else: + raise ConversionError( + f"{scope}: unknown aggregate_type '{agg}'") + + if measure.get("filters"): + _warn(scope, "measure filters have no OSI form; expression emitted " + "unfiltered, original measure preserved in custom_extensions") + reconstructible = False + + # Placement/name recovery: only stashed when the exporter would otherwise + # derive a different view or name. + derived_target = _derived_placement(expr, effective_base) + if not reconstructible: + stashed_measure = { + k: v for k, v in measure.items() + if k not in ("description", "synonyms", "ai_context") + } + stash["measure"] = stashed_measure + stash["view"] = vname + if metric_name != mname: + stash["name"] = mname + else: + if derived_target != vname: + stash["view"] = vname + if metric_name != mname: + stash["name"] = mname + + metric = { + "name": metric_name, + "expression": {"dialects": [{"dialect": DIALECT_ANSI, "expression": expr}]}, + } + if measure.get("description"): + metric["description"] = measure["description"] + ai = {} + if measure.get("ai_context"): + ai["instructions"] = measure["ai_context"] + if measure.get("synonyms"): + ai["synonyms"] = list(measure["synonyms"]) + if ai: + metric["ai_context"] = ai + write_stash(metric, stash) + return metric + + +def _measure_operand(vname, sql): + """Translate a measure's operand sql to an OSI reference: a same-view field + or bare column becomes `view.name` (the qualified form OSI metrics use); + anything else keeps its view qualifiers and is left as-is.""" + inner, _ = omni_sql_to_osi(sql, own_view=None) + inner = inner.strip() + if is_simple_identifier(inner): + return f"{vname}.{inner}" + return inner + + +def _derived_placement(expr, effective_base): + """Mirror the exporter's placement rule: a metric lands on the single view + its expression references, else on the base view.""" + refs = set(re.findall(r"(?.view.yaml` per dataset, a +`relationships.yaml` join list, one generated `topics/.topic.yaml` +(base view chosen like a fact table), and -- when a prior import stashed one -- +the original `model.yaml`. See README.md for the capability summary. + +Usage (CLI): + osi-omni export -i model.yaml -o omni_model/ [--base-view orders] [--dialect SNOWFLAKE] +""" + +import re +import warnings + +from ._common import ( + ConversionError, + DEFAULT_TIMEFRAMES, + MODEL_FILE, + OSI_VERSION, + REL_MANY_TO_ONE, + REL_ONE_TO_MANY, + RELATIONSHIPS_FILE, + dump_yaml, + foreign_vendor_extensions, + instructions_of, + is_simple_identifier, + load_yaml, + osi_expr_refs_to_omni, + parse_source, + pick_expression, + read_stash, + require_str, + sanitize_name, + synonyms_of, + topic_file, + view_file, + write_stash, # noqa: F401 (re-exported for symmetry in tests) +) + + +def _warn(scope, msg): + warnings.warn(f"[{scope}] {msg}") + + +# Dimension-level stash keys restored verbatim onto the exported dimension. +# `sql` is handled separately (it replaces the derived expression). +_DIM_STASH_PASSTHROUGH_EXCLUDE = {"sql"} + +# One OSI metric aggregate call maps to a structured Omni measure. +_AGG_TO_OMNI = { + "SUM": "sum", + "COUNT": "count", + "AVG": "average", + "MIN": "min", + "MAX": "max", + "MEDIAN": "median", +} + +_AGG_CALL_RE = re.compile( + r"^\s*(SUM|COUNT|AVG|MIN|MAX|MEDIAN)\s*\((.*)\)\s*$", + re.IGNORECASE | re.DOTALL, +) + +# `view.column` -- a dotted reference an OSI metric uses to point into a dataset. +_DOTTED_REF_RE = re.compile( + r"(? 1: + _warn("model", "multiple semantic models found; converting only the first") + + return _convert_model(models[0], base_view, dialect) + + +def _convert_model(model, explicit_base_view, dialect): + name = model.get("name", "") + dataset_list = model.get("datasets", []) or [] + if not dataset_list: + raise ConversionError(f"Model '{name}' has no datasets") + + # Dataset -> Omni view names. Sanitization collisions (and case-insensitive + # duplicates, which sanitize identically) fail loudly rather than merging. + view_names = {} + taken = set() + for d in dataset_list: + ds_name = require_str(d, "name", f"Model '{name}': dataset") + view_names[ds_name] = sanitize_name(ds_name, f"Model '{name}': dataset", taken) + taken.add(view_names[ds_name].lower()) + datasets = {d["name"]: d for d in dataset_list} + relationships = model.get("relationships", []) or [] + for rel in relationships: + scope = f"Model '{name}': relationship '{rel.get('name', '')}'" + if (require_str(rel, "from", scope) not in datasets + or require_str(rel, "to", scope) not in datasets): + raise ConversionError(f"{scope} references an unknown dataset") + + model_stash = read_stash(model) + + files = {} + + # model.yaml: only a stashed original (a fresh model needs no model file; + # model-wide Omni settings have no OSI source to generate from). + if model_stash.get("model_file") is not None: + files[MODEL_FILE] = dump_yaml(model_stash["model_file"]) + + # Views (and the per-view dimension name maps the other stages need). A + # stashed original file path (e.g. `DELIGHTED/response.view`) wins over the + # canonical `views/.view.yaml` layout. + dims_by_view = {} + view_paths = {} + for ds_name, ds in datasets.items(): + vname = view_names[ds_name] + view, dim_names = _convert_dataset(ds, vname, view_names, dialect) + view_paths[vname] = read_stash(ds).get("file") or view_file(vname) + files[view_paths[vname]] = dump_yaml(view) + dims_by_view[vname] = dim_names + + # Relationships: converted OSI relationships in order, with any joins a + # prior import could not map (they touch a query/extends view) reinserted + # verbatim at their original positions. + imported = "topics" in model_stash + rel_entries = [ + _convert_relationship(rel, view_names, imported) for rel in relationships + ] + for item in sorted(model_stash.get("extra_relationships") or [], + key=lambda x: x.get("index", 0)): + rel_entries.insert(min(item.get("index", 0), len(rel_entries)), + item["entry"]) + if rel_entries: + files[RELATIONSHIPS_FILE] = dump_yaml(rel_entries) + + # Base view: explicit flag > stashed original > FK-sink heuristic. Resolved + # lazily -- a model whose topics are stashed and whose metrics all carry (or + # derive) their own placement never needs one, so the multi-root error only + # fires when a base view is genuinely required. + base_hint = explicit_base_view or model_stash.get("base_view") + base_cache = [] + + def resolve_base(): + if not base_cache: + base_cache.append( + view_names[_pick_base_view(name, datasets, relationships, base_hint)]) + return base_cache[0] + + # Measures (from OSI metrics) attach to views. + measures_by_view = {} + for metric in model.get("metrics", []) or []: + placed = _convert_metric(metric, resolve_base, view_names, dims_by_view, + dialect) + if placed is None: + continue + target_view, mname, measure = placed + target = measures_by_view.setdefault(target_view, {}) + if mname.lower() in {m.lower() for m in target}: + raise ConversionError( + f"Model '{name}': two metrics map to measure '{mname}' on view " + f"'{target_view}'; rename one in the OSI model.") + target[mname] = measure + for vname, measures in measures_by_view.items(): + view = load_yaml(files[view_paths[vname]], f"view '{vname}'") or {} + clashes = set(measures) & set(view.get("dimensions") or {}) + for c in sorted(clashes): + _warn(f"measure '{c}'", + f"name collides with a dimension on view '{vname}'; Omni requires " + f"unique field names per view -- rename before use") + existing = view.setdefault("measures", {}) + existing.update(measures) + files[view_paths[vname]] = dump_yaml(view) + + # Topics: stashed originals restore verbatim (with natively-mapped properties + # re-injected on the mapped topic). The `topics` stash key being *present* -- + # even empty -- means the original Omni model's topic set is known, so a + # fresh topic is only generated for hand-authored OSI (no stash). + if "topics" in model_stash: + mapped = model_stash.get("mapped_topic") + topic_paths = model_stash.get("topic_files") or {} + for tname, topic in (model_stash["topics"] or {}).items(): + topic = dict(topic) + if tname == mapped: + if model.get("description"): + topic["description"] = model["description"] + instructions = instructions_of(model.get("ai_context")) + if instructions: + topic["ai_context"] = instructions + files[topic_paths.get(tname) or topic_file(tname)] = dump_yaml(topic) + else: + tname = sanitize_name(name, f"Model '{name}'", set()) + files[topic_file(tname)] = dump_yaml( + _build_topic(model, resolve_base(), view_names, relationships)) + + # Files a prior import could not convert (query views, unrecognized files) + # restore verbatim. + for fname, text in (model_stash.get("extra_files") or {}).items(): + files[fname] = text + + _warn_dropped_model(model) + return files + + +def _convert_dataset(ds, vname, view_names, dialect): + """Build one Omni view dict from an OSI dataset. Returns (view, dims) where + dims = {"cols": {column: dimension name}, "names": set of dimension names} + (used to resolve primary keys and metric references).""" + ds_name = ds["name"] + scope = f"dataset '{ds_name}'" + stash = read_stash(ds) + + view = {} + parsed = parse_source(ds.get("source"), ds_name) + if parsed[0] == "sql": + view["sql"] = parsed[1] + else: + _, catalog, schema, table = parsed + if catalog: + view["catalog"] = catalog + view["schema"] = schema + # table_name defaults to the *file's* name -- the basename of the + # stashed original path when there is one (a schema-folder layout names + # the view `schema__table` but the file `SCHEMA/table.view`), else the + # view name. Emit only when it differs. + default_table = vname + if stash.get("file"): + base = stash["file"].rsplit("/", 1)[-1] + default_table = re.sub(r"\.view(\.ya?ml)?$", "", base) + if "table_name" in stash: # was spelled out even though redundant + view["table_name"] = stash["table_name"] + elif table != default_table: + view["table_name"] = table + if ds.get("description"): + view["description"] = ds["description"] + instructions = instructions_of(ds.get("ai_context")) + if instructions: + view["ai_context"] = instructions + if synonyms_of(ds.get("ai_context")): + _warn(scope, "dataset ai_context synonyms have no Omni view-level home; dropped") + + # Restore stashed Omni view extras (label, hidden, tags, filters:, ...) + # verbatim. Keys the converter derives itself are not stashed on import. + for key, value in (stash.get("view_extras") or {}).items(): + view[key] = value + + dimensions = {} + col_to_dim = {} + taken = set() + for field in ds.get("fields", []) or []: + fname = require_str(field, "name", f"{scope}: field") + fscope = f"field '{fname}'" + dname = sanitize_name(fname, f"{scope}: field", taken) + taken.add(dname.lower()) + expr = pick_expression(field.get("expression"), dialect) + if expr is None: + _warn(fscope, "no usable ANSI_SQL (or preferred-dialect) expression; " + "dropping field") + continue + dim = _convert_field(field, dname, expr, fscope) + dimensions[dname] = dim + if is_simple_identifier(expr): + col_to_dim[expr.strip()] = dname + + # primary_key: mark the matching dimension (creating a hidden one for a key + # column no field covers); a composite key becomes the view-level + # custom_compound_primary_key_sql list of field names -- restored verbatim + # when a prior import stashed the original (`${view.field}`-form) list. + pk = ds.get("primary_key") or [] + if stash.get("custom_compound_primary_key_sql"): + view["custom_compound_primary_key_sql"] = \ + stash["custom_compound_primary_key_sql"] + elif pk: + pk_dims = [] + for col in pk: + # A key entry is either a raw column some dimension covers, or + # already a dimension name (import resolves a computed dimension's + # key to its field name). + dname = col_to_dim.get(col) or (col if col in dimensions else None) + if dname is None: + dname = sanitize_name(col, f"{scope}: primary key column", taken) + taken.add(dname.lower()) + dim = {"hidden": True} + if dname != col: + dim["sql"] = col + dimensions[dname] = dim + col_to_dim[col] = dname + pk_dims.append(dname) + if len(pk_dims) == 1: + dimensions[pk_dims[0]]["primary_key"] = True + else: + view["custom_compound_primary_key_sql"] = pk_dims + + if ds.get("unique_keys"): + # A unique key that merely restates the primary key is redundant, not lost. + extra = [k for k in ds["unique_keys"] if list(k) != list(pk)] + if extra: + _warn(scope, "unique_keys have no Omni home; dropped") + + # Fields a prior import could not convert (Omni template syntax in sql) + # restore verbatim alongside the mapped ones. + dimensions.update(stash.get("extra_dimensions") or {}) + if stash.get("extra_measures"): + view["measures"] = dict(stash["extra_measures"]) + if dimensions: + view["dimensions"] = dimensions + if foreign_vendor_extensions(ds): + _warn(scope, "foreign-vendor custom_extensions dropped") + return view, {"cols": col_to_dim, "names": set(dimensions)} + + +def _convert_field(field, dname, expr, fscope): + stash = read_stash(field) + dim = {} + + # A stashed original Omni `sql` (import rewrote its ${...} refs) wins, so + # Omni -> OSI -> Omni restores the exact expression. + if "sql" in stash: + dim["sql"] = stash["sql"] + elif is_simple_identifier(expr): + if expr.strip() != dname: + dim["sql"] = expr.strip() + # else: the dimension inherits the same-named schema column -- no sql key. + else: + # Raw SQL over the view's own columns is legal in Omni sql. References to + # other views would need ${view.field} form; OSI field expressions are + # dataset-scoped, so this is emitted as-is. + dim["sql"] = expr + + if field.get("label"): + dim["label"] = field["label"] + if field.get("description"): + dim["description"] = field["description"] + syns = synonyms_of(field.get("ai_context")) + if syns: + dim["synonyms"] = syns + instructions = instructions_of(field.get("ai_context")) + if instructions: + dim["ai_context"] = instructions + + if (field.get("dimension") or {}).get("is_time"): + # The stashed list wins even when empty (`timeframes: []` is a real + # Omni value); the default list is only for hand-authored OSI. + dim["timeframes"] = (stash["timeframes"] if "timeframes" in stash + else list(DEFAULT_TIMEFRAMES)) + elif "timeframes" in stash: + dim["timeframes"] = stash["timeframes"] + + for key, value in stash.items(): + if key in ("sql", "timeframes") or key in dim: + continue + dim[key] = value + + if foreign_vendor_extensions(field): + _warn(fscope, "foreign-vendor custom_extensions dropped") + return dim + + +def _convert_relationship(rel, view_names, imported=False): + rname = rel.get("name", "") + scope = f"relationship '{rname}'" + from_cols = rel.get("from_columns") or [] + to_cols = rel.get("to_columns") or [] + if not isinstance(from_cols, list) or not isinstance(to_cols, list) \ + or not from_cols or not to_cols: + raise ConversionError( + f"Relationship '{rname}': from_columns and to_columns are required lists") + if len(from_cols) != len(to_cols): + raise ConversionError( + f"Relationship '{rname}': from_columns ({len(from_cols)}) and " + f"to_columns ({len(to_cols)}) must have the same length") + + stash = read_stash(rel) + from_view, to_view = view_names[rel["from"]], view_names[rel["to"]] + if stash.get("relationship_type") == REL_ONE_TO_MANY: + # The import flipped a one_to_many join so the OSI `from` is the many + # side; flip back to the originally declared orientation. + from_view, to_view = to_view, from_view + from_cols, to_cols = to_cols, from_cols + entry = {"join_from_view": from_view, "join_to_view": to_view} + # Aliased joins (join_*_view_as) are Omni-only; restore before on_sql so the + # stashed on_sql (which may reference the alias) stays consistent. + for key in ("join_from_view_as", "join_from_view_as_label", + "join_to_view_as", "join_to_view_as_label"): + if key in stash: + entry[key] = stash[key] + + if "on_sql" in stash: + entry["on_sql"] = stash["on_sql"] + else: + entry["on_sql"] = " AND ".join( + "${" + f"{from_view}.{fc}" + "} = ${" + f"{to_view}.{tc}" + "}" + for fc, tc in zip(from_cols, to_cols) + ) + # OSI orientation is many(from) -> one(to). The stash holds the declared + # type/join_type verbatim (even an explicit Omni default). An imported join + # with no stashed key genuinely omitted it; fresh OSI states many_to_one. + if "relationship_type" in stash: + entry["relationship_type"] = stash["relationship_type"] + elif not imported: + entry["relationship_type"] = REL_MANY_TO_ONE + if stash.get("join_type"): + entry["join_type"] = stash["join_type"] + for key in ("reversible", "where_sql", "id"): + if key in stash: + entry[key] = stash[key] + + if rel.get("ai_context"): + _warn(scope, "relationship ai_context has no Omni home; dropped") + if foreign_vendor_extensions(rel): + _warn(scope, "foreign-vendor custom_extensions dropped") + return entry + + +def _pick_base_view(model_name, datasets, relationships, hint): + """Choose the topic's base view: an explicit hint if given, else the dataset + that is never a relationship `to` (the FK sink of a many-to-one star).""" + if hint is not None: + if hint not in datasets: + raise ConversionError( + f"Model '{model_name}': requested base view '{hint}' is not a dataset") + return hint + if len(datasets) == 1: + return next(iter(datasets)) + if not relationships: + raise ConversionError( + f"Model '{model_name}': {len(datasets)} datasets but no relationships; " + f"name the topic's base view with --base-view.") + incoming = {name: 0 for name in datasets} + for rel in relationships: + incoming[rel["to"]] += 1 + roots = [n for n in datasets if incoming[n] == 0] + if not roots: + raise ConversionError( + f"Model '{model_name}': every dataset is a relationship target (the " + f"graph has a cycle); name the topic's base view with --base-view.") + if len(roots) > 1: + raise ConversionError( + f"Model '{model_name}': multiple candidate base views {sorted(roots)}; " + f"name the topic's base view with --base-view.") + return roots[0] + + +def _build_topic(model, base_vname, view_names, relationships): + """Generate the topic for a fresh export: base view + the nested join map of + every view reachable from it, plus the model's description/AI context.""" + topic = {"base_view": base_vname} + if model.get("description"): + topic["description"] = model["description"] + instructions = instructions_of(model.get("ai_context")) + if instructions: + topic["ai_context"] = instructions + + # BFS the (undirected) relationship graph out from the base view; each view + # joins once, on its first-discovered (shortest) path. Views left unreachable + # simply stay out of the topic -- they are still exported and joinable, and + # every relationship remains in relationships.yaml either way. + adj = {} + for rel in relationships: + a, b = view_names[rel["from"]], view_names[rel["to"]] + adj.setdefault(a, []).append(b) + adj.setdefault(b, []).append(a) + children = {base_vname: {}} + seen = {base_vname} + queue = [base_vname] + while queue: + cur = queue.pop(0) + for neighbor in adj.get(cur, []): + if neighbor not in seen: + seen.add(neighbor) + children[cur][neighbor] = {} + children[neighbor] = children[cur][neighbor] + queue.append(neighbor) + if children[base_vname]: + topic["joins"] = children[base_vname] + return topic + + +def _convert_metric(metric, resolve_base, view_names, dims_by_view, dialect): + """Map one OSI metric to an Omni measure. Returns (view_name, measure_name, + measure_dict), or None when the metric has no usable expression.""" + mname_raw = require_str(metric, "name", "metric") + scope = f"metric '{mname_raw}'" + stash = read_stash(metric) + # The import qualifies a colliding measure name as `__` and + # stashes the original; restore it, else sanitize the OSI name. + mname = stash.get("name") or sanitize_name(mname_raw, scope, set()) + + if "measure" in stash: + # A prior import stashed the original Omni measure (a filtered, exotic, + # or otherwise non-reconstructible one) -- restore it verbatim and + # re-inject the natively-mapped properties. + measure = dict(stash["measure"]) + _apply_metric_metadata(metric, measure) + return stash.get("view") or resolve_base(), mname, measure + + expr = pick_expression(metric.get("expression"), dialect) + if expr is None: + _warn(scope, "no usable ANSI_SQL (or preferred-dialect) expression; " + "dropping metric") + return None + + sanitized_views = set(view_names.values()) + referenced = { + m.group(1) + for m in _DOTTED_REF_RE.finditer(expr) + if m.group(1) in sanitized_views + } + + measure = None + target = None + m = _AGG_CALL_RE.match(expr) + if m and _balanced(m.group(2)): + func, inner = m.group(1).upper(), m.group(2).strip() + distinct = re.match(r"(?i)^DISTINCT\s+(.+)$", inner, re.DOTALL) + if func == "COUNT" and distinct: + func, inner = "COUNT_DISTINCT", distinct.group(1).strip() + agg = "count_distinct" if func == "COUNT_DISTINCT" else _AGG_TO_OMNI[func] + if func == "COUNT" and inner == "*": + measure, target = {"aggregate_type": "count"}, None + else: + dotted = _DOTTED_REF_RE.fullmatch(inner) + if dotted and dotted.group(1) in sanitized_views: + # AGG(view.name): a structured measure on that view. `name` may be + # a modeled field (referenced as ${name}) or a raw column covered + # by a field; only an unmodeled name stays a raw column ref. + vname, ref = dotted.group(1), dotted.group(2) + dims = dims_by_view.get(vname) or {"cols": {}, "names": set()} + dim = dims["cols"].get(ref) or (ref if ref in dims["names"] else None) + measure = {"sql": "${" + dim + "}" if dim else ref, + "aggregate_type": agg} + target = vname + elif not referenced: + # AGG over the base view's own columns (bare or computed). + measure = {"sql": inner, "aggregate_type": agg} + target = None + elif len(referenced) == 1: + measure = {"sql": osi_expr_refs_to_omni(inner, sanitized_views), + "aggregate_type": agg} + target = next(iter(referenced)) + + if measure is None: + # Anything else (a ratio, a multi-view aggregate, window SQL) becomes a + # raw-SQL measure; `view.col` references switch to `${view.col}` form. + target = next(iter(referenced)) if len(referenced) == 1 else None + measure = {"sql": osi_expr_refs_to_omni(expr, sanitized_views)} + if len(referenced) > 1: + _warn(scope, f"expression spans views {sorted(referenced)}; emitted as a " + f"raw-SQL measure on the base view -- verify the join path") + + # Stashed placement (from import) wins; else the derived view; else base. + target = stash.get("view") or target or resolve_base() + _apply_metric_metadata(metric, measure) + if foreign_vendor_extensions(metric): + _warn(scope, "foreign-vendor custom_extensions dropped") + return target, mname, measure + + +def _apply_metric_metadata(metric, measure): + if metric.get("description"): + measure["description"] = metric["description"] + syns = synonyms_of(metric.get("ai_context")) + if syns: + measure["synonyms"] = syns + instructions = instructions_of(metric.get("ai_context")) + if instructions: + measure["ai_context"] = instructions + + +def _balanced(s): + depth = 0 + for ch in s: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth < 0: + return False + return depth == 0 + + +def _warn_dropped_model(model): + if foreign_vendor_extensions(model): + _warn("model", "foreign-vendor custom_extensions dropped") + ai = model.get("ai_context") + if isinstance(ai, dict): + if ai.get("synonyms"): + _warn("model", "model ai_context synonyms have no Omni home; dropped") + if ai.get("examples"): + _warn("model", "model ai_context examples have no Omni home (Omni " + "sample_queries need a full query definition); dropped") diff --git a/converters/omni/tests/_roundtrip_helpers.py b/converters/omni/tests/_roundtrip_helpers.py new file mode 100644 index 0000000..3c55d65 --- /dev/null +++ b/converters/omni/tests/_roundtrip_helpers.py @@ -0,0 +1,378 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared model builders and round-trip assertions for property-based tests. + +This module is deliberately free of any third-party test dependency (no +hypothesis, no pytest) so the generation + assertion logic can run two ways: + + - driven by Hypothesis strategies (see test_roundtrip_properties.py), and + - driven by a plain seeded `random.Random` (RandomRnd below), which is how the + logic is exercised in environments where hypothesis is not installed. + +Both drivers implement the small `Rnd` interface (chance/count/pick/text/colname); +the builders below depend only on that interface, so the generated model space is +identical regardless of driver. + +The builders intentionally generate within the *round-trippable subset* -- the +shapes the converter reproduces exactly. Known normalizations are avoided by +construction (documented inline), e.g.: + - Omni names are generated already valid (lowercase snake_case), so the + sanitizer never renames anything; + - OSI metric expressions reference *field names* (`view.field`), which survive + the `${field}` modeled-reference trip; a raw column that differs from its + field's name would come back as the field name; + - OSI relationship names use the canonical `_to_` form the importer + regenerates. +Name fuzzing (collisions, reserved words) is left to the targeted unit tests, +which assert the converter *rejects* or *warns on* those inputs. +""" + +import random +import string +import warnings + +from osi_omni import convert_omni_to_osi, convert_osi_to_omni +from osi_omni._common import OSI_VERSION, dump_yaml, load_yaml + +from _util import strip_normalized + +_AGGS = ["sum", "average", "min", "max", "median", "count_distinct"] +_OSI_AGGS = ["SUM", "AVG", "MIN", "MAX", "MEDIAN"] + + +# --- Rnd backend for offline (no hypothesis) runs -------------------------------- + +class RandomRnd: + """The `Rnd` interface backed by a seeded `random.Random`.""" + + def __init__(self, seed): + self.r = random.Random(seed) + + def chance(self, p=0.5): + return self.r.random() < p + + def count(self, lo, hi): + return self.r.randint(lo, hi) + + def pick(self, seq): + return self.r.choice(list(seq)) + + def text(self): + # Alphanumeric with optional interior spaces; no leading/trailing space + # and no YAML-special characters, so the value is preserved verbatim + # through a dump/load cycle. + alnum = string.ascii_letters + string.digits + n = self.r.randint(0, 10) + body = "".join(self.r.choice(alnum + " ") for _ in range(n)) + return (self.r.choice(alnum) + body).strip() or "x" + + def colname(self): + first = self.r.choice(string.ascii_lowercase) + rest = "".join( + self.r.choice(string.ascii_lowercase + string.digits + "_") + for _ in range(self.r.randint(0, 7)) + ) + return first + rest + + +class _Names: + """Hands out globally-unique names with a given prefix.""" + + def __init__(self): + self._n = {} + + def next(self, prefix): + i = self._n.get(prefix, 0) + self._n[prefix] = i + 1 + return f"{prefix}{i}" + + +# --- Omni model builder (for Omni -> OSI -> Omni) --------------------------------- + +def _maybe_meta(rnd, target): + if rnd.chance(0.4): + target["description"] = rnd.text() + if rnd.chance(0.3): + target["label"] = rnd.text() + if rnd.chance(0.3): + target["synonyms"] = [rnd.text() for _ in range(rnd.count(1, 3))] + if rnd.chance(0.2): + target["ai_context"] = rnd.text() + + +def _build_dimensions(rnd, names): + dims = {} + for _ in range(rnd.count(1, 4)): + dname = names.next("d") + dim = {} + flavor = rnd.count(0, 3) + if flavor == 1: + dim["sql"] = rnd.colname() # bare raw column + elif flavor == 2: + dim["sql"] = f'"{rnd.colname().upper()}"' # quoted identifier + elif flavor == 3 and dims: + dim["sql"] = "${" + rnd.pick(list(dims)) + "} + 1" # field ref + _maybe_meta(rnd, dim) + if rnd.chance(0.25): + dim["format"] = rnd.pick(["usdcurrency_2", "number_0", "percent_1", "id"]) + if rnd.chance(0.2): + dim["hidden"] = True + if rnd.chance(0.2): + dim["group_label"] = rnd.text() + if rnd.chance(0.2): + dim["timeframes"] = ["raw", "date", "month"] + dims[dname] = dim + if rnd.chance(0.6): + # primary_key on a dimension whose sql is absent or a bare column. + for dname, dim in dims.items(): + sql = dim.get("sql", "") + if "$" not in sql and '"' not in sql: + dim["primary_key"] = True + break + return dims + + +def _build_measures(rnd, names, dims, shared_measure_name): + measures = {} + for _ in range(rnd.count(0, 3)): + mname = names.next("m") + flavor = rnd.count(0, 4) + if flavor == 0: + m = {"aggregate_type": "count"} + elif flavor == 1 and dims: + m = {"sql": "${" + rnd.pick(list(dims)) + "}", + "aggregate_type": rnd.pick(_AGGS)} + elif flavor == 2: + m = {"sql": rnd.colname(), "aggregate_type": rnd.pick(_AGGS)} + elif flavor == 3: + m = {"sql": f"SUM({rnd.colname()}) / 100"} # raw-SQL measure + else: + m = {"sql": rnd.colname(), "aggregate_type": "percentile", + "percentile": rnd.pick([50, 75, 95])} + if rnd.chance(0.3): + m["description"] = rnd.text() + if rnd.chance(0.2): + m["synonyms"] = [rnd.text() for _ in range(rnd.count(1, 2))] + if rnd.chance(0.2): + m["format"] = "usdcurrency_0" + if rnd.chance(0.2) and m.get("aggregate_type") == "count": + m["filters"] = {rnd.colname(): {"is": rnd.text()}} + measures[mname] = m + if shared_measure_name and rnd.chance(0.5): + # The same measure name on several views exercises the name-qualification + # (`view__measure`) and stashed-name restore paths. + measures[shared_measure_name] = {"aggregate_type": "count"} + return measures + + +def build_omni(rnd): + """Generate an Omni model ({filename: YAML str}) in the round-trippable subset.""" + names = _Names() + n_views = rnd.count(1, 4) + view_names = [names.next("v") for _ in range(n_views)] + + files = {} + dims_of = {} + for vname in view_names: + view = {"schema": rnd.colname()} + if rnd.chance(0.4): + view["catalog"] = rnd.colname() + if rnd.chance(0.4): + view["table_name"] = rnd.colname().upper() + if rnd.chance(0.3): + view["description"] = rnd.text() + if rnd.chance(0.25): + view["label"] = rnd.text() # stash-only view extra + if rnd.chance(0.2): + view["tags"] = [rnd.colname()] # stash-only view extra + dims = _build_dimensions(rnd, names) + view["dimensions"] = dims + dims_of[vname] = dims + measures = _build_measures(rnd, names, dims, "count") + if measures: + view["measures"] = measures + files[f"views/{vname}.view.yaml"] = dump_yaml(view) + + rels = [] + for vname in view_names[1:]: + n_cols = rnd.count(1, 2) + clauses = [ + "${" + f"{view_names[0]}.{rnd.colname()}" + "} = ${" + f"{vname}.{rnd.colname()}" + "}" + for _ in range(n_cols) + ] + rel = {"join_from_view": view_names[0], "join_to_view": vname, + "on_sql": " AND ".join(clauses), + "relationship_type": rnd.pick( + ["many_to_one", "many_to_one", "one_to_many", "one_to_one"])} + if rnd.chance(0.3): + rel["join_type"] = rnd.pick(["inner", "full_outer"]) + if rnd.chance(0.2): + rel["reversible"] = True + if rnd.chance(0.2): + rel["where_sql"] = "${" + f"{vname}.{rnd.colname()}" + "}" + rels.append(rel) + if rels: + files["relationships.yaml"] = dump_yaml(rels) + + if rnd.chance(0.7): + topic = {"base_view": view_names[0]} + if rnd.chance(0.5): + topic["description"] = rnd.text() + if rnd.chance(0.4): + topic["ai_context"] = rnd.text() + if rnd.chance(0.4): + topic["label"] = rnd.text() + if len(view_names) > 1 and rnd.chance(0.6): + topic["joins"] = {v: {} for v in view_names[1:]} + if rnd.chance(0.3): + topic["default_filters"] = { + f"{view_names[0]}.{rnd.colname()}": {"is": rnd.text()}} + files[f"topics/{names.next('t')}.topic.yaml"] = dump_yaml(topic) + + if rnd.chance(0.4): + files["model.yaml"] = dump_yaml({ + "week_start_day": rnd.pick(["Sunday", "Monday"]), + "included_schemas": [rnd.colname()], + }) + return files + + +# --- OSI model builder (for OSI -> Omni -> OSI) ----------------------------------- + +def _osi_field(rnd, names): + fname = names.next("f") + flavor = rnd.count(0, 2) + if flavor == 0: + expr = fname # column named like the field + elif flavor == 1: + expr = rnd.colname() # renamed bare column + else: + expr = f"{rnd.colname()} || {rnd.colname()}" # computed expression + field = {"name": fname, + "expression": {"dialects": [{"dialect": "ANSI_SQL", + "expression": expr}]}} + if rnd.chance(0.4): + field["description"] = rnd.text() + if rnd.chance(0.3): + field["label"] = rnd.text() + ai = {} + if rnd.chance(0.3): + ai["synonyms"] = [rnd.text() for _ in range(rnd.count(1, 2))] + if rnd.chance(0.2): + ai["instructions"] = rnd.text() + if ai: + field["ai_context"] = ai + if rnd.chance(0.2): + field["dimension"] = {"is_time": True} + return field, expr + + +def build_osi(rnd): + """Generate an OSI model dict in the round-trippable subset.""" + names = _Names() + fact = names.next("fact") + dim_names = [names.next("dim") for _ in range(rnd.count(0, 3))] + + datasets = [] + fields_of = {} + for ds_name in [fact] + dim_names: + ds = {"name": ds_name, + "source": f"{rnd.colname()}.{rnd.colname()}.{rnd.colname()}"} + fields, simple_fields = [], [] + for _ in range(rnd.count(1, 4)): + field, expr = _osi_field(rnd, names) + fields.append(field) + if expr == field["name"]: + simple_fields.append(field["name"]) + if rnd.chance(0.3): + ds["description"] = rnd.text() + ds["fields"] = fields + # A primary key over a field whose column matches its name survives the + # dimension round-trip byte-for-byte. + if simple_fields and rnd.chance(0.5): + ds["primary_key"] = [simple_fields[0]] + datasets.append(ds) + fields_of[ds_name] = [f["name"] for f in fields] + + relationships = [] + for dname in dim_names: + n = rnd.count(1, 2) + relationships.append({ + "name": f"{fact}_to_{dname}", + "from": fact, "to": dname, + "from_columns": [f"fk{i}_{rnd.colname()}" for i in range(n)], + "to_columns": [f"pk{i}_{rnd.colname()}" for i in range(n)], + }) + + metrics = [] + for _ in range(rnd.count(0, 3)): + mname = names.next("metric") + flavor = rnd.count(0, 2) + target = rnd.pick([fact] + dim_names) + if flavor == 0: + expr = "COUNT(*)" + elif flavor == 1 and fields_of[target]: + expr = f"{rnd.pick(_OSI_AGGS)}({target}.{rnd.pick(fields_of[target])})" + else: + expr = f"SUM({fact}.{fields_of[fact][0]}) / COUNT(*)" + metric = {"name": mname, + "expression": {"dialects": [{"dialect": "ANSI_SQL", + "expression": expr}]}} + if rnd.chance(0.4): + metric["description"] = rnd.text() + if rnd.chance(0.3): + metric["ai_context"] = {"synonyms": [rnd.text()]} + metrics.append(metric) + + model = {"name": names.next("model")} + if rnd.chance(0.4): + model["description"] = rnd.text() + if rnd.chance(0.3): + model["ai_context"] = {"instructions": rnd.text()} + model["datasets"] = datasets + if relationships: + model["relationships"] = relationships + if metrics: + model["metrics"] = metrics + return {"version": OSI_VERSION, "semantic_model": [model]} + + +# --- Round-trip assertions ------------------------------------------------------- + +def _quiet(fn, *args, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(*args, **kwargs) + + +def assert_omni_roundtrip(files): + """An Omni model survives Omni -> OSI -> Omni with every file identical + (structurally -- YAML key order and formatting aside).""" + osi = _quiet(convert_omni_to_osi, files) + files2 = _quiet(convert_osi_to_omni, osi) + parsed1 = {name: load_yaml(text, name) for name, text in files.items()} + parsed2 = {name: load_yaml(text, name) for name, text in files2.items()} + assert parsed2 == parsed1 + + +def assert_osi_roundtrip(osi): + """An OSI model dict survives OSI -> Omni -> OSI up to the documented + normalizations (see _util.strip_normalized).""" + files = _quiet(convert_osi_to_omni, dump_yaml(osi)) + osi2 = load_yaml(_quiet(convert_omni_to_osi, files)) + assert strip_normalized(osi2) == strip_normalized(osi) diff --git a/converters/omni/tests/_util.py b/converters/omni/tests/_util.py new file mode 100644 index 0000000..2cb1599 --- /dev/null +++ b/converters/omni/tests/_util.py @@ -0,0 +1,133 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared test helpers: fixture loading and structural normalization.""" + +import copy +import json +import pathlib + +from osi_omni._common import load_yaml # src is on sys.path via conftest.py + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] + + +def load_fixture(name): + with open(FIXTURES / name) as fh: + return fh.read() + + +def load_fixture_dir(name): + """Read a fixture Omni model directory as {relative posix path: text}.""" + root = FIXTURES / name + files = {} + for path in sorted(root.rglob("*")): + if path.is_file(): + files[path.relative_to(root).as_posix()] = path.read_text() + return files + + +def parse(yaml_str): + return load_yaml(yaml_str) + + +def parse_files(files): + """Parse every YAML file of an Omni model dict for structural comparison.""" + return {name: load_yaml(text, name) for name, text in files.items()} + + +def canon(obj): + """Deep-copy with every `custom_extensions[].data` JSON string parsed into a + dict, so comparisons are insensitive to JSON key order / whitespace.""" + obj = copy.deepcopy(obj) + + def walk(node): + if isinstance(node, dict): + for ext in node.get("custom_extensions") or []: + if isinstance(ext.get("data"), str): + ext["data"] = json.loads(ext["data"]) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for v in node: + walk(v) + + walk(obj) + return obj + + +def strip_normalized(osi): + """Normalize away what the OSI -> Omni -> OSI trip changes by design, so a + round-trip comparison reflects the documented behavior: + + - `custom_extensions` everywhere: the import adds OMNI stashes (topic set, + timeframe lists, ...) that a hand-authored source model does not carry. + - `unique_keys`: no Omni home (dropped with a warning on export). + - relationship `name`: regenerated as `_to_` on import. + - relationship `ai_context`: no Omni home (dropped with a warning). + - model `ai_context` synonyms/examples: no Omni home; only the + instructions text maps (onto the topic). + - `dimension: {is_time: false}`: equivalent to an absent `dimension` + (only `is_time: true` has an Omni form -- `timeframes`). + - a primary-key column no field covers materializes as a hidden + dimension on export, so it comes back as an extra (stash-only) field. + """ + osi = copy.deepcopy(osi) + for model in osi.get("semantic_model", []): + model.pop("custom_extensions", None) + ai = model.get("ai_context") + if isinstance(ai, dict): + ai.pop("synonyms", None) + ai.pop("examples", None) + if not ai: + model.pop("ai_context") + for ds in model.get("datasets", []): + ds.pop("custom_extensions", None) + ds.pop("unique_keys", None) + ai = ds.get("ai_context") + if isinstance(ai, dict): + ai.pop("synonyms", None) + if not ai: + ds.pop("ai_context") + fields = ds.get("fields", []) or [] + for field in fields: + field.pop("custom_extensions", None) + if field.get("dimension") == {"is_time": False}: + field.pop("dimension") + # Drop backfilled key fields: a bare-column field named after a + # primary_key column, carrying nothing but its expression. + pk = set(ds.get("primary_key") or []) + ds_fields = [ + f for f in fields + if not (f["name"] in pk and set(f) <= {"name", "expression"}) + ] + if ds_fields: + ds["fields"] = ds_fields + else: + ds.pop("fields", None) + for rel in model.get("relationships", []) or []: + rel["name"] = f"{rel['from']}_to_{rel['to']}" + rel.pop("ai_context", None) + rel.pop("custom_extensions", None) + for metric in model.get("metrics", []) or []: + metric.pop("custom_extensions", None) + # Metric order is not semantic; the import regroups metrics by the view + # their measure lives on. + if model.get("metrics"): + model["metrics"].sort(key=lambda m: m["name"]) + return osi diff --git a/converters/omni/tests/conftest.py b/converters/omni/tests/conftest.py new file mode 100644 index 0000000..0bcde53 --- /dev/null +++ b/converters/omni/tests/conftest.py @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pathlib +import sys + +# Make the converter modules in ../src importable from the tests. +_SRC = pathlib.Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(_SRC)) diff --git a/converters/omni/tests/fixtures/fixtureA_omni/relationships.yaml b/converters/omni/tests/fixtures/fixtureA_omni/relationships.yaml new file mode 100644 index 0000000..75e293b --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureA_omni/relationships.yaml @@ -0,0 +1,21 @@ +# 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. + +- join_from_view: orders + join_to_view: customer + on_sql: ${orders.o_custkey} = ${customer.c_custkey} + relationship_type: many_to_one diff --git a/converters/omni/tests/fixtures/fixtureA_omni/topics/sales.topic.yaml b/converters/omni/tests/fixtures/fixtureA_omni/topics/sales.topic.yaml new file mode 100644 index 0000000..f3df13b --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureA_omni/topics/sales.topic.yaml @@ -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. + +base_view: orders +description: Sales orders with customer attributes +ai_context: Use this model for order analysis. +joins: + customer: {} diff --git a/converters/omni/tests/fixtures/fixtureA_omni/views/customer.view.yaml b/converters/omni/tests/fixtures/fixtureA_omni/views/customer.view.yaml new file mode 100644 index 0000000..6ebe1e1 --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureA_omni/views/customer.view.yaml @@ -0,0 +1,24 @@ +# 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. + +catalog: samples +schema: tpch +dimensions: + c_custkey: + primary_key: true + c_name: + description: Customer name diff --git a/converters/omni/tests/fixtures/fixtureA_omni/views/orders.view.yaml b/converters/omni/tests/fixtures/fixtureA_omni/views/orders.view.yaml new file mode 100644 index 0000000..77cda90 --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureA_omni/views/orders.view.yaml @@ -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. + +catalog: samples +schema: tpch +description: One row per order +dimensions: + o_orderkey: + description: Order identifier + primary_key: true + o_custkey: {} + o_orderdate: + label: Order Date + synonyms: + - order date + - date + timeframes: + - raw + - date + - week + - month + - quarter + - year + o_totalprice: {} +measures: + total_revenue: + sql: ${o_totalprice} + aggregate_type: sum + description: Total order revenue + synonyms: + - revenue + - sales + order_count: + aggregate_type: count + description: Number of orders diff --git a/converters/omni/tests/fixtures/fixtureA_osi.yaml b/converters/omni/tests/fixtures/fixtureA_osi.yaml new file mode 100644 index 0000000..0cb830f --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureA_osi.yaml @@ -0,0 +1,100 @@ +# 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. + +# yaml-language-server: $schema=../../../../core-spec/osi-schema.json +# +# Fixture A -- all-native round-trip (OSI -> Omni -> OSI). +# Star schema; every construct maps to a native Omni slot. Documented +# normalizations on the OSI -> Omni -> OSI trip: relationship names are +# regenerated (`_to_`), and unique_keys that merely restate the +# primary key are absorbed by it. + +version: "0.2.0.dev0" + +semantic_model: + - name: sales + description: Sales orders with customer attributes + ai_context: + instructions: "Use this model for order analysis." + datasets: + - name: orders # fact: no incoming relationship -> topic base_view + source: samples.tpch.orders + primary_key: [o_orderkey] + description: One row per order + fields: + - name: o_orderkey + expression: + dialects: + - dialect: ANSI_SQL + expression: o_orderkey + description: Order identifier + - name: o_custkey + expression: + dialects: + - dialect: ANSI_SQL + expression: o_custkey + - name: o_orderdate + expression: + dialects: + - dialect: ANSI_SQL + expression: o_orderdate + label: Order Date + dimension: + is_time: true + ai_context: + synonyms: [order date, date] + - name: o_totalprice + expression: + dialects: + - dialect: ANSI_SQL + expression: o_totalprice + - name: customer + source: samples.tpch.customer + primary_key: [c_custkey] + fields: + - name: c_custkey + expression: + dialects: + - dialect: ANSI_SQL + expression: c_custkey + - name: c_name + expression: + dialects: + - dialect: ANSI_SQL + expression: c_name + description: Customer name + relationships: + - name: orders_to_customer + from: orders + to: customer + from_columns: [o_custkey] + to_columns: [c_custkey] + metrics: + - name: total_revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.o_totalprice) + description: Total order revenue + ai_context: + synonyms: [revenue, sales] + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: COUNT(*) + description: Number of orders diff --git a/converters/omni/tests/fixtures/fixtureB_omni/model.yaml b/converters/omni/tests/fixtures/fixtureB_omni/model.yaml new file mode 100644 index 0000000..b9602ec --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureB_omni/model.yaml @@ -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. + +# Fixture B -- stash round-trip (Omni -> OSI -> Omni, lossless). +# The model file has no OSI home and round-trips via the model-level stash. +included_schemas: +- ecomm +week_start_day: Sunday +access_grants: + pii_access: + user_attribute: role + allowed_values: + - admin diff --git a/converters/omni/tests/fixtures/fixtureB_omni/relationships.yaml b/converters/omni/tests/fixtures/fixtureB_omni/relationships.yaml new file mode 100644 index 0000000..6dbc77e --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureB_omni/relationships.yaml @@ -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. + +# Exercises: non-default join_type, reversible, a composite-key one_to_many +# join (flipped to OSI's many-side-first orientation and flipped back on export). +- join_from_view: order_items + join_to_view: users + on_sql: ${order_items.user_id} = ${users.id} + relationship_type: many_to_one + join_type: inner + reversible: true +- join_from_view: users + join_to_view: orders + on_sql: ${users.id} = ${orders.user_id} AND ${users.region} = ${orders.region} + relationship_type: one_to_many diff --git a/converters/omni/tests/fixtures/fixtureB_omni/topics/order_analysis.topic.yaml b/converters/omni/tests/fixtures/fixtureB_omni/topics/order_analysis.topic.yaml new file mode 100644 index 0000000..9107f42 --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureB_omni/topics/order_analysis.topic.yaml @@ -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. + +# Topic curation (label, joins, default_filters, fields) has no OSI home and +# round-trips via the model-level stash; description/ai_context map natively. +base_view: order_items +label: Order Analysis +description: Line-item order analysis +ai_context: You are an ecommerce analyst. +joins: + users: + orders: {} +default_filters: + users.state: + is: California +fields: +- all_views.* +- -users.state diff --git a/converters/omni/tests/fixtures/fixtureB_omni/views/order_items.view.yaml b/converters/omni/tests/fixtures/fixtureB_omni/views/order_items.view.yaml new file mode 100644 index 0000000..07f479c --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureB_omni/views/order_items.view.yaml @@ -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. + +# Exercises the dimension/measure stash: format, hidden, group_label, +# timeframes, convert_tz, a quoted-identifier sql, a ${field} reference, a +# filtered measure, and a percentile measure. +schema: ecomm +table_name: ORDER_ITEMS +label: Order Items +description: One row per line item +dimensions: + id: + primary_key: true + format: id + hidden: true + user_id: {} + sale_price: + sql: '"SALE_PRICE"' + format: usdcurrency_2 + group_label: Money + created_at: + timeframes: + - raw + - date + - month + convert_tz: false + full_price: + sql: ${sale_price} * 1.1 + label: Full Price +measures: + count: + aggregate_type: count + total_revenue: + sql: ${sale_price} + aggregate_type: sum + format: usdcurrency_0 + description: Total revenue + synonyms: + - sales + - revenue + completed: + aggregate_type: count + filters: + status: + is: complete + p95_price: + sql: ${sale_price} + aggregate_type: percentile + percentile: 95 diff --git a/converters/omni/tests/fixtures/fixtureB_omni/views/orders.view.yaml b/converters/omni/tests/fixtures/fixtureB_omni/views/orders.view.yaml new file mode 100644 index 0000000..f20a220 --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureB_omni/views/orders.view.yaml @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +schema: ecomm +dimensions: + order_id: + primary_key: true + user_id: {} + region: {} diff --git a/converters/omni/tests/fixtures/fixtureB_omni/views/users.view.yaml b/converters/omni/tests/fixtures/fixtureB_omni/views/users.view.yaml new file mode 100644 index 0000000..4cf2572 --- /dev/null +++ b/converters/omni/tests/fixtures/fixtureB_omni/views/users.view.yaml @@ -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. + +schema: ecomm +dimensions: + id: + primary_key: true + region: {} + state: {} +measures: + count: + aggregate_type: count diff --git a/converters/omni/tests/fixtures/tpcds_omni/relationships.yaml b/converters/omni/tests/fixtures/tpcds_omni/relationships.yaml new file mode 100644 index 0000000..33c0abb --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/relationships.yaml @@ -0,0 +1,33 @@ +# 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. + +- join_from_view: store_sales + join_to_view: date_dim + on_sql: ${store_sales.ss_sold_date_sk} = ${date_dim.d_date_sk} + relationship_type: many_to_one +- join_from_view: store_sales + join_to_view: customer + on_sql: ${store_sales.ss_customer_sk} = ${customer.c_customer_sk} + relationship_type: many_to_one +- join_from_view: store_sales + join_to_view: item + on_sql: ${store_sales.ss_item_sk} = ${item.i_item_sk} + relationship_type: many_to_one +- join_from_view: store_sales + join_to_view: store + on_sql: ${store_sales.ss_store_sk} = ${store.s_store_sk} + relationship_type: many_to_one diff --git a/converters/omni/tests/fixtures/tpcds_omni/topics/tpcds_retail_model.topic.yaml b/converters/omni/tests/fixtures/tpcds_omni/topics/tpcds_retail_model.topic.yaml new file mode 100644 index 0000000..783d4cf --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/topics/tpcds_retail_model.topic.yaml @@ -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. + +base_view: store_sales +description: TPC-DS retail semantic model for sales and customer analytics +ai_context: Use this semantic model for retail analytics. It provides comprehensive + sales, customer, product, and store data from the TPC-DS benchmark. The model supports + time-based analysis, customer segmentation, product performance, and store operations + metrics. +joins: + date_dim: {} + customer: {} + item: {} + store: {} diff --git a/converters/omni/tests/fixtures/tpcds_omni/views/customer.view.yaml b/converters/omni/tests/fixtures/tpcds_omni/views/customer.view.yaml new file mode 100644 index 0000000..c195c6d --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/views/customer.view.yaml @@ -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. + +catalog: tpcds +schema: public +description: Customer dimension with demographic information +dimensions: + c_customer_sk: + description: Surrogate key for customer + primary_key: true + c_customer_id: + description: Business key for customer + synonyms: + - customer ID + - customer number + c_first_name: + description: Customer first name + c_last_name: + description: Customer last name + customer_full_name: + sql: c_first_name || ' ' || c_last_name + description: Customer full name (computed field) + synonyms: + - full name + - customer name + c_email_address: + description: Customer email address + synonyms: + - email + - contact diff --git a/converters/omni/tests/fixtures/tpcds_omni/views/date_dim.view.yaml b/converters/omni/tests/fixtures/tpcds_omni/views/date_dim.view.yaml new file mode 100644 index 0000000..068c808 --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/views/date_dim.view.yaml @@ -0,0 +1,70 @@ +# 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. + +catalog: tpcds +schema: public +description: Date dimension with calendar attributes +dimensions: + d_date_sk: + description: Surrogate key for date + primary_key: true + d_date: + description: Actual date value + synonyms: + - date + - calendar date + timeframes: + - raw + - date + - week + - month + - quarter + - year + d_year: + description: Year + synonyms: + - year + timeframes: + - raw + - date + - week + - month + - quarter + - year + d_quarter_name: + description: Quarter name (e.g., 2024Q1) + synonyms: + - quarter + - fiscal quarter + timeframes: + - raw + - date + - week + - month + - quarter + - year + d_month_name: + description: Month name + synonyms: + - month + timeframes: + - raw + - date + - week + - month + - quarter + - year diff --git a/converters/omni/tests/fixtures/tpcds_omni/views/item.view.yaml b/converters/omni/tests/fixtures/tpcds_omni/views/item.view.yaml new file mode 100644 index 0000000..08436b3 --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/views/item.view.yaml @@ -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. + +catalog: tpcds +schema: public +description: Item/Product dimension with product attributes +dimensions: + i_item_sk: + description: Surrogate key for item + primary_key: true + i_item_id: + description: Business key for item + synonyms: + - item ID + - product ID + - SKU + i_item_desc: + description: Item description + synonyms: + - product description + - item name + i_brand: + description: Brand name + synonyms: + - brand + - manufacturer + i_category: + description: Item category + synonyms: + - product category + - department + i_current_price: + description: Current price of the item + synonyms: + - price + - list price diff --git a/converters/omni/tests/fixtures/tpcds_omni/views/store.view.yaml b/converters/omni/tests/fixtures/tpcds_omni/views/store.view.yaml new file mode 100644 index 0000000..342a480 --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/views/store.view.yaml @@ -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. + +catalog: tpcds +schema: public +description: Store dimension with location and store attributes +dimensions: + s_store_sk: + description: Surrogate key for store + primary_key: true + s_store_id: + description: Business key for store + synonyms: + - store ID + - store number + s_store_name: + description: Store name + synonyms: + - store name + - location name + s_city: + description: City where store is located + synonyms: + - city + - location + s_state: + description: State where store is located + synonyms: + - state + - region + s_number_employees: + description: Number of employees at the store + synonyms: + - employee count + - staff size diff --git a/converters/omni/tests/fixtures/tpcds_omni/views/store_sales.view.yaml b/converters/omni/tests/fixtures/tpcds_omni/views/store_sales.view.yaml new file mode 100644 index 0000000..084dc55 --- /dev/null +++ b/converters/omni/tests/fixtures/tpcds_omni/views/store_sales.view.yaml @@ -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. + +catalog: tpcds +schema: public +description: Fact table containing all store sales transactions +custom_compound_primary_key_sql: +- ss_item_sk +- ss_ticket_number +dimensions: + ss_sold_date_sk: + description: Foreign key to date dimension + synonyms: + - sale date + - transaction date + ss_item_sk: + description: Foreign key to item dimension + synonyms: + - product + - item + ss_customer_sk: + description: Foreign key to customer dimension + synonyms: + - customer + - buyer + ss_store_sk: + description: Foreign key to store dimension + synonyms: + - store + - location + ss_quantity: + description: Quantity of items sold + synonyms: + - units sold + - quantity + ss_sales_price: + description: Sales price per unit + synonyms: + - unit price + - price + ss_ext_sales_price: + description: Extended sales price (quantity * price) + synonyms: + - total price + - line total + ss_net_profit: + description: Net profit from the sale + synonyms: + - profit + - margin + ss_ticket_number: + hidden: true +measures: + total_sales: + sql: ${ss_ext_sales_price} + aggregate_type: sum + description: Total sales revenue across all transactions + synonyms: + - total revenue + - gross sales + - sales amount + total_profit: + sql: ${ss_net_profit} + aggregate_type: sum + description: Total net profit from store sales + synonyms: + - net profit + - total earnings + - profit + customer_lifetime_value: + sql: SUM(${store_sales.ss_ext_sales_price}) / COUNT(DISTINCT ${customer.c_customer_sk}) + description: Average lifetime sales value per customer + synonyms: + - CLV + - LTV + - customer value + - lifetime revenue + sales_by_brand: + sql: ${ss_ext_sales_price} + aggregate_type: sum + description: Total sales by brand (requires grouping by item.i_brand) + synonyms: + - brand sales + - brand performance + - brand revenue + store_productivity: + sql: SUM(${store_sales.ss_ext_sales_price}) / NULLIF(SUM(${store.s_number_employees}), + 0) + description: Sales per employee across stores + synonyms: + - sales per employee + - employee productivity + - revenue per employee diff --git a/converters/omni/tests/test_omni_to_osi.py b/converters/omni/tests/test_omni_to_osi.py new file mode 100644 index 0000000..704d544 --- /dev/null +++ b/converters/omni/tests/test_omni_to_osi.py @@ -0,0 +1,393 @@ +# 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. + +"""Unit tests for the Omni -> OSI importer.""" + +import json +import warnings + +import pytest + +from osi_omni import ConversionError, convert_omni_to_osi +from osi_omni._common import dump_yaml +from _util import load_fixture_dir, parse + + +def imp(files, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return parse(convert_omni_to_osi(files, **kwargs))["semantic_model"][0] + + +def minimal_files(**view_overrides): + view = {"schema": "sch", "dimensions": {"id": {"primary_key": True}, + "amount": {}}} + view.update(view_overrides) + return {"views/orders.view.yaml": dump_yaml(view)} + + +def stash_of(obj): + for ext in obj.get("custom_extensions") or []: + if ext.get("vendor_name") == "OMNI": + return json.loads(ext["data"]) + return {} + + +def dataset(model, name): + return next(d for d in model["datasets"] if d["name"] == name) + + +def field(ds, name): + return next(f for f in ds.get("fields", []) if f["name"] == name) + + +def expr_of(obj): + return obj["expression"]["dialects"][0]["expression"] + + +# --- structure -------------------------------------------------------------- + +def test_view_becomes_dataset_with_source(): + model = imp(minimal_files()) + ds = dataset(model, "orders") + assert ds["source"] == "sch.orders" # table_name defaults to the view name + assert ds["primary_key"] == ["id"] + assert expr_of(field(ds, "amount")) == "amount" + + +def test_catalog_and_table_name_join_into_source(): + model = imp(minimal_files(catalog="db", table_name="RAW_ORDERS")) + assert dataset(model, "orders")["source"] == "db.sch.RAW_ORDERS" + + +def test_sql_view_becomes_subquery_source(): + files = {"views/v.view.yaml": dump_yaml({"sql": "SELECT 1 AS x", + "dimensions": {"x": {}}})} + assert dataset(imp(files), "v")["source"] == "SELECT 1 AS x" + + +def test_view_without_schema_or_sql_is_stashed_not_converted(): + # An extends-only view has no standalone dataset form; it is preserved in + # the model stash (and a model with nothing else convertible is an error). + files = dict(minimal_files()) + files["views/v.view.yaml"] = dump_yaml({"extends": ["orders"], + "dimensions": {"x": {}}}) + model = imp(files) + assert [d["name"] for d in model["datasets"]] == ["orders"] + assert "views/v.view.yaml" in stash_of(model)["extra_files"] + + with pytest.raises(ConversionError, match="no convertible view files"): + imp({"views/v.view.yaml": dump_yaml({"dimensions": {"x": {}}})}) + + +def test_no_view_files_rejected(): + with pytest.raises(ConversionError, match="no convertible view files"): + imp({"model.yaml": "{}\n"}) + + +def test_dimension_metadata_maps(): + files = minimal_files(dimensions={ + "amount": {"label": "Amount", "description": "The amount", + "synonyms": ["value"], "ai_context": "Prefer this."}}) + f = field(dataset(imp(files), "orders"), "amount") + assert f["label"] == "Amount" + assert f["description"] == "The amount" + assert f["ai_context"] == {"instructions": "Prefer this.", + "synonyms": ["value"]} + + +def test_timeframes_map_to_is_time_and_stash(): + files = minimal_files(dimensions={"created_at": {"timeframes": ["raw", "date"]}}) + f = field(dataset(imp(files), "orders"), "created_at") + assert f["dimension"] == {"is_time": True} + assert stash_of(f)["timeframes"] == ["raw", "date"] + + +def test_field_reference_sql_translates_and_stashes_original(): + files = minimal_files(dimensions={ + "sale_price": {}, + "full_price": {"sql": "${sale_price} * 1.1"}}) + f = field(dataset(imp(files), "orders"), "full_price") + assert expr_of(f) == "sale_price * 1.1" + assert stash_of(f)["sql"] == "${sale_price} * 1.1" + + +def test_table_ref_sql_translates(): + files = minimal_files(dimensions={"x": {"sql": "${TABLE}.raw_x"}}) + f = field(dataset(imp(files), "orders"), "x") + assert expr_of(f) == "raw_x" + + +def test_omni_only_dimension_params_stash(): + files = minimal_files(dimensions={ + "amount": {"format": "usdcurrency_2", "hidden": True, + "group_label": "Money"}}) + f = field(dataset(imp(files), "orders"), "amount") + assert stash_of(f) == {"_v": 1, "format": "usdcurrency_2", "hidden": True, + "group_label": "Money"} + + +def test_compound_primary_key_resolves_to_columns(): + files = minimal_files( + custom_compound_primary_key_sql=["id", "line"], + dimensions={"id": {}, "line": {"sql": "line_no"}}) + assert dataset(imp(files), "orders")["primary_key"] == ["id", "line_no"] + + +def test_view_extras_stash(): + files = minimal_files(label="Orders", hidden=True, tags=["fact"]) + ds = dataset(imp(files), "orders") + assert stash_of(ds)["view_extras"] == {"label": "Orders", "hidden": True, + "tags": ["fact"]} + + +# --- relationships ---------------------------------------------------------- + +def _two_view_files(rels): + return { + "views/orders.view.yaml": dump_yaml( + {"schema": "s", "dimensions": {"id": {"primary_key": True}, + "user_id": {}}}), + "views/users.view.yaml": dump_yaml( + {"schema": "s", "dimensions": {"id": {"primary_key": True}}}), + "relationships.yaml": dump_yaml(rels), + } + + +def test_many_to_one_join_decomposes(): + model = imp(_two_view_files([ + {"join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.user_id} = ${users.id}", + "relationship_type": "many_to_one"}])) + rel = model["relationships"][0] + assert (rel["from"], rel["to"]) == ("orders", "users") + assert rel["from_columns"] == ["user_id"] + assert rel["to_columns"] == ["id"] + assert rel["name"] == "orders_to_users" + + +def test_one_to_many_join_flips_to_many_side_first(): + model = imp(_two_view_files([ + {"join_from_view": "users", "join_to_view": "orders", + "on_sql": "${users.id} = ${orders.user_id}", + "relationship_type": "one_to_many"}])) + rel = model["relationships"][0] + assert (rel["from"], rel["to"]) == ("orders", "users") + assert rel["from_columns"] == ["user_id"] + assert rel["to_columns"] == ["id"] + assert stash_of(rel)["relationship_type"] == "one_to_many" + + +def test_composite_on_sql_decomposes_in_order(): + model = imp(_two_view_files([ + {"join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.a} = ${users.b} AND ${orders.c} = ${users.d}", + "relationship_type": "many_to_one"}])) + rel = model["relationships"][0] + assert rel["from_columns"] == ["a", "c"] + assert rel["to_columns"] == ["b", "d"] + + +def test_field_reference_in_on_sql_resolves_to_column(): + files = { + "views/orders.view.yaml": dump_yaml( + {"schema": "s", + "dimensions": {"user_key": {"sql": "user_id_raw"}}}), + "views/users.view.yaml": dump_yaml( + {"schema": "s", "dimensions": {"id": {}}}), + "relationships.yaml": dump_yaml([ + {"join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.user_key} = ${users.id}", + "relationship_type": "many_to_one"}]), + } + rel = imp(files)["relationships"][0] + assert rel["from_columns"] == ["user_id_raw"] + + +def test_non_equi_join_is_stashed_not_converted(): + # A range join is valid in Omni but has no OSI relationship form; it is + # preserved verbatim (with its position) rather than failing the import. + entry = {"join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.total} >= ${users.threshold}", + "relationship_type": "many_to_one"} + model = imp(_two_view_files([entry])) + assert "relationships" not in model + assert stash_of(model)["extra_relationships"] == [ + {"index": 0, "entry": entry}] + + +def test_join_referencing_third_view_is_stashed_not_converted(): + entry = {"join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.x} = ${ghost.y}", + "relationship_type": "many_to_one"} + model = imp(_two_view_files([entry])) + assert "relationships" not in model + assert stash_of(model)["extra_relationships"] == [ + {"index": 0, "entry": entry}] + + +def test_join_to_missing_view_rejected(): + with pytest.raises(ConversionError, match="has no view file"): + imp({"views/orders.view.yaml": dump_yaml({"schema": "s"}), + "relationships.yaml": dump_yaml([ + {"join_from_view": "orders", "join_to_view": "ghost", + "on_sql": "${orders.x} = ${ghost.y}", + "relationship_type": "many_to_one"}])}) + + +def test_join_type_and_where_sql_stash(): + model = imp(_two_view_files([ + {"join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.user_id} = ${users.id}", + "relationship_type": "many_to_one", "join_type": "inner", + "where_sql": "${users.active}", "reversible": True}])) + stash = stash_of(model["relationships"][0]) + assert stash["join_type"] == "inner" + assert stash["where_sql"] == "${users.active}" + assert stash["reversible"] is True + + +# --- measures --------------------------------------------------------------- + +def test_count_measure_becomes_count_star_metric(): + files = minimal_files(measures={"count": {"aggregate_type": "count"}}) + metric = imp(files)["metrics"][0] + assert expr_of(metric) == "COUNT(*)" + assert metric["name"] == "count" + + +def test_sum_measure_qualifies_operand(): + files = minimal_files(measures={ + "total": {"sql": "${amount}", "aggregate_type": "sum", + "description": "Total", "synonyms": ["revenue"]}}) + metric = imp(files)["metrics"][0] + assert expr_of(metric) == "SUM(orders.amount)" + assert metric["description"] == "Total" + assert metric["ai_context"] == {"synonyms": ["revenue"]} + + +def test_count_distinct_measure(): + files = minimal_files(measures={ + "n_users": {"sql": "${amount}", "aggregate_type": "count_distinct"}}) + assert expr_of(imp(files)["metrics"][0]) == "COUNT(DISTINCT orders.amount)" + + +def test_raw_sql_measure_keeps_view_qualifiers(): + files = minimal_files(measures={ + "ratio": {"sql": "SUM(${orders.amount}) / COUNT(*)"}}) + assert expr_of(imp(files)["metrics"][0]) == "SUM(orders.amount) / COUNT(*)" + + +def test_filtered_measure_stashes_original(): + files = minimal_files(measures={ + "done": {"aggregate_type": "count", + "filters": {"status": {"is": "complete"}}}}) + metric = imp(files)["metrics"][0] + assert expr_of(metric) == "COUNT(*)" + stash = stash_of(metric) + assert stash["measure"]["filters"] == {"status": {"is": "complete"}} + assert stash["view"] == "orders" + + +def test_percentile_measure_best_effort_expression(): + files = minimal_files(measures={ + "p95": {"sql": "${amount}", "aggregate_type": "percentile", + "percentile": 95}}) + metric = imp(files)["metrics"][0] + assert expr_of(metric) == ( + "PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY orders.amount)") + assert stash_of(metric)["measure"]["aggregate_type"] == "percentile" + + +def test_unknown_aggregate_type_rejected(): + files = minimal_files(measures={"x": {"aggregate_type": "mystery"}}) + with pytest.raises(ConversionError, match="unknown aggregate_type"): + imp(files) + + +def test_colliding_measure_names_qualify_with_view(): + files = { + "views/a.view.yaml": dump_yaml( + {"schema": "s", "measures": {"count": {"aggregate_type": "count"}}}), + "views/b.view.yaml": dump_yaml( + {"schema": "s", "measures": {"count": {"aggregate_type": "count"}}}), + } + names = sorted(m["name"] for m in imp(files)["metrics"]) + assert names == ["a__count", "b__count"] + + +# --- topics and the model file ---------------------------------------------- + +def test_topic_maps_onto_model(): + model = imp(load_fixture_dir("fixtureB_omni")) + assert model["name"] == "order_analysis" + assert model["description"] == "Line-item order analysis" + assert model["ai_context"] == {"instructions": "You are an ecommerce analyst."} + stash = stash_of(model) + assert stash["mapped_topic"] == "order_analysis" + assert stash["base_view"] == "order_items" + topic_stash = stash["topics"]["order_analysis"] + assert "description" not in topic_stash # natively mapped, not duplicated + assert topic_stash["label"] == "Order Analysis" + assert topic_stash["joins"] == {"users": {"orders": {}}} + + +def test_model_file_stashes_verbatim(): + model = imp(load_fixture_dir("fixtureB_omni")) + model_file = stash_of(model)["model_file"] + assert model_file["week_start_day"] == "Sunday" + assert model_file["included_schemas"] == ["ecomm"] + + +def test_no_topics_stashes_empty_topic_set(): + model = imp(minimal_files()) + assert stash_of(model)["topics"] == {} + + +def test_topic_flag_selects_mapped_topic(): + files = dict(load_fixture_dir("fixtureB_omni")) + files["topics/second.topic.yaml"] = dump_yaml( + {"base_view": "users", "description": "Second"}) + model = imp(files, topic="second") + assert model["description"] == "Second" + + +def test_unknown_topic_flag_rejected(): + with pytest.raises(ConversionError, match="not found"): + imp(load_fixture_dir("fixtureB_omni"), topic="ghost") + + +def test_model_name_flag_overrides_topic_name(): + model = imp(load_fixture_dir("fixtureB_omni"), model_name="my_model") + assert model["name"] == "my_model" + + +def test_query_view_preserved_as_extra_file(): + files = minimal_files() + files["views/facts.query.view.yaml"] = "schema: s\nsql: SELECT 1\n" + with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") + model = parse(convert_omni_to_osi(files))["semantic_model"][0] + assert any("query views" in str(w.message) for w in ws) + assert "views/facts.query.view.yaml" in stash_of(model)["extra_files"] + + +def test_relationships_must_be_a_list(): + files = minimal_files() + files["relationships.yaml"] = dump_yaml({"not": "a list"}) + with pytest.raises(ConversionError, match="top-level YAML list"): + imp(files) diff --git a/converters/omni/tests/test_osi_to_omni.py b/converters/omni/tests/test_osi_to_omni.py new file mode 100644 index 0000000..e6d1d0a --- /dev/null +++ b/converters/omni/tests/test_osi_to_omni.py @@ -0,0 +1,351 @@ +# 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. + +"""Unit tests for the OSI -> Omni exporter.""" + +import warnings + +import pytest + +from osi_omni import ConversionError, convert_osi_to_omni +from osi_omni._common import dump_yaml +from _util import REPO_ROOT, load_fixture, load_fixture_dir, parse, parse_files + + +def export(osi_yaml, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return convert_osi_to_omni(osi_yaml, **kwargs) + + +def minimal(**model_overrides): + model = { + "name": "m", + "datasets": [{"name": "orders", "source": "db.sch.orders", + "fields": [_field("amount")]}], + } + model.update(model_overrides) + return dump_yaml({"version": "0.2.0.dev0", "semantic_model": [model]}) + + +def _field(name, expr=None, dialect="ANSI_SQL", **extra): + f = {"name": name, + "expression": {"dialects": [{"dialect": dialect, + "expression": expr or name}]}} + f.update(extra) + return f + + +# --- fixtures --------------------------------------------------------------- + +def test_fixtureA_export_matches_expected(): + files = export(load_fixture("fixtureA_osi.yaml")) + assert parse_files(files) == parse_files(load_fixture_dir("fixtureA_omni")) + + +def test_tpcds_export_matches_expected(): + with open(REPO_ROOT / "examples" / "tpcds_semantic_model.yaml") as fh: + files = export(fh.read()) + assert parse_files(files) == parse_files(load_fixture_dir("tpcds_omni")) + + +# --- structure -------------------------------------------------------------- + +def test_three_part_source_splits_into_catalog_schema_table(): + files = export(minimal()) + view = parse(files["views/orders.view.yaml"]) + assert view["catalog"] == "db" + assert view["schema"] == "sch" + assert "table_name" not in view # table matches the view name + + +def test_table_name_emitted_when_it_differs(): + files = export(minimal(datasets=[{"name": "orders", "source": "sch.raw_orders"}])) + view = parse(files["views/orders.view.yaml"]) + assert view == {"schema": "sch", "table_name": "raw_orders"} + + +def test_subquery_source_becomes_sql_view(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "SELECT * FROM t"}])) + assert parse(files["views/orders.view.yaml"])["sql"] == "SELECT * FROM t" + + +def test_one_part_source_rejected(): + with pytest.raises(ConversionError, match="no schema part"): + export(minimal(datasets=[{"name": "orders", "source": "orders"}])) + + +def test_field_same_named_bare_column_gets_no_sql(): + files = export(minimal()) + assert parse(files["views/orders.view.yaml"])["dimensions"]["amount"] == {} + + +def test_field_renamed_bare_column_gets_sql(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", + "fields": [_field("total", "amount")]}])) + dims = parse(files["views/orders.view.yaml"])["dimensions"] + assert dims["total"] == {"sql": "amount"} + + +def test_complex_expression_emitted_verbatim(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", + "fields": [_field("full_name", "first || ' ' || last")]}])) + dims = parse(files["views/orders.view.yaml"])["dimensions"] + assert dims["full_name"]["sql"] == "first || ' ' || last" + + +def test_is_time_maps_to_default_timeframes(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", + "fields": [_field("created_at", dimension={"is_time": True})]}])) + dims = parse(files["views/orders.view.yaml"])["dimensions"] + assert dims["created_at"]["timeframes"] == [ + "raw", "date", "week", "month", "quarter", "year"] + + +def test_single_primary_key_marks_dimension(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", "primary_key": ["id"], + "fields": [_field("id")]}])) + assert parse(files["views/orders.view.yaml"])["dimensions"]["id"][ + "primary_key"] is True + + +def test_composite_primary_key_uses_compound_key(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", + "primary_key": ["id", "line"], "fields": [_field("id")]}])) + view = parse(files["views/orders.view.yaml"]) + assert view["custom_compound_primary_key_sql"] == ["id", "line"] + # The uncovered key column materialized as a hidden dimension. + assert view["dimensions"]["line"] == {"hidden": True} + + +def test_field_metadata_maps(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", + "fields": [_field("amount", label="Amount", description="The amount", + ai_context={"synonyms": ["value"], + "instructions": "Prefer this."})]}])) + dim = parse(files["views/orders.view.yaml"])["dimensions"]["amount"] + assert dim["label"] == "Amount" + assert dim["description"] == "The amount" + assert dim["synonyms"] == ["value"] + assert dim["ai_context"] == "Prefer this." + + +def test_relationship_becomes_join_entry(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", + "fields": [_field("user_id")]}, + {"name": "users", "source": "db.sch.users", "fields": [_field("id")]}, + ], relationships=[ + {"name": "r", "from": "orders", "to": "users", + "from_columns": ["user_id"], "to_columns": ["id"]}])) + rels = parse(files["relationships.yaml"]) + assert rels == [{ + "join_from_view": "orders", "join_to_view": "users", + "on_sql": "${orders.user_id} = ${users.id}", + "relationship_type": "many_to_one"}] + + +def test_composite_relationship_joins_with_and(): + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders"}, + {"name": "users", "source": "db.sch.users"}, + ], relationships=[ + {"name": "r", "from": "orders", "to": "users", + "from_columns": ["a", "b"], "to_columns": ["c", "d"]}])) + on_sql = parse(files["relationships.yaml"])[0]["on_sql"] + assert on_sql == "${orders.a} = ${users.c} AND ${orders.b} = ${users.d}" + + +def test_topic_generated_with_fk_sink_base_and_join_tree(): + files = export(load_fixture("fixtureA_osi.yaml")) + topic = parse(files["topics/sales.topic.yaml"]) + assert topic["base_view"] == "orders" + assert topic["joins"] == {"customer": {}} + assert topic["description"] == "Sales orders with customer attributes" + assert topic["ai_context"] == "Use this model for order analysis." + + +def test_explicit_base_view_overrides_heuristic(): + files = export(load_fixture("fixtureA_osi.yaml"), base_view="customer") + topic = parse(files["topics/sales.topic.yaml"]) + assert topic["base_view"] == "customer" + assert topic["joins"] == {"orders": {}} + + +def test_unknown_base_view_rejected(): + with pytest.raises(ConversionError, match="not a dataset"): + export(minimal(), base_view="nope") + + +def test_multiple_roots_require_base_view(): + osi = minimal(datasets=[{"name": "a", "source": "db.s.a"}, + {"name": "b", "source": "db.s.b"}]) + with pytest.raises(ConversionError, match="--base-view"): + export(osi) + + +def test_metric_aggregate_becomes_structured_measure(): + files = export(minimal(metrics=[ + {"name": "total", "expression": {"dialects": [ + {"dialect": "ANSI_SQL", "expression": "SUM(orders.amount)"}]}}])) + measures = parse(files["views/orders.view.yaml"])["measures"] + assert measures["total"] == {"sql": "${amount}", "aggregate_type": "sum"} + + +def test_count_star_becomes_count_measure_on_base_view(): + files = export(minimal(metrics=[ + {"name": "n", "expression": {"dialects": [ + {"dialect": "ANSI_SQL", "expression": "COUNT(*)"}]}}])) + assert parse(files["views/orders.view.yaml"])["measures"]["n"] == { + "aggregate_type": "count"} + + +def test_count_distinct_metric(): + files = export(minimal(metrics=[ + {"name": "n", "expression": {"dialects": [ + {"dialect": "ANSI_SQL", + "expression": "COUNT(DISTINCT orders.amount)"}]}}])) + assert parse(files["views/orders.view.yaml"])["measures"]["n"] == { + "sql": "${amount}", "aggregate_type": "count_distinct"} + + +def test_ratio_metric_becomes_raw_sql_measure(): + files = export(minimal(metrics=[ + {"name": "avg_x", "expression": {"dialects": [ + {"dialect": "ANSI_SQL", + "expression": "SUM(orders.amount) / COUNT(*)"}]}}])) + measure = parse(files["views/orders.view.yaml"])["measures"]["avg_x"] + assert measure == {"sql": "SUM(${orders.amount}) / COUNT(*)"} + + +def test_dialect_preference(): + field = {"name": "x", "expression": {"dialects": [ + {"dialect": "ANSI_SQL", "expression": "ansi_col"}, + {"dialect": "SNOWFLAKE", "expression": "snow_col"}]}} + files = export(minimal(datasets=[ + {"name": "orders", "source": "db.sch.orders", "fields": [field]}]), + dialect="SNOWFLAKE") + assert parse(files["views/orders.view.yaml"])["dimensions"]["x"][ + "sql"] == "snow_col" + + +def test_names_are_sanitized(): + files = export(dump_yaml({"version": "0.2.0.dev0", "semantic_model": [{ + "name": "My Model", + "datasets": [{"name": "Order Items", "source": "db.sch.t", + "fields": [_field("Total Price", "p")]}]}]})) + assert "views/order_items.view.yaml" in files + assert "topics/my_model.topic.yaml" in files + dims = parse(files["views/order_items.view.yaml"])["dimensions"] + assert dims == {"total_price": {"sql": "p"}} + + +def test_sanitization_collision_rejected(): + osi = minimal(datasets=[{"name": "Orders", "source": "db.s.a"}, + {"name": "orders", "source": "db.s.b"}]) + with pytest.raises(ConversionError, match="collides"): + export(osi) + + +def test_duplicate_metric_names_rejected(): + metric = {"name": "total", "expression": {"dialects": [ + {"dialect": "ANSI_SQL", "expression": "SUM(orders.amount)"}]}} + with pytest.raises(ConversionError, match="two metrics"): + export(minimal(metrics=[metric, dict(metric, name="Total")])) + + +def test_relationship_column_count_mismatch_rejected(): + osi = minimal(datasets=[{"name": "a", "source": "db.s.a"}, + {"name": "b", "source": "db.s.b"}], + relationships=[{"name": "r", "from": "a", "to": "b", + "from_columns": ["x"], "to_columns": ["y", "z"]}]) + with pytest.raises(ConversionError, match="same length"): + export(osi) + + +def test_unknown_relationship_dataset_rejected(): + osi = minimal(relationships=[{"name": "r", "from": "orders", "to": "ghost", + "from_columns": ["x"], "to_columns": ["y"]}]) + with pytest.raises(ConversionError, match="unknown dataset"): + export(osi) + + +def test_unsupported_version_rejected(): + with pytest.raises(ConversionError, match="Unsupported OSI version"): + export(dump_yaml({"version": "9.9.9", "semantic_model": [{"name": "m"}]})) + + +# --- warnings --------------------------------------------------------------- + +def _warnings_of(osi_yaml, **kwargs): + with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") + convert_osi_to_omni(osi_yaml, **kwargs) + return [str(w.message) for w in ws] + + +def test_unique_keys_warn(): + msgs = _warnings_of(minimal(datasets=[ + {"name": "orders", "source": "db.s.t", "primary_key": ["id"], + "unique_keys": [["email"]], "fields": [_field("id")]}])) + assert any("unique_keys" in m for m in msgs) + + +def test_unique_keys_restating_primary_key_do_not_warn(): + msgs = _warnings_of(minimal(datasets=[ + {"name": "orders", "source": "db.s.t", "primary_key": ["id"], + "unique_keys": [["id"]], "fields": [_field("id")]}])) + assert not any("unique_keys" in m for m in msgs) + + +def test_field_without_usable_dialect_warns_and_drops(): + osi = minimal(datasets=[ + {"name": "orders", "source": "db.s.t", + "fields": [_field("x", dialect="MDX")]}]) + msgs = _warnings_of(osi) + assert any("no usable" in m for m in msgs) + assert "dimensions" not in parse(export(osi)["views/orders.view.yaml"]) + + +def test_relationship_ai_context_warns(): + msgs = _warnings_of(minimal(datasets=[ + {"name": "a", "source": "db.s.a"}, {"name": "b", "source": "db.s.b"}], + relationships=[{"name": "r", "from": "a", "to": "b", + "from_columns": ["x"], "to_columns": ["y"], + "ai_context": {"synonyms": ["join"]}}])) + assert any("relationship ai_context" in m for m in msgs) + + +def test_foreign_vendor_extensions_warn(): + msgs = _warnings_of(minimal(custom_extensions=[ + {"vendor_name": "DBT", "data": "{}"}])) + assert any("foreign-vendor" in m for m in msgs) + + +def test_multiple_models_warn_and_first_converts(): + osi = parse(minimal()) + osi["semantic_model"].append({"name": "second", "datasets": [ + {"name": "x", "source": "db.s.x"}]}) + msgs = _warnings_of(dump_yaml(osi)) + assert any("multiple semantic models" in m for m in msgs) diff --git a/converters/omni/tests/test_real_world_layout.py b/converters/omni/tests/test_real_world_layout.py new file mode 100644 index 0000000..c78864d --- /dev/null +++ b/converters/omni/tests/test_real_world_layout.py @@ -0,0 +1,187 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Round-trip tests for the model shape Omni's API/IDE actually emits. + +A model pulled from a real instance (`omni models yaml-get`) differs from the +canonical `views/.view.yaml` layout these converters write: + + - view files live in per-schema folders (`DELIGHTED/response.view`) and the + model refers to them by a schema-qualified name (`delighted__response`) + recorded in a `# Reference this view as ...` header comment; + - joins restate Omni defaults (`join_type: always_left`, + `relationship_type: many_to_one`) and write compound keys / on_sql with + `${view.field}` references, mixed-case AND, and alias references; + - extends-only views, non-equi joins, template-syntax sql, empty-string + metadata, `timeframes: []`, camelCase/underscore-edged identifiers, and + schema names with spaces all occur. + +Every one of these was found in production models; the round trip through OSI +must reproduce the original files exactly (same paths, same parsed content). +""" + +import warnings + +import pytest + +from osi_omni import ConversionError, convert_omni_to_osi, convert_osi_to_omni +from osi_omni._common import dump_yaml, load_yaml + + +REAL_FILES = { + "model": dump_yaml({"included_schemas": ["DELIGHTED", "GITHUB"]}), + "relationships": dump_yaml([ + # Omni defaults restated explicitly; ${view.field} refs; declared type. + {"join_from_view": "delighted__response", + "join_to_view": "delighted__person", + "join_type": "always_left", + "on_sql": "${delighted__response.person_id} = ${delighted__person.id}", + "relationship_type": "assumed_many_to_one"}, + # Non-equi (range) join: valid Omni, no OSI form -- stashed. + {"join_from_view": "delighted__response", + "join_to_view": "github__team_membership", + "join_type": "always_left", + "on_sql": "${delighted__response.created_at} >= " + "${github__team_membership.valid_from}", + "relationship_type": "many_to_one"}, + # Aliased join: on_sql references the alias, lowercase `and`. + {"join_from_view": "github__team_membership", + "join_to_view": "delighted__person", + "join_to_view_as": "membership_owner", + "on_sql": "${github__team_membership.user_id} = ${membership_owner.id} " + "and ${github__team_membership.org} = ${membership_owner.org}", + "relationship_type": "many_to_one"}, + # Second join between the same pair (name dedup on the OSI side). + {"join_from_view": "github__team_membership", + "join_to_view": "delighted__person", + "on_sql": "${github__team_membership.approver_id} = " + "${delighted__person.id}", + "relationship_type": "many_to_one"}, + ]), + "DELIGHTED/response.view": ( + "# Reference this view as delighted__response\n" + dump_yaml({ + "schema": "DELIGHTED", "table_name": "RESPONSE", + "description": "", # present-but-empty metadata survives + "custom_compound_primary_key_sql": [ + "${delighted__response.id}", "${delighted__response.org}"], + "dimensions": { + "id": {"sql": '"ID"'}, + "org": {"sql": '"ORG"'}, + "person_id": {"sql": '"PERSON_ID"'}, + "created_at": {"sql": '"CREATED_AT"', "timeframes": []}, + "self_named": {"sql": "self_named"}, # explicit same-named col + "_fivetran_id": {"sql": '"_FIVETRAN_ID"'}, + "payload_modelId": { # camelCase (JSON-flattened) name + "sql": "CAST(${delighted__response.payload}['modelId'] " + "AS VARCHAR)"}, + "templated": {"sql": "CASE WHEN {{# delighted__response.f.filter }}" + " ${id} {{/ delighted__response.f.filter }} " + "THEN 1 ELSE 0 END"}, + }, + "measures": {"count": {"aggregate_type": "count"}}, + })), + "DELIGHTED/person.view": ( + "# Reference this view as delighted__person\n" + dump_yaml({ + "schema": "DELIGHTED", # table_name implicit = file name + "dimensions": {"id": {"sql": '"ID"', "primary_key": True}, + "org": {"sql": '"ORG"'}}, + })), + "GITHUB/team_membership.view": ( + "# Reference this view as github__team_membership\n" + dump_yaml({ + "schema": "GITHUB", "table_name": "TEAM_MEMBERSHIP", + "dimensions": {"user_id": {}, "approver_id": {}, "org": {}, + "valid_from": {}}, + })), + # Extends-only view: no schema/sql of its own -- preserved, not converted. + "DELIGHTED/response_ext.view": ( + "# Reference this view as delighted__response_ext\n" + dump_yaml({ + "extends": ["delighted__response"], + "dimensions": {"extra": {"sql": "1"}}, + })), + # Uploaded-CSV schema with a space in its name. + "Omni Views/upload.view": ( + "# Reference this view as upload\n" + dump_yaml({ + "schema": "Omni Views", + "uploaded_table_name": "upload.csv::abc", + "dimensions": {"user_id": {}}, + })), + "Marketing/insights.topic": dump_yaml({ + "base_view": "delighted__response", + "description": "Survey insights."}), +} + + +def _roundtrip(files): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + osi_yaml = convert_omni_to_osi(files) + return osi_yaml, convert_osi_to_omni(osi_yaml) + + +def test_api_layout_roundtrip_is_lossless(): + _, exported = _roundtrip(REAL_FILES) + original = {k if "." in k.split("/")[-1] else k + ".yaml": v + for k, v in REAL_FILES.items()} + assert set(exported) == set(original) + for fname in original: + assert load_yaml(exported[fname]) == load_yaml(original[fname]), fname + + +def test_qualified_view_names_come_from_reference_comment(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + osi = load_yaml(convert_omni_to_osi(REAL_FILES))["semantic_model"][0] + names = {d["name"] for d in osi["datasets"]} + assert "delighted__response" in names + assert "upload" in names # comment name wins even with a folder + # The qualified name resolves relationship references and same-view + # ${view.field} compound-key entries. + rel = osi["relationships"][0] + assert rel["from"] == "delighted__response" + assert rel["to"] == "delighted__person" + response = next(d for d in osi["datasets"] + if d["name"] == "delighted__response") + assert response["primary_key"] == ["id", "org"] + assert response["source"] == "DELIGHTED.RESPONSE" + upload = next(d for d in osi["datasets"] if d["name"] == "upload") + assert upload["source"] == '"Omni Views".upload' + + +def test_unmappable_omni_features_drop_out_of_osi_but_survive(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + osi = load_yaml(convert_omni_to_osi(REAL_FILES))["semantic_model"][0] + # The extends view is not a dataset; the templated dimension not a field. + assert "delighted__response_ext" not in {d["name"] for d in osi["datasets"]} + response = next(d for d in osi["datasets"] + if d["name"] == "delighted__response") + assert "templated" not in {f["name"] for f in response["fields"]} + # The non-equi join is not an OSI relationship; the two same-pair joins get + # unique OSI names. + names = [r["name"] for r in osi["relationships"]] + assert len(names) == len(set(names)) == 3 + + +def test_duplicate_canonical_view_names_rejected(): + files = { + "A/orders.view": "# Reference this view as orders\nschema: A\n", + "B/orders.view": "# Reference this view as orders\nschema: B\n", + } + with pytest.raises(ConversionError, match="two view files"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + convert_omni_to_osi(files) diff --git a/converters/omni/tests/test_roundtrip.py b/converters/omni/tests/test_roundtrip.py new file mode 100644 index 0000000..6c4f5dd --- /dev/null +++ b/converters/omni/tests/test_roundtrip.py @@ -0,0 +1,82 @@ +# 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. + +"""Fixture-based round-trip tests. + +- Omni -> OSI -> Omni must be lossless (the stash carries everything). +- OSI -> Omni -> OSI must be identical up to the documented normalizations + (see _util.strip_normalized). +- Every OSI document the importer emits must validate against the core-spec + JSON schema (skipped when jsonschema is not installed). +""" + +import warnings + +import pytest + +from osi_omni import convert_omni_to_osi, convert_osi_to_omni +from _util import ( + REPO_ROOT, + load_fixture, + load_fixture_dir, + parse, + parse_files, + strip_normalized, +) + + +def _quiet(fn, *args, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(*args, **kwargs) + + +@pytest.mark.parametrize("fixture", ["fixtureA_omni", "fixtureB_omni", "tpcds_omni"]) +def test_omni_roundtrip_is_lossless(fixture): + files = load_fixture_dir(fixture) + osi = _quiet(convert_omni_to_osi, files) + files2 = _quiet(convert_osi_to_omni, osi) + assert parse_files(files2) == parse_files(files) + + +@pytest.mark.parametrize("path", [ + "fixtureA_osi.yaml", + str(REPO_ROOT / "examples" / "tpcds_semantic_model.yaml"), +]) +def test_osi_roundtrip_up_to_documented_normalizations(path): + if "/" in path: + with open(path) as fh: + osi_yaml = fh.read() + else: + osi_yaml = load_fixture(path) + files = _quiet(convert_osi_to_omni, osi_yaml) + osi2 = _quiet(convert_omni_to_osi, files) + original = strip_normalized(parse(osi_yaml)) + # Foreign-vendor extensions are dropped on export (documented); OSI-side + # normalization strips all custom_extensions from both sides. + assert strip_normalized(parse(osi2)) == original + + +@pytest.mark.parametrize("fixture", ["fixtureA_omni", "fixtureB_omni", "tpcds_omni"]) +def test_imported_osi_validates_against_core_spec_schema(fixture): + jsonschema = pytest.importorskip("jsonschema") + import json + + with open(REPO_ROOT / "core-spec" / "osi-schema.json") as fh: + schema = json.load(fh) + osi = _quiet(convert_omni_to_osi, load_fixture_dir(fixture)) + jsonschema.validate(parse(osi), schema) diff --git a/converters/omni/tests/test_roundtrip_properties.py b/converters/omni/tests/test_roundtrip_properties.py new file mode 100644 index 0000000..4d2973d --- /dev/null +++ b/converters/omni/tests/test_roundtrip_properties.py @@ -0,0 +1,100 @@ +# 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. + +"""Property-based round-trip tests. + +Two drivers over the same builders (see _roundtrip_helpers): + + - Hypothesis, when installed: minimal failing examples and example databases. + - A plain seeded random.Random sweep otherwise, so the property logic still + runs in minimal environments. +""" + +import pytest + +from _roundtrip_helpers import ( + RandomRnd, + assert_omni_roundtrip, + assert_osi_roundtrip, + build_omni, + build_osi, +) + +try: + from hypothesis import given, settings, strategies as st + + HAVE_HYPOTHESIS = True +except ImportError: # pragma: no cover + HAVE_HYPOTHESIS = False + + +if HAVE_HYPOTHESIS: + + class _HypRnd: + """The Rnd interface driven by hypothesis' `data` strategy.""" + + def __init__(self, data): + self.data = data + + def chance(self, p=0.5): + return self.data.draw(st.floats(0, 1)) < p + + def count(self, lo, hi): + return self.data.draw(st.integers(lo, hi)) + + def pick(self, seq): + return self.data.draw(st.sampled_from(list(seq))) + + def text(self): + alnum = st.characters(whitelist_categories=("Lu", "Ll", "Nd"), + max_codepoint=0x7A) + head = self.data.draw(st.text(alphabet=alnum, min_size=1, max_size=1)) + body = self.data.draw(st.text( + alphabet=st.sampled_from( + list("abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ")), + max_size=10)) + return (head + body).strip() + + def colname(self): + first = self.data.draw(st.sampled_from( + list("abcdefghijklmnopqrstuvwxyz"))) + rest = self.data.draw(st.text( + alphabet=st.sampled_from( + list("abcdefghijklmnopqrstuvwxyz0123456789_")), + max_size=7)) + return first + rest + + @given(st.data()) + @settings(max_examples=50, deadline=None) + def test_omni_roundtrip_property(data): + assert_omni_roundtrip(build_omni(_HypRnd(data))) + + @given(st.data()) + @settings(max_examples=50, deadline=None) + def test_osi_roundtrip_property(data): + assert_osi_roundtrip(build_osi(_HypRnd(data))) + +else: # pragma: no cover - exercised only without hypothesis + + @pytest.mark.parametrize("seed", range(50)) + def test_omni_roundtrip_seeded(seed): + assert_omni_roundtrip(build_omni(RandomRnd(seed))) + + @pytest.mark.parametrize("seed", range(50)) + def test_osi_roundtrip_seeded(seed): + assert_osi_roundtrip(build_osi(RandomRnd(seed))) diff --git a/converters/omni/uv.lock b/converters/omni/uv.lock new file mode 100644 index 0000000..5a3af71 --- /dev/null +++ b/converters/omni/uv.lock @@ -0,0 +1,306 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.156.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/83/8dbe89bdb8c6f25a7a52e7898af6d82fe35dfef08e5c702f6e33231ce6c6/hypothesis-6.156.6.tar.gz", hash = "sha256:96de02faefa3ce079873541da96f42595583bb001e8e4219294ed7d4501cc4cc", size = 476304, upload-time = "2026-07-10T20:56:49.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/dc/0c2a851f06c91d5ac9ef0f3b9615efc1ed650411d2eee23b6334f491c85e/hypothesis-6.156.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:caf6a93d011c10972da111c38ceb34ced20feaa8581e2b350c0655b022e27875", size = 747998, upload-time = "2026-07-10T20:56:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f8/59203ca978ab51595d12d6bc7e7a63300d7373431ab42ca3f1742e45db68/hypothesis-6.156.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:07f2bc9df1aeba80e12029c1618e2ee54abc440068c305d7075ffd6b85251843", size = 743073, upload-time = "2026-07-10T20:55:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/68/d8/86a0023740434098d1b187a62bd5f99b198f098fb43e7fc58342283a8270/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baca17f4803ad4aa151732326f3990baf54c3127df44aa872ac5bdf8a98a9a6", size = 1070169, upload-time = "2026-07-10T20:55:49.47Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/673453915fd0c67673f35a4876ba88f48c621335f293f3537d77b27d4286/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8083806645f84243aade727f4978185caaa0b7190af4318673999ee15fdbf424", size = 1121760, upload-time = "2026-07-10T20:55:53.502Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/3a5557f52912f2fecc6ed59642dcf80dd8e89d0d9664502b68e23d66bf3d/hypothesis-6.156.6-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a922eedcd8618f9c2e17b79fa7b3f3f0b2df34e201958611cc3f0f46cca33c10", size = 1111440, upload-time = "2026-07-10T20:55:43.054Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/ae636d4ca7f996a1ccb4b3d5997d949f1718fba52b01559b3ab53b237b3f/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5291bd33c4704d274d7c214d5c200e77f372a06644f5cbbe96dcbe53cb2fbf10", size = 1244944, upload-time = "2026-07-10T20:55:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/1e/79/c425d22d734be0268ca60d120c6296299e4220a1783cb1a4cc76232807bb/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55f3ec50161b4a95bae63bff2b5166e45935b493013d3be30ede279bf6192318", size = 1288808, upload-time = "2026-07-10T20:56:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/cc9f479d22cbdd36ddfc55a978378eddadd183b09339ebdb81be33bb18e7/hypothesis-6.156.6-cp310-abi3-win32.whl", hash = "sha256:e96570ca5cdd9a5f2ff9e80a6fb2fd5420ebf33b833d7de5b09b6ebb26a3eb6c", size = 634868, upload-time = "2026-07-10T20:55:37.959Z" }, + { url = "https://files.pythonhosted.org/packages/d6/89/2008d287289841a936456cb13443ca89d88da6e4527d611d482e9544164d/hypothesis-6.156.6-cp310-abi3-win_amd64.whl", hash = "sha256:32710718c22fe8c5571464e898bb87d282837b02617d6ad68130abf7cb4843cb", size = 640382, upload-time = "2026-07-10T20:55:30.634Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dc/b502504a972af7368b62e712268d310ffbc81d0ebfb31d5a9c60332a5063/hypothesis-6.156.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:afec0631a7a557acb50f665108b5d1d1123c21bc702d156a740db1cc33be4a7d", size = 749319, upload-time = "2026-07-10T20:55:35.708Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/a4867bc2b8b81d9b1648992fb7e4a732b3db480ff2d02df2c7b59189c812/hypothesis-6.156.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583a658162ee7e1a82155e3f146932a3a2269030294f3c83f5cf52fcaa0562c3", size = 743969, upload-time = "2026-07-10T20:55:31.983Z" }, + { url = "https://files.pythonhosted.org/packages/2e/22/6ece4337e01c634594bb177009c049aa3133c151d9e397edccb6c3938567/hypothesis-6.156.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9600277defbfa769d8a6af264cbdff16294682c210c2d058ef39348fec16c0c", size = 1070874, upload-time = "2026-07-10T20:55:44.277Z" }, + { url = "https://files.pythonhosted.org/packages/05/00/5820a3b1fe264b23770f77dbb3ac0f6fdadd21b640624791a05950f934da/hypothesis-6.156.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7c3f067166bfc28b67f0042fc5fdcc87b3b58664da0c2f563fe544448b7ab3b", size = 1122191, upload-time = "2026-07-10T20:56:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/0861ed15c96302577229334655e038e8c46e4b5b2a8cae971409a7e176e5/hypothesis-6.156.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f57842c1c5314839bdf4be5cff108589ff435cd7192c035dc48e6f14032915a5", size = 1246060, upload-time = "2026-07-10T20:55:41.834Z" }, + { url = "https://files.pythonhosted.org/packages/64/5c/63b60c6566eafac0b150f6acae5d1cbe0ed64dc294c863888eb87088a542/hypothesis-6.156.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c4a787c3af0035846034461792f2a62f1c43af850feb970a5b53a629d64b52fb", size = 1289510, upload-time = "2026-07-10T20:55:33.133Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/bfe57f182f51784549210903241057ae94784858117b965613089f5f89ad/hypothesis-6.156.6-cp310-cp310-win_amd64.whl", hash = "sha256:02accb187617ebaebb120da931f799a3cb0df7c38706f97f9d022441d4faf533", size = 640384, upload-time = "2026-07-10T20:56:26.939Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/e4a0796190d8089e85f06731e21fdddd7e8edd3a4e562101527a048e21c4/hypothesis-6.156.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5baec7943a14d106e982121dd4f74cfc5ef45e37c17f94fe49338d3d1377f38c", size = 748988, upload-time = "2026-07-10T20:56:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/4a789b286cd2cced31992e1f683036b51dd6909b934ea007ffb43aa3a32f/hypothesis-6.156.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5f519905ddeb10e23b8ba2c254541a5b1a8f146fe0551be94d972f4a77226f4", size = 743754, upload-time = "2026-07-10T20:56:09.113Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/3dd36c1c03d24ae3ffc3c5b0eca8cc4ae90c07abc320f76509eceb37019a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c108655960b58ded3ca71b2dc5c69fb2ba7e9c723aeb6106facec3892d09087", size = 1070732, upload-time = "2026-07-10T20:56:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/57/51/befc4b816b471078034a875eb1ef69e0411ab84bcce582b4be173258785a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c8d37bebb6924729bc0bbf5852689df568842948abe4d93dd0ad3377adf76fa", size = 1121988, upload-time = "2026-07-10T20:56:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/b7/a796f5e3e4b7cb911ff346008d49720296d1f4073490b8bc1cce6b3fbb07/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74137bef6d502305c3648b2ed1a9bb4bc05fb1025e96b30a2c092204c40fe097", size = 1245596, upload-time = "2026-07-10T20:55:50.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/65/849c4cba44a6f6cc888fd931124429b24180234ccc4883abab8cad5fcfcb/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5e0afdf79cceed20fcf0a9fb80d4064a9b2b53d4d4eecbac0e21208a13f5a31b", size = 1289172, upload-time = "2026-07-10T20:55:58.915Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/7106a110df29eb631d66776e8aa8128f82f04a9dd2b6b22b612e6025e3a2/hypothesis-6.156.6-cp311-cp311-win_amd64.whl", hash = "sha256:84dc89caaf741a02f904ca7bd02b1af99650c75552868162290208aeecb70858", size = 640222, upload-time = "2026-07-10T20:56:10.396Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9f009005b9c796f4a40424484ac7e70847bc088456fd940a937f96bb4b6d/hypothesis-6.156.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a2a728b514fceb81e3f0464508911d5220fd74dadc3270f859427a686b60c4cf", size = 748844, upload-time = "2026-07-10T20:56:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/4d852bb8a9c73a68b18eca9b5b085285282122166e158f4d2a477639bfee/hypothesis-6.156.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7489b9a8f9df8227edd6c7cd8b9ccfab2483bab24da6a474c175973ca2294f58", size = 741936, upload-time = "2026-07-10T20:55:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/b9968070ae042f9bf3149bb6ba6399d5f28f452e0fb7f638cafc69ff0b9a/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42760873d6db1069d6edbaa355a61b9078a9950259efcfc72fc695741d7db7cd", size = 1069749, upload-time = "2026-07-10T20:56:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/753806f5292b40aeab1d269e408e3a7e85be3c0d88828fb78ab4a34d6626/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e66aaa7385538a5d617174d47c198ee807f06de99e282a67c6cb724c69340d", size = 1120983, upload-time = "2026-07-10T20:56:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/85/88/8386d064d680be27e936eba94f1448bc93ef6fa05473ee5034139f1c4284/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08796b674c0b31a5dd4119b2173823390055921588d13eb77324e861b00fd7f8", size = 1243911, upload-time = "2026-07-10T20:55:54.799Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8c/7524c1e5279e7728eb47c99f2357cbc5f08ae92e9bce49bf50118b53f9c9/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4ca8cc26ea2d31d22cf7710e92951cfaa921f0f8aa1b6db33a5176335f583a4f", size = 1287806, upload-time = "2026-07-10T20:56:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/c347ad913e1c5f2988956fe17826c0400b4ce470b973e6c248e97b6a0acf/hypothesis-6.156.6-cp312-cp312-win_amd64.whl", hash = "sha256:c3363d3fb8015594636689572510bb6090602d8e8e838a5693c2d52d3b5b09d8", size = 637679, upload-time = "2026-07-10T20:55:39.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/9583fe153573523dac27226c89e041a86ad4aeeae08c868160cbb93d39d2/hypothesis-6.156.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:59a8def90d9a5a9b67e1ac529e903a2363ceb6cf873c209da6b4284c5daab671", size = 749264, upload-time = "2026-07-10T20:56:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/e4113d06769b544f0fb77ffea9195b598b4c56a298905c21fd47c4eed388/hypothesis-6.156.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c574c3224563d730848bc5d1ef1683c4f83993400c0167899fe328f4bfcd4725", size = 742095, upload-time = "2026-07-10T20:56:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/a47666ede10384e8978722cade7ab96a42df71d2ab577317092d0fed7c8a/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01bb8270c46b3ef53b0c2d23ff613ea506d609d06f936d823ea57c58b66b05f7", size = 1069917, upload-time = "2026-07-10T20:56:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/93/75f6057dadd9dc0134f37c08d5d14d04d3cd7374debbcb0cc4569c6712f1/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ea6559c13606e13b645927f2e0906e52b5ac5d99b40d3abaaeb2e8c7ceeb75", size = 1121204, upload-time = "2026-07-10T20:55:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/62/87/308efef08bc60d1e673d035e8ca8e9663f4b6b3ba519c3cdebf6583c2b76/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2d47054d0230f0dd9b6868fc030126c7a6c25527144272ff376cc4e9c39f7540", size = 1244168, upload-time = "2026-07-10T20:55:40.288Z" }, + { url = "https://files.pythonhosted.org/packages/3b/66/de8fff5bd9a40a4056dafbe7f904887ef12632282bbbac90f1977c30dd3b/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:050c8c0815f88d47dd0875a92698d20d61639b7b721ee043a6d687c7f14ff7d8", size = 1288127, upload-time = "2026-07-10T20:56:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/794fb26e1fd3ff004978f8f18b7aa7e1c2270ba72e1f977b987a812064f8/hypothesis-6.156.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0d73edab7b8a0051b3634f2d04d62b7e7282f8f274963b11188ee4957d672ef", size = 637954, upload-time = "2026-07-10T20:56:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/5b4b27984cb43c60e95f570b069660335dad34cb67f7d226017c5d35d31e/hypothesis-6.156.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:34a70a7b8226e34d658072d8fb81d03f97f0a75ceb536329a321b94ce2232fd6", size = 749312, upload-time = "2026-07-10T20:55:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/709cceffc28666c9d4cb75ffc6df5ce30db8c7dd5cc2c8b38a2fd837427f/hypothesis-6.156.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f1969646beead7d8cf6a2537d2765af89d73056e2cb218e7fae92b83802250a3", size = 742332, upload-time = "2026-07-10T20:56:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/cfc79b13d8dd3cd6de6b9df921c557efe8528a9c90a3a7cd93b37188d57e/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbc2ec7b7d905e6b6ec1635f6340bfa52aaab718101c59f052bc012a6b486cd8", size = 1070109, upload-time = "2026-07-10T20:55:48.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/1da4def1f006b5ad01187ff96379e24c37439d659ec10c3e944c03436c0f/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9367ae25dfa6dc1af37904785e43c4f8fe1c4118cafdc2f06514154fbdd90992", size = 1121528, upload-time = "2026-07-10T20:56:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/744e4f5e3d635dea20dbedf3fa486e2a6fa5210e0a52a0d5c4da56babd84/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:455f09107ec07c78f2a83cb8fc19e23879c9d51cdc831de6f9cb6ec4059cb9af", size = 1244690, upload-time = "2026-07-10T20:56:31.854Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/42252fcd5e521d140dac532f29c2a13ca8f22908cb545ffdd64b5e225680/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c76634c45a3ceee4c4fdfed39aebd08b8b822ec8b0c556877ef82846fd777730", size = 1288519, upload-time = "2026-07-10T20:56:03.429Z" }, + { url = "https://files.pythonhosted.org/packages/44/e7/176df9e47cf583d2b8d234b78c0aac3a47075ad5d147e60b2c21a1338bb1/hypothesis-6.156.6-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eb7e9f8343bc6b948937e6ec12e6879ed25a17b53ceccbd2b84adadd3d511698", size = 586452, upload-time = "2026-07-10T20:56:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/2c8a0411bbe76429f3ae738ef9a00107201bf6146d9534350014ce369d98/hypothesis-6.156.6-cp314-cp314-win_amd64.whl", hash = "sha256:f9631cd604ae6032c3edf99160dc1b9e33873f2e52762246b24f07fb758652ae", size = 637774, upload-time = "2026-07-10T20:56:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/2a/22/8115005e9aa72c8d63d90e9db5e0b8425fd8950fbc5d6e332805d4d32c9e/hypothesis-6.156.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1f81163d36d3763b09ffaef7c3a71e88174ca3e6816201fca9d1d159f448fdb5", size = 747428, upload-time = "2026-07-10T20:56:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c2/66bfe9337f4a4b1f7754ee6d01d950280152a81d0d797e6c1d9eb0909750/hypothesis-6.156.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:556b905767e36147918634a64356aa05d8c956576f00aee01eb351678f193908", size = 740889, upload-time = "2026-07-10T20:55:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/69f45af2d4f0950b7d1af3cdbdd800b88a6c2370331481eda79d6171fbe3/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f2604b28d16d696aaaf4954d20f907b27e54034df98e64746a20c74c319f03", size = 1069270, upload-time = "2026-07-10T20:56:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/f8/43/6b2549885da08f5e50ba34fb8e0d0a60b2f190ffd516fac220f8db5b5869/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe012ad66dbe7b8e8ddef6f6992ab1b36719ea64430c2bf1ff7135521052a15", size = 1120409, upload-time = "2026-07-10T20:55:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/70/97/745c778c3eb29befa2367b1ded8437eecfbbe6932359d0f831275bc32170/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfa3c7b758f7278081c6bfec5f89b43c4eb075c0c9eb095323f7a9eb019b513", size = 1243111, upload-time = "2026-07-10T20:56:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/c5ec6a442dc9b822f47064bda4b6d3e739dccdd1c5bf44c9a57fb6136830/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0db1f4573800c618773622f03cb6533bb3377430ef938c9476ba10c39d22591", size = 1287262, upload-time = "2026-07-10T20:56:23.749Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/c134d61710e14b68b010215dcf6bd57d2ec05cd169dff8bfab8fefc2d410/hypothesis-6.156.6-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd0c4a7b9f809f1e23a4d15adfa9c5d99869b9afc327350a5e563350b78e48", size = 637862, upload-time = "2026-07-10T20:56:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/b94f3a31665527b6181616b72990fcf8d6d5fa82b4187aab104ab5f548f0/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8893b4da90e06828846c1b100c3414a7729d047a020d854c0899ae9339df0e70", size = 749575, upload-time = "2026-07-10T20:55:29.371Z" }, + { url = "https://files.pythonhosted.org/packages/21/74/dcf695f79f526543ae5d0f8c1325508e9fe990a996c0e0853129a9a5d81d/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7fc0b7df9b28d028e4cc295b2ac8fbbbc22e090a23382c92fff5e37696be74f", size = 744351, upload-time = "2026-07-10T20:56:36.46Z" }, + { url = "https://files.pythonhosted.org/packages/11/d3/5bff4c55c6995a6c43f66ec8e5866b56e34f03837fd0be0e4922f3bab168/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38ed3178526382d392d04ad699ad7a2e53845e521a09d40f1cbbc1e1ff63ba48", size = 1070916, upload-time = "2026-07-10T20:56:19.256Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/5ed42117a69221ea118caaff933d8212039a0ac0bc15afa915635f13984c/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ad94e28aabf4db0d479297d43b8a2a01e7caaa9bdfccfdac7a4a3717e05b993", size = 1122625, upload-time = "2026-07-10T20:56:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/02499badc6e3f3e980941021edf5fd780c895d8d08c9015e78516340ed83/hypothesis-6.156.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:983a5cfd955994bffc7eb02976241f7a1f3c2d94dbc3389430c45858fa5c1ae0", size = 640823, upload-time = "2026-07-10T20:55:45.461Z" }, +] + +[[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 = "osi-omni" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.0" }, + { name = "pytest", specifier = ">=8.0" }, +] + +[[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 = "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 = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { 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 = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[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" }, +] diff --git a/converters/orionbelt/README.md b/converters/orionbelt/README.md new file mode 100644 index 0000000..bcc96bb --- /dev/null +++ b/converters/orionbelt/README.md @@ -0,0 +1,125 @@ +# apache-ossie-orionbelt + +Bidirectional converter between **OBML** (OrionBelt Markup Language) semantic +models and **OSI** ([Open Semantic Interchange](https://open-semantic-interchange.org/)), +the open standard for portable semantic models (metrics, dimensions, +relationships). + +This package is licensed under **Apache-2.0** and may be used freely. It is the +OrionBelt converter in the OSI converter ecosystem. The canonical source is +developed in the +[orionbelt-semantic-layer](https://github.com/ralfbecher/orionbelt-semantic-layer) +repository (under `packages/osi-orionbelt`) and published to PyPI from there; +file issues and contributions upstream. + +## Requirements + +- Python 3.12+ +- [uv](https://docs.astral.sh/uv/) (recommended) or pip + +## Install + +```bash +pip install apache-ossie-orionbelt +``` + +Optional deep OBML semantic validation (cycles, duplicate names, invalid refs) +via the full OrionBelt engine: + +```bash +pip install "apache-ossie-orionbelt[obml-validation]" +``` + +Without that extra, OBML validation runs JSON-schema checks only and emits a +warning for the deeper semantic pass. + +## CLI + +A single `ossie-orionbelt` command with two subcommands (mirroring `ossie-dbt`): + +| Subcommand | Direction | In | Out | +|---------|-----------|----|----| +| `obml-to-osi` | OBML -> OSI core-spec | OBML YAML | OSI YAML | +| `obml-to-osi --ontology` | OBML -> OSI ontology | OBML YAML | OSI ontology YAML | +| `osi-to-obml` | OSI core-spec -> OBML | OSI YAML | OBML YAML | + +```bash +ossie-orionbelt obml-to-osi -i model.obml.yaml -o model.osi.yaml +ossie-orionbelt obml-to-osi --ontology -i model.obml.yaml -o model.ontology.yaml +ossie-orionbelt osi-to-obml -i model.osi.yaml -o model.obml.yaml +``` + +`-i/--input` and `-o/--output` are required. Each subcommand prints conversion +warnings and a validation summary to stderr, and exits non-zero when the +produced document fails schema validation (unless `--no-validate`). Run +`ossie-orionbelt --help` or `ossie-orionbelt obml-to-osi --help` for the full +option list. + +## Python API + +```python +import yaml +from ossie_orionbelt import OBMLtoOSI, OSItoOBML, validate_osi + +obml = yaml.safe_load(open("model.obml.yaml")) +osi = OBMLtoOSI(obml, "sales", "Sales model").convert() +result = validate_osi(osi) +assert result.valid + +obml_again = OSItoOBML(osi).convert() +``` + +## Vendor extensions + +OSI `custom_extensions` carry vendor-tagged payloads. This converter: + +- emits OrionBelt/OBML-proprietary data under the **`ORIONBELT`** vendor on OBML + to OSI (OBML-only filters, settings, owner, refresh, type info, etc.); +- stashes OSI-native fields that OBML can't represent (unique keys, field + labels, leftover `ai_context`) under the **`OSI`** vendor when going OSI to + OBML, restoring them to first-class OSI fields on the way back; +- **preserves third-party vendor extensions verbatim** (e.g. `SNOWFLAKE`, + `DBT`, `SALESFORCE`, `GOODDATA`) at the model, dataset, field, and + measure/metric levels, so a full OSI to OBML to OSI roundtrip keeps the + original vendor and data. OSI has no separate dimension entity, so an OBML + dimension's foreign extensions surface on its OSI field. + +Legacy `COMMON` / `OBSL` tags from earlier converter versions are still accepted +on read. + +## Limitations / unsupported constructs + +Some OBML constructs have no native OSI equivalent and are carried in vendor +`custom_extensions` (`obml_*` payloads) so they round-trip without loss back to +OBML, but are not interpreted by other OSI consumers: + +- **Many-to-many joins** - represented in OBML join cardinality; flagged on + export. +- **Named secondary join paths** - OBML's multiple join paths between the same + pair of objects are an OBML-specific topology feature. +- **Measures / metrics and column-level value concepts in the ontology layer** - + not represented in the OSI ontology export. +- **OSI metrics with no OBML representation** - a metric whose only expression is + in a non-SQL dialect (`MDX`, `TABLEAU`, `MAQL`), or whose SQL expression cannot + be decomposed into OBML measures/metrics, is **not** dropped: the original OSI + metric is preserved verbatim in a model-level `OSI`-vendor `custom_extension` + (`obml_unconverted_metrics`) and re-emitted on OBML to OSI, so the OSI to OBML + to OSI roundtrip stays lossless. A `LOSSY:` warning is raised for each such + metric because it is **not queryable through OBML**. SQL expressions in the + `ANSI_SQL`, `SNOWFLAKE`, and `DATABRICKS` dialects are all read on import. + +OSI v0.1.x inputs are accepted on read via a legacy normalization shim; output +targets OSI **v0.2.0.dev0**. + +See [`osi_obml_mapping_analysis.md`](./osi_obml_mapping_analysis.md) for the +full OBML <-> OSI core-spec mapping and +[`osi_obml_ontology_mapping_analysis.md`](./osi_obml_ontology_mapping_analysis.md) +for the ontology-layer mapping and its documented gaps. + +## Development + +```bash +uv sync # install +uv run pytest # run the test suite (includes a TPC-DS baseline) +uv run ruff check && uv run mypy src/ossie_orionbelt +``` diff --git a/converters/orionbelt/osi_obml_mapping_analysis.md b/converters/orionbelt/osi_obml_mapping_analysis.md new file mode 100644 index 0000000..d94fe09 --- /dev/null +++ b/converters/orionbelt/osi_obml_mapping_analysis.md @@ -0,0 +1,269 @@ +# OSI ↔ OBML Mapping Analysis + +> Bidirectional conversion between [Open Semantic Interchange (OSI)](https://github.com/open-semantic-interchange/OSI) v0.2.0.dev0 and [OrionBelt ML (OBML)](https://github.com/ralfbecher/orionbelt-semantic-layer) v1.0 semantic model formats. OSI v0.1.x inputs are still accepted on read via a legacy normalization shim; output targets v0.2.0.dev0. + +## 1. Structural Comparison + +| Aspect | OSI v0.2.0.dev0 | OBML v1.0 | +|---|---|---| +| **Top-level** | `semantic_model[]` (array of models) | Single model with `dataObjects`, `dimensions`, `measures`, `metrics` sections | +| **Tables / Entities** | `datasets[]` (flat array) | `dataObjects{}` (named dictionary) | +| **Column identifiers** | `fields[].name` (snake_case code) | `columns{}.code` (with display name as dict key) | +| **Expressions** | `expression.dialects[]` per field (multi-dialect) | Single SQL expression via `code` (single dialect) | +| **Joins / Relationships** | `relationships[]` (global, separate section) | `joins[]` (inline on each data object) | +| **Dimensions** | `field.dimension.is_time` (inline flag on fields) | `dimensions{}` (separate top-level section) | +| **Measures** | N/A (merged into metrics) | `measures{}` (explicit aggregation definitions) | +| **Metrics** | `metrics[]` with full SQL expressions | `metrics{}` with `{[Measure]}` references | +| **AI Context** | `ai_context` on every entity | `customExtensions` with `vendor: "OSI"` | +| **Extensibility** | `custom_extensions[]` per entity | `customExtensions[]` per entity | +| **Keys** | `primary_key`, `unique_keys` on datasets | N/A | +| **Secondary joins** | N/A | `secondary: true`, `pathName` | +| **Fan-out protection** | N/A | `allowFanOut`, `reduceToRelationDimensionality` | + +## 2. Key Differences + +### 2.1 Naming Convention + +- **OSI** uses snake_case codes everywhere (`name: "store_sales"`) +- **OBML** supports dual naming — a display name as the dictionary key and a `code` for the physical SQL reference + +During OSI → OBML conversion, field names are used directly as both the display name and code. During OBML → OSI conversion, the `code` value becomes the OSI field `name`. + +### 2.2 Relationship Placement + +- **OSI** defines relationships globally, referencing dataset names by string +- **OBML** defines joins inline on the "from" side data object + +The converter restructures between these two representations automatically, preserving column mappings and generating descriptive relationship names. + +### 2.3 Measures vs. Metrics + +This is the most fundamental structural difference between the two formats. + +- **OSI** has a single "metrics" concept with full SQL expressions (e.g., `SUM(store_sales.ss_ext_sales_price)`) +- **OBML** explicitly separates: + - **Measures**: Simple aggregations on columns (e.g., `SUM` of `ss_ext_sales_price`) + - **Metrics**: Derived calculations referencing measures via `{[Name]}` syntax (e.g., `{[total_sales]} / {[customer_count]}`) + +The converter handles this decomposition automatically: + +| OSI metric type | OBML mapping | +|---|---| +| `AGG(dataset.column)` | Direct measure | +| `AGG(DISTINCT dataset.column)` | Measure with `distinct: true` | +| `AGG(expr)` (e.g., `SUM(a.x * a.y)`) | Expression-based measure | +| Multi-aggregation expression | Auto-generated measures + metric formula | + +**Example** — OSI metric `customer_lifetime_value`: +```yaml +# OSI +expression: SUM(store_sales.ss_ext_sales_price) / COUNT(DISTINCT customer.c_customer_sk) +``` + +is decomposed into OBML: +```yaml +# OBML measures (auto-generated) +measures: + total_sales: + columns: + - dataObject: store_sales + column: ss_ext_sales_price + resultType: float + aggregation: sum + + _customer_c_customer_sk_count_distinct: + columns: + - dataObject: customer + column: c_customer_sk + resultType: float + aggregation: count + distinct: true + +# OBML metric (references the measures) +metrics: + customer_lifetime_value: + expression: "{[total_sales]} / {[_customer_c_customer_sk_count_distinct]}" +``` + +When a simple OSI metric (e.g., `SUM(store_sales.ss_ext_sales_price)`) is equivalent to an existing named measure, the converter deduplicates and reuses the named measure rather than creating a redundant auto-measure. + +### 2.4 AI Context Preservation + +OSI's `ai_context` (instructions, synonyms, examples) is preserved losslessly during conversion via OBML's `customExtensions` mechanism: + +```yaml +# OSI input +ai_context: + synonyms: + - "sales transactions" + - "store purchases" + +# OBML output (via customExtensions) +customExtensions: + - vendor: OSI + data: '{"synonyms": ["sales transactions", "store purchases"]}' +``` + +This applies at all levels: datasets → data objects, fields → columns, and model-level `ai_context`. + +During OBML → OSI conversion, the `customExtensions` with `vendor: "OSI"` are read back and restored as native `ai_context` on the OSI side. + +### 2.5 OBML-Specific Features (Not Representable in OSI) + +These OBML features have no direct OSI equivalent. Where possible, metadata is preserved in OSI `ai_context` or `custom_extensions` (with `vendor_name: "COMMON"` and `obml_`-prefixed keys) for lossless roundtrip: + +- Secondary joins with `pathName` (preserved in relationship `ai_context`) +- `allowFanOut` — preserved in metric `custom_extensions` (`obml_allow_fan_out`) +- Dynamic date filters (`dynamicDate`, `dynamicDateRange`) — not yet preserved +- `timeGrain` on dimensions — preserved in field `custom_extensions` (`obml_time_grain`) +- Dimension `format` — preserved in field `custom_extensions` (`obml_dimension_format`) +- Measure filters — preserved in metric `custom_extensions` (`obml_filters`) +- Measure `total` — preserved in metric `custom_extensions` (`obml_total`) +- Measure `format` — preserved in metric `custom_extensions` (`obml_format`) +- Measure `delimiter` — preserved in metric `custom_extensions` (`obml_delimiter`) +- Measure `withinGroup` — preserved in metric `custom_extensions` (`obml_within_group`) +- Metric `format` — preserved in metric `custom_extensions` (`obml_format`) +- Locale settings — not yet preserved +- `abstractType` (OBML type system) — preserved in field `custom_extensions` (`obml_abstract_type`) + +### 2.6 OSI-Specific Features and How They Map to OBML + +- **`primary_key`** — natively represented: OSI's dataset-level `primary_key` array maps to per-column `primaryKey: true` on OBML columns (`DataObjectColumn.primaryKey`), and back to the dataset array on export. +- **`unique_keys`** — no native OBML equivalent; round-trips via an `OSI`-vendor `customExtension` (`obml_unique_keys`). +- **Multi-dialect expressions** — on import the converter reads the first available SQL dialect in the order `ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`; non-SQL dialects (`MDX`, `TABLEAU`, `MAQL`) are not parsed. A metric with no SQL-parseable dialect, or an expression OBML cannot decompose, is preserved verbatim (`obml_unconverted_metrics`) with a `LOSSY:` warning rather than dropped. On export, OBML measures/metrics emit `ANSI_SQL`. +- **`ai_context`** — preserved losslessly via `customExtensions` (see Section 2.4). +- **`custom_extensions`** — mapped to OBML `customExtensions`. + +## 3. Conversion Strategies + +### 3.1 OSI → OBML + +1. Parse `source` string to extract `database`, `schema`, and `table` +2. Convert fields to columns with type inference (heuristic-based `abstractType`) +3. Restructure global relationships into inline joins on data objects +4. Decompose metric SQL expressions into OBML measures + metrics +5. Extract dimension-flagged fields into the top-level `dimensions` section (excluding FK/PK join keys) +6. Preserve `ai_context` losslessly via `customExtensions` (vendor: `"OSI"`) + +### 3.2 OBML → OSI + +1. Combine `database.schema.code` into the OSI `source` string +2. Convert columns to fields with `ANSI_SQL` dialect expressions +3. Extract inline joins into global relationships with generated names +4. Convert measures to OSI metrics with SQL expressions +5. Expand metric templates by substituting measure SQL into `{[Name]}` references +6. Map OBML dimension metadata into `field.dimension.is_time` flags +7. Preserve secondary join info in relationship `ai_context` +8. Store OBML-specific type info in `custom_extensions` with `vendor_name: "COMMON"` + +## 4. Validation + +The converter includes dual-layer validation for both formats, ensuring that converted output is structurally and semantically correct. + +### 4.1 OBML Validation + +1. **JSON Schema** — validates against `schema/obml-schema.json` (Draft 7) +2. **Semantic** — runs OrionBelt's `ReferenceResolver` + `SemanticValidator` (reference integrity, cycle detection, multipath detection, duplicate identifiers) + +### 4.2 OSI Validation + +1. **JSON Schema** — validates against the OSI core `osi-schema.json` (Draft 2020-12), resolved from the repo's `core-spec/` (no vendored copy) +2. **Unique names** — checks uniqueness of dataset, field, metric, and relationship names +3. **References** — verifies that relationship `from`/`to` reference existing datasets + +Validation runs automatically after each conversion. Use `--no-validate` to skip. + +## 5. Converter Usage + +### CLI + +A single `ossie-orionbelt` command with two subcommands is installed with the package: + +```bash +# OSI → OBML +ossie-orionbelt osi-to-obml -i tpcds_osi.yaml -o tpcds_as_obml.yaml + +# OBML → OSI +ossie-orionbelt obml-to-osi -i tpcds_as_obml.yaml -o tpcds_obml_as_osi.yaml \ + --model-name tpcds_retail_model \ + --description "TPC-DS retail semantic model" + +# OBML → OSI ontology document +ossie-orionbelt obml-to-osi --ontology -i tpcds_as_obml.yaml -o tpcds_ontology.yaml + +# Skip validation +ossie-orionbelt osi-to-obml -i input.yaml -o output.yaml --no-validate +``` + +### CLI Options + +| Subcommand / Option | Description | +|---|---| +| `osi-to-obml` | Convert OSI → OBML | +| `obml-to-osi` | Convert OBML → OSI | +| `--ontology` | (`obml-to-osi`) emit an OSI ontology document instead of core-spec | +| `-i`, `--input` | Input file (required) | +| `-o`, `--output` | Output file (required) | +| `--model-name` | Model name for OBML → OSI | +| `--description` | Model description for OBML → OSI | +| `--ai-instructions` | AI instructions for OBML → OSI | +| `--database` | Default database for OSI → OBML (default: `ANALYTICS`) | +| `--schema` | Default schema for OSI → OBML (default: `PUBLIC`) | +| `--no-validate` | Skip post-conversion validation | + +### Python API + +```python +from ossie_orionbelt import OSItoOBML, OBMLtoOSI, validate_obml, validate_osi + +# OSI → OBML +converter = OSItoOBML(osi_dict) +obml = converter.convert() +result = validate_obml(obml) +assert result.valid + +# OBML → OSI +converter = OBMLtoOSI(obml_dict, model_name="my_model") +osi = converter.convert() +result = validate_osi(osi) +assert result.valid +``` + +## 6. Example: TPC-DS Roundtrip + +The converter is validated against the official [TPC-DS example](https://github.com/open-semantic-interchange/OSI/blob/main/examples/tpcds_semantic_model.yaml) from the OSI repository. That file is vendored at `tests/fixtures/tpcds_semantic_model.yaml` and exercised by `tests/test_osi_tpcds_baseline.py`, which runs the OSI converters guide's [conceptual conversion flow](https://github.com/open-semantic-interchange/OSI/blob/main/converters/index.md#example-conceptual-conversion-flow) end to end: OSI to OBML to OSI, asserting validity at each step and that the example's `SALESFORCE` and `DBT` custom extensions survive the round-trip (step 7). + +### OSI → OBML + +The TPC-DS OSI model with 5 datasets, 4 relationships, and 5 metrics converts cleanly to OBML: + +- 5 data objects with inline joins +- 16 dimensions (FK/PK join keys excluded) +- 5 measures (3 direct + 2 auto-generated for metric decomposition) +- 2 metrics (composite expressions referencing measures) +- All `ai_context` synonyms preserved via `customExtensions` + +### OBML → OSI (Roundtrip) + +Converting the OBML output back to OSI produces a valid OSI model where: + +- All `ai_context` synonyms are restored from `customExtensions` +- Measures are re-expanded into SQL metric expressions +- Inline joins are extracted back into global relationships +- OBML type information is preserved in `custom_extensions` + +### Files + +| File | Description | +|---|---| +| `tests/fixtures/tpcds_osi.yaml` | Official TPC-DS OSI example (from OSI repo) | +| `tests/fixtures/tpcds_as_obml.yaml` | Converted OBML output | +| `core-spec/osi-schema.json` | OSI core JSON Schema (Draft 2020-12), resolved at runtime; not vendored | +| `src/ossie_orionbelt/converter.py` | Bidirectional converter with validation | + +## 7. Future Considerations + +- **MCP/API integration** — Expose OSI import/export as OrionBelt MCP tools or REST API endpoints +- **Multi-dialect support** — Preserve non-ANSI dialect expressions during roundtrip +- **Primary keys in OBML** — Add optional `primary_key` to data objects for richer metadata +- **Vendor enum** — Register `"ORIONBELT"` as an OSI vendor for OBML-specific extensions diff --git a/converters/orionbelt/osi_obml_ontology_mapping_analysis.md b/converters/orionbelt/osi_obml_ontology_mapping_analysis.md new file mode 100644 index 0000000..0764bf1 --- /dev/null +++ b/converters/orionbelt/osi_obml_ontology_mapping_analysis.md @@ -0,0 +1,82 @@ +# OBML → OSI Ontology Mapping Analysis + +This pins the rules used by `OBMLtoOSIOntology` to derive an **OSI ontology +document** (OSI ontology format, version `0.2.0.dev0`) from an OBML semantic +model. + +The OSI ontology is a **separate document** from the OSI core-spec semantic +model (different `$id`, different required root). It is produced alongside the +core export, never merged into it — OSI's own `validation/validate.py` validates +one document against one schema and reads only the first YAML document, so a +combined or multi-doc file is not portable. See the `include_ontology` flag on +the export endpoints. + +## Document shape produced + +```yaml +version: 0.2.0.dev0 +name: # required +description: # optional +ai_context: { instructions: ... } # optional, from ai_instructions / model +ontology: # required, minItems 1 + - concept: + name: # = OBML dataObject display name + type: EntityType + description: ... + relationships: # outgoing joins keyed by this entity + - name: _to_ + roles: [{ concept: }] # declaring concept (A) is the implicit first role + multiplicity: ManyToOne | OneToOne + verbalizes: ["{} relates to {}"] +ontology_mappings: + - name: _map + semantic_model: { ...full OSI core-spec model... } # reused from OBMLtoOSI + concept_mappings: + - concept: + object_mappings: [{ expression: "." }] + link_mappings: + - relationship: _to_ + object_mapping: { concept: , expression: "." } +``` + +## Mapping rules + +| OBML construct | OSI ontology target | Notes | +|----------------|---------------------|-------| +| `dataObject` | `EntityType` `Concept` (one per object) | name = display name (matches OSI dataset name for ref consistency) | +| `dataObject.description` / `.comment` | `concept.description` | first non-empty wins | +| `join` (A → B) | `Relationship` under A's component | declaring concept A is the implicit first role; B is an explicit `role` | +| `join.joinType` | `Relationship.multiplicity` | `many-to-one`→`ManyToOne`, `one-to-one`→`OneToOne` | +| `column.primaryKey` | entity `object_mappings[].expression` | `
.`; identifies the entity | +| `join.columnsFrom` (FK) | `link_mappings[].object_mapping.expression` | `.`; binds the relationship to its far role | +| whole core model | `ontology_mappings[].semantic_model` | embedded verbatim from `OBMLtoOSI.convert()` | + +`
` is the final identifier of the dataset `source` (e.g. `db.schema.t` → `t`), +falling back to the dataset name when `source` has no dotted physical table. + +## Gaps and warnings (emitted to `warnings`) + +| OBML construct | Handling | Reason | +|----------------|----------|--------| +| `joinType: many-to-many` | relationship **skipped** + warning | OSI `Multiplicity` enum is only `ManyToOne`/`OneToOne` | +| missing/unknown `joinType` | defaults to `ManyToOne` + warning | matches core-converter default | +| secondary / `pathName` joins | emitted as ordinary relationships + warning | OSI ontology has no named-alternate-path concept | +| composite primary/foreign keys | first column used + warning | `object_mapping.expression` is a single scalar SQL expression | +| measures / metrics | **not** in the ontology layer | live only in the embedded core `semantic_model`; ontology models entities/relationships | +| columns (non-key) as value concepts | not modeled (entities only) | keeps v1 valid and focused; ORM-style `ValueType` modeling deferred | +| `verbalizes` / `derived_by` / `requires` / `identify_by` | `verbalizes` emitted as a generated stub; others omitted | OBML has no native source for fact-based verbalization or derivation | + +## Validation + +`validate_osi_ontology()` runs semantic checks only (ossie ships no OSI ontology +schema, and the converter emits ontology documents but never consumes them): +unique concept names; relationship `roles` reference defined concepts; +`concept_mappings` reference defined concepts. + +## Stability note + +OSI ontology is `0.2.0.dev0` (pre-1.0). This exporter is the supported, +schema-validated direction. An ontology **importer** (OSI ontology → OBML) is +intentionally deferred until OSI drops the `dev` pre-release suffix, because the +gaps above make the reverse direction lossy. Import the OSI **core spec** +instead (already supported). diff --git a/converters/orionbelt/pyproject.toml b/converters/orionbelt/pyproject.toml new file mode 100644 index 0000000..7305fac --- /dev/null +++ b/converters/orionbelt/pyproject.toml @@ -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. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "mypy>=1.10", + "ruff>=0.4", + "types-jsonschema", + "types-PyYAML", +] + +[project] +name = "apache-ossie-orionbelt" +version = "0.2.0.dev0" +description = "Bidirectional OBML <-> OSI (Open Semantic Interchange) converter for OrionBelt semantic models" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] +requires-python = ">=3.12" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", + "orionbelt" +] +dependencies = [ + "pyyaml>=6.0", + "jsonschema>=4.18", + "referencing>=0.30", +] + +[project.optional-dependencies] +# Deep OBML semantic validation (cycles, duplicate names, invalid refs) via the +# full OrionBelt engine. Optional: validate_obml degrades to JSON-schema-only +# checks with a warning when this is not installed. +obml-validation = ["orionbelt-semantic-layer"] + +[project.scripts] +ossie-orionbelt = "ossie_orionbelt.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_orionbelt"] + +# The OSI core schema is not tracked as a copy in this package (it lives once at +# the repo's core-spec/). Bundle it into the built wheel from that single source +# so a pip-installed converter still validates OSI documents self-contained. +# In-tree (dev/test) runs resolve it from core-spec/ via validation._osi_schema_path. +[tool.hatch.build.targets.wheel.force-include] +"../../core-spec/osi-schema.json" = "ossie_orionbelt/schemas/osi-schema.json" + +# Only the OBML schema (OrionBelt's own format) is vendored under +# src/ossie_orionbelt/schemas/. OSI core documents are validated against the +# repo's core-spec/osi-schema.json, resolved at runtime by +# validation._osi_schema_path (no duplicate copy). The OSI ontology export is +# validated semantically only (ossie ships no ontology schema). + +[tool.mypy] +python_version = "3.12" +files = ["src/ossie_orionbelt"] + +# The OrionBelt engine is an optional runtime dependency: validate_obml uses it +# for semantic validation when present and degrades gracefully otherwise, so it +# is not installed in this standalone converter and has no published stubs. +[[tool.mypy.overrides]] +module = ["orionbelt.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "B", "A", "SIM"] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] \ No newline at end of file diff --git a/converters/orionbelt/src/ossie_orionbelt/__init__.py b/converters/orionbelt/src/ossie_orionbelt/__init__.py new file mode 100644 index 0000000..3471599 --- /dev/null +++ b/converters/orionbelt/src/ossie_orionbelt/__init__.py @@ -0,0 +1,58 @@ +# 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. + +"""ossie-orionbelt: bidirectional OBML <-> OSI converter. + +Converts between OrionBelt Markup Language (OBML) semantic models and Open +Semantic Interchange (OSI) models, in both directions, plus an OSI ontology +emitter. Validation helpers check OBML and OSI documents against their JSON +schemas. + +Public API: + OSItoOBML - convert an OSI model dict to OBML + OBMLtoOSI - convert an OBML model dict to OSI core-spec + OBMLtoOSIOntology - emit an OSI ontology document from an OBML model + validate_obml - validate an OBML model dict + validate_osi - validate an OSI model dict + validate_osi_ontology - validate an OSI ontology document dict + ValidationResult - structured validation result +""" + +from __future__ import annotations + +from ossie_orionbelt.converter import ( + OBMLtoOSI, + OBMLtoOSIOntology, + OSItoOBML, + ValidationResult, + validate_obml, + validate_osi, + validate_osi_ontology, +) + +__version__ = "0.1.0" + +__all__ = [ + "OBMLtoOSI", + "OBMLtoOSIOntology", + "OSItoOBML", + "ValidationResult", + "validate_obml", + "validate_osi", + "validate_osi_ontology", + "__version__", +] diff --git a/converters/orionbelt/src/ossie_orionbelt/_common.py b/converters/orionbelt/src/ossie_orionbelt/_common.py new file mode 100644 index 0000000..f0e73a3 --- /dev/null +++ b/converters/orionbelt/src/ossie_orionbelt/_common.py @@ -0,0 +1,93 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared constants and mapping tables for the OSI ↔ OBML converter. + +These module-level constants are used by more than one of the converter +direction classes (``OSItoOBML``, ``OBMLtoOSI``, ``OBMLtoOSIOntology``) and the +validation helpers. They live here so both the facade ``converter`` module and +the per-direction class modules can import them without forming an import cycle. +""" + +from __future__ import annotations + +import re + +# ─── Spec version pin ─────────────────────────────────────────────────────── +# Single source of truth for the OSI spec we emit. Bump when upstream cuts +# a stable v0.2.0 (drop the ``.dev0`` suffix). All read paths accept both +# 0.1.x (via the legacy shim) and 0.2.x. +_OSI_VERSION = "0.2.0.dev0" + +# SQL dialects (of the OSI enum) whose aggregation expressions our regex-based +# metric parser can read, in preference order. ANSI_SQL first; SNOWFLAKE and +# DATABRICKS are SQL engines OrionBelt also targets, and their simple/expression +# aggregations (``SUM(t.c)``, ``SUM(t.a * t.b)``) are syntactically identical to +# ANSI. MDX / TABLEAU / MAQL are non-SQL languages and are never parsed as SQL. +_SQL_PARSEABLE_DIALECTS = ("ANSI_SQL", "SNOWFLAKE", "DATABRICKS") + +# Matches a ``dataset.column`` reference inside a SQL expression, where each +# side is a bare identifier or a quoted identifier (double quotes, backticks, or +# brackets). The leading lookbehind prevents matching the tail of a longer path +# (``a.b.c``) or a mid-token boundary; the bare form must start with a letter or +# underscore so numeric literals (``1.5``) are never treated as references. +_COLUMN_REF_RE = re.compile( + r'(?[A-Za-z_]\w*|"[^"]+"|`[^`]+`|\[[^\]]+\])' + r"\s*\.\s*" + r'(?P[A-Za-z_]\w*|"[^"]+"|`[^`]+`|\[[^\]]+\])' +) +# Vendor identities for custom_extensions. +# ORIONBELT - OrionBelt/OBML-proprietary payloads we author on OBML -> OSI. +# OSI - OSI-native fields OBML can't hold (unique_keys, field label, +# ai_context leftovers), stashed into OBML on OSI -> OBML. +# Read paths also accept the legacy tags we emitted before this scheme so older +# documents still round-trip; foreign vendors (SNOWFLAKE, DBT, ...) are +# preserved verbatim, never relabelled. +_VENDOR_OBML = "ORIONBELT" +_VENDOR_OSI = "OSI" +_OBML_VENDOR_READ = ("ORIONBELT", "COMMON") +_OSI_VENDOR_READ = ("OSI", "OBSL") +# Vendors the converter handles internally (its own payloads + native-field +# stashes). Any custom_extension from a vendor outside this set is third-party +# and is carried through verbatim in both directions, never relabelled. +_INTERNAL_VENDORS = frozenset({"ORIONBELT", "COMMON", "OSI", "OBSL"}) + +# ─── Type mapping ─────────────────────────────────────────────────────────── + +OBML_TO_OSI_TYPE = { + "string": "string", + "json": "string", + "int": "integer", + "float": "number", + "date": "date", + "time": "time", + "time_tz": "time", + "timestamp": "timestamp", + "timestamp_tz": "timestamp", + "boolean": "boolean", +} + +OSI_TO_OBML_TYPE = { + "string": "string", + "integer": "int", + "number": "float", + "date": "date", + "time": "time", + "timestamp": "timestamp", + "boolean": "boolean", +} diff --git a/converters/orionbelt/src/ossie_orionbelt/cli.py b/converters/orionbelt/src/ossie_orionbelt/cli.py new file mode 100644 index 0000000..ec70aef --- /dev/null +++ b/converters/orionbelt/src/ossie_orionbelt/cli.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. + +"""Command-line entry point for the OBML <-> OSI converter. + +A single ``ossie-orionbelt`` command with two format-named subcommands, mirroring +the OSI converter convention (e.g. ``osi-dbt msi-to-osi``): + + ossie-orionbelt obml-to-osi -i model.obml.yaml -o model.osi.yaml + ossie-orionbelt obml-to-osi --ontology -i model.obml.yaml -o model.ontology.yaml + ossie-orionbelt osi-to-obml -i model.osi.yaml -o model.obml.yaml + +Both subcommands print conversion warnings and a validation summary to stderr, +and exit non-zero when the produced document fails schema validation (unless +``--no-validate``). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +import yaml + +from ossie_orionbelt.converter import ( + OBMLtoOSI, + OBMLtoOSIOntology, + OSItoOBML, + validate_obml, + validate_osi, + validate_osi_ontology, +) + + +def _load(input_path: str) -> dict[str, Any]: + data = yaml.safe_load(Path(input_path).read_text()) + if not isinstance(data, dict): + print(f"Error: {input_path} is not a YAML mapping", file=sys.stderr) + raise SystemExit(2) + return data + + +def _emit(result: dict[str, Any], output: str) -> None: + output_yaml = yaml.dump( + result, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120 + ) + Path(output).write_text(output_yaml) + print(f"Written to {output}", file=sys.stderr) + + +def _print_warnings(warnings: list[str]) -> None: + if warnings: + print("\nConversion warnings:", file=sys.stderr) + for w in warnings: + print(f" - {w}", file=sys.stderr) + + +def _report_validation(label: str, result: dict[str, Any], validate_fn: Any) -> bool: + """Validate ``result`` and print a summary. Return True if there are errors.""" + print(f"\nValidating {label}...", file=sys.stderr) + vr = validate_fn(result) + for line in vr.summary_lines(): + print(line, file=sys.stderr) + if vr.valid: + print(f"{label} is valid", file=sys.stderr) + return False + print(f"{label} has validation errors", file=sys.stderr) + return True + + +def _cmd_obml_to_osi(args: argparse.Namespace) -> int: + """OBML -> OSI core-spec (or, with --ontology, OSI ontology).""" + data = _load(args.input) + + validate_fn: Any + if args.ontology: + converter: Any = OBMLtoOSIOntology( + data, args.model_name, args.description, args.ai_instructions + ) + result = converter.convert() + validate_fn, label = validate_osi_ontology, "OSI ontology output" + else: + converter = OBMLtoOSI(data, args.model_name, args.description, args.ai_instructions) + result = converter.convert() + validate_fn, label = validate_osi, "OSI output" + + _emit(result, args.output) + _print_warnings(converter.warnings) + + if args.no_validate: + return 0 + return 1 if _report_validation(label, result, validate_fn) else 0 + + +def _cmd_osi_to_obml(args: argparse.Namespace) -> int: + """OSI core-spec -> OBML.""" + data = _load(args.input) + + converter = OSItoOBML(data, args.database, args.schema) + result = converter.convert() + + _emit(result, args.output) + _print_warnings(converter.warnings) + + if args.no_validate: + return 0 + return 1 if _report_validation("OBML output", result, validate_obml) else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="ossie-orionbelt", + description="Convert between OrionBelt OBML and OSI YAML.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + o2s = subparsers.add_parser("obml-to-osi", help="Convert OBML YAML → OSI YAML") + o2s.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to OBML YAML") + o2s.add_argument( + "-o", "--output", required=True, metavar="FILE", help="Path for output OSI YAML" + ) + o2s.add_argument( + "--ontology", action="store_true", help="Emit an OSI ontology document instead of core-spec" + ) + o2s.add_argument( + "--model-name", default="semantic_model", metavar="NAME", help="OSI semantic model name" + ) + o2s.add_argument("--description", default="", metavar="TEXT", help="OSI model description") + o2s.add_argument( + "--ai-instructions", default="", metavar="TEXT", help="OSI ai_context instructions" + ) + o2s.add_argument("--no-validate", action="store_true", help="Skip output validation") + + s2o = subparsers.add_parser("osi-to-obml", help="Convert OSI YAML → OBML YAML") + s2o.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to OSI YAML") + s2o.add_argument( + "-o", "--output", required=True, metavar="FILE", help="Path for output OBML YAML" + ) + s2o.add_argument( + "--database", default="ANALYTICS", metavar="NAME", help="Default database for OBML output" + ) + s2o.add_argument( + "--schema", default="PUBLIC", metavar="NAME", help="Default schema for OBML output" + ) + s2o.add_argument("--no-validate", action="store_true", help="Skip output validation") + + args = parser.parse_args(argv) + if args.command == "obml-to-osi": + return _cmd_obml_to_osi(args) + if args.command == "osi-to-obml": + return _cmd_osi_to_obml(args) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/converters/orionbelt/src/ossie_orionbelt/converter.py b/converters/orionbelt/src/ossie_orionbelt/converter.py new file mode 100644 index 0000000..ae3f5d7 --- /dev/null +++ b/converters/orionbelt/src/ossie_orionbelt/converter.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# 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. +""" +OSI ↔ OBML Bidirectional Converter +=================================== +Converts between Open Semantic Interchange (OSI v0.2.0.dev0) YAML models +and OrionBelt Markup Language (OBML v1.0) YAML models. + +OSI v0.1.1 inputs are still accepted on read — the legacy shim +``_normalize_legacy_v01`` promotes pre-v0.2 custom_extensions into the +v0.2 first-class fields before regular parsing runs. + +Author: OrionBelt / RALFORION + +This module is a thin **facade**. The converter implementation is split across +sibling modules to keep each file focused: + +* :mod:`ossie_orionbelt._common` — shared constants and mapping tables +* :mod:`ossie_orionbelt.osi_to_obml` — :class:`OSItoOBML` +* :mod:`ossie_orionbelt.obml_to_osi` — :class:`OBMLtoOSI` +* :mod:`ossie_orionbelt.ontology` — :class:`OBMLtoOSIOntology` +* :mod:`ossie_orionbelt.validation` — :class:`ValidationResult` + ``validate_*`` + +Every public name is re-exported here so ``ossie_orionbelt.converter.`` +continues to work unchanged. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + +from ossie_orionbelt._common import ( + _COLUMN_REF_RE as _COLUMN_REF_RE, +) +from ossie_orionbelt._common import ( + _INTERNAL_VENDORS as _INTERNAL_VENDORS, +) +from ossie_orionbelt._common import ( + _OBML_VENDOR_READ as _OBML_VENDOR_READ, +) +from ossie_orionbelt._common import ( + _OSI_VENDOR_READ as _OSI_VENDOR_READ, +) +from ossie_orionbelt._common import ( + _OSI_VERSION as _OSI_VERSION, +) +from ossie_orionbelt._common import ( + _SQL_PARSEABLE_DIALECTS as _SQL_PARSEABLE_DIALECTS, +) +from ossie_orionbelt._common import ( + _VENDOR_OBML as _VENDOR_OBML, +) +from ossie_orionbelt._common import ( + _VENDOR_OSI as _VENDOR_OSI, +) + +# Shared constants / mapping tables (re-exported for backwards compatibility). +# The ``X as X`` aliases mark these as intentional re-exports so historic +# ``from ossie_orionbelt.converter import `` imports keep working. +from ossie_orionbelt._common import ( + OBML_TO_OSI_TYPE as OBML_TO_OSI_TYPE, +) +from ossie_orionbelt._common import ( + OSI_TO_OBML_TYPE as OSI_TO_OBML_TYPE, +) +from ossie_orionbelt.obml_to_osi import OBMLtoOSI as OBMLtoOSI +from ossie_orionbelt.ontology import OBMLtoOSIOntology as OBMLtoOSIOntology +from ossie_orionbelt.osi_to_obml import OSItoOBML as OSItoOBML +from ossie_orionbelt.validation import ( + _OBML_SCHEMA_PATH as _OBML_SCHEMA_PATH, +) +from ossie_orionbelt.validation import ( + _OSI_SCHEMA_PATH as _OSI_SCHEMA_PATH, +) +from ossie_orionbelt.validation import ( + _SCHEMAS_DIR as _SCHEMAS_DIR, +) +from ossie_orionbelt.validation import ( + _SCRIPT_DIR as _SCRIPT_DIR, +) +from ossie_orionbelt.validation import ( + ValidationResult as ValidationResult, +) +from ossie_orionbelt.validation import ( + _validate_json_schema as _validate_json_schema, +) +from ossie_orionbelt.validation import ( + validate_obml as validate_obml, +) +from ossie_orionbelt.validation import ( + validate_osi as validate_osi, +) +from ossie_orionbelt.validation import ( + validate_osi_ontology as validate_osi_ontology, +) + +__all__ = [ + "OBML_TO_OSI_TYPE", + "OSI_TO_OBML_TYPE", + "OBMLtoOSI", + "OBMLtoOSIOntology", + "OSItoOBML", + "ValidationResult", + "validate_obml", + "validate_osi", + "validate_osi_ontology", + "main", +] + + +# ═══════════════════════════════════════════════════════════════════════════ +# CLI Entry Point +# ═══════════════════════════════════════════════════════════════════════════ + + +def main(): + parser = argparse.ArgumentParser(description="OSI ↔ OBML Bidirectional Converter") + parser.add_argument("direction", choices=["osi2obml", "obml2osi"], help="Conversion direction") + parser.add_argument("input", nargs="?", help="Input YAML file") + parser.add_argument("-o", "--output", help="Output YAML file") + parser.add_argument( + "--name", default="semantic_model", help="Model name for OBML→OSI conversion" + ) + parser.add_argument( + "--description", default="", help="Model description for OBML→OSI conversion" + ) + parser.add_argument( + "--ai-instructions", default="", help="AI instructions for OBML→OSI conversion" + ) + parser.add_argument( + "--database", default="ANALYTICS", help="Default database for OSI→OBML conversion" + ) + parser.add_argument("--schema", default="PUBLIC", help="Default schema for OSI→OBML conversion") + parser.add_argument( + "--no-validate", action="store_true", help="Skip OBML validation after conversion" + ) + + args = parser.parse_args() + + if not args.input: + parser.error("Input file is required for conversion") + + input_path = Path(args.input) + with open(input_path) as f: + data = yaml.safe_load(f) + + if args.direction == "osi2obml": + converter = OSItoOBML(data, args.database, args.schema) + result = converter.convert() + warnings = converter.warnings + else: + converter = OBMLtoOSI(data, args.name, args.description, args.ai_instructions) + result = converter.convert() + warnings = converter.warnings + + # Output + output_yaml = yaml.dump( + result, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120 + ) + + if args.output: + with open(args.output, "w") as f: + f.write(output_yaml) + print(f"✅ Converted to {args.output}") + else: + print(output_yaml) + + if warnings: + print("\n⚠️ Conversion warnings:", file=sys.stderr) + for w in warnings: + print(f" - {w}", file=sys.stderr) + + # ── Validate output ──────────────────────────────────────────────── + if not args.no_validate: + has_errors = False + + if args.direction == "osi2obml": + # Validate OBML output + print("\n🔍 Validating OBML output...", file=sys.stderr) + vr = validate_obml(result) + for line in vr.summary_lines(): + print(line, file=sys.stderr) + if vr.valid: + print("✅ OBML output is valid", file=sys.stderr) + else: + print("❌ OBML output has validation errors", file=sys.stderr) + has_errors = True + else: + # Validate OBML input (source) and OSI output + print("\n🔍 Validating OBML input...", file=sys.stderr) + vr_obml = validate_obml(data) + for line in vr_obml.summary_lines(): + print(line, file=sys.stderr) + if vr_obml.valid: + print("✅ OBML input is valid", file=sys.stderr) + else: + print("❌ OBML input has validation errors", file=sys.stderr) + has_errors = True + + print("\n🔍 Validating OSI output...", file=sys.stderr) + vr_osi = validate_osi(result) + for line in vr_osi.summary_lines(): + print(line, file=sys.stderr) + if vr_osi.valid: + print("✅ OSI output is valid", file=sys.stderr) + else: + print("❌ OSI output has validation errors", file=sys.stderr) + has_errors = True + + if has_errors: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/converters/orionbelt/src/ossie_orionbelt/obml_to_osi.py b/converters/orionbelt/src/ossie_orionbelt/obml_to_osi.py new file mode 100644 index 0000000..db9ce97 --- /dev/null +++ b/converters/orionbelt/src/ossie_orionbelt/obml_to_osi.py @@ -0,0 +1,1141 @@ +# 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. + +"""OBML → OSI conversion (the :class:`OBMLtoOSI` direction). + +Extracted verbatim from ``converter.py``; see that module for the package-level +docstring and the shared constants in :mod:`ossie_orionbelt._common`. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from ossie_orionbelt._common import ( + _INTERNAL_VENDORS, + _OSI_VENDOR_READ, + _OSI_VERSION, + _VENDOR_OBML, + OBML_TO_OSI_TYPE, +) + + +class OBMLtoOSI: + """Convert an OBML semantic model YAML to OSI format.""" + + def __init__( + self, + obml: dict, + model_name: str = "semantic_model", + model_description: str = "", + ai_instructions: str = "", + ): + self.obml = obml + self.model_name = model_name + self.model_description = model_description + self.ai_instructions = ai_instructions + self.warnings: list[str] = [] + + def convert(self) -> dict: + # Reset warnings so a second convert() call on the same instance does + # not duplicate them. + self.warnings = [] + + osi: dict[str, Any] = {"version": _OSI_VERSION} + + data_objects = self.obml.get("dataObjects", {}) + obml_dimensions = self.obml.get("dimensions", {}) + obml_measures = self.obml.get("measures", {}) + obml_metrics = self.obml.get("metrics", {}) + + # ── Datasets ──────────────────────────────────────────────── + datasets = [] + all_relationships = [] + + for do_name, do_obj in data_objects.items(): + dataset, rels = self._convert_data_object(do_name, do_obj, obml_dimensions) + datasets.append(dataset) + all_relationships.extend(rels) + + # ── Metrics (OBML measures + metrics → OSI metrics) ──────── + osi_metrics = self._convert_measures_and_metrics(obml_measures, obml_metrics, data_objects) + + # Re-emit OSI metrics that OBML could not represent and that the import + # path preserved verbatim (vendor OSI, ``obml_unconverted_metrics``). + # This closes the OSI -> OBML -> OSI roundtrip for non-SQL or + # non-decomposable metrics. + self._merge_restored_metrics(osi_metrics) + + # ── Build semantic model ──────────────────────────────────── + sem_model: dict[str, Any] = {"name": self.model_name} + # Prefer OBML model-level description, fall back to constructor param + obml_description = self.obml.get("description", "") + model_desc = obml_description or self.model_description + if model_desc: + sem_model["description"] = model_desc + if self.ai_instructions: + sem_model["ai_context"] = {"instructions": self.ai_instructions} + + sem_model["datasets"] = datasets + + if all_relationships: + sem_model["relationships"] = all_relationships + + if osi_metrics: + sem_model["metrics"] = osi_metrics + + # Add OBML as custom extension for lossless roundtrip info + roundtrip_data: dict[str, Any] = { + "source_format": "OBML", + "source_version": str(self.obml.get("version", "1.0")), + "converter": "ossie-orionbelt", + } + # Preserve model-level static filters for roundtrip + obml_filters = self.obml.get("filters", []) + if obml_filters: + roundtrip_data["obml_filters"] = obml_filters + # Preserve model settings for roundtrip + obml_settings = self.obml.get("settings") + if obml_settings: + roundtrip_data["obml_settings"] = obml_settings + # Preserve model owner for roundtrip + obml_owner = self.obml.get("owner") + if obml_owner: + roundtrip_data["obml_owner"] = obml_owner + # Preserve count-synthesis knobs (OSI has no native equivalent). The + # synthesized ``.count`` measures themselves are NOT emitted — + # they are derived and regenerate on load — but the knobs must survive + # a roundtrip. ``is not None`` so an explicit ``exposeCounts: false`` is + # preserved (``False`` is falsy). + expose_counts = self.obml.get("exposeCounts") + if expose_counts is not None: + roundtrip_data["obml_expose_counts"] = expose_counts + count_label_pattern = self.obml.get("countLabelPattern") + if count_label_pattern is not None: + roundtrip_data["obml_count_label_pattern"] = count_label_pattern + sem_model["custom_extensions"] = [ + { + "vendor_name": _VENDOR_OBML, + "data": json.dumps(roundtrip_data), + } + ] + # Re-emit third-party model-level vendor extensions verbatim + self._emit_foreign_extensions( + self.obml.get("customExtensions"), sem_model["custom_extensions"] + ) + + osi["semantic_model"] = [sem_model] + + # The published OSI core schema forbids root-level ``dialects`` / + # ``vendors`` (root is additionalProperties:false, only ``version`` + + # ``semantic_model``). Dialects live per-expression in + # ``expression.dialects[]`` and vendors per-entity in + # ``custom_extensions[].vendor_name`` — the schema-valid homes — so the + # document stays fully conformant without root advertisement arrays. + # See OSI PR #148 (and the single-document-dialect direction in #52). + return osi + + def _emit_foreign_extensions(self, obml_exts: list[dict] | None, osi_exts: list[dict]) -> None: + """Re-emit third-party OBML customExtensions as OSI custom_extensions. + + Mirrors ``OSItoOBML._carry_foreign_extensions``: extensions from a + vendor we do not handle internally are passed back to OSI under their + original vendor name, completing the roundtrip. + """ + for ext in obml_exts or []: + vendor = ext.get("vendor") + if vendor and vendor not in _INTERNAL_VENDORS: + osi_exts.append({"vendor_name": vendor, "data": ext.get("data", "")}) + + def _convert_data_object( + self, do_name: str, do_obj: dict, obml_dimensions: dict + ) -> tuple[dict, list]: + """Convert an OBML dataObject to an OSI dataset + relationships.""" + database = do_obj.get("database", "") + schema = do_obj.get("schema", "") + code = do_obj.get("code", "") + source = f"{database}.{schema}.{code}" if database else code + + # Use the OBML display name as the OSI dataset name so that + # relationship references (joinTo) stay consistent in roundtrips + osi_name = do_name + + dataset: dict[str, Any] = { + "name": osi_name, + "source": source, + } + + # ── Primary key (v0.2 first-class) ────────────────────────── + # Collect columns flagged with ``primaryKey: true`` in OBML order + # (TrackedLoader / Python dict preserves declaration order, which + # is significant for composite PKs). + pk_columns = [ + col_name + for col_name, col in (do_obj.get("columns", {}) or {}).items() + if col.get("primaryKey") + ] + # Use the physical ``code`` for each column when present — OSI + # field names mirror the physical column code (see _convert_column). + if pk_columns: + columns_map = do_obj.get("columns", {}) or {} + dataset["primary_key"] = [ + columns_map[c].get("code", c.lower().replace(" ", "_")) for c in pk_columns + ] + + # ── Unique keys (v0.2 first-class, lossless roundtrip via OBSL) ── + # OBML doesn't model unique keys natively today; round-trip via the + # ``OBSL``-vendor ``obml_unique_keys`` payload that originated from + # a prior OSI → OBML conversion (or hand-authored OBML). + unique_keys_extra: list[list[str]] | None = None + for ext in do_obj.get("customExtensions", []) or []: + if ext.get("vendor") not in _OSI_VENDOR_READ: + continue + try: + data = json.loads(ext.get("data", "{}")) + except (json.JSONDecodeError, TypeError): + continue + uk = data.get("obml_unique_keys") + if isinstance(uk, list) and all(isinstance(g, list) for g in uk): + unique_keys_extra = [list(g) for g in uk] + break + if unique_keys_extra: + dataset["unique_keys"] = unique_keys_extra + + if do_obj.get("description"): + dataset["description"] = do_obj["description"] + elif do_obj.get("comment"): + dataset["description"] = do_obj["comment"] + + # ── Rebuild ai_context: native synonyms + remaining from customExtensions + ai_ctx: dict[str, Any] = {} + for ext in do_obj.get("customExtensions", []): + if ext.get("vendor") == "OSI": + try: + ai_data = json.loads(ext.get("data", "{}")) + if ai_data: + ai_ctx.update(ai_data) + except (json.JSONDecodeError, TypeError): + pass + # Merge native OBML synonyms into ai_context.synonyms + obml_synonyms = do_obj.get("synonyms", []) + if obml_synonyms: + existing = ai_ctx.get("synonyms", []) + merged = list(existing) + [s for s in obml_synonyms if s not in existing] + ai_ctx["synonyms"] = merged + if ai_ctx: + dataset["ai_context"] = ai_ctx + + # ── Fields ────────────────────────────────────────────────── + fields = [] + columns = do_obj.get("columns", {}) + for col_name, col_obj in columns.items(): + field = self._convert_column(col_name, col_obj, do_name, obml_dimensions) + fields.append(field) + + if fields: + dataset["fields"] = fields + + # ── Relationships (from OBML joins) ───────────────────────── + relationships = [] + joins = do_obj.get("joins", []) + for i, join in enumerate(joins): + rel = self._convert_join_to_relationship(osi_name, do_name, do_obj, join, i) + if rel: + relationships.append(rel) + + # ── Preserve DataObject owner/comment + refresh in custom_extensions ── + do_extras: dict[str, Any] = {} + if do_obj.get("owner"): + do_extras["obml_owner"] = do_obj["owner"] + if do_obj.get("comment"): + do_extras["obml_comment"] = do_obj["comment"] + # OBML-only freshness contract — round-tripped through OSI + # custom_extensions since OSI has no native equivalent. See + # design/PLAN_freshness_driven_cache.md §5. + if do_obj.get("refresh"): + do_extras["obml_refresh"] = do_obj["refresh"] + # Count-synthesis knobs (``is not None`` so ``countable: false`` survives). + if do_obj.get("countable") is not None: + do_extras["obml_countable"] = do_obj["countable"] + if do_obj.get("countLabel") is not None: + do_extras["obml_count_label"] = do_obj["countLabel"] + if do_extras: + ds_exts = dataset.setdefault("custom_extensions", []) + ds_exts.append( + { + "vendor_name": _VENDOR_OBML, + "data": json.dumps(do_extras), + } + ) + + # Re-emit third-party vendor extensions verbatim + self._emit_foreign_extensions( + do_obj.get("customExtensions"), dataset.setdefault("custom_extensions", []) + ) + if not dataset["custom_extensions"]: + del dataset["custom_extensions"] + + return dataset, relationships + + def _convert_column( + self, col_name: str, col_obj: dict, do_name: str, obml_dimensions: dict + ) -> dict: + """Convert an OBML column to an OSI field.""" + code = col_obj.get("code", col_name.lower().replace(" ", "_")) + + field: dict[str, Any] = { + "name": code, + "expression": { + "dialects": [ + { + "dialect": "ANSI_SQL", + "expression": code, + } + ] + }, + } + + # Check if this column is used as a dimension + is_dimension = False + is_time = False + synonyms = [] + + for dim_name, dim_obj in obml_dimensions.items(): + if dim_obj.get("dataObject") == do_name and dim_obj.get("column") == col_name: + is_dimension = True + if dim_obj.get("resultType") in ("date", "time", "timestamp", "timestamp_tz"): + is_time = True + # The dimension display name is a synonym + if dim_name != col_name: + synonyms.append(dim_name) + break + + abstract_type = col_obj.get("abstractType", "string") + if abstract_type in ("date", "timestamp", "timestamp_tz"): + is_time = True + + if is_dimension or is_time: + field["dimension"] = {"is_time": is_time} + + if col_obj.get("description"): + field["description"] = col_obj["description"] + elif col_obj.get("comment"): + field["description"] = col_obj["comment"] + else: + field["description"] = col_name # Use display name as description + + # ── Field label (OSI v0.2 first-class) ── + # Surfaced from OBSL-vendor customExtensions ``obml_field_label`` — + # round-trip path for OSI → OBML → OSI fidelity. OBML has no + # native column ``label`` today. + for ext in col_obj.get("customExtensions", []) or []: + if ext.get("vendor") not in _OSI_VENDOR_READ: + continue + try: + ext_label_data = json.loads(ext.get("data", "{}")) + except (json.JSONDecodeError, TypeError): + continue + if ext_label_data.get("obml_field_label"): + field["label"] = ext_label_data["obml_field_label"] + break + + # Restore ai_context from customExtensions (OSI vendor) if present + ai_ctx: dict[str, Any] = {} + for ext in col_obj.get("customExtensions", []): + if ext.get("vendor") == "OSI": + try: + ai_data = json.loads(ext.get("data", "{}")) + if ai_data: + ai_ctx.update(ai_data) + except (json.JSONDecodeError, TypeError): + pass + + # Merge native OBML column synonyms into ai_context + obml_col_synonyms = col_obj.get("synonyms", []) + if obml_col_synonyms: + existing = ai_ctx.get("synonyms", []) + merged = list(existing) + [s for s in obml_col_synonyms if s not in existing] + ai_ctx["synonyms"] = merged + + # Build ai_context with synonyms from display name + display_synonym = col_name.lower() + code_clean = code.lower() + if display_synonym != code_clean: + synonyms.insert(0, col_name) + if synonyms: + ai_ctx.setdefault("synonyms", []).extend( + s for s in synonyms if s not in ai_ctx.get("synonyms", []) + ) + if ai_ctx: + field["ai_context"] = ai_ctx + + # Preserve OBML type info in custom_extensions for roundtrip fidelity + abstract_type = col_obj.get("abstractType", "string") + osi_type = OBML_TO_OSI_TYPE.get(abstract_type, "string") + ext_data: dict[str, Any] = { + "data_type": osi_type, + "obml_abstract_type": abstract_type, + } + # Preserve OBML-only column properties + if col_obj.get("sqlType"): + ext_data["obml_sql_type"] = col_obj["sqlType"] + if col_obj.get("sqlPrecision") is not None: + ext_data["obml_sql_precision"] = col_obj["sqlPrecision"] + if col_obj.get("sqlScale") is not None: + ext_data["obml_sql_scale"] = col_obj["sqlScale"] + if col_obj.get("numClass"): + ext_data["obml_num_class"] = col_obj["numClass"] + if col_obj.get("comment"): + ext_data["obml_comment"] = col_obj["comment"] + if col_obj.get("owner"): + ext_data["obml_owner"] = col_obj["owner"] + # Preserve OBML-only dimension properties (timeGrain, format, resultType, etc.) + matched_dim: dict[str, Any] | None = None + for _dim_name, dim_obj in obml_dimensions.items(): + if dim_obj.get("dataObject") == do_name and dim_obj.get("column") == col_name: + matched_dim = dim_obj + if dim_obj.get("timeGrain"): + ext_data["obml_time_grain"] = dim_obj["timeGrain"] + if dim_obj.get("format"): + ext_data["obml_dimension_format"] = dim_obj["format"] + if dim_obj.get("resultType"): + ext_data["obml_dimension_result_type"] = dim_obj["resultType"] + if dim_obj.get("description"): + ext_data["obml_dimension_description"] = dim_obj["description"] + if dim_obj.get("owner"): + ext_data["obml_dimension_owner"] = dim_obj["owner"] + if dim_obj.get("via"): + ext_data["obml_dimension_via"] = dim_obj["via"] + break + field["custom_extensions"] = [ + { + "vendor_name": _VENDOR_OBML, + "data": json.dumps(ext_data), + } + ] + + # Re-emit third-party vendor extensions verbatim. OSI has no separate + # dimension entity, so a matched dimension's foreign extensions surface + # on the field too (they re-import onto the column). + self._emit_foreign_extensions(col_obj.get("customExtensions"), field["custom_extensions"]) + if matched_dim is not None: + self._emit_foreign_extensions( + matched_dim.get("customExtensions"), field["custom_extensions"] + ) + + return field + + def _convert_join_to_relationship( + self, osi_from_name: str, _obml_from_name: str, from_do: dict, join: dict, index: int + ) -> dict | None: + """Convert an OBML join to an OSI relationship.""" + join_to_display = join.get("joinTo", "") + # Use the OBML display name as the OSI target name (consistent with + # _convert_data_object which uses display name as OSI dataset name) + to_name = join_to_display + target_do = self.obml.get("dataObjects", {}).get(join_to_display, {}) + + # Map column display names to codes + from_columns_display = join.get("columnsFrom", []) + to_columns_display = join.get("columnsTo", []) + + from_cols = self._resolve_column_codes(from_do, from_columns_display) + to_cols = self._resolve_column_codes(target_do, to_columns_display) + + # Generate relationship name + path_name = join.get("pathName", "") + if path_name: + rel_name = f"{osi_from_name}_to_{to_name}_{path_name}" + else: + rel_name = f"{osi_from_name}_to_{to_name}" + if index > 0: + rel_name += f"_{index}" + + rel: dict[str, Any] = { + "name": rel_name, + "from": osi_from_name, + "to": to_name, + "from_columns": from_cols, + "to_columns": to_cols, + } + + # Preserve secondary join info in ai_context + if join.get("secondary"): + rel["ai_context"] = { + "instructions": ( + f"Secondary/alternative join path" + f"{(' named: ' + path_name) if path_name else ''}. " + f"Use only when explicitly needed." + ) + } + + return rel + + def _resolve_column_codes(self, do_obj: dict, col_display_names: list) -> list: + """Resolve OBML column display names to their code values.""" + columns = do_obj.get("columns", {}) + codes = [] + for display in col_display_names: + col = columns.get(display, {}) + codes.append(col.get("code", display.lower().replace(" ", "_"))) + return codes + + def _restore_unconverted_metrics(self) -> list[dict]: + """Recover OSI metrics preserved verbatim during OSI -> OBML import. + + The import path stashes metrics OBML can't represent under an OSI-vendor + model-level customExtension (``obml_unconverted_metrics``). Re-emit them + unchanged so a full OSI -> OBML -> OSI roundtrip keeps them. + """ + restored: list[dict] = [] + for ext in self.obml.get("customExtensions", []) or []: + if ext.get("vendor") not in _OSI_VENDOR_READ: + continue + try: + data = json.loads(ext.get("data", "{}")) + except (json.JSONDecodeError, TypeError): + continue + preserved = data.get("obml_unconverted_metrics") + if isinstance(preserved, list): + restored.extend(m for m in preserved if isinstance(m, dict)) + return restored + + def _merge_restored_metrics(self, osi_metrics: list[dict]) -> None: + """Append preserved (unconverted) OSI metrics to the converted ones. + + Name-collision guard: if a queryable OBML measure/metric now owns a name + a stale preserved metric also uses, skip the preserved copy (the real + metric wins) so the OSI output has no duplicate metric names and passes + semantic validation. Each appended metric carries its own dialects + (``expression.dialects[]``) and vendors (``custom_extensions``) + verbatim, which are the schema-valid homes for that metadata. + """ + existing = {m.get("name") for m in osi_metrics if isinstance(m, dict)} + for restored in self._restore_unconverted_metrics(): + name = restored.get("name") + if name in existing: + self.warnings.append( + f"Preserved OSI metric '{name}' dropped on export: a converted " + f"OBML metric now uses that name." + ) + continue + existing.add(name) + osi_metrics.append(restored) + + def _convert_measures_and_metrics( + self, obml_measures: dict, obml_metrics: dict, data_objects: dict + ) -> list: + """Convert OBML measures and metrics to OSI metrics.""" + osi_metrics = [] + + # Convert each OBML measure to an OSI metric + for measure_name, measure_obj in obml_measures.items(): + osi_metric = self._convert_measure(measure_name, measure_obj, data_objects) + if osi_metric: + self._carry_foreign_to_osi_metric(measure_obj, osi_metric) + osi_metrics.append(osi_metric) + + # Convert OBML metrics (which reference measures) to OSI metrics + for metric_name, metric_obj in obml_metrics.items(): + if metric_obj.get("type") == "cumulative": + osi_metric = self._convert_obml_cumulative_metric( + metric_name, metric_obj, obml_measures, data_objects + ) + elif metric_obj.get("type") == "period_over_period": + osi_metric = self._convert_obml_pop_metric( + metric_name, metric_obj, obml_measures, data_objects + ) + elif metric_obj.get("type") == "window": + osi_metric = self._convert_obml_window_metric( + metric_name, metric_obj, obml_measures, data_objects + ) + else: + osi_metric = self._convert_obml_metric( + metric_name, metric_obj, obml_measures, data_objects + ) + if osi_metric: + self._carry_foreign_to_osi_metric(metric_obj, osi_metric) + osi_metrics.append(osi_metric) + + return osi_metrics + + def _carry_foreign_to_osi_metric(self, obml_obj: dict, osi_metric: dict) -> None: + """Re-emit third-party vendor extensions on an OBML measure/metric to + the OSI metric, dropping the key again if nothing foreign was added.""" + self._emit_foreign_extensions( + obml_obj.get("customExtensions"), osi_metric.setdefault("custom_extensions", []) + ) + if not osi_metric["custom_extensions"]: + del osi_metric["custom_extensions"] + + def _convert_measure(self, name: str, measure: dict, data_objects: dict) -> dict | None: + """Convert an OBML measure to an OSI metric.""" + + columns = measure.get("columns", []) + agg = measure.get("aggregation", "sum").upper() + distinct = measure.get("distinct", False) + obml_synonyms = measure.get("synonyms", []) + + # Build ai_context with synonyms (name + native OBML synonyms) + ai_synonyms = [name] + [s for s in obml_synonyms if s != name] + ai_ctx: dict[str, Any] = {"synonyms": ai_synonyms} if ai_synonyms else {} + + # ``aggregation: measure`` delegates resolution to the engine + # (Databricks Metric View) — there is no source column to read + # and no ANSI_SQL expression to emit. OSI has no first-class + # concept for engine-delegated aggregation, so we serialize the + # measure as an OSI metric whose expression is the literal + # ``MEASURE(" *
CalculatedN/AMODEL.semanticCalculatedDimensions
* - * @param osiDataset The OSI dataset + * @param osiDataset The Ossie dataset * @param sfDataObject The Salesforce data object to add direct fields to * @param outputData The Salesforce model for adding calculated dimensions */ @@ -267,7 +286,7 @@ private void processFieldsForDataset( for (Object osiFieldObj : osiFields) { Map osiField = asMap(osiFieldObj); - // Determine field type based on OSI structure + // Determine field type based on Ossie structure boolean hasDimension = osiField.containsKey(DIMENSION); ExpressionInfo expressionInfo = unwrapExpression(osiField); @@ -316,7 +335,7 @@ private void processFieldsForDataset( * Maps field properties based on whether the field is calculated. * Includes common properties plus type-specific properties. * - * @param osiField The OSI field + * @param osiField The Ossie field * @param expression The extracted expression string * @return A map with Salesforce field properties */ @@ -343,10 +362,10 @@ private Map mapFieldProperties( } /** - * Creates a Salesforce semanticCalculatedDimension from an OSI field with a calculated expression. + * Creates a Salesforce semanticCalculatedDimension from an Ossie field with a calculated expression. * Per schema: required properties are apiName and expression. * - * @param osiField The OSI field + * @param osiField The Ossie field * @param expression The calculated expression * @return A map representing a semanticCalculatedDimension */ @@ -422,10 +441,10 @@ private record RoutingResult(List dataObjectDimensions, List dat private record ExpressionInfo(String expression, String dialect) {} /** - * Extracts the expression value and dialect from OSI field's expression.dialects[0].expression. + * Extracts the expression value and dialect from Ossie field's expression.dialects[0].expression. * This unwraps the nested structure to get the simple column reference and its dialect. * - * @param osiField The OSI field containing expression structure + * @param osiField The Ossie field containing expression structure * @return ExpressionInfo containing the expression string and dialect type, or null if not found */ private ExpressionInfo unwrapExpression(Map osiField) { @@ -532,7 +551,7 @@ private void applyFieldDefaults(Map sfField) { * * * @param sourceData The source Salesforce data (will be modified to remove converted dimensions) - * @param outputData The output OSI data containing datasets + * @param outputData The output Ossie data containing datasets */ private void processModelLevelCalculatedDimensions( Map sourceData, Map outputData) { @@ -615,11 +634,11 @@ private String getSingleDataObjectFromDependencies(List dependencies) { } /** - * Converts a Salesforce semanticCalculatedDimension to an OSI field with dimension property. + * Converts a Salesforce semanticCalculatedDimension to an Ossie field with dimension property. * Similar to convertDimensionToOsiField but handles expression (not dataObjectFieldName). * * @param calcDim The Salesforce calculated dimension - * @return An OSI field map with dimension property and wrapped expression + * @return An Ossie field map with dimension property and wrapped expression */ private Map convertCalculatedDimensionToField(Map calcDim) { Map osiField = new LinkedHashMap<>(); diff --git a/converters/salesforce/src/main/java/org/osi/converter/GenericMappingEngine.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/GenericMappingEngine.java similarity index 82% rename from converters/salesforce/src/main/java/org/osi/converter/GenericMappingEngine.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/GenericMappingEngine.java index 86abc7d..2c71b6e 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/GenericMappingEngine.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/GenericMappingEngine.java @@ -1,9 +1,28 @@ -package org.osi.converter; +/* + * 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. + */ + +package org.apache.ossie.converter; -import static org.osi.util.DataStructureUtils.*; +import static org.apache.ossie.util.DataStructureUtils.*; -import org.osi.util.MappingUtils; -import org.osi.util.PathUtils; +import org.apache.ossie.util.MappingUtils; +import org.apache.ossie.util.PathUtils; import java.util.LinkedHashMap; import java.util.List; diff --git a/converters/salesforce/src/main/java/org/osi/converter/MetricMappingHandler.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricMappingHandler.java similarity index 70% rename from converters/salesforce/src/main/java/org/osi/converter/MetricMappingHandler.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/MetricMappingHandler.java index 4d1a9da..c54d46a 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/MetricMappingHandler.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricMappingHandler.java @@ -1,23 +1,42 @@ -package org.osi.converter; +/* + * 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. + */ + +package org.apache.ossie.converter; -import static org.osi.converter.ConverterConstants.*; -import static org.osi.util.DataStructureUtils.*; +import static org.apache.ossie.converter.ConverterConstants.*; +import static org.apache.ossie.util.DataStructureUtils.*; -import org.osi.converter.ConverterConstants.Level; -import org.osi.converter.pipeline.PipelineStep; +import org.apache.ossie.converter.ConverterConstants.Level; +import org.apache.ossie.converter.pipeline.PipelineStep; import java.util.*; -import org.osi.util.MappingUtils; +import org.apache.ossie.util.MappingUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Bidirectional handler for mapping metrics between OSI and Salesforce formats. + * Bidirectional handler for mapping metrics between Ossie and Salesforce formats. * *

Supports both conversion directions: *

    - *
  • OSI → Salesforce: unwrap expression from dialects structure
  • - *
  • Salesforce → OSI: wrap expression in dialects structure
  • + *
  • Ossie → Salesforce: unwrap expression from dialects structure
  • + *
  • Salesforce → Ossie: wrap expression in dialects structure
  • *
* */ @@ -47,7 +66,7 @@ public void execute(Map sourceData, Map outputDa } /** - * Maps OSI metrics to Salesforce semanticCalculatedMeasurements. + * Maps Ossie metrics to Salesforce semanticCalculatedMeasurements. */ private void mapOsiToSalesforce( Map sourceData, Map outputData, Map mappings) { @@ -61,11 +80,11 @@ private void mapOsiToSalesforce( Map metricMappings = MappingUtils.filterMappingsByPrefix(mappings, METRICS); metricMappings.keySet().forEach(mappings::remove); - logger.debug("Metrics are not mapped in OSI to Salesforce direction"); + logger.debug("Metrics are not mapped in Ossie to Salesforce direction"); } /** - * Maps Salesforce semanticCalculatedMeasurements to OSI metrics. + * Maps Salesforce semanticCalculatedMeasurements to Ossie metrics. */ private void mapSalesforceToOsi( Map sourceData, Map outputData, Map mappings) { @@ -100,7 +119,7 @@ private void mapSalesforceToOsi( /** - * Wraps expressions for SF→OSI conversion. + * Wraps expressions for SF→Ossie conversion. */ private void wrapExpressions(List sfMetrics, List osiMetrics) { for (int i = 0; i < sfMetrics.size() && i < osiMetrics.size(); i++) { @@ -110,14 +129,14 @@ private void wrapExpressions(List sfMetrics, List osiMetrics) { // Get expression from SF metric String expressionValue = getString(sfMetric, EXPRESSION); if (expressionValue != null) { - // Wrap in OSI dialect structure + // Wrap in Ossie dialect structure osiMetric.put(EXPRESSION, wrapExpression(expressionValue)); } } } /** - * Wraps a simple expression string in OSI's expression.dialects structure. + * Wraps a simple expression string in Ossie's expression.dialects structure. * Tags expressions with TABLEAU dialect as they come from Salesforce (Tableau CRM). */ private Map wrapExpression(String expressionValue) { diff --git a/converters/salesforce/src/main/java/org/osi/converter/RelationshipMappingHandler.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/RelationshipMappingHandler.java similarity index 90% rename from converters/salesforce/src/main/java/org/osi/converter/RelationshipMappingHandler.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/RelationshipMappingHandler.java index cd669a6..92f67d0 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/RelationshipMappingHandler.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/RelationshipMappingHandler.java @@ -1,23 +1,42 @@ -package org.osi.converter; +/* + * 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. + */ + +package org.apache.ossie.converter; -import static org.osi.converter.ConverterConstants.*; -import static org.osi.util.DataStructureUtils.*; +import static org.apache.ossie.converter.ConverterConstants.*; +import static org.apache.ossie.util.DataStructureUtils.*; -import org.osi.converter.ConverterConstants.Level; -import org.osi.converter.pipeline.PipelineStep; +import org.apache.ossie.converter.ConverterConstants.Level; +import org.apache.ossie.converter.pipeline.PipelineStep; import java.util.*; -import org.osi.util.MappingUtils; +import org.apache.ossie.util.MappingUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Bidirectional handler for mapping relationships between OSI and Salesforce formats. + * Bidirectional handler for mapping relationships between Ossie and Salesforce formats. * *

Supports both conversion directions: *

    - *
  • OSI → Salesforce: from/to/from_columns/to_columns → criteria array
  • - *
  • Salesforce → OSI: criteria array → from/to/from_columns/to_columns
  • + *
  • Ossie → Salesforce: from/to/from_columns/to_columns → criteria array
  • + *
  • Salesforce → Ossie: criteria array → from/to/from_columns/to_columns
  • *
* */ @@ -44,7 +63,7 @@ public void execute(Map sourceData, Map outputDa } /** - * Maps OSI relationships to Salesforce semanticRelationships. + * Maps Ossie relationships to Salesforce semanticRelationships. */ private void mapOsiToSalesforce( Map sourceData, Map outputData, Map mappings) { @@ -84,7 +103,7 @@ private void mapOsiToSalesforce( } /** - * Maps Salesforce semanticRelationships to OSI relationships. + * Maps Salesforce semanticRelationships to Ossie relationships. */ private void mapSalesforceToOsi( Map sourceData, Map outputData, Map mappings) { @@ -147,7 +166,7 @@ private void mapSalesforceToOsi( } /** - * Reconstructs criteria for OSI→SF conversion. + * Reconstructs criteria for Ossie→SF conversion. */ private void reconstructCriteria(List osiRelationships, List sfRelationships) { for (int i = 0; i < osiRelationships.size() && i < sfRelationships.size(); i++) { @@ -200,7 +219,7 @@ private void reconstructCriteria(List osiRelationships, List sfR } /** - * Deconstructs criteria for SF→OSI conversion. + * Deconstructs criteria for SF→Ossie conversion. * Note: This method only processes supported relationships (TableField types). * Unsupported relationships are filtered out earlier and stored in custom_extensions. */ @@ -267,7 +286,7 @@ private void applyDefaults(List sfRelationships) { /** * Validates and filters relationships, removing those that reference non-existent fields. (Calculated fields that are not supported) * - * @param osiRelationships List of OSI relationships to validate + * @param osiRelationships List of Ossie relationships to validate * @param outputData The output data containing semanticDataObjects with their fields * @return Filtered list of valid relationships */ diff --git a/converters/salesforce/src/main/java/org/osi/converter/SemanticModelMappingHandler.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/SemanticModelMappingHandler.java similarity index 68% rename from converters/salesforce/src/main/java/org/osi/converter/SemanticModelMappingHandler.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/SemanticModelMappingHandler.java index 5bbfbbd..d6c997c 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/SemanticModelMappingHandler.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/SemanticModelMappingHandler.java @@ -1,27 +1,46 @@ -package org.osi.converter; +/* + * 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. + */ + +package org.apache.ossie.converter; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import org.osi.converter.ConverterConstants.Level; -import org.osi.converter.pipeline.PipelineStep; -import org.osi.exception.ConversionException; -import org.osi.util.MappingUtils; +import org.apache.ossie.converter.ConverterConstants.Level; +import org.apache.ossie.converter.pipeline.PipelineStep; +import org.apache.ossie.exception.ConversionException; +import org.apache.ossie.util.MappingUtils; import java.util.Map; import java.util.Set; -import static org.osi.converter.ConverterConstants.AI_CONTEXT; -import static org.osi.converter.ConverterConstants.API_NAME; -import static org.osi.converter.ConverterConstants.BUSINESS_PREFERENCES; -import static org.osi.converter.ConverterConstants.LABEL; +import static org.apache.ossie.converter.ConverterConstants.AI_CONTEXT; +import static org.apache.ossie.converter.ConverterConstants.API_NAME; +import static org.apache.ossie.converter.ConverterConstants.BUSINESS_PREFERENCES; +import static org.apache.ossie.converter.ConverterConstants.LABEL; /** * Bidirectional handler for mapping top-level semantic model properties. * *

Supports both conversion directions: *

    - *
  • OSI → Salesforce: name → apiName, apply SF defaults, restore custom_extensions
  • - *
  • Salesforce → OSI: apiName → name, strip SF defaults, store custom_extensions
  • + *
  • Ossie → Salesforce: name → apiName, apply SF defaults, restore custom_extensions
  • + *
  • Salesforce → Ossie: apiName → name, strip SF defaults, store custom_extensions
  • *
* *

Handles only top-level scalar properties (not arrays). @@ -49,7 +68,7 @@ public void execute(Map sourceData, Map outputDa } /** - * Maps OSI top-level properties to Salesforce format. + * Maps Ossie top-level properties to Salesforce format. * Steps: generic mappings → manual conversions → restore custom extensions */ private void mapOsiToSalesforce( @@ -66,7 +85,7 @@ private void mapOsiToSalesforce( } /** - * Maps Salesforce top-level properties to OSI format. + * Maps Salesforce top-level properties to Ossie format. * Steps: generic mappings → manual conversions → store custom extensions */ private void mapSalesforceToOsi( @@ -84,10 +103,10 @@ private void mapSalesforceToOsi( } /** - * Converts Salesforce businessPreferences to OSI ai_context. + * Converts Salesforce businessPreferences to Ossie ai_context. * * @param sourceData Salesforce data containing businessPreferences - * @param outputData OSI data to populate with ai_context + * @param outputData Ossie data to populate with ai_context */ private void convertBusinessPreferencesToAiContext(Map sourceData, Map outputData) { Object businessPreferences = sourceData.get(BUSINESS_PREFERENCES); @@ -97,7 +116,7 @@ private void convertBusinessPreferencesToAiContext(Map sourceDat } /** - * Converts OSI ai_context (string or object) to Salesforce businessPreferences (string). + * Converts Ossie ai_context (string or object) to Salesforce businessPreferences (string). * *

ai_context can be: *

    @@ -105,7 +124,7 @@ private void convertBusinessPreferencesToAiContext(Map sourceDat *
  • An object - serialized to JSON string
  • *
* - * @param sourceData OSI data containing ai_context + * @param sourceData Ossie data containing ai_context * @param outputData Salesforce data to populate with businessPreferences */ private void convertAiContextToBusinessPreferences(Map sourceData, Map outputData) { @@ -129,7 +148,7 @@ private void convertAiContextToBusinessPreferences(Map sourceDat /** * Applies default values for required Salesforce semantic model properties. - * Used when converting OSI → Salesforce. + * Used when converting Ossie → Salesforce. */ private void applyDefaults(Map outputData) { if (!outputData.containsKey(LABEL)) { diff --git a/converters/salesforce/src/main/java/org/osi/converter/pipeline/DirectionConfig.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/DirectionConfig.java similarity index 58% rename from converters/salesforce/src/main/java/org/osi/converter/pipeline/DirectionConfig.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/DirectionConfig.java index b7df1db..0978f91 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/pipeline/DirectionConfig.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/DirectionConfig.java @@ -1,6 +1,25 @@ -package org.osi.converter.pipeline; +/* + * 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. + */ + +package org.apache.ossie.converter.pipeline; -import static org.osi.converter.ConverterConstants.*; +import static org.apache.ossie.converter.ConverterConstants.*; /** * Configuration for a specific conversion direction. diff --git a/converters/salesforce/src/main/java/org/osi/converter/pipeline/HandlerFactory.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/HandlerFactory.java similarity index 62% rename from converters/salesforce/src/main/java/org/osi/converter/pipeline/HandlerFactory.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/HandlerFactory.java index 4b98824..f4f7591 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/pipeline/HandlerFactory.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/HandlerFactory.java @@ -1,7 +1,26 @@ -package org.osi.converter.pipeline; +/* + * 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. + */ + +package org.apache.ossie.converter.pipeline; -import org.osi.converter.*; -import org.osi.exception.ConversionException; +import org.apache.ossie.converter.*; +import org.apache.ossie.exception.ConversionException; /** * Factory for creating handler instances using a hardcoded registry. diff --git a/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineConfig.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineConfig.java new file mode 100644 index 0000000..d1c3cc7 --- /dev/null +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineConfig.java @@ -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. + */ + +package org.apache.ossie.converter.pipeline; + +import java.util.List; +import java.util.Map; + +/** + * Root configuration model matching pipeline-config.yaml structure. + * + */ +public class PipelineConfig { + private Map> pipelines; // Direction -> handler names + private Map directionConfigs; // Direction -> config + + public Map> getPipelines() { + return pipelines; + } + + public void setPipelines(Map> pipelines) { + this.pipelines = pipelines; + } + + public Map getDirectionConfigs() { + return directionConfigs; + } + + public void setDirectionConfigs(Map directionConfigs) { + this.directionConfigs = directionConfigs; + } +} diff --git a/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineConfigLoader.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineConfigLoader.java similarity index 71% rename from converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineConfigLoader.java rename to converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineConfigLoader.java index 6805a54..6cf1913 100644 --- a/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineConfigLoader.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineConfigLoader.java @@ -1,9 +1,28 @@ -package org.osi.converter.pipeline; +/* + * 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. + */ + +package org.apache.ossie.converter.pipeline; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import org.osi.exception.ConversionException; +import org.apache.ossie.exception.ConversionException; import java.io.IOException; import java.io.InputStream; diff --git a/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineStep.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineStep.java new file mode 100644 index 0000000..95fef55 --- /dev/null +++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/pipeline/PipelineStep.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.ossie.converter.pipeline; + +import java.util.Map; + +/** + * Base interface for pipeline steps. + * Both mapping handlers and special wrapper steps implement this interface. + * + */ +public interface PipelineStep { + /** + * Execute this pipeline step. + * + * @param sourceData The source data (may be modified by handler) + * @param outputData The output data being built + * @param mappings Property mappings + */ + void execute(Map sourceData, Map outputData, Map mappings); +} diff --git a/converters/salesforce/src/main/java/org/apache/ossie/exception/ConversionException.java b/converters/salesforce/src/main/java/org/apache/ossie/exception/ConversionException.java new file mode 100644 index 0000000..704622d --- /dev/null +++ b/converters/salesforce/src/main/java/org/apache/ossie/exception/ConversionException.java @@ -0,0 +1,47 @@ +/* + * 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. + */ + +package org.apache.ossie.exception; + +/** + * Exception thrown when a conversion operation fails. + * This can happen during YAML to JSON or JSON to YAML conversion. + * + */ +public class ConversionException extends RuntimeException { + + /** + * Constructs a new ConversionException with the specified detail message. + * + * @param message the detail message + */ + public ConversionException(String message) { + super(message); + } + + /** + * Constructs a new ConversionException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + */ + public ConversionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/converters/salesforce/src/main/java/org/apache/ossie/exception/InvalidInputException.java b/converters/salesforce/src/main/java/org/apache/ossie/exception/InvalidInputException.java new file mode 100644 index 0000000..0c52a8e --- /dev/null +++ b/converters/salesforce/src/main/java/org/apache/ossie/exception/InvalidInputException.java @@ -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. + */ + +package org.apache.ossie.exception; + +/** + * Exception thrown when the input is invalid. + * This includes cases where the file does not exist, is not readable, + * or if the file/String contains invalid YAML/JSON content. + * + */ +public class InvalidInputException extends RuntimeException { + + /** + * Constructs a new InvalidInputException with the specified detail message. + * + * @param message the detail message + */ + public InvalidInputException(String message) { + super(message); + } + + /** + * Constructs a new InvalidInputException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + */ + public InvalidInputException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/converters/salesforce/src/main/java/org/apache/ossie/exception/ValidationException.java b/converters/salesforce/src/main/java/org/apache/ossie/exception/ValidationException.java new file mode 100644 index 0000000..f6c33b3 --- /dev/null +++ b/converters/salesforce/src/main/java/org/apache/ossie/exception/ValidationException.java @@ -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. + */ + +package org.apache.ossie.exception; + +/** + * Exception thrown when schema validation fails. + * + */ +public class ValidationException extends RuntimeException { + + public ValidationException(String message) { + super(message); + } + + public ValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/converters/salesforce/src/main/java/org/osi/mapper/FileBasedPropertyMapper.java b/converters/salesforce/src/main/java/org/apache/ossie/mapper/FileBasedPropertyMapper.java similarity index 73% rename from converters/salesforce/src/main/java/org/osi/mapper/FileBasedPropertyMapper.java rename to converters/salesforce/src/main/java/org/apache/ossie/mapper/FileBasedPropertyMapper.java index 40d4783..a40e6e0 100644 --- a/converters/salesforce/src/main/java/org/osi/mapper/FileBasedPropertyMapper.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/mapper/FileBasedPropertyMapper.java @@ -1,9 +1,28 @@ -package org.osi.mapper; +/* + * 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. + */ + +package org.apache.ossie.mapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import org.osi.exception.InvalidInputException; +import org.apache.ossie.exception.InvalidInputException; import java.io.IOException; import java.io.InputStream; import java.util.Collections; @@ -14,7 +33,7 @@ /** * Property mapper that loads mappings from a YAML configuration file. * - *

This mapper reads the bundled mappings.yaml which defines OSI-Salesforce + *

This mapper reads the bundled mappings.yaml which defines Ossie-Salesforce * property mappings. The transformation is performed by the converter classes.

* */ @@ -28,7 +47,7 @@ public class FileBasedPropertyMapper implements PropertyMapper { /** * Constructs a FileBasedPropertyMapper with the specified mappings. * - * @param mappings the OSI-Salesforce mappings + * @param mappings the Ossie-Salesforce mappings */ public FileBasedPropertyMapper(Map mappings) { this.mappings = mappings != null ? new LinkedHashMap<>(mappings) : new LinkedHashMap<>(); @@ -36,9 +55,9 @@ public FileBasedPropertyMapper(Map mappings) { } /** - * Generates reverse mappings (Salesforce to OSI) from the OSI to Salesforce mappings. + * Generates reverse mappings (Salesforce to Ossie) from the Ossie to Salesforce mappings. * - * @return the Salesforce-to-OSI mappings + * @return the Salesforce-to-Ossie mappings */ private Map generateReverseMappings() { return mappings.entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); diff --git a/converters/salesforce/src/main/java/org/apache/ossie/mapper/PropertyMapper.java b/converters/salesforce/src/main/java/org/apache/ossie/mapper/PropertyMapper.java new file mode 100644 index 0000000..1c6ccf8 --- /dev/null +++ b/converters/salesforce/src/main/java/org/apache/ossie/mapper/PropertyMapper.java @@ -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. + */ + +package org.apache.ossie.mapper; + +import java.util.Map; + +/** + * Interface for mapping properties between Ossie and Salesforce formats during conversion. + * + *

Implementations of this interface define bidirectional mappings between Ossie YAML format + * and Salesforce JSON format. This supports nested paths using dot notation + * (e.g., "datasets.name" ↔ "semanticDataObjects.apiName").

+ * + * + */ +public interface PropertyMapper { + + /** + * Returns the mapping from Ossie property paths to Salesforce property paths. + * + * @return a map where keys are Ossie property paths and values are Salesforce property paths + */ + Map getOsiToSalesforceMappings(); + + /** + * Returns the mapping from Salesforce property paths to Ossie property paths. + *

This is the reverse mapping of {@link #getOsiToSalesforceMappings()}. + * + * @return a map where keys are Salesforce property paths and values are Ossie property paths + */ + Map getSalesforceToOsiMappings(); +} diff --git a/converters/salesforce/src/main/java/org/osi/util/DataStructureUtils.java b/converters/salesforce/src/main/java/org/apache/ossie/util/DataStructureUtils.java similarity index 81% rename from converters/salesforce/src/main/java/org/osi/util/DataStructureUtils.java rename to converters/salesforce/src/main/java/org/apache/ossie/util/DataStructureUtils.java index 87f3a02..4b40928 100644 --- a/converters/salesforce/src/main/java/org/osi/util/DataStructureUtils.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/util/DataStructureUtils.java @@ -1,4 +1,23 @@ -package org.osi.util; +/* + * 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. + */ + +package org.apache.ossie.util; import java.util.ArrayList; import java.util.List; diff --git a/converters/salesforce/src/main/java/org/osi/util/MappingUtils.java b/converters/salesforce/src/main/java/org/apache/ossie/util/MappingUtils.java similarity index 81% rename from converters/salesforce/src/main/java/org/osi/util/MappingUtils.java rename to converters/salesforce/src/main/java/org/apache/ossie/util/MappingUtils.java index cb17380..014a9e8 100644 --- a/converters/salesforce/src/main/java/org/osi/util/MappingUtils.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/util/MappingUtils.java @@ -1,4 +1,23 @@ -package org.osi.util; +/* + * 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. + */ + +package org.apache.ossie.util; import java.util.*; diff --git a/converters/salesforce/src/main/java/org/osi/util/PathUtils.java b/converters/salesforce/src/main/java/org/apache/ossie/util/PathUtils.java similarity index 77% rename from converters/salesforce/src/main/java/org/osi/util/PathUtils.java rename to converters/salesforce/src/main/java/org/apache/ossie/util/PathUtils.java index 4ed4861..461dd10 100644 --- a/converters/salesforce/src/main/java/org/osi/util/PathUtils.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/util/PathUtils.java @@ -1,7 +1,26 @@ -package org.osi.util; +/* + * 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. + */ + +package org.apache.ossie.util; -import static org.osi.util.DataStructureUtils.asMap; -import static org.osi.util.DataStructureUtils.asList; +import static org.apache.ossie.util.DataStructureUtils.asMap; +import static org.apache.ossie.util.DataStructureUtils.asList; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/converters/salesforce/src/main/java/org/osi/validator/SchemaValidator.java b/converters/salesforce/src/main/java/org/apache/ossie/validator/SchemaValidator.java similarity index 77% rename from converters/salesforce/src/main/java/org/osi/validator/SchemaValidator.java rename to converters/salesforce/src/main/java/org/apache/ossie/validator/SchemaValidator.java index 9cd0ada..b1bea2c 100644 --- a/converters/salesforce/src/main/java/org/osi/validator/SchemaValidator.java +++ b/converters/salesforce/src/main/java/org/apache/ossie/validator/SchemaValidator.java @@ -1,4 +1,23 @@ -package org.osi.validator; +/* + * 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. + */ + +package org.apache.ossie.validator; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,12 +30,12 @@ import java.util.Set; import java.util.stream.Collectors; -import org.osi.exception.ValidationException; +import org.apache.ossie.exception.ValidationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Validates OSI semantic model data against JSON Schema. + * Validates Ossie semantic model data against JSON Schema. * *

Uses networknt/json-schema-validator for schema validation. * Collects all validation errors for better developer experience. @@ -65,7 +84,7 @@ private JsonSchema loadSchema() { } /** - * Validates OSI semantic model data against the schema. + * Validates Ossie semantic model data against the schema. * * @param data The semantic model data to validate * @throws ValidationException if validation fails with details of all errors diff --git a/converters/salesforce/src/main/java/org/osi/converter/ConversionDirection.java b/converters/salesforce/src/main/java/org/osi/converter/ConversionDirection.java deleted file mode 100644 index e87a1b5..0000000 --- a/converters/salesforce/src/main/java/org/osi/converter/ConversionDirection.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.osi.converter; - -/** - * Enum representing the direction of conversion. - * - */ -public enum ConversionDirection { - /** - * Converting from OSI YAML format to Salesforce JSON format. - */ - OSI_TO_SALESFORCE, - - /** - * Converting from Salesforce JSON format to OSI YAML format. - */ - SALESFORCE_TO_OSI; - - /** - * Converts enum name to pipeline configuration key. - * Maps OSI_TO_SALESFORCE -> "osiToSalesforce" and SALESFORCE_TO_OSI -> "salesforceToOsi" - * - * @return The pipeline key used in YAML configuration - */ - public String toPipelineKey() { - return switch (this) { - case OSI_TO_SALESFORCE -> "osiToSalesforce"; - case SALESFORCE_TO_OSI -> "salesforceToOsi"; - }; - } -} diff --git a/converters/salesforce/src/main/java/org/osi/converter/Converter.java b/converters/salesforce/src/main/java/org/osi/converter/Converter.java deleted file mode 100644 index 97d0848..0000000 --- a/converters/salesforce/src/main/java/org/osi/converter/Converter.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.osi.converter; - -import java.nio.file.Path; -import java.util.List; - -/** - * Interface for converting between data formats (YAML and JSON). - * - *

This is the external API for conversion. Implementations handle the conversion - * of data from one format to another, with support for property mapping.

- * - */ -public interface Converter { - - /** - * Converts the input file and writes results to the specified output directory. - * - *

Each semantic model is written to a separate file named after its apiName. - * Example: "Sales_Model.json", "Marketing_Model.json" - * - * @param inputPath the path to the input file - * @param outputDir the directory where output files will be written - */ - void convert(Path inputPath, Path outputDir); - - /** - * Converts string content from the source format to the target format. - * - *

For OSI to Salesforce: returns one Salesforce model per OSI semantic_model entry. - *

For Salesforce to OSI: returns one OSI document with one semantic_model entry. - * - * @param content the content to convert - * @return list of converted content strings (one per semantic model) - */ - List convert(String content); -} diff --git a/converters/salesforce/src/main/java/org/osi/converter/ConverterFactory.java b/converters/salesforce/src/main/java/org/osi/converter/ConverterFactory.java deleted file mode 100644 index 9e78e9b..0000000 --- a/converters/salesforce/src/main/java/org/osi/converter/ConverterFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.osi.converter; - -/** - * Factory for creating converters based on conversion direction. - * - */ -public class ConverterFactory { - - /** - * Creates a converter for the specified direction. - * - * @param direction The conversion direction - * @return A Converter instance configured for the specified direction - */ - public static Converter getConverter(ConversionDirection direction) { - return new ConverterImpl(direction); - } -} diff --git a/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineConfig.java b/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineConfig.java deleted file mode 100644 index ec9f142..0000000 --- a/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.osi.converter.pipeline; - -import java.util.List; -import java.util.Map; - -/** - * Root configuration model matching pipeline-config.yaml structure. - * - */ -public class PipelineConfig { - private Map> pipelines; // Direction -> handler names - private Map directionConfigs; // Direction -> config - - public Map> getPipelines() { - return pipelines; - } - - public void setPipelines(Map> pipelines) { - this.pipelines = pipelines; - } - - public Map getDirectionConfigs() { - return directionConfigs; - } - - public void setDirectionConfigs(Map directionConfigs) { - this.directionConfigs = directionConfigs; - } -} diff --git a/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineStep.java b/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineStep.java deleted file mode 100644 index bec154d..0000000 --- a/converters/salesforce/src/main/java/org/osi/converter/pipeline/PipelineStep.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.osi.converter.pipeline; - -import java.util.Map; - -/** - * Base interface for pipeline steps. - * Both mapping handlers and special wrapper steps implement this interface. - * - */ -public interface PipelineStep { - /** - * Execute this pipeline step. - * - * @param sourceData The source data (may be modified by handler) - * @param outputData The output data being built - * @param mappings Property mappings - */ - void execute(Map sourceData, Map outputData, Map mappings); -} diff --git a/converters/salesforce/src/main/java/org/osi/exception/ConversionException.java b/converters/salesforce/src/main/java/org/osi/exception/ConversionException.java deleted file mode 100644 index f9d8fdd..0000000 --- a/converters/salesforce/src/main/java/org/osi/exception/ConversionException.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.osi.exception; - -/** - * Exception thrown when a conversion operation fails. - * This can happen during YAML to JSON or JSON to YAML conversion. - * - */ -public class ConversionException extends RuntimeException { - - /** - * Constructs a new ConversionException with the specified detail message. - * - * @param message the detail message - */ - public ConversionException(String message) { - super(message); - } - - /** - * Constructs a new ConversionException with the specified detail message and cause. - * - * @param message the detail message - * @param cause the cause of the exception - */ - public ConversionException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/converters/salesforce/src/main/java/org/osi/exception/InvalidInputException.java b/converters/salesforce/src/main/java/org/osi/exception/InvalidInputException.java deleted file mode 100644 index 079dc63..0000000 --- a/converters/salesforce/src/main/java/org/osi/exception/InvalidInputException.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.osi.exception; - -/** - * Exception thrown when the input is invalid. - * This includes cases where the file does not exist, is not readable, - * or if the file/String contains invalid YAML/JSON content. - * - */ -public class InvalidInputException extends RuntimeException { - - /** - * Constructs a new InvalidInputException with the specified detail message. - * - * @param message the detail message - */ - public InvalidInputException(String message) { - super(message); - } - - /** - * Constructs a new InvalidInputException with the specified detail message and cause. - * - * @param message the detail message - * @param cause the cause of the exception - */ - public InvalidInputException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/converters/salesforce/src/main/java/org/osi/exception/ValidationException.java b/converters/salesforce/src/main/java/org/osi/exception/ValidationException.java deleted file mode 100644 index 68d6268..0000000 --- a/converters/salesforce/src/main/java/org/osi/exception/ValidationException.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.osi.exception; - -/** - * Exception thrown when schema validation fails. - * - */ -public class ValidationException extends RuntimeException { - - public ValidationException(String message) { - super(message); - } - - public ValidationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/converters/salesforce/src/main/java/org/osi/mapper/PropertyMapper.java b/converters/salesforce/src/main/java/org/osi/mapper/PropertyMapper.java deleted file mode 100644 index af8789b..0000000 --- a/converters/salesforce/src/main/java/org/osi/mapper/PropertyMapper.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.osi.mapper; - -import java.util.Map; - -/** - * Interface for mapping properties between OSI and Salesforce formats during conversion. - * - *

Implementations of this interface define bidirectional mappings between OSI YAML format - * and Salesforce JSON format. This supports nested paths using dot notation - * (e.g., "datasets.name" ↔ "semanticDataObjects.apiName").

- * - * - */ -public interface PropertyMapper { - - /** - * Returns the mapping from OSI property paths to Salesforce property paths. - * - * @return a map where keys are OSI property paths and values are Salesforce property paths - */ - Map getOsiToSalesforceMappings(); - - /** - * Returns the mapping from Salesforce property paths to OSI property paths. - *

This is the reverse mapping of {@link #getOsiToSalesforceMappings()}. - * - * @return a map where keys are Salesforce property paths and values are OSI property paths - */ - Map getSalesforceToOsiMappings(); -} diff --git a/converters/salesforce/src/main/resources/mappings.yaml b/converters/salesforce/src/main/resources/mappings.yaml index fddc085..15f70cb 100644 --- a/converters/salesforce/src/main/resources/mappings.yaml +++ b/converters/salesforce/src/main/resources/mappings.yaml @@ -1,8 +1,25 @@ +# 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. + # ============================================================================= -# OSI-Salesforce Converter - Mapping Configuration +# Ossie-Salesforce Converter - Mapping Configuration # ============================================================================= # -# This file defines ONLY straightforward property mappings between OSI YAML +# This file defines ONLY straightforward property mappings between Ossie YAML # format and Salesforce Semantic Model JSON format. # # Complex mappings (arrays, routing, transformations) are handled programmatically diff --git a/converters/salesforce/src/main/resources/osi-salesforce-converter-config.yaml b/converters/salesforce/src/main/resources/osi-salesforce-converter-config.yaml index fea5fea..371c90d 100644 --- a/converters/salesforce/src/main/resources/osi-salesforce-converter-config.yaml +++ b/converters/salesforce/src/main/resources/osi-salesforce-converter-config.yaml @@ -1,3 +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. + # Pipeline definitions - each direction lists handlers to execute in order pipelines: diff --git a/converters/salesforce/src/test/java/org/osi/OsiToSalesforceConverterTest.java b/converters/salesforce/src/test/java/org/apache/ossie/OsiToSalesforceConverterTest.java similarity index 89% rename from converters/salesforce/src/test/java/org/osi/OsiToSalesforceConverterTest.java rename to converters/salesforce/src/test/java/org/apache/ossie/OsiToSalesforceConverterTest.java index defdd36..94fa6b2 100644 --- a/converters/salesforce/src/test/java/org/osi/OsiToSalesforceConverterTest.java +++ b/converters/salesforce/src/test/java/org/apache/ossie/OsiToSalesforceConverterTest.java @@ -1,19 +1,38 @@ -package org.osi; +/* + * 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. + */ + +package org.apache.ossie; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import org.osi.converter.Converter; -import org.osi.converter.ConverterFactory; -import org.osi.converter.ConversionDirection; -import org.osi.converter.CustomExtensionHandler; -import org.osi.validator.SchemaValidator; +import org.apache.ossie.converter.Converter; +import org.apache.ossie.converter.ConverterFactory; +import org.apache.ossie.converter.ConversionDirection; +import org.apache.ossie.converter.CustomExtensionHandler; +import org.apache.ossie.validator.SchemaValidator; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.osi.converter.pipeline.DirectionConfig; -import org.osi.converter.pipeline.HandlerFactory; -import org.osi.converter.pipeline.PipelineConfig; -import org.osi.converter.pipeline.PipelineConfigLoader; +import org.apache.ossie.converter.pipeline.DirectionConfig; +import org.apache.ossie.converter.pipeline.HandlerFactory; +import org.apache.ossie.converter.pipeline.PipelineConfig; +import org.apache.ossie.converter.pipeline.PipelineConfigLoader; import java.io.IOException; import java.nio.file.Files; @@ -25,7 +44,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; /** - * Comprehensive integration test for OSI → Salesforce conversion. + * Comprehensive integration test for Ossie → Salesforce conversion. * Uses real example file: osiToSalesforce.yaml */ class OsiToSalesforceConverterTest { @@ -55,10 +74,10 @@ static void checkSchemaAvailability() { System.err.println(" and save it to: src/main/resources/schemas/salesforce-semantic-model-schema.json\n"); } if (!osiSchemaExists) { - System.err.println("\n WARNING: OSI schema not found at " + SchemaValidator.OSI_SCHEMA_PATH); + System.err.println("\n WARNING: Ossie schema not found at " + SchemaValidator.OSI_SCHEMA_PATH); System.err.println(" Skipping OsiToSalesforceConverterTest tests."); System.err.println(" To run these tests, download the schema from:"); - System.err.println(" https://github.com/open-semantic-interchange/OSI/blob/main/core-spec/osi-schema.json"); + System.err.println(" https://github.com/apache/ossie/blob/main/core-spec/osi-schema.json"); System.err.println(" and save it to: src/main/resources/schemas/osi-schema.json\n"); } warningPrinted = true; @@ -67,7 +86,7 @@ static void checkSchemaAvailability() { @BeforeEach void setUp() throws IOException { - assumeTrue(osiSchemaExists, "OSI schema file is required but not found. See README for setup instructions."); + assumeTrue(osiSchemaExists, "Ossie schema file is required but not found. See README for setup instructions."); converter = ConverterFactory.getConverter(ConversionDirection.OSI_TO_SALESFORCE); jsonMapper = new ObjectMapper(); @@ -255,7 +274,7 @@ void testMetricsNotConvertedInOsiToSalesforce() throws Exception { Map sfModel = jsonMapper.readValue(results.get(0), new TypeReference>() {}); List> calcMeasurements = (List>) sfModel.get("semanticCalculatedMeasurements"); - assertNull(calcMeasurements, "Metrics from OSI are not converted to semanticCalculatedMeasurements in OSI->SF direction"); + assertNull(calcMeasurements, "Metrics from Ossie are not converted to semanticCalculatedMeasurements in Ossie->SF direction"); } @Test diff --git a/converters/salesforce/src/test/java/org/osi/SalesforceToOsiConverterTest.java b/converters/salesforce/src/test/java/org/apache/ossie/SalesforceToOsiConverterTest.java similarity index 90% rename from converters/salesforce/src/test/java/org/osi/SalesforceToOsiConverterTest.java rename to converters/salesforce/src/test/java/org/apache/ossie/SalesforceToOsiConverterTest.java index 9a11af7..1926843 100644 --- a/converters/salesforce/src/test/java/org/osi/SalesforceToOsiConverterTest.java +++ b/converters/salesforce/src/test/java/org/apache/ossie/SalesforceToOsiConverterTest.java @@ -1,16 +1,35 @@ -package org.osi; +/* + * 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. + */ + +package org.apache.ossie; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import org.osi.converter.Converter; -import org.osi.converter.ConverterFactory; -import org.osi.converter.ConversionDirection; -import org.osi.converter.CustomExtensionHandler; -import org.osi.validator.SchemaValidator; +import org.apache.ossie.converter.Converter; +import org.apache.ossie.converter.ConverterFactory; +import org.apache.ossie.converter.ConversionDirection; +import org.apache.ossie.converter.CustomExtensionHandler; +import org.apache.ossie.validator.SchemaValidator; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.osi.converter.pipeline.*; +import org.apache.ossie.converter.pipeline.*; import java.io.IOException; import java.nio.file.Files; @@ -22,7 +41,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; /** - * Comprehensive integration test for Salesforce → OSI conversion. + * Comprehensive integration test for Salesforce → Ossie conversion. * Uses real example file: salesforceToOsi.json */ class SalesforceToOsiConverterTest { @@ -51,10 +70,10 @@ static void checkSchemaAvailability() { System.err.println(" and save it to: src/main/resources/schemas/salesforce-semantic-model-schema.json\n"); } if (!osiSchemaExists) { - System.err.println("\n WARNING: OSI schema not found at " + SchemaValidator.OSI_SCHEMA_PATH); + System.err.println("\n WARNING: Ossie schema not found at " + SchemaValidator.OSI_SCHEMA_PATH); System.err.println(" Skipping SalesforceToOsiConverterTest tests."); System.err.println(" To run these tests, download the schema from:"); - System.err.println(" https://github.com/open-semantic-interchange/OSI/blob/main/core-spec/osi-schema.json"); + System.err.println(" https://github.com/apache/ossie/blob/main/core-spec/osi-schema.json"); System.err.println(" and save it to: src/main/resources/schemas/osi-schema.json\n"); } warningPrinted = true; @@ -64,7 +83,7 @@ static void checkSchemaAvailability() { @BeforeEach void setUp() throws IOException { assumeTrue(schemaExists, "Salesforce schema file is required but not found. See README for setup instructions."); - assumeTrue(osiSchemaExists, "OSI schema file is required but not found. See README for setup instructions."); + assumeTrue(osiSchemaExists, "Ossie schema file is required but not found. See README for setup instructions."); converter = ConverterFactory.getConverter(ConversionDirection.SALESFORCE_TO_OSI); yamlMapper = new ObjectMapper(new YAMLFactory()); @@ -303,7 +322,7 @@ void testOutputCompilesWithOsiSchema() throws Exception { Map osiRoot = yamlMapper.readValue(osiYaml, Map.class); SchemaValidator validator = new SchemaValidator(new ObjectMapper(), SchemaValidator.OSI_SCHEMA_PATH); - assertDoesNotThrow(() -> validator.validate(osiRoot), "Output should comply with OSI schema"); + assertDoesNotThrow(() -> validator.validate(osiRoot), "Output should comply with Ossie schema"); } @Test @@ -368,11 +387,11 @@ void testHandlerFactoryForSalesforceToOsi() { @Test void testConverterImplExtractModelNameFromOsiFormat() throws Exception { - // Test extractModelName specifically handles OSI wrapped format + // Test extractModelName specifically handles Ossie wrapped format List results = converter.convert(salesforceJson); String osiYaml = results.get(0); - // The result is wrapped OSI format - extractModelName should handle this + // The result is wrapped Ossie format - extractModelName should handle this Map osiRoot = yamlMapper.readValue(osiYaml, Map.class); assertTrue(osiRoot.containsKey("semantic_model")); diff --git a/converters/salesforce/src/test/resources/examples/osiToSalesforce.yaml b/converters/salesforce/src/test/resources/examples/osiToSalesforce.yaml index 46e578b..edb1859 100644 --- a/converters/salesforce/src/test/resources/examples/osiToSalesforce.yaml +++ b/converters/salesforce/src/test/resources/examples/osiToSalesforce.yaml @@ -1,3 +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. + version: 0.2.0.dev0 semantic_model: - name: Customer_Orders_Model diff --git a/converters/snowflake/README.md b/converters/snowflake/README.md index 93ee961..0026e0a 100644 --- a/converters/snowflake/README.md +++ b/converters/snowflake/README.md @@ -1,27 +1,46 @@ -# OSI to Snowflake Converter + + +# Apache Ossie to Snowflake Converter + +Converts Ossie YAML semantic models to [Snowflake Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) semantic model YAML. Pure offline conversion — no Snowflake connection required. > **Note:** This converter is under active development. It handles common cases but has not been thoroughly tested against all edge cases — use with caution in production. ## Setup ```bash -pip3 install -r requirements.txt +uv sync ``` ## Usage ```bash -python3 src/osi_to_snowflake_yaml_converter.py -i input.yaml -o output.yaml +uv run ossie-snowflake -i input.yaml -o output.yaml ``` ## Tests ```bash -python3 -m pytest tests/ +uv run pytest ``` ## Limitations -Some OSI concepts (e.g., `ai_context` on relationships) do not have a native counterpart in the Snowflake semantic model. These are dropped during conversion and the converter will emit warnings so you know what was left behind. +Some Ossie concepts (e.g., `ai_context` on relationships) do not have a native counterpart in the Snowflake semantic model. These are dropped during conversion and the converter will emit warnings so you know what was left behind. diff --git a/converters/snowflake/pyproject.toml b/converters/snowflake/pyproject.toml new file mode 100644 index 0000000..0523f39 --- /dev/null +++ b/converters/snowflake/pyproject.toml @@ -0,0 +1,62 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[project] +name = "apache-ossie-snowflake" +version = "0.2.0.dev0" +description = "Snowflake Cortex Analyst <> Apache Ossie converter" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] +requires-python = ">=3.11" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", + "Snowflake" +] +dependencies = [ + "PyYAML>=5.0", +] + +[project.scripts] +ossie-snowflake = "ossie_snowflake.converter:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_snowflake"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] \ No newline at end of file diff --git a/converters/snowflake/requirements.txt b/converters/snowflake/requirements.txt index 8d5fd98..c5dcb69 100644 --- a/converters/snowflake/requirements.txt +++ b/converters/snowflake/requirements.txt @@ -1 +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. + PyYAML>=5.0 diff --git a/converters/snowflake/src/ossie_snowflake/__init__.py b/converters/snowflake/src/ossie_snowflake/__init__.py new file mode 100644 index 0000000..d216be4 --- /dev/null +++ b/converters/snowflake/src/ossie_snowflake/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. \ No newline at end of file diff --git a/converters/snowflake/src/osi_to_snowflake_yaml_converter.py b/converters/snowflake/src/ossie_snowflake/converter.py similarity index 78% rename from converters/snowflake/src/osi_to_snowflake_yaml_converter.py rename to converters/snowflake/src/ossie_snowflake/converter.py index f2a8520..447827d 100644 --- a/converters/snowflake/src/osi_to_snowflake_yaml_converter.py +++ b/converters/snowflake/src/ossie_snowflake/converter.py @@ -1,10 +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. + """ -Converts an OSI (Open Semantic Interchange) YAML semantic model to a Snowflake +Converts an Ossie (Open Semantic Interchange) YAML semantic model to a Snowflake Cortex Analyst semantic model YAML. Pure offline conversion — no Snowflake connection required. Usage: - python3 osi_to_snowflake_yaml_converter.py -i input.yaml -o output.yaml + python3 converter.py -i input.yaml -o output.yaml """ import argparse @@ -18,21 +35,21 @@ class OsiConversionError(Exception): - """Raised when an OSI YAML cannot be converted to Snowflake format.""" + """Raised when an Ossie YAML cannot be converted to Snowflake format.""" def convert_osi_to_snowflake(osi_yaml_str): - """Top-level entry point. Parses OSI YAML, validates, converts, returns + """Top-level entry point. Parses Ossie YAML, validates, converts, returns Snowflake YAML string. - Expects the standard OSI wrapped format:: + Expects the standard Ossie wrapped format:: version: "0.2.0.dev0" semantic_model: - name: ... Args: - osi_yaml_str: OSI YAML as a string. + osi_yaml_str: Ossie YAML as a string. Returns: Snowflake Cortex Analyst semantic model YAML string. @@ -42,34 +59,34 @@ def convert_osi_to_snowflake(osi_yaml_str): """ root = yaml.safe_load(osi_yaml_str) if not isinstance(root, dict): - raise OsiConversionError("Invalid OSI YAML: expected a mapping at the root") + raise OsiConversionError("Invalid Ossie YAML: expected a mapping at the root") version_str = str(root.get("version", "")) if version_str != SUPPORTED_VERSION: raise OsiConversionError( - f"Unsupported OSI specification version '{version_str}'. " + f"Unsupported Ossie specification version '{version_str}'. " f"Supported: {SUPPORTED_VERSION}" ) semantic_model = root.get("semantic_model") if not isinstance(semantic_model, list) or len(semantic_model) == 0: raise OsiConversionError( - "Invalid OSI YAML: 'semantic_model' must be a non-empty list" + "Invalid Ossie YAML: 'semantic_model' must be a non-empty list" ) if len(semantic_model) > 1: warnings.warn( - f"OSI YAML contains {len(semantic_model)} semantic models; " + f"Ossie YAML contains {len(semantic_model)} semantic models; " f"only the first will be converted" ) - osi = semantic_model[0] - if not isinstance(osi, dict): + ossie = semantic_model[0] + if not isinstance(ossie, dict): raise OsiConversionError( - "Invalid OSI YAML: 'semantic_model' entries must be mappings" + "Invalid Ossie YAML: 'semantic_model' entries must be mappings" ) - snowflake_model = _convert_model(osi) + snowflake_model = _convert_model(ossie) return yaml.dump( snowflake_model, @@ -79,38 +96,38 @@ def convert_osi_to_snowflake(osi_yaml_str): ) -def _convert_model(osi): - """Converts the root OSI model dict to a Snowflake semantic model dict.""" - name = osi.get("name") +def _convert_model(ossie): + """Converts the root Ossie model dict to a Snowflake semantic model dict.""" + name = ossie.get("name") if not name: raise OsiConversionError("Missing required 'name' field in semantic model") result = {} result["name"] = name - description = osi.get("description") - ai_context = osi.get("ai_context") + description = ossie.get("description") + ai_context = ossie.get("ai_context") if isinstance(ai_context, str) and ai_context: description = f"{description}\n{ai_context}" if description else ai_context if description: result["description"] = description # datasets -> tables - datasets = osi.get("datasets") + datasets = ossie.get("datasets") if datasets: tables = [_convert_dataset(ds) for ds in datasets] if tables: result["tables"] = tables # relationships - relationships = osi.get("relationships") + relationships = ossie.get("relationships") if relationships: converted_rels = [_convert_relationship(rel) for rel in relationships] if converted_rels: result["relationships"] = converted_rels # metrics - metrics = osi.get("metrics") + metrics = ossie.get("metrics") if metrics: converted_metrics = [] for m in metrics: @@ -121,13 +138,13 @@ def _convert_model(osi): result["metrics"] = converted_metrics dropped_ai = ["ai_context"] if isinstance(ai_context, dict) and ai_context else [] - _warn_dropped_fields(osi, "model", extra_dropped=dropped_ai) + _warn_dropped_fields(ossie, "model", extra_dropped=dropped_ai) return result def _convert_dataset(dataset): - """Converts an OSI dataset dict to a Snowflake table dict.""" + """Converts an Ossie dataset dict to a Snowflake table dict.""" result = {} name = dataset.get("name") if not name: @@ -209,11 +226,11 @@ def _classify_field(field): def _convert_named_expr(entry, kind): - """Converts an OSI field or metric dict to a Snowflake entry with name, expr, + """Converts an Ossie field or metric dict to a Snowflake entry with name, expr, description, and synonyms. Args: - entry: The OSI field or metric dict. + entry: The Ossie field or metric dict. kind: Human-readable type for error messages (e.g., "field", "metric"). """ name = entry.get("name") @@ -250,7 +267,7 @@ def _convert_named_expr(entry, kind): def _convert_relationship(rel): - """Converts an OSI relationship dict to a Snowflake relationship dict.""" + """Converts an Ossie relationship dict to a Snowflake relationship dict.""" result = {} rel_name = rel.get("name") if not rel_name: @@ -340,9 +357,25 @@ def _normalize_identifier(identifier): return stripped return stripped.upper() +def _split_identifiers(source_str): + """Split a dot-separated identifier string while respecting double quotes.""" + parts = [] + current = [] + in_quotes = False + for char in source_str: + if char == '"': + in_quotes = not in_quotes + current.append(char) + elif char == "." and not in_quotes: + parts.append("".join(current).strip()) + current = [] + else: + current.append(char) + parts.append("".join(current).strip()) + return parts def _parse_source(source): - """Parses an OSI dataset source string into a Snowflake base_table dict. + """Parses an Ossie dataset source string into a Snowflake base_table dict. Returns None if source is empty/None. Returns {"definition": source} for subqueries. Otherwise splits into 3-part db.schema.table. @@ -361,11 +394,7 @@ def _parse_source(source): "WITH ", "WITH\n", "WITH\t")): return {"definition": source_stripped} - # Strict 3-part rule: source must be db.schema.table. This may be relaxed - # in the future to allow 1- or 2-part names. - # TODO: Quoted identifiers (e.g., "my.db"."my schema"."my table") are not - # handled. Basic dot-splitting only. - parts = source_stripped.split(".") + parts = _split_identifiers(source_stripped) if len(parts) == 3: # Only uppercase unquoted identifiers; preserve quoted ones as-is. return { @@ -393,13 +422,13 @@ def _extract_synonyms(ai_context): def _warn_dropped_fields(source, context, extra_dropped=None): - """Warns about OSI fields that have no Snowflake counterpart and are dropped. + """Warns about Ossie fields that have no Snowflake counterpart and are dropped. Checks for universally-dropped fields (custom_extensions, label, version). Callers handle ai_context warnings themselves since consumption logic varies. Args: - source: The OSI dict being converted. + source: The Ossie dict being converted. context: Human-readable description (e.g., "field 'col1'"). extra_dropped: Optional list of additional field descriptions to report as dropped (e.g., ai_context details computed by the caller). @@ -424,10 +453,10 @@ def _warn_dropped_fields(source, context, extra_dropped=None): def main(): parser = argparse.ArgumentParser( - description="Convert OSI YAML semantic model to Snowflake Cortex Analyst YAML" + description="Convert Ossie YAML semantic model to Snowflake Cortex Analyst YAML" ) parser.add_argument( - "-i", "--input", required=True, help="Path to the OSI YAML input file" + "-i", "--input", required=True, help="Path to the Ossie YAML input file" ) parser.add_argument( "-o", "--output", required=True, help="Path to write the Snowflake YAML output" diff --git a/converters/snowflake/tests/example_converted_tpcds_semantic_model.yaml b/converters/snowflake/tests/example_converted_tpcds_semantic_model.yaml index 9220ab6..b2d4aff 100644 --- a/converters/snowflake/tests/example_converted_tpcds_semantic_model.yaml +++ b/converters/snowflake/tests/example_converted_tpcds_semantic_model.yaml @@ -1,3 +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. + name: tpcds_retail_model description: TPC-DS retail semantic model for sales and customer analytics tables: diff --git a/converters/snowflake/tests/test_osi_to_snowflake_yaml_converter.py b/converters/snowflake/tests/test_osi_to_snowflake_yaml_converter.py index 7a8de1c..c1b5469 100644 --- a/converters/snowflake/tests/test_osi_to_snowflake_yaml_converter.py +++ b/converters/snowflake/tests/test_osi_to_snowflake_yaml_converter.py @@ -1,15 +1,28 @@ -"""Tests for the OSI to Snowflake YAML converter.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Ossie to Snowflake YAML converter.""" -import sys import warnings -from pathlib import Path import pytest import yaml -# Make src/ importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) -from osi_to_snowflake_yaml_converter import ( +from ossie_snowflake.converter import ( OsiConversionError, convert_osi_to_snowflake, _classify_field, @@ -29,7 +42,7 @@ # --------------------------------------------------------------------------- def _wrap_osi(model_dict): - """Wrap a model dict in the standard OSI envelope.""" + """Wrap a model dict in the standard Ossie envelope.""" return yaml.dump( {"version": "0.2.0.dev0", "semantic_model": [model_dict]}, default_flow_style=False, @@ -37,7 +50,7 @@ def _wrap_osi(model_dict): def _minimal_model(**overrides): - """Return a minimal valid OSI model dict.""" + """Return a minimal valid Ossie model dict.""" base = { "name": "test_model", "datasets": [ @@ -97,6 +110,14 @@ def test_quoted_identifiers_preserved(self): "table": '"myTable"', } + def test_quoted_identifiers_with_dots_preserved(self): + result = _parse_source('"my.db"."my schema"."my table"') + assert result == { + "database": '"my.db"', + "schema": '"my schema"', + "table": '"my table"', + } + def test_subquery_select(self): result = _parse_source("SELECT * FROM foo") assert result == {"definition": "SELECT * FROM foo"} @@ -478,7 +499,7 @@ def test_invalid_yaml_root_raises(self): def test_wrong_version_raises(self): bad = yaml.dump({"version": "9.9.9", "semantic_model": [{"name": "m"}]}) - with pytest.raises(OsiConversionError, match="Unsupported OSI specification"): + with pytest.raises(OsiConversionError, match="Unsupported Ossie specification"): convert_osi_to_snowflake(bad) def test_missing_semantic_model_raises(self): @@ -605,7 +626,7 @@ def test_subquery_source(self): # --------------------------------------------------------------------------- -# _warn_dropped_fields (OSI concepts with no Snowflake counterpart) +# _warn_dropped_fields (Ossie concepts with no Snowflake counterpart) # --------------------------------------------------------------------------- class TestWarnDroppedFields: diff --git a/converters/snowflake/uv.lock b/converters/snowflake/uv.lock new file mode 100644 index 0000000..eaa7da9 --- /dev/null +++ b/converters/snowflake/uv.lock @@ -0,0 +1,138 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "apache-ossie-snowflake" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=5.0" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "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" }, +] diff --git a/converters/wisdom/README.md b/converters/wisdom/README.md new file mode 100644 index 0000000..c5884aa --- /dev/null +++ b/converters/wisdom/README.md @@ -0,0 +1,113 @@ + + +# WisdomAI ↔ Apache Ossie Converter + +Converts between a [WisdomAI](https://wisdom.ai) domain export (format `1.0`, produced by +wisdom's `exportDomain` API and consumed by `importDomain`) and an Ossie semantic model +YAML document. + +## Usage + +```bash +pip install -e ../../python -e . +ossie-wisdom wisdom-to-osi -i domain-export.json -o semantic_model.yaml +ossie-wisdom osi-to-wisdom -i semantic_model.yaml -o domain-export.json +``` + +Conversion warnings (information loss) are printed to stderr; the output YAML validates +against the [Ossie JSON Schema](../../core-spec/osi-schema.json): + +```bash +python ../../validation/validate.py semantic_model.yaml --schema ../../core-spec/osi-schema.json +``` + +## Field mapping + +| Ossie | Wisdom | +|-------|--------| +| `semantic_model[].name` | domain `ref.name` | +| `semantic_model[].description` | domain `description` | +| `semantic_model[].ai_context` | `domainSystemInstructions` + each domain `knowledge[].content` as a bulleted list | +| `datasets[].name` | table `ref.name` | +| `datasets[].source` | table `location.database.schema.dbTable` | +| `datasets[].description` | table `description` | +| `datasets[].primary_key` | table `primaryKey.columns`, else columns flagged `isPrimaryKey` | +| `datasets[].fields[]` | table `columns[]` (expression = bare column name) and `formulas[]` (expression verbatim) | +| `fields[].label` | column/formula `properties.displayName` | +| `fields[].dimension.is_time` | set when `properties.dataType` is `DATE`, `DATETIME`, or `TIMESTAMP` | +| `relationships[]` | domain `relationshipGraph.relationships[]` (see cardinality below) | +| `metrics[]` | every table's `measures[]`, hoisted to the model level | + +Expressions are emitted verbatim under the Ossie dialect mapped from the table's connection +(`snowflake → SNOWFLAKE`, `databricks → DATABRICKS`, `bigquery → BIGQUERY`). Connections with +any other dialect fall back to `ANSI_SQL` verbatim with an `UNSUPPORTED_DIALECT` warning. + +### Relationship cardinality + +Ossie encodes cardinality by direction (`from` = many side, `to` = one side), so wisdom's +`relationshipType` is folded into the edge direction: + +| Wisdom `relationshipType` | Ossie | +|---------------------------|-------| +| `MANY_TO_ONE` | `from` = left, `to` = right | +| `ONE_TO_MANY` | `from` = right, `to` = left | +| `ONE_TO_ONE` | `from` = left, plus an `ai_context` note (direction is arbitrary) | +| `MANY_TO_MANY` | `from` = left, plus an `ai_context` note and a `CARDINALITY_LOSS` warning | + +Compound join conditions that are an `AND` of equality conditions are flattened into +positional `from_columns`/`to_columns` arrays; any other compound condition (e.g. `OR`) +drops the relationship with a `RELATIONSHIP_DROPPED` warning. + +## Ossie → wisdom (export direction) + +`osi-to-wisdom` emits a domain export mirroring wisdom's `exportDomain` JSON, inverting +the mapping above so that wisdom → Ossie → wisdom and Ossie → wisdom → Ossie round-trips +are stable: + +- Model `ai_context` splits back into `domainSystemInstructions` (leading text) and one + knowledge item per `- ` bullet. +- A field whose expression is just its own (possibly quoted) name becomes a column; + anything else becomes a formula. `dimension.is_time` becomes a `TIMESTAMP` data type + (wisdom re-derives exact types from the warehouse). +- Relationships become `MANY_TO_ONE` edges (Ossie's `from` is the many side); the + `ai_context` notes written by `wisdom-to-osi` restore `ONE_TO_ONE`/`MANY_TO_MANY`, and + composite keys become compound `AND` join conditions. +- Metrics attach to the first dataset their expression references (a + `METRIC_TABLE_UNRESOLVED` warning falls back to the first dataset). +- Connections are per-dialect placeholders (`et-connection-snowflake`, ...) expected to be + remapped when the domain is imported; all IDs are derived deterministically from names, + so re-runs produce identical output. +- Not representable in wisdom (dropped with warnings): extra semantic models beyond the + first, `unique_keys`, `custom_extensions`, and `ai_context` on fields/metrics (plus + synonyms/examples anywhere). + +## Known limitations + +- Hidden columns and stale measures are converted anyway (with a `STALE_MEASURE` warning + for the latter), so the output may reference columns wisdom itself hides from querying. +- Out of scope for now: reviewed queries, recommended questions, synonym sets, row-level + security config, per-knowledge schema annotations, column enum values, and LLM prompts. + +## Development + +```bash +pip install -e ../../python -e . pytest +pytest tests/ +``` diff --git a/converters/wisdom/pyproject.toml b/converters/wisdom/pyproject.toml new file mode 100644 index 0000000..15052cc --- /dev/null +++ b/converters/wisdom/pyproject.toml @@ -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. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[project] +name = "apache-ossie-wisdom" +version = "0.2.0.dev0" +description = "WisdomAI domain export <> Apache Ossie converter" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] +requires-python = ">=3.11" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", + "WisdomAI" +] +dependencies = [ + "apache-ossie>=0.2.0.dev0", + "PyYAML>=6.0", +] + +[project.scripts] +ossie-wisdom = "ossie_wisdom.cli:main" + +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_wisdom"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.uv] +required-version = ">=0.9.0" +default-groups = [ + "dev" +] + +# apache-ossie is not yet published to PyPI; resolve it from the in-repo +# package for now. Remove this block once apache-ossie published to PyPI. +[tool.uv.sources] +apache-ossie = { path = "../../python", editable = true} diff --git a/converters/wisdom/src/ossie_wisdom/__init__.py b/converters/wisdom/src/ossie_wisdom/__init__.py new file mode 100644 index 0000000..337704d --- /dev/null +++ b/converters/wisdom/src/ossie_wisdom/__init__.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 ossie_wisdom.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult +from ossie_wisdom.osi_to_wisdom import OSIToWisdomConverter +from ossie_wisdom.wisdom_to_osi import WisdomToOSIConverter + +__all__ = [ + "ConverterIssue", + "ConverterIssueType", + "ConverterResult", + "OSIToWisdomConverter", + "WisdomToOSIConverter", +] diff --git a/converters/wisdom/src/ossie_wisdom/cli.py b/converters/wisdom/src/ossie_wisdom/cli.py new file mode 100644 index 0000000..6248697 --- /dev/null +++ b/converters/wisdom/src/ossie_wisdom/cli.py @@ -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. + +"""CLI entry point for the ossie-wisdom converter. + +Usage: + ossie-wisdom wisdom-to-osi -i domain-export.json -o output.yaml + ossie-wisdom osi-to-wisdom -i input.yaml -o domain-export.json +""" + +import argparse +import json +import sys +from pathlib import Path + +import yaml + +from ossie import OSIDocument +from ossie_wisdom.converter_issues import ConverterIssueType +from ossie_wisdom.osi_to_wisdom import OSIToWisdomConverter +from ossie_wisdom.wisdom_to_osi import WisdomToOSIConverter + +_ISSUE_REASON: dict[ConverterIssueType, str] = { + ConverterIssueType.UNSUPPORTED_DIALECT: "the connection dialect has no Ossie equivalent; expressions were emitted verbatim as ANSI_SQL", + ConverterIssueType.CARDINALITY_LOSS: "Ossie relationships encode cardinality by direction and cannot represent many-to-many", + ConverterIssueType.RELATIONSHIP_DROPPED: "the relationship uses a join that cannot be represented (non-table source, OR/non-equi condition, or unknown dataset)", + ConverterIssueType.METRIC_NAME_COLLISION: "another table defines a measure with the same name; this one was prefixed with its table name", + ConverterIssueType.STALE_MEASURE: "wisdom marked this measure stale; it was converted anyway", + ConverterIssueType.DUPLICATE_FIELD_DROPPED: "the dataset already has a field with this name", + ConverterIssueType.EXTRA_MODEL_DROPPED: "a wisdom domain export holds a single domain; only the first semantic model was converted", + ConverterIssueType.AI_CONTEXT_DROPPED: "wisdom has no equivalent for ai_context at this level (or for synonyms/examples)", + ConverterIssueType.METRIC_TABLE_UNRESOLVED: "the metric expression references no known dataset; it was attached to the first dataset", + ConverterIssueType.MISSING_DIALECT_EXPRESSION: "no expression was available in the dataset's dialect or ANSI_SQL; the first available dialect was used", + ConverterIssueType.UNIQUE_KEYS_DROPPED: "wisdom has no unique-key construct", + ConverterIssueType.CUSTOM_EXTENSION_DROPPED: "wisdom cannot store Ossie custom extensions", +} + +_DROPPED_ISSUE_TYPES = { + ConverterIssueType.RELATIONSHIP_DROPPED, + ConverterIssueType.DUPLICATE_FIELD_DROPPED, + ConverterIssueType.EXTRA_MODEL_DROPPED, + ConverterIssueType.AI_CONTEXT_DROPPED, + ConverterIssueType.UNIQUE_KEYS_DROPPED, + ConverterIssueType.CUSTOM_EXTENSION_DROPPED, +} + + +def _print_issues(result) -> None: + for issue in result.issues: + verb = "was dropped" if issue.issue_type in _DROPPED_ISSUE_TYPES else "was converted with loss" + reason = _ISSUE_REASON[issue.issue_type] + print(f"[WARNING] {issue.issue_type.value}: {issue.element_name} {verb} during conversion because {reason}", file=sys.stderr) + + +def _cmd_wisdom_to_osi(args: argparse.Namespace) -> None: + input_path = Path(args.input) + output_path = Path(args.output) + + export = json.loads(input_path.read_text()) + result = WisdomToOSIConverter().convert(export) + + _print_issues(result) + output_path.write_text(result.output.to_osi_yaml()) + print(f"Written to {output_path}", file=sys.stderr) + + +def _cmd_osi_to_wisdom(args: argparse.Namespace) -> None: + input_path = Path(args.input) + output_path = Path(args.output) + + document = OSIDocument.model_validate(yaml.safe_load(input_path.read_text())) + result = OSIToWisdomConverter().convert(document) + + _print_issues(result) + output_path.write_text(json.dumps(result.output, indent=2) + "\n") + print(f"Written to {output_path}", file=sys.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="ossie-wisdom", + description="Convert a WisdomAI domain export JSON to Ossie YAML.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + wisdom_to_osi = subparsers.add_parser("wisdom-to-osi", help="Convert domain-export.json → Ossie YAML") + wisdom_to_osi.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to wisdom domain export JSON") + wisdom_to_osi.add_argument("-o", "--output", required=True, metavar="FILE", help="Path for output Ossie YAML") + + osi_to_wisdom = subparsers.add_parser("osi-to-wisdom", help="Convert Ossie YAML → domain-export.json") + osi_to_wisdom.add_argument("-i", "--input", required=True, metavar="FILE", help="Path to Ossie YAML") + osi_to_wisdom.add_argument("-o", "--output", required=True, metavar="FILE", help="Path for output wisdom domain export JSON") + + args = parser.parse_args() + if args.command == "wisdom-to-osi": + _cmd_wisdom_to_osi(args) + elif args.command == "osi-to-wisdom": + _cmd_osi_to_wisdom(args) + + +if __name__ == "__main__": + main() diff --git a/converters/wisdom/src/ossie_wisdom/converter_issues.py b/converters/wisdom/src/ossie_wisdom/converter_issues.py new file mode 100644 index 0000000..fcef92f --- /dev/null +++ b/converters/wisdom/src/ossie_wisdom/converter_issues.py @@ -0,0 +1,56 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass +from enum import Enum +from typing import Generic, List, TypeVar + + +class ConverterIssueType(Enum): + """Identifies the kind of information loss that occurred during conversion.""" + + UNSUPPORTED_DIALECT = "UNSUPPORTED_DIALECT" + CARDINALITY_LOSS = "CARDINALITY_LOSS" + RELATIONSHIP_DROPPED = "RELATIONSHIP_DROPPED" + METRIC_NAME_COLLISION = "METRIC_NAME_COLLISION" + STALE_MEASURE = "STALE_MEASURE" + DUPLICATE_FIELD_DROPPED = "DUPLICATE_FIELD_DROPPED" + EXTRA_MODEL_DROPPED = "EXTRA_MODEL_DROPPED" + AI_CONTEXT_DROPPED = "AI_CONTEXT_DROPPED" + METRIC_TABLE_UNRESOLVED = "METRIC_TABLE_UNRESOLVED" + MISSING_DIALECT_EXPRESSION = "MISSING_DIALECT_EXPRESSION" + UNIQUE_KEYS_DROPPED = "UNIQUE_KEYS_DROPPED" + CUSTOM_EXTENSION_DROPPED = "CUSTOM_EXTENSION_DROPPED" + + +@dataclass(frozen=True) +class ConverterIssue: + """Records a single instance of information loss during conversion.""" + + issue_type: ConverterIssueType + element_name: str + + +T = TypeVar("T") + + +@dataclass(frozen=True) +class ConverterResult(Generic[T]): + """Return value of a converter's convert() method, pairing the output with any conversion issues.""" + + output: T + issues: List[ConverterIssue] diff --git a/converters/wisdom/src/ossie_wisdom/osi_to_wisdom.py b/converters/wisdom/src/ossie_wisdom/osi_to_wisdom.py new file mode 100644 index 0000000..14aee07 --- /dev/null +++ b/converters/wisdom/src/ossie_wisdom/osi_to_wisdom.py @@ -0,0 +1,394 @@ +# 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. + +"""Converts an Ossie Document into a WisdomAI domain export (format 1.0). + +The output mirrors the JSON produced by wisdom's ``exportDomain`` RPC so it +can be fed to ``importDomain``. Inverse of :mod:`ossie_wisdom.wisdom_to_osi`: +model ``ai_context`` splits back into system instructions and knowledge items, +fields split back into columns and formulas, relationship direction is read as +many-to-one (with the ai_context notes restoring one-to-one/many-to-many), and +metrics attach to the table their expression references. + +IDs are derived deterministically from element names, and connections are +per-dialect placeholders expected to be remapped when the domain is imported. +""" + +import hashlib +import re +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +from ossie import ( + OSIAIContextObject, + OSIDataset, + OSIDialect, + OSIDocument, + OSIExpression, + OSISemanticModel, +) +from ossie_wisdom.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult + +_WISDOM_DIALECT = { + OSIDialect.SNOWFLAKE: "snowflake", + OSIDialect.DATABRICKS: "databricks", + OSIDialect.BIGQUERY: "bigquery", + OSIDialect.ANSI_SQL: "ansi", +} + + +def _stable_id(prefix: str, value: str) -> str: + return f"{prefix}_{hashlib.md5(value.encode('utf-8')).hexdigest()}" + + +class OSIToWisdomConverter: + """Converts an Ossie Document into a wisdom domain export dict.""" + + def convert(self, document: OSIDocument, exported_at: Optional[str] = None) -> ConverterResult[dict]: + issues: List[ConverterIssue] = [] + + model = document.semantic_model[0] + for extra in document.semantic_model[1:]: + issues.append(ConverterIssue(issue_type=ConverterIssueType.EXTRA_MODEL_DROPPED, element_name=extra.name)) + self._report_custom_extensions(model, issues) + + domain_uuid = _stable_id("ET_DOMAIN", model.name) + zsheet_refs = { + dataset.name: {"uuid": _stable_id("ET_ZSHEET", dataset.name), "name": dataset.name, "version": "1"} + for dataset in model.datasets + } + dataset_dialects = {dataset.name: self._infer_dataset_dialect(dataset) for dataset in model.datasets} + measures_by_dataset = self._assign_metrics(model, dataset_dialects, issues) + + tables = [] + table_metadata = [] + for dataset in model.datasets: + zsheet = self._convert_dataset( + dataset, zsheet_refs, dataset_dialects[dataset.name], measures_by_dataset.get(dataset.name, []), issues + ) + tables.append({"zsheet_uuid": zsheet_refs[dataset.name]["uuid"], "zsheet_json": zsheet}) + location = zsheet["location"] + table_metadata.append( + { + "zsheet_uuid": zsheet_refs[dataset.name]["uuid"], + "connection_id": location["connectionId"], + "database": location["database"], + "schema": location["schema"], + "table_name": location["dbTable"], + } + ) + + instructions, knowledge = self._split_ai_context(model, issues) + domain_zsheet: dict = { + "ref": {"uuid": domain_uuid, "name": model.name, "version": "1"}, + "zsheetType": "DOMAIN", + "relationshipGraph": { + "zsheets": list(zsheet_refs.values()), + "relationships": self._convert_relationships(model, zsheet_refs, issues), + }, + } + if model.description: + domain_zsheet["description"] = model.description + if instructions: + domain_zsheet["domainSystemInstructions"] = instructions + if knowledge: + domain_zsheet["knowledge"] = knowledge + + connections = [ + {"connection_id": f"et-connection-{dialect}", "dialect": dialect, "name": dialect} + for dialect in sorted({_WISDOM_DIALECT[d] for d in dataset_dialects.values()}) + ] + + export = { + "version": "1.0", + "export_metadata": { + "exported_at": exported_at or datetime.now(timezone.utc).isoformat(), + "source_domain_id": domain_uuid, + "domain_name": model.name, + }, + "domain": {"zsheet_json": domain_zsheet}, + "tables": tables, + "connections": connections, + "reviewed_queries": {"ref": None, "items_json": "{}"}, + "synonym_sets": {"ref": None, "items_json": "{}"}, + "table_metadata": table_metadata, + "recommended_questions": [], + } + return ConverterResult(output=export, issues=issues) + + def _report_custom_extensions(self, model: OSISemanticModel, issues: List[ConverterIssue]) -> None: + elements = [(model.name, model.custom_extensions)] + for dataset in model.datasets: + elements.append((dataset.name, dataset.custom_extensions)) + for field in dataset.fields or []: + elements.append((f"{dataset.name}.{field.name}", field.custom_extensions)) + for relationship in model.relationships or []: + elements.append((relationship.name, relationship.custom_extensions)) + for metric in model.metrics or []: + elements.append((metric.name, metric.custom_extensions)) + for name, extensions in elements: + if extensions: + issues.append( + ConverterIssue(issue_type=ConverterIssueType.CUSTOM_EXTENSION_DROPPED, element_name=name) + ) + + def _infer_dataset_dialect(self, dataset: OSIDataset) -> OSIDialect: + for field in dataset.fields or []: + for entry in field.expression.dialects: + if entry.dialect in (OSIDialect.SNOWFLAKE, OSIDialect.DATABRICKS, OSIDialect.BIGQUERY): + return entry.dialect + return OSIDialect.ANSI_SQL + + def _split_ai_context(self, model: OSISemanticModel, issues: List[ConverterIssue]) -> Tuple[str, List[dict]]: + ai_context = model.ai_context + if ai_context is None: + return "", [] + if isinstance(ai_context, OSIAIContextObject): + if ai_context.synonyms or ai_context.examples: + issues.append(ConverterIssue(issue_type=ConverterIssueType.AI_CONTEXT_DROPPED, element_name=model.name)) + text = ai_context.instructions or "" + else: + text = ai_context + + instruction_lines: List[str] = [] + contents: List[str] = [] + current: Optional[str] = None + for line in text.split("\n"): + if line.startswith("- "): + if current is not None: + contents.append(current) + current = line[2:] + elif current is not None: + current += "\n" + line + else: + instruction_lines.append(line) + if current is not None: + contents.append(current) + + knowledge = [ + {"name": content, "content": content, "id": _stable_id("ET_UNSTRUCTURED_KNOWLEDGE", content)} + for content in contents + if content.strip() + ] + return "\n".join(instruction_lines).strip(), knowledge + + def _convert_dataset( + self, + dataset: OSIDataset, + zsheet_refs: Dict[str, dict], + dialect: OSIDialect, + measures: List[dict], + issues: List[ConverterIssue], + ) -> dict: + ref = zsheet_refs[dataset.name] + ref_lite = {"uuid": ref["uuid"], "name": ref["name"]} + database, schema, table = self._split_source(dataset.source) + + columns: List[dict] = [] + formulas: List[dict] = [] + for field in dataset.fields or []: + expression = self._pick_expression(field.expression, dialect, f"{dataset.name}.{field.name}", issues) + if field.ai_context is not None: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.AI_CONTEXT_DROPPED, element_name=f"{dataset.name}.{field.name}" + ) + ) + properties: dict = {} + if field.label: + properties["displayName"] = field.label + if field.dimension and field.dimension.is_time: + properties["dataType"] = "TIMESTAMP" + if self._is_bare_column(expression, field.name): + column: dict = {"name": field.name} + if field.description: + column["description"] = field.description + if properties: + column["properties"] = properties + column["location"] = {"name": field.name, "zsheetRef": ref_lite} + columns.append(column) + else: + formula: dict = {"name": field.name, "expression": expression} + if field.description: + formula["description"] = field.description + if properties: + formula["properties"] = properties + formula["location"] = {"name": field.name, "zsheetRef": ref_lite} + formula["id"] = _stable_id("FORMULA", f"{dataset.name}.{field.name}") + formulas.append(formula) + + zsheet: dict = { + "ref": ref, + "location": { + "database": database, + "schema": schema, + "dbTable": table, + "connectionId": f"et-connection-{_WISDOM_DIALECT[dialect]}", + }, + "source": {"alias": dataset.name, "zsheet": ref_lite}, + "columns": columns, + } + if dataset.description: + zsheet["description"] = dataset.description + if formulas: + zsheet["formulas"] = formulas + if measures: + zsheet["measures"] = measures + if dataset.primary_key: + zsheet["primaryKey"] = {"columns": list(dataset.primary_key)} + if dataset.unique_keys: + issues.append(ConverterIssue(issue_type=ConverterIssueType.UNIQUE_KEYS_DROPPED, element_name=dataset.name)) + if dataset.ai_context is not None: + content = self._ai_context_text(dataset.ai_context, dataset.name, issues) + if content: + zsheet["knowledge"] = [ + {"name": content, "content": content, "id": _stable_id("ET_UNSTRUCTURED_KNOWLEDGE", content)} + ] + return zsheet + + def _ai_context_text(self, ai_context, element_name: str, issues: List[ConverterIssue]) -> str: + if isinstance(ai_context, OSIAIContextObject): + if ai_context.synonyms or ai_context.examples: + issues.append( + ConverterIssue(issue_type=ConverterIssueType.AI_CONTEXT_DROPPED, element_name=element_name) + ) + return ai_context.instructions or "" + return ai_context + + def _split_source(self, source: str) -> Tuple[str, str, str]: + parts = source.split(".") + if len(parts) >= 3: + return parts[0], parts[1], ".".join(parts[2:]) + if len(parts) == 2: + return "", parts[0], parts[1] + return "", "", source + + def _pick_expression( + self, expression: OSIExpression, dialect: OSIDialect, element_name: str, issues: List[ConverterIssue] + ) -> str: + by_dialect = {entry.dialect: entry.expression for entry in expression.dialects} + if dialect in by_dialect: + return by_dialect[dialect] + if OSIDialect.ANSI_SQL in by_dialect: + return by_dialect[OSIDialect.ANSI_SQL] + issues.append( + ConverterIssue(issue_type=ConverterIssueType.MISSING_DIALECT_EXPRESSION, element_name=element_name) + ) + return expression.dialects[0].expression + + def _is_bare_column(self, expression: str, name: str) -> bool: + return expression in (name, f'"{name}"', f"`{name}`") + + def _assign_metrics( + self, model: OSISemanticModel, dataset_dialects: Dict[str, OSIDialect], issues: List[ConverterIssue] + ) -> Dict[str, List[dict]]: + measures: Dict[str, List[dict]] = {} + dataset_names = [dataset.name for dataset in model.datasets] + for metric in model.metrics or []: + if metric.ai_context is not None: + issues.append( + ConverterIssue(issue_type=ConverterIssueType.AI_CONTEXT_DROPPED, element_name=metric.name) + ) + # Attach to a dataset first so the expression can be picked in that dataset's dialect. + reference_text = " ".join(entry.expression for entry in metric.expression.dialects) + dataset_name = self._find_referenced_dataset(reference_text, dataset_names) + if dataset_name is None: + dataset_name = dataset_names[0] + issues.append( + ConverterIssue(issue_type=ConverterIssueType.METRIC_TABLE_UNRESOLVED, element_name=metric.name) + ) + expression = self._pick_expression( + metric.expression, dataset_dialects[dataset_name], metric.name, issues + ) + ref = {"uuid": _stable_id("ET_ZSHEET", dataset_name), "name": dataset_name} + measure: dict = {"name": metric.name, "expression": expression} + if metric.description: + measure["description"] = metric.description + measure["location"] = {"name": metric.name, "zsheetRef": ref} + measure["id"] = _stable_id("MEASURE", f"{dataset_name}.{metric.name}") + measures.setdefault(dataset_name, []).append(measure) + return measures + + def _find_referenced_dataset(self, expression: str, dataset_names: List[str]) -> Optional[str]: + best: Optional[str] = None + best_position = len(expression) + 1 + for name in dataset_names: + pattern = re.compile(r'(? List[dict]: + edges = [] + for relationship in model.relationships or []: + left = relationship.from_dataset + right = relationship.to + if left not in zsheet_refs or right not in zsheet_refs: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.RELATIONSHIP_DROPPED, element_name=relationship.name + ) + ) + continue + relationship_type = "MANY_TO_ONE" + if isinstance(relationship.ai_context, str): + if relationship.ai_context.startswith("one-to-one"): + relationship_type = "ONE_TO_ONE" + elif relationship.ai_context.startswith("many-to-many"): + relationship_type = "MANY_TO_MANY" + else: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.AI_CONTEXT_DROPPED, element_name=relationship.name + ) + ) + elif relationship.ai_context is not None: + issues.append( + ConverterIssue(issue_type=ConverterIssueType.AI_CONTEXT_DROPPED, element_name=relationship.name) + ) + + left_ref = {"uuid": zsheet_refs[left]["uuid"], "name": left} + right_ref = {"uuid": zsheet_refs[right]["uuid"], "name": right} + conditions = [ + { + "leftColumn": {"name": from_column, "zsheetRef": left_ref}, + "rightColumn": {"name": to_column, "zsheetRef": right_ref}, + } + for from_column, to_column in zip(relationship.from_columns, relationship.to_columns) + ] + properties: dict = {"relationshipType": relationship_type} + if len(conditions) == 1: + properties["joinCondition"] = conditions[0] + else: + properties["compoundJoinCondition"] = { + "nestedCondition": { + "logicalOperator": "AND", + "conditions": [{"simpleCondition": condition} for condition in conditions], + } + } + edges.append( + { + "properties": properties, + "leftDataSource": {"zsheet": left_ref}, + "rightDataSource": {"zsheet": right_ref}, + } + ) + return edges diff --git a/converters/wisdom/src/ossie_wisdom/wisdom_to_osi.py b/converters/wisdom/src/ossie_wisdom/wisdom_to_osi.py new file mode 100644 index 0000000..6fc8129 --- /dev/null +++ b/converters/wisdom/src/ossie_wisdom/wisdom_to_osi.py @@ -0,0 +1,361 @@ +# 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. + +"""Converts a WisdomAI domain export (format 1.0) into an Ossie Document. + +The input is the JSON produced by wisdom's ``exportDomain`` RPC: a wrapper +holding the domain ZSheet, one ZSheet per table, and connection metadata, +all serialized as protobuf-JSON (camelCase keys). +""" + +import re +from typing import Dict, List, Optional, Set, Tuple + +from ossie import ( + OSIDataset, + OSIDialect, + OSIDialectExpression, + OSIDimension, + OSIDocument, + OSIExpression, + OSIField, + OSIMetric, + OSIRelationship, + OSISemanticModel, +) +from ossie_wisdom.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult + +_DIALECT_MAP: Dict[str, OSIDialect] = { + "snowflake": OSIDialect.SNOWFLAKE, + "databricks": OSIDialect.DATABRICKS, + "bigquery": OSIDialect.BIGQUERY, + "ansi": OSIDialect.ANSI_SQL, + "ansi_sql": OSIDialect.ANSI_SQL, +} + +_BACKTICK_DIALECTS = {OSIDialect.DATABRICKS, OSIDialect.BIGQUERY} + +_TIME_DATA_TYPES = {"DATE", "DATETIME", "TIMESTAMP"} + +_SIMPLE_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class WisdomToOSIConverter: + """Converts a wisdom domain export dict into an Ossie Document.""" + + def convert(self, export: dict) -> ConverterResult[OSIDocument]: + issues: List[ConverterIssue] = [] + + domain = export.get("domain", {}).get("zsheet_json", {}) + dialect_by_connection = self._build_dialect_index(export, issues) + + datasets = [] + metrics: List[OSIMetric] = [] + for table in export.get("tables", []): + zsheet = table.get("zsheet_json", {}) + dialect = self._resolve_dialect(zsheet, dialect_by_connection) + datasets.append(self._convert_table(zsheet, dialect, issues)) + metrics.extend(self._convert_measures(zsheet, dialect, issues)) + if not datasets: + raise ValueError("Export contains no tables; an Ossie semantic model requires at least one dataset.") + metrics = self._dedupe_metrics(metrics, issues) + + dataset_names = {d.name for d in datasets} + relationships = self._convert_relationships(domain, dataset_names, issues) + + model = OSISemanticModel( + name=domain.get("ref", {}).get("name") or export.get("export_metadata", {}).get("domain_name", "domain"), + description=domain.get("description") or None, + ai_context=self._build_ai_context(domain), + datasets=datasets, + relationships=relationships or None, + metrics=[metric for _, metric in metrics] or None, + ) + return ConverterResult(output=OSIDocument(semantic_model=[model]), issues=issues) + + def _build_dialect_index(self, export: dict, issues: List[ConverterIssue]) -> Dict[str, OSIDialect]: + index: Dict[str, OSIDialect] = {} + for connection in export.get("connections", []): + dialect_name = connection.get("dialect", "").lower() + dialect = _DIALECT_MAP.get(dialect_name) + if dialect is None: + dialect = OSIDialect.ANSI_SQL + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.UNSUPPORTED_DIALECT, + element_name=f"{connection.get('name', connection.get('connection_id', '?'))} ({dialect_name})", + ) + ) + index[connection.get("connection_id", "")] = dialect + return index + + def _resolve_dialect(self, zsheet: dict, dialect_by_connection: Dict[str, OSIDialect]) -> OSIDialect: + connection_id = zsheet.get("location", {}).get("connectionId", "") + return dialect_by_connection.get(connection_id, OSIDialect.ANSI_SQL) + + def _build_ai_context(self, domain: dict) -> Optional[str]: + parts: List[str] = [] + system_instructions = domain.get("domainSystemInstructions", "").strip() + if system_instructions: + parts.append(system_instructions) + for knowledge in domain.get("knowledge", []): + content = knowledge.get("content", "").strip() + if content: + parts.append(f"- {content}") + return "\n".join(parts) or None + + def _convert_table(self, zsheet: dict, dialect: OSIDialect, issues: List[ConverterIssue]) -> OSIDataset: + name = zsheet.get("ref", {}).get("name", "") + location = zsheet.get("location", {}) + source = ".".join( + part for part in (location.get("database"), location.get("schema"), location.get("dbTable")) if part + ) + + fields: List[OSIField] = [] + seen: Set[str] = set() + for column in zsheet.get("columns", []): + field = self._convert_column(column, dialect) + if field.name in seen: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.DUPLICATE_FIELD_DROPPED, element_name=f"{name}.{field.name}" + ) + ) + continue + seen.add(field.name) + fields.append(field) + for formula in zsheet.get("formulas", []): + field = self._convert_formula(formula, dialect) + if field is None: + continue + if field.name in seen: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.DUPLICATE_FIELD_DROPPED, element_name=f"{name}.{field.name}" + ) + ) + continue + seen.add(field.name) + fields.append(field) + + return OSIDataset( + name=name, + source=source, + primary_key=self._extract_primary_key(zsheet), + description=zsheet.get("description") or None, + fields=fields or None, + ) + + def _extract_primary_key(self, zsheet: dict) -> Optional[List[str]]: + primary_key = zsheet.get("primaryKey", {}).get("columns") + if primary_key: + return list(primary_key) + flagged = [ + column["name"] + for column in zsheet.get("columns", []) + if column.get("properties", {}).get("isPrimaryKey") + ] + return flagged or None + + def _convert_column(self, column: dict, dialect: OSIDialect) -> OSIField: + properties = column.get("properties", {}) + return OSIField( + name=column.get("name", ""), + expression=self._make_expression(self._quote_identifier(column.get("name", ""), dialect), dialect), + dimension=OSIDimension(is_time=True) if properties.get("dataType") in _TIME_DATA_TYPES else None, + label=properties.get("displayName") or None, + description=column.get("description") or None, + ) + + def _convert_formula(self, formula: dict, dialect: OSIDialect) -> Optional[OSIField]: + name = formula.get("name", "") + expression = formula.get("expression", "") + if not name or not expression: + return None + properties = formula.get("properties", {}) + return OSIField( + name=name, + expression=self._make_expression(expression, dialect), + dimension=OSIDimension(is_time=True) if properties.get("dataType") in _TIME_DATA_TYPES else None, + label=properties.get("displayName") or None, + description=formula.get("description") or None, + ) + + def _convert_measures( + self, zsheet: dict, dialect: OSIDialect, issues: List[ConverterIssue] + ) -> List[Tuple[str, OSIMetric]]: + table_name = zsheet.get("ref", {}).get("name", "") + metrics: List[Tuple[str, OSIMetric]] = [] + for measure in zsheet.get("measures", []): + name = measure.get("name", "") + expression = measure.get("expression", "") + if not name or not expression: + continue + if measure.get("staleReason"): + issues.append( + ConverterIssue(issue_type=ConverterIssueType.STALE_MEASURE, element_name=f"{table_name}.{name}") + ) + metrics.append( + ( + table_name, + OSIMetric( + name=name, + expression=self._make_expression(expression, dialect), + description=measure.get("description") or None, + ), + ) + ) + return metrics + + def _dedupe_metrics( + self, metrics: List[Tuple[str, OSIMetric]], issues: List[ConverterIssue] + ) -> List[Tuple[str, OSIMetric]]: + result: List[Tuple[str, OSIMetric]] = [] + seen: Set[str] = set() + for table_name, metric in metrics: + name = metric.name + if name in seen: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.METRIC_NAME_COLLISION, element_name=f"{table_name}.{name}" + ) + ) + name = f"{table_name}_{name}" + suffix = 2 + while name in seen: + name = f"{table_name}_{metric.name}_{suffix}" + suffix += 1 + metric = metric.model_copy(update={"name": name}) + seen.add(name) + result.append((table_name, metric)) + return result + + def _convert_relationships( + self, domain: dict, dataset_names: Set[str], issues: List[ConverterIssue] + ) -> List[OSIRelationship]: + relationships: List[OSIRelationship] = [] + seen_names: Set[str] = set() + edges = domain.get("relationshipGraph", {}).get("relationships", []) + for edge in edges: + left = edge.get("leftDataSource", {}).get("zsheet", {}).get("name") + right = edge.get("rightDataSource", {}).get("zsheet", {}).get("name") + properties = edge.get("properties", {}) + column_pairs = self._extract_column_pairs(properties) + if not left or not right or column_pairs is None: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.RELATIONSHIP_DROPPED, + element_name=f"{left or '?'} <-> {right or '?'}", + ) + ) + continue + if left not in dataset_names or right not in dataset_names: + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.RELATIONSHIP_DROPPED, element_name=f"{left} <-> {right}" + ) + ) + continue + + relationship_type = properties.get("relationshipType", "") + ai_context: Optional[str] = None + # Ossie encodes cardinality by direction: `from` is the many side, `to` the one side. + if relationship_type == "ONE_TO_MANY": + from_dataset, to_dataset = right, left + from_columns = [pair[1] for pair in column_pairs] + to_columns = [pair[0] for pair in column_pairs] + else: + from_dataset, to_dataset = left, right + from_columns = [pair[0] for pair in column_pairs] + to_columns = [pair[1] for pair in column_pairs] + if relationship_type == "ONE_TO_ONE": + ai_context = "one-to-one relationship" + elif relationship_type == "MANY_TO_MANY": + ai_context = "many-to-many relationship; cardinality is not representable in Ossie" + issues.append( + ConverterIssue( + issue_type=ConverterIssueType.CARDINALITY_LOSS, element_name=f"{left} <-> {right}" + ) + ) + + name = f"{from_dataset}_to_{to_dataset}" + suffix = 2 + while name in seen_names: + name = f"{from_dataset}_to_{to_dataset}_{suffix}" + suffix += 1 + seen_names.add(name) + + relationships.append( + OSIRelationship( + name=name, + from_dataset=from_dataset, + to=to_dataset, + from_columns=from_columns, + to_columns=to_columns, + ai_context=ai_context, + ) + ) + return relationships + + def _extract_column_pairs(self, properties: dict) -> Optional[List[Tuple[str, str]]]: + """Returns (left_column, right_column) pairs, or None when the condition cannot be represented.""" + condition = properties.get("joinCondition") + if condition is not None: + pair = self._simple_condition_pair(condition) + return [pair] if pair else None + compound = properties.get("compoundJoinCondition") + if compound is not None: + return self._flatten_compound_condition(compound) + return None + + def _simple_condition_pair(self, condition: dict) -> Optional[Tuple[str, str]]: + left = condition.get("leftColumn", {}).get("name") + right = condition.get("rightColumn", {}).get("name") + # The only join operator wisdom emits is EQUAL (proto default, omitted in JSON). + if condition.get("operator") not in (None, "EQUAL") or not left or not right: + return None + return (left, right) + + def _flatten_compound_condition(self, compound: dict) -> Optional[List[Tuple[str, str]]]: + """Flattens an AND-of-equals compound condition into column pairs; None for anything else.""" + simple = compound.get("simpleCondition") + if simple is not None: + pair = self._simple_condition_pair(simple) + return [pair] if pair else None + nested = compound.get("nestedCondition") + if nested is not None: + if nested.get("logicalOperator") != "AND": + return None + pairs: List[Tuple[str, str]] = [] + for child in nested.get("conditions", []): + child_pairs = self._flatten_compound_condition(child) + if child_pairs is None: + return None + pairs.extend(child_pairs) + return pairs or None + return None + + def _quote_identifier(self, name: str, dialect: OSIDialect) -> str: + """Quotes a column name for use as a SQL expression when it is not a plain identifier.""" + if _SIMPLE_IDENTIFIER.match(name): + return name + if dialect in _BACKTICK_DIALECTS: + return "`" + name.replace("`", "``") + "`" + return '"' + name.replace('"', '""') + '"' + + def _make_expression(self, expression: str, dialect: OSIDialect) -> OSIExpression: + return OSIExpression(dialects=[OSIDialectExpression(dialect=dialect, expression=expression)]) diff --git a/converters/wisdom/tests/__init__.py b/converters/wisdom/tests/__init__.py new file mode 100644 index 0000000..13a8339 --- /dev/null +++ b/converters/wisdom/tests/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/converters/wisdom/tests/fixtures/sample_export.json b/converters/wisdom/tests/fixtures/sample_export.json new file mode 100644 index 0000000..ebe256c --- /dev/null +++ b/converters/wisdom/tests/fixtures/sample_export.json @@ -0,0 +1,227 @@ +{ + "version": "1.0", + "export_metadata": { + "exported_at": "2026-07-10T00:00:00+00:00", + "source_domain_id": "ET_DOMAIN_sample", + "domain_name": "Sample Sales" + }, + "domain": { + "zsheet_json": { + "ref": {"uuid": "ET_DOMAIN_sample", "name": "Sample Sales", "version": "1"}, + "description": "Synthetic sales domain used for converter tests", + "zsheetType": "DOMAIN", + "domainSystemInstructions": "Only answer questions about sales data.", + "knowledge": [ + { + "name": "Fiscal year", + "content": "The fiscal year starts in February.", + "id": "ET_UNSTRUCTURED_KNOWLEDGE_1" + }, + { + "name": "Pipeline", + "content": "Pipeline refers to open orders expected to close this quarter.", + "id": "ET_UNSTRUCTURED_KNOWLEDGE_2", + "schemaAnnotation": {"relevance": "NOT_SCHEMA_RELATED"} + } + ], + "relationshipGraph": { + "zsheets": [ + {"uuid": "ET_ZSHEET_orders", "name": "orders", "version": "1"}, + {"uuid": "ET_ZSHEET_customers", "name": "customers", "version": "1"}, + {"uuid": "ET_ZSHEET_tags", "name": "tags", "version": "1"} + ], + "relationships": [ + { + "properties": { + "joinCondition": { + "leftColumn": {"name": "customer_id", "zsheetRef": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightColumn": {"name": "customer_id", "zsheetRef": {"uuid": "ET_ZSHEET_customers", "name": "customers"}} + }, + "relationshipType": "MANY_TO_ONE" + }, + "leftDataSource": {"zsheet": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightDataSource": {"zsheet": {"uuid": "ET_ZSHEET_customers", "name": "customers"}} + }, + { + "properties": { + "joinCondition": { + "leftColumn": {"name": "customer_id", "zsheetRef": {"uuid": "ET_ZSHEET_customers", "name": "customers"}}, + "rightColumn": {"name": "customer_id", "zsheetRef": {"uuid": "ET_ZSHEET_orders", "name": "orders"}} + }, + "relationshipType": "ONE_TO_MANY" + }, + "leftDataSource": {"zsheet": {"uuid": "ET_ZSHEET_customers", "name": "customers"}}, + "rightDataSource": {"zsheet": {"uuid": "ET_ZSHEET_orders", "name": "orders"}} + }, + { + "properties": { + "joinCondition": { + "leftColumn": {"name": "order_id", "zsheetRef": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightColumn": {"name": "order_id", "zsheetRef": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + }, + "relationshipType": "MANY_TO_MANY" + }, + "leftDataSource": {"zsheet": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightDataSource": {"zsheet": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + }, + { + "properties": { + "compoundJoinCondition": { + "nestedCondition": { + "logicalOperator": "AND", + "conditions": [ + { + "simpleCondition": { + "leftColumn": {"name": "order_id", "zsheetRef": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightColumn": {"name": "order_id", "zsheetRef": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + } + }, + { + "simpleCondition": { + "leftColumn": {"name": "customer_id", "zsheetRef": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightColumn": {"name": "customer_id", "zsheetRef": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + } + } + ] + } + }, + "relationshipType": "MANY_TO_ONE" + }, + "leftDataSource": {"zsheet": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightDataSource": {"zsheet": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + }, + { + "properties": { + "compoundJoinCondition": { + "nestedCondition": { + "logicalOperator": "OR", + "conditions": [ + { + "simpleCondition": { + "leftColumn": {"name": "order_id", "zsheetRef": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightColumn": {"name": "order_id", "zsheetRef": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + } + } + ] + } + }, + "relationshipType": "MANY_TO_ONE" + }, + "leftDataSource": {"zsheet": {"uuid": "ET_ZSHEET_orders", "name": "orders"}}, + "rightDataSource": {"zsheet": {"uuid": "ET_ZSHEET_tags", "name": "tags"}} + } + ] + } + } + }, + "tables": [ + { + "zsheet_uuid": "ET_ZSHEET_orders", + "zsheet_json": { + "ref": {"uuid": "ET_ZSHEET_orders", "name": "orders", "version": "1"}, + "description": "Customer orders", + "location": { + "database": "analytics", + "schema": "sales", + "dbTable": "orders", + "connectionId": "et-connection-snowflake" + }, + "columns": [ + {"name": "order_id", "properties": {"dataType": "INT64", "isPrimaryKey": true}}, + {"name": "customer_id", "properties": {"dataType": "INT64"}}, + {"name": "order_date", "properties": {"dataType": "DATE"}}, + {"name": "amount", "properties": {"dataType": "DOUBLE", "visibility": "HIDDEN"}}, + { + "name": "status", + "description": "Current order status", + "properties": {"dataType": "VARCHAR", "displayName": "Order Status", "isEnum": true} + }, + {"name": "Discount - Percent", "properties": {"dataType": "DOUBLE"}} + ], + "formulas": [ + { + "name": "is_large", + "expression": "CASE WHEN \"orders\".\"amount\" > 100 THEN TRUE ELSE FALSE END", + "description": "true when the order amount exceeds 100", + "properties": {"dataType": "BOOL", "displayName": "Is Large"}, + "id": "FORMULA_1" + } + ], + "measures": [ + { + "name": "total_amount", + "expression": "SUM(\"orders\".\"amount\")", + "description": "Total order amount", + "staleReason": "Column orders.amount is hidden in this domain and cannot be queried.", + "id": "MEASURE_1" + } + ] + } + }, + { + "zsheet_uuid": "ET_ZSHEET_customers", + "zsheet_json": { + "ref": {"uuid": "ET_ZSHEET_customers", "name": "customers", "version": "1"}, + "description": "Customer master data", + "location": { + "database": "analytics", + "schema": "sales", + "dbTable": "customers", + "connectionId": "et-connection-snowflake" + }, + "primaryKey": {"columns": ["customer_id"]}, + "columns": [ + {"name": "customer_id", "properties": {"dataType": "INT64"}}, + {"name": "name", "properties": {"dataType": "VARCHAR"}}, + {"name": "region", "properties": {"dataType": "VARCHAR"}} + ], + "formulas": [ + { + "name": "region", + "expression": "UPPER(\"customers\".\"region\")", + "properties": {"dataType": "VARCHAR"}, + "id": "FORMULA_2" + } + ], + "measures": [ + { + "name": "total_amount", + "expression": "COUNT(DISTINCT \"customers\".\"customer_id\")", + "description": "Distinct customer count (name collides with the orders measure)", + "id": "MEASURE_2" + } + ] + } + }, + { + "zsheet_uuid": "ET_ZSHEET_tags", + "zsheet_json": { + "ref": {"uuid": "ET_ZSHEET_tags", "name": "tags", "version": "1"}, + "description": "Order tags", + "location": { + "database": "tagdb", + "schema": "public", + "dbTable": "tags", + "connectionId": "et-connection-postgres" + }, + "columns": [ + {"name": "tag_id", "properties": {"dataType": "INT64"}}, + {"name": "order_id", "properties": {"dataType": "INT64"}}, + {"name": "customer_id", "properties": {"dataType": "INT64"}} + ] + } + } + ], + "connections": [ + {"connection_id": "et-connection-snowflake", "dialect": "snowflake", "name": "Snowflake"}, + {"connection_id": "et-connection-postgres", "dialect": "postgres", "name": "Postgres"} + ], + "reviewed_queries": {"ref": null, "items_json": "{}"}, + "synonym_sets": {"ref": null, "items_json": "{}"}, + "table_metadata": [ + {"zsheet_uuid": "ET_ZSHEET_orders", "connection_id": "et-connection-snowflake", "database": "analytics", "schema": "sales", "table_name": "orders"}, + {"zsheet_uuid": "ET_ZSHEET_customers", "connection_id": "et-connection-snowflake", "database": "analytics", "schema": "sales", "table_name": "customers"}, + {"zsheet_uuid": "ET_ZSHEET_tags", "connection_id": "et-connection-postgres", "database": "tagdb", "schema": "public", "table_name": "tags"} + ], + "recommended_questions": ["What is the total order amount by region?"] +} diff --git a/converters/wisdom/tests/test_osi_to_wisdom.py b/converters/wisdom/tests/test_osi_to_wisdom.py new file mode 100644 index 0000000..1ee1717 --- /dev/null +++ b/converters/wisdom/tests/test_osi_to_wisdom.py @@ -0,0 +1,229 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json +from pathlib import Path + +import pytest + +from ossie import ( + OSIDataset, + OSIDialect, + OSIDialectExpression, + OSIDocument, + OSIExpression, + OSIField, + OSIRelationship, + OSISemanticModel, +) +from ossie_wisdom import ConverterIssueType, OSIToWisdomConverter, WisdomToOSIConverter + +FIXTURE = Path(__file__).parent / "fixtures" / "sample_export.json" + + +def _snowflake(expression): + return OSIExpression(dialects=[OSIDialectExpression(dialect=OSIDialect.SNOWFLAKE, expression=expression)]) + + +@pytest.fixture(scope="module") +def osi_document(): + export = json.loads(FIXTURE.read_text()) + return WisdomToOSIConverter().convert(export).output + + +@pytest.fixture(scope="module") +def result(osi_document): + return OSIToWisdomConverter().convert(osi_document, exported_at="2026-07-10T00:00:00+00:00") + + +@pytest.fixture(scope="module") +def export(result): + return result.output + + +def _issues_of(result, issue_type): + return [issue for issue in result.issues if issue.issue_type is issue_type] + + +def _table(export, name): + return next( + table["zsheet_json"] for table in export["tables"] if table["zsheet_json"]["ref"]["name"] == name + ) + + +def test_export_envelope(export): + assert export["version"] == "1.0" + assert export["export_metadata"]["domain_name"] == "Sample Sales" + assert export["export_metadata"]["source_domain_id"] == export["domain"]["zsheet_json"]["ref"]["uuid"] + domain = export["domain"]["zsheet_json"] + assert domain["zsheetType"] == "DOMAIN" + assert domain["description"] == "Synthetic sales domain used for converter tests" + + +def test_ai_context_splits_into_instructions_and_knowledge(export): + domain = export["domain"]["zsheet_json"] + assert domain["domainSystemInstructions"] == "Only answer questions about sales data." + assert [knowledge["content"] for knowledge in domain["knowledge"]] == [ + "The fiscal year starts in February.", + "Pipeline refers to open orders expected to close this quarter.", + ] + + +def test_tables_and_locations(export): + orders = _table(export, "orders") + assert orders["location"]["database"] == "analytics" + assert orders["location"]["schema"] == "sales" + assert orders["location"]["dbTable"] == "orders" + assert orders["primaryKey"] == {"columns": ["order_id"]} + assert _table(export, "customers")["primaryKey"] == {"columns": ["customer_id"]} + metadata = {entry["zsheet_uuid"]: entry for entry in export["table_metadata"]} + assert metadata[orders["ref"]["uuid"]]["table_name"] == "orders" + + +def test_fields_split_into_columns_and_formulas(export): + orders = _table(export, "orders") + column_names = [column["name"] for column in orders["columns"]] + assert "order_id" in column_names + # A quoted bare-name expression is recognized as a plain column. + assert "Discount - Percent" in column_names + formulas = {formula["name"]: formula for formula in orders["formulas"]} + assert set(formulas) == {"is_large"} + assert formulas["is_large"]["expression"] == 'CASE WHEN "orders"."amount" > 100 THEN TRUE ELSE FALSE END' + assert formulas["is_large"]["properties"]["displayName"] == "Is Large" + status = next(column for column in orders["columns"] if column["name"] == "status") + assert status["description"] == "Current order status" + assert status["properties"]["displayName"] == "Order Status" + + +def test_metrics_attach_to_referenced_tables(export): + orders_measures = {measure["name"] for measure in _table(export, "orders")["measures"]} + customers_measures = {measure["name"] for measure in _table(export, "customers")["measures"]} + assert orders_measures == {"total_amount"} + assert customers_measures == {"customers_total_amount"} + + +def test_relationship_types_restored(export): + edges = export["domain"]["zsheet_json"]["relationshipGraph"]["relationships"] + types = [edge["properties"]["relationshipType"] for edge in edges] + assert types == ["MANY_TO_ONE", "MANY_TO_ONE", "MANY_TO_MANY", "MANY_TO_ONE"] + compound = edges[3]["properties"]["compoundJoinCondition"]["nestedCondition"] + assert compound["logicalOperator"] == "AND" + assert len(compound["conditions"]) == 2 + + +def test_connections_are_per_dialect(export): + dialects = {connection["dialect"] for connection in export["connections"]} + assert dialects == {"snowflake", "ansi"} + assert _table(export, "orders")["location"]["connectionId"] == "et-connection-snowflake" + assert _table(export, "tags")["location"]["connectionId"] == "et-connection-ansi" + + +def test_round_trip_preserves_osi_document(osi_document, export): + round_tripped = WisdomToOSIConverter().convert(export).output + assert round_tripped == osi_document + + +def test_deterministic_output(osi_document, export): + again = OSIToWisdomConverter().convert(osi_document, exported_at="2026-07-10T00:00:00+00:00").output + assert again == export + + +def test_extra_models_and_unrepresentable_elements_are_reported(): + dataset = OSIDataset( + name="orders", + source="analytics.sales.orders", + unique_keys=[["order_id"]], + fields=[ + OSIField(name="order_id", expression=_snowflake("order_id"), ai_context="the identifier"), + ], + ) + second = OSISemanticModel(name="second", datasets=[OSIDataset(name="d", source="a.b.c")]) + document = OSIDocument( + semantic_model=[ + OSISemanticModel( + name="first", + datasets=[dataset], + relationships=[ + OSIRelationship( + name="orders_to_missing", + from_dataset="orders", + to="missing", + from_columns=["x"], + to_columns=["y"], + ) + ], + ), + second, + ] + ) + result = OSIToWisdomConverter().convert(document, exported_at="2026-07-10T00:00:00+00:00") + assert [issue.element_name for issue in _issues_of(result, ConverterIssueType.EXTRA_MODEL_DROPPED)] == ["second"] + assert [issue.element_name for issue in _issues_of(result, ConverterIssueType.UNIQUE_KEYS_DROPPED)] == ["orders"] + assert [issue.element_name for issue in _issues_of(result, ConverterIssueType.AI_CONTEXT_DROPPED)] == [ + "orders.order_id" + ] + assert [issue.element_name for issue in _issues_of(result, ConverterIssueType.RELATIONSHIP_DROPPED)] == [ + "orders_to_missing" + ] + assert result.output["domain"]["zsheet_json"]["relationshipGraph"]["relationships"] == [] + + +def test_one_to_one_note_restores_relationship_type(): + document = OSIDocument( + semantic_model=[ + OSISemanticModel( + name="m", + datasets=[ + OSIDataset(name="a", source="db.s.a"), + OSIDataset(name="b", source="db.s.b"), + ], + relationships=[ + OSIRelationship( + name="a_to_b", + from_dataset="a", + to="b", + from_columns=["id"], + to_columns=["id"], + ai_context="one-to-one relationship", + ) + ], + ) + ] + ) + export = OSIToWisdomConverter().convert(document, exported_at="2026-07-10T00:00:00+00:00").output + edges = export["domain"]["zsheet_json"]["relationshipGraph"]["relationships"] + assert edges[0]["properties"]["relationshipType"] == "ONE_TO_ONE" + + +def test_unresolved_metric_attaches_to_first_dataset(): + from ossie import OSIMetric + + document = OSIDocument( + semantic_model=[ + OSISemanticModel( + name="m", + datasets=[OSIDataset(name="a", source="db.s.a"), OSIDataset(name="b", source="db.s.b")], + metrics=[OSIMetric(name="row_count", expression=_snowflake("COUNT(*)"))], + ) + ] + ) + result = OSIToWisdomConverter().convert(document, exported_at="2026-07-10T00:00:00+00:00") + export = result.output + assert [measure["name"] for measure in _table(export, "a")["measures"]] == ["row_count"] + assert [issue.element_name for issue in _issues_of(result, ConverterIssueType.METRIC_TABLE_UNRESOLVED)] == [ + "row_count" + ] diff --git a/converters/wisdom/tests/test_wisdom_to_osi.py b/converters/wisdom/tests/test_wisdom_to_osi.py new file mode 100644 index 0000000..eaa1c2d --- /dev/null +++ b/converters/wisdom/tests/test_wisdom_to_osi.py @@ -0,0 +1,167 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json +from pathlib import Path + +import pytest +import yaml + +from ossie import OSIDialect, OSIDocument +from ossie_wisdom import ConverterIssueType, WisdomToOSIConverter + +FIXTURE = Path(__file__).parent / "fixtures" / "sample_export.json" + + +@pytest.fixture(scope="module") +def result(): + export = json.loads(FIXTURE.read_text()) + return WisdomToOSIConverter().convert(export) + + +@pytest.fixture(scope="module") +def model(result): + assert len(result.output.semantic_model) == 1 + return result.output.semantic_model[0] + + +def _issues_of(result, issue_type): + return [issue for issue in result.issues if issue.issue_type is issue_type] + + +def test_model_name_and_description(model): + assert model.name == "Sample Sales" + assert model.description == "Synthetic sales domain used for converter tests" + + +def test_knowledge_becomes_model_ai_context(model): + assert model.ai_context == ( + "Only answer questions about sales data.\n" + "- The fiscal year starts in February.\n" + "- Pipeline refers to open orders expected to close this quarter." + ) + + +def test_datasets(model): + datasets = {dataset.name: dataset for dataset in model.datasets} + assert set(datasets) == {"orders", "customers", "tags"} + assert datasets["orders"].source == "analytics.sales.orders" + assert datasets["orders"].description == "Customer orders" + # Explicit primaryKey wins; otherwise per-column isPrimaryKey flags are collected. + assert datasets["customers"].primary_key == ["customer_id"] + assert datasets["orders"].primary_key == ["order_id"] + assert datasets["tags"].primary_key is None + + +def test_columns_become_fields(model): + orders = next(dataset for dataset in model.datasets if dataset.name == "orders") + fields = {field.name: field for field in orders.fields} + assert fields["order_id"].expression.dialects[0].expression == "order_id" + assert fields["order_id"].expression.dialects[0].dialect is OSIDialect.SNOWFLAKE + assert fields["order_date"].dimension.is_time is True + assert fields["order_id"].dimension is None + assert fields["status"].label == "Order Status" + assert fields["status"].description == "Current order status" + # Hidden columns are included. + assert "amount" in fields + # Non-identifier column names are quoted in the expression so they parse as SQL. + assert fields["Discount - Percent"].expression.dialects[0].expression == '"Discount - Percent"' + + +def test_formulas_become_fields(model): + orders = next(dataset for dataset in model.datasets if dataset.name == "orders") + fields = {field.name: field for field in orders.fields} + is_large = fields["is_large"] + assert is_large.expression.dialects[0].expression == 'CASE WHEN "orders"."amount" > 100 THEN TRUE ELSE FALSE END' + assert is_large.expression.dialects[0].dialect is OSIDialect.SNOWFLAKE + assert is_large.label == "Is Large" + assert is_large.description == "true when the order amount exceeds 100" + + +def test_formula_colliding_with_column_is_dropped(result, model): + customers = next(dataset for dataset in model.datasets if dataset.name == "customers") + assert [field.name for field in customers.fields].count("region") == 1 + dropped = _issues_of(result, ConverterIssueType.DUPLICATE_FIELD_DROPPED) + assert [issue.element_name for issue in dropped] == ["customers.region"] + + +def test_unsupported_dialect_falls_back_to_ansi(result, model): + tags = next(dataset for dataset in model.datasets if dataset.name == "tags") + assert all(field.expression.dialects[0].dialect is OSIDialect.ANSI_SQL for field in tags.fields) + unsupported = _issues_of(result, ConverterIssueType.UNSUPPORTED_DIALECT) + assert len(unsupported) == 1 + assert "postgres" in unsupported[0].element_name + + +def test_relationship_directions(model): + relationships = {relationship.name: relationship for relationship in model.relationships} + # MANY_TO_ONE keeps left as the many side. + many_to_one = relationships["orders_to_customers"] + assert (many_to_one.from_dataset, many_to_one.to) == ("orders", "customers") + assert many_to_one.from_columns == ["customer_id"] + assert many_to_one.to_columns == ["customer_id"] + # ONE_TO_MANY is flipped so `from` is the many side; the name is deduped. + flipped = relationships["orders_to_customers_2"] + assert (flipped.from_dataset, flipped.to) == ("orders", "customers") + + +def test_many_to_many_is_kept_with_cardinality_loss(result, model): + relationships = {relationship.name: relationship for relationship in model.relationships} + many_to_many = relationships["orders_to_tags"] + assert many_to_many.ai_context == "many-to-many relationship; cardinality is not representable in Ossie" + losses = _issues_of(result, ConverterIssueType.CARDINALITY_LOSS) + assert [issue.element_name for issue in losses] == ["orders <-> tags"] + + +def test_compound_and_join_is_flattened(model): + relationships = {relationship.name: relationship for relationship in model.relationships} + compound = relationships["orders_to_tags_2"] + assert compound.from_columns == ["order_id", "customer_id"] + assert compound.to_columns == ["order_id", "customer_id"] + + +def test_or_join_is_dropped(result, model): + assert len(model.relationships) == 4 + dropped = _issues_of(result, ConverterIssueType.RELATIONSHIP_DROPPED) + assert [issue.element_name for issue in dropped] == ["orders <-> tags"] + + +def test_measures_become_metrics(result, model): + metrics = {metric.name: metric for metric in model.metrics} + assert set(metrics) == {"total_amount", "customers_total_amount"} + total = metrics["total_amount"] + assert total.expression.dialects[0].expression == 'SUM("orders"."amount")' + assert total.expression.dialects[0].dialect is OSIDialect.SNOWFLAKE + assert total.description == "Total order amount" + collisions = _issues_of(result, ConverterIssueType.METRIC_NAME_COLLISION) + assert [issue.element_name for issue in collisions] == ["customers.total_amount"] + + +def test_stale_measure_is_kept_with_warning(result, model): + assert any(metric.name == "total_amount" for metric in model.metrics) + stale = _issues_of(result, ConverterIssueType.STALE_MEASURE) + assert [issue.element_name for issue in stale] == ["orders.total_amount"] + + +def test_output_round_trips_through_osi_yaml(result): + document = OSIDocument.model_validate(yaml.safe_load(result.output.to_osi_yaml())) + assert document == result.output + + +def test_export_without_tables_is_rejected(): + with pytest.raises(ValueError, match="no tables"): + WisdomToOSIConverter().convert({"domain": {"zsheet_json": {"ref": {"name": "empty"}}}, "tables": []}) diff --git a/core-spec/expression_language.md b/core-spec/expression_language.md new file mode 100644 index 0000000..8ec34ad --- /dev/null +++ b/core-spec/expression_language.md @@ -0,0 +1,761 @@ +# Ossie Proposal: Expression Language + +**Current Status:** Proposed Final + +**Working Group** + +| Lead(s) | Participants | +| :---- | :---- | +| Will Pugh, Snowflake Khushboo Bhatia, Snowflake | LLyod Tabb, Malloy Dianne Wood, Atscale Lior Ebel, Salesforce Quigley Malcolm, dbt Labs Kurt, Relational AI Justin Talbot, Databricks Pavel Tiunov, Cube Damian Waldron, Thoughtspot Oliver Laslett, Lightdash Martin Traverso, Starburst JB Onofré, The ASF Raul Beiroa, Denodo | + +## Overview + +![Ossie Layers](img/Ossie_layers.png) + +There are two layers in Ossie that need an expression language: + +* **Ontology layer.** This layer maps onto the ontology layer which sits above the logical layer. It maps more closely to modelling languages like OWL, [(Py)Rel](https://docs.relational.ai) from RelationalAI, and [Legend](https://legend.finos.org) from Goldman Sachs +* **Logical layer.** This layer maps directly to the databases and physical layer. It maps closely to traditional BI semantic models. + +This proposal is only targeted at the Logical Layer. It would be nice if the Ontological layer could re-use the same expression language, but that will be treated as a separate proposal. + +This document defines the SQL expression language subset that Ossie-compliant implementations MUST support. The goal is to provide a portable expression language that works across all Ossie implementations while allowing vendors to expose richer database-specific functionality through dialect extensions. In particular, it is meant for expressions at the logical layer. This means metrics, fields, filters, etc In the future, expressions such as arbitrary join expressions should also use this expression language. + +We expect there will be extensions to this language to cover concepts such as sub-queries, grain calculations, etc. However, these will each have their own proposal. + +### Design Principles + +1. **Portability**: Core functions work identically across all implementations +2. **Familiarity**: Based on widely-adopted SQL syntax and semantics +3. **Analytical Focus**: Prioritizes functions commonly used in BI and analytics +4. **Extensibility**: Vendor dialects can extend beyond the core + +### Changes to YAML + +1) Create a new dialect in the Ossie spec: Ossie\_SQL\_2026, which refers to this language specification. +2) Make Ossie\_SQL\_2026 the default dialect if one is not chosen. + +### Standards Reference + +The core language is based on **ANSI SQL:2003 Core** (ISO/IEC 9075-2:2003), selected for its: + +- Wide adoption across major databases (Snowflake, Databricks, PostgreSQL, BigQuery) +- Well-defined semantics +- Support for modern analytical features (window functions, CTEs) + +### Namespacing and Identifier Resolution + +The identifiers will match standard SQL identifiers: + +`Field: ` + +`FieldExpr: Field | Field ‘.’ Field` + +The Ossie spec currently contains three namespaces, which determine the visibility and uniqueness of each value. Where and how a field (or metric) is defined will determine the namespace for it, which in turn determines the ways it can be addressed by other fields. + +All identifiers MUST be valid names and follow ANSI SQL naming, with the size limitation of 128 characters for identifiers. Many databases support longer identifiers, however, this number is safe for a broad number of vendors. + +Regular identifiers (unquoted) should be case insensitive. For example, an identifier id is regular, so it would match with Id or iD. Comparing quoted and non-quoted identifiers is DB specific, so for best portability it is best to use simple identifiers. + +The quote character for the Ossie dialect will follow ANSI SQL and support the double quote character (“). This means that if an expression is in a field expression or as an identifier in the YAML, this will be the expected quoting. However, there are some databases that use other escape characters. Working with these have the option of either creating expressions using their dialect or having the Ossie document written in the Ossie dialect, but then having the SQL Interface queried in the local dialect. The SQL Interface will be defined in a different document. + +#### Comparison Table + +| You type this in SQL | Equivalen to | Will it match a column created as id? | +| :---- |:-------------|:--------------------------------------------------| +| id | ID | **Yes** (Standard behavior) | +| Id | ID | **Yes** (Standard behavior) | +| "ID" | ID | **Yes** (Force-matched to normalized case) | +| "id" | id | **No** (Quotes cause an exact match to lowercase) | + +Sometimes, we may refer to a **normalized identifier**. This is a form the identifiers can be put in, so they can be matched easily and matches can be made with case-sensitive, exact matching. For **normalized identifiers**: + +* Regular identifiers are upper cased +* Quoted identifiers have their quotes stripped and any escaped characters are unescaped + +#### Name Spaces + +Namespaces define how an identifier is looked up in an expression. They are covered in the semantics document. Identifiers +will be able to be multi-part and the parts will be separated by the '.' characters, E.g. `dataset.field` This matches SQL conventions. + +## SQL Language Subset + +### Supported SQL Constructs + +Ossie expressions support the following SQL constructs within any expression: + +| Construct | Notes | +| :---- |:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Column and Metric references | Varies based on whether in Ontology or Semantic models. See namespaceing in [OSI Discussion Point: Core Analytic Abstractions](https://docs.google.com/document/d/1si8DqU4arG18ZgX4HnRG5D_zS2X7V1s-vgNY35rvxhM/edit?tab=t.0#heading=h.le505t8uoyfy) And future Ontology documentation | +| Arithmetic operators | `+`, `-`, `*`, `/`, `%` (modulo) | +| Comparison operators | `=`, `<>`, `!=`, `<`, `>`, `<=`, `>=` | +| Logical operators | `AND`, `OR`, `NOT` | +| `BETWEEN` | `x BETWEEN a AND b` | +| `IN` / `NOT IN` | `x IN (a, b, c)` This only supports lists of values, not subqueries. | +| `LIKE` / `ILIKE` | Pattern matching | +| `IS NULL` / `IS NOT NULL` | Null checks | +| `CASE WHEN` | Conditional logic | +| Aggregate functions | Core functions used for aggregations | +| Window functions | Core supported window functions | +| Scalar functions | See function categories below | +| Parentheses | Expression grouping | + +### + +### Not Supported in Expressions + +| Construct | Reason | +| :---- | :---- | +| `SELECT` / `FROM` / `JOIN` | Handled by semantic layer | +| `GROUP BY` | Controlled by grain specification | +| `WHERE` | Use filter property instead | +| Subqueries | Use field references instead, or EXISTS\_IN() for filtering based on a subquery. | +| CTEs | Use field references instead | +| `UNION` / `INTERSECT` / `EXCEPT` | Not applicable to expressions | +| DDL statements | Out of scope | +| DML statements | Out of scope | + +### Operator Precedence + +Standard SQL operator precedence applies (highest to lowest): + +1. Parentheses `()` +2. Unary operators: `+`, `-`, `NOT` +3. Multiplication/Division: `*`, `/`, `%` +4. Addition/Subtraction: `+`, `-` +5. Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `IN`, `BETWEEN`, `IS NULL` +6`AND` +7`OR` + +--- + +## Aggregation Functions + +### Core Aggregation Functions (REQUIRED) + +| Function | Syntax | Description | Decomposability | +| :---- | :---- | :---- | :---- | +| `SUM` | `SUM(expr)` | Sum of values | Distributive | +| `COUNT` | `COUNT(expr)` | Count of non-null values | Distributive | +| `COUNT(*)` | `COUNT(*)` | Count of all rows | Distributive | +| `COUNT(DISTINCT expr)` | `COUNT(DISTINCT expr)` | Count of distinct values | Holistic | +| `AVG` | `AVG(expr)` | Arithmetic mean | Algebraic | +| `MIN` | `MIN(expr)` | Minimum value | Distributive | +| `MAX` | `MAX(expr)` | Maximum value | Distributive | + +### Statistical Aggregations (REQUIRED) + +| Function | Syntax | Description | Decomposability | +| :---- | :---- | :---- | :---- | +| `STDDEV` | `STDDEV(expr)` | Sample standard deviation | Algebraic | +| `STDDEV_POP` | `STDDEV_POP(expr)` | Population standard deviation | Algebraic | +| `STDDEV_SAMP` | `STDDEV_SAMP(expr)` | Sample standard deviation (alias for STDDEV) | Algebraic | +| `VARIANCE` | `VARIANCE(expr)` | Sample variance | Algebraic | +| `VAR_POP` | `VAR_POP(expr)` | Population variance | Algebraic | +| `VAR_SAMP` | `VAR_SAMP(expr)` | Sample variance (alias for VARIANCE) | Algebraic | + +### Percentile Functions (REQUIRED) + +| Function | Syntax | Description | Decomposability | +| :---- | :---- | :---- | :---- | +| `MEDIAN` | `MEDIAN(expr)` | Median value (50th percentile) | Holistic | +| `PERCENTILE_CONT` | `PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)` | Continuous percentile (interpolated) | Holistic | +| `PERCENTILE_DISC` | `PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)` | Discrete percentile (actual value) | Holistic | + +Where `p` is a value between 0 and 1 (e.g., 0.5 for median, 0.75 for 75th percentile). + +### Approximate Aggregations (RECOMMENDED) + +Approximate functions trade exact accuracy for significantly better performance on large datasets. They use probabilistic algorithms (sketches) that are efficiently mergeable, making them well-suited for distributed computation. + +| Function | Syntax | Description | Typical Error | +| :---- | :---- | :---- | :---- | +| `APPROX_COUNT_DISTINCT` | `APPROX_COUNT_DISTINCT(expr)` | Approximate distinct count using HyperLogLog or something similar. Actual method is up to providers. | \~2% | +| `APPROX_PERCENTILE` | `APPROX_PERCENTILE(expr, p)` | Approximate percentile using t-digest or similar | \~1% | + +```sql +-- Approximate distinct count (much faster than COUNT(DISTINCT) on large data) +APPROX_COUNT_DISTINCT(customer_id) + +-- Approximate median +APPROX_PERCENTILE(amount, 0.5) + +-- Approximate 95th percentile +APPROX_PERCENTILE(response_time, 0.95) +``` + +**Database Support:** + +| Function | Snowflake | BigQuery | Databricks | PostgreSQL | +| :---- | :---- | :---- | :---- | :---- | +| `APPROX_COUNT_DISTINCT` | ✅ | ✅ | ✅ | ❌ (extension) | +| `APPROX_PERCENTILE` | ✅ | ✅ `APPROX_QUANTILES` | ✅ | ❌ | + +**Note**: BigQuery uses `APPROX_QUANTILES(expr, num_buckets)` which returns an array. To get a specific percentile: `APPROX_QUANTILES(amount, 100)[OFFSET(50)]` for median. + +--- + +### Conditional Aggregations (REQUIRED) + +SUM / COUNT aggregation functions support `DISTINCT.` +All aggregations should support filtered aggregation: + +```sql +-- DISTINCT modifier +SUM(DISTINCT amount) +COUNT(DISTINCT customer_id) + +-- Filtered aggregation via CASE +SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) +COUNT(CASE WHEN status = 'completed' THEN 1 END) +``` + +### Decomposability Reference + +For multi-stage aggregation (see [Ossie Analytical Context Extension](https://docs.google.com/document/d/1MKNySGmEv_C6CzBZ7um9Ym3_mMvmOolpDuwPvRzQ1bo/edit?usp=sharing)): + +| Category | Functions | +| :---- | :---- | +| **Distributive** | SUM, COUNT, MIN, MAX | +| **Algebraic** | AVG, STDDEV, VARIANCE | +| **Holistic** | MEDIAN, PERCENTILE, COUNT DISTINCT | +| **Sketch-based** | APPROX\_COUNT\_DISTINCT, APPROX\_PERCENTILE | + +--- + +## Date/Time Functions + +### Current Date/Time (REQUIRED) + +| Function | Syntax | Returns | Description | +| :---- | :---- | :---- | :---- | +| `CURRENT_DATE` | `CURRENT_DATE` or `CURRENT_DATE()` | DATE | Current date | +| `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP` or `CURRENT_TIMESTAMP()` | TIMESTAMP | Current timestamp | +| `CURRENT_TIME` | `CURRENT_TIME` or `CURRENT_TIME()` | TIME | Current time | + +### Date/Time Extraction (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `YEAR` | `YEAR(date_expr)` | Extract year (integer) | +| `QUARTER` | `QUARTER(date_expr)` | Extract quarter (1-4) | +| `MONTH` | `MONTH(date_expr)` | Extract month (1-12) | +| `DAY` | `DAY(date_expr)` | Extract day of month (1-31) | +| `DAYOFYEAR` | `DAYOFYEAR(date_expr)` | Day of year (1-366) | +| `HOUR` | `HOUR(timestamp_expr)` | Extract hour (0-23) | +| `MINUTE` | `MINUTE(timestamp_expr)` | Extract minute (0-59) | +| `SECOND` | `SECOND(timestamp_expr)` | Extract second (0-59) | + +### Alternative Extraction Syntax (REQUIRED) + +```sql +-- EXTRACT function (SQL standard) +EXTRACT(YEAR FROM date_expr) +EXTRACT(MONTH FROM date_expr) +EXTRACT(DAY FROM date_expr) + +-- DATE_PART function (common alternative) +DATE_PART('year', date_expr) +DATE_PART('month', date_expr) +DATE_PART('day', date_expr) +``` + +Supported date parts for `EXTRACT` and `DATE_PART`: + +- `YEAR`, `QUARTER`, `MONTH`, `WEEK`, `DAY` +- `DAYOFWEEK`, `DAYOFYEAR` +- `HOUR`, `MINUTE`, `SECOND`, `MILLISECOND` + +### Date Truncation (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `DATE_TRUNC` | `DATE_TRUNC(part, date_expr)` | Truncate to specified precision | + +Supported parts: `'year'`, `'quarter'`, `'month'`, `'week'`, `'day'`, `'hour'`, `'minute'`, `'second'` + +```sql +-- Examples +DATE_TRUNC('month', order_date) -- First day of month +DATE_TRUNC('quarter', order_date) -- First day of quarter +DATE_TRUNC('week', order_date) -- First day of week (Monday) +``` + +### Date Arithmetic (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `DATEADD` | `DATEADD(part, amount, date_expr)` | Add interval to date | +| `DATEDIFF` | `DATEDIFF(part, start_date, end_date)` | Difference between dates | + +```sql +-- Add/subtract intervals +DATEADD(day, 7, order_date) -- Add 7 days +DATEADD(month, -1, order_date) -- Subtract 1 month +DATEADD(year, 1, order_date) -- Add 1 year + +-- Calculate differences +DATEDIFF(day, start_date, end_date) -- Days between dates +DATEDIFF(month, start_date, end_date) -- Months between dates +DATEDIFF(year, start_date, end_date) -- Years between dates +``` + +### Date/Time Construction (REQUIRED) + +Construct DATE, TIME, and TIMESTAMP values using ANSI typed literals or `CAST`. +ISO-8601 strings (`YYYY-MM-DD`, `YYYY-MM-DD HH:MI:SS`, `HH:MI:SS`) require no format +model and behave identically across engines, making them the portable default. + +| Form | Syntax | Description | +| :---- | :---- | :---- | +| Typed literal | `DATE '2024-01-15'` | Construct a DATE | +| Typed literal | `TIMESTAMP_NTZ '2024-01-15 10:30:00'` | Construct a wall-clock timestamp (no time zone) | +| Typed literal | `TIME '10:30:00'` | Construct a TIME | +| Cast | `CAST('2024-01-15' AS DATE)` | Parse ISO string to DATE | +| Cast | `CAST('2024-01-15 10:30:00' AS TIMESTAMP_NTZ)` | Parse ISO string to timestamp | +| Cast | `CAST('10:30:00' AS TIME)` | Parse ISO string to TIME | +| `TO_DATE` | `TO_DATE(string)` | Parse ISO string to DATE | +| `TO_TIMESTAMP` | `TO_TIMESTAMP(string)` | Parse ISO string to timestamp | + +### Date/Time Construction from Format Strings (EXPERIMENTAL) + +Parsing with an explicit format string relies on a datetime format model whose token +vocabulary differs across engines (Oracle/`TO_CHAR`-style, `strftime` `%`-codes, and +Java/LDML patterns are all in use). + +For portability, we are looking to restrict the `format` argument to +the portable core format tokens defined in Date Formatting below, and prefer the +single-argument, ISO-8601 forms above where possible. + +**Since, this differs so widely across databases, consider this experimental for now.** + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `TO_DATE` | `TO_DATE(string, format)` | Parse string to date | +| `TO_TIMESTAMP` | `TO_TIMESTAMP(string, format)` | Parse string to timestamp | + +### Date Formatting (EXPERIMENTAL) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `TO_CHAR` | `TO_CHAR(date_expr, format)` | Format date as string | + +Ossie defines a portable core of format tokens: the tokens that can be expressed in +every major engine's datetime format model. + +This feature is experimental. Implementations choosing to support these should support the following tokens for the +`format` argument of `TO_CHAR`. The `strftime` and Java/LDML columns below are informative, +provided to aid translation. + +| Token | Meaning | `strftime` (C / Python / BigQuery) | Java/LDML (Spark, .NET) | +| :---- | :---- | :---- | :---- | +| `YYYY` | 4-digit year | `%Y` | `yyyy` | +| `YY` | 2-digit year | `%y` | `yy` | +| `MM` | Month (01-12) | `%m` | `MM` | +| `MON` | Abbreviated month name | `%b` | `MMM` | +| `MONTH` | Full month name | `%B` | `MMMM` | +| `DD` | Day of month (01-31) | `%d` | `dd` | +| `DY` | Abbreviated day name | `%a` | `EEE` | +| `DAY` | Full day name | `%A` | `EEEE` | +| `HH24` | Hour (00-23) | `%H` | `HH` | +| `HH12` (`HH`) | Hour (01-12) | `%I` | `hh` | +| `MI` | Minute (00-59) | `%M` | `mm` | +| `SS` | Second (00-59) | `%S` | `ss` | +| `AM` / `PM` | Meridiem indicator | `%p` | `a` | + +**Locale-dependent output.** The name tokens (`MON`, `MONTH`, `DY`, `DAY`, `AM`/`PM`) +render text whose language is governed by engine/session locale settings; the spelling +is not guaranteed identical across engines. + +**Fractional seconds** are available everywhere but the token and precision differ +(Oracle `FF1`–`FF9`, `strftime` `%f`, Java `S`…`SSSSSS`); treat sub-second formatting as +a dialect extension. +--- + +## String Functions + +### String Manipulation (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `CONCAT` | `CONCAT(str1, str2, ...)` | Concatenate strings | +| `||` | `str1 || str2` | Concatenation operator | +| `LENGTH` | `LENGTH(str)` | String length in characters | +| `LOWER` | `LOWER(str)` | Convert to lowercase | +| `UPPER` | `UPPER(str)` | Convert to uppercase | +| `TRIM` | `TRIM(str)` | Remove leading/trailing whitespace | +| `LTRIM` | `LTRIM(str)` | Remove leading whitespace | +| `RTRIM` | `RTRIM(str)` | Remove trailing whitespace | +| `LEFT` | `LEFT(str, n)` | First n characters | +| `RIGHT` | `RIGHT(str, n)` | Last n characters | +| `SUBSTRING` | `SUBSTRING(str, start, length)` | Extract substring | +| `REPLACE` | `REPLACE(str, from, to)` | Replace occurrences | +| `SPLIT_PART` | `SPLIT_PART(str, delimiter, part)` | Extract part by delimiter | + +### String Search (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `POSITION` | `POSITION(substr IN str)` | Position of substring (1-based) | +| `CHARINDEX` | `CHARINDEX(substr, str)` | Alias for POSITION | +| `CONTAINS` | `CONTAINS(str, substr)` | Returns TRUE if contains | +| `STARTSWITH` | `STARTSWITH(str, prefix)` | Returns TRUE if starts with | +| `ENDSWITH` | `ENDSWITH(str, suffix)` | Returns TRUE if ends with | + +### Pattern Matching (REQUIRED) + +| Pattern | Syntax | Description | +| :---- | :---- | :---- | +| `LIKE` | `str LIKE pattern` | Case-sensitive pattern match | +| `ILIKE` | `str ILIKE pattern` | Case-insensitive pattern match | +| `REGEXP_LIKE` | `REGEXP_LIKE(str, pattern)` | Regular expression match | + +Pattern wildcards for `LIKE`: + +- `%` \- Match any sequence of characters +- `_` \- Match any single character + +### Regular Expressions (RECOMMENDED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `REGEXP_EXTRACT` | `REGEXP_EXTRACT(str, pattern)` | Extract first match | +| `REGEXP_REPLACE` | `REGEXP_REPLACE(str, pattern, replacement)` | Replace matches | +| `REGEXP_COUNT` | `REGEXP_COUNT(str, pattern)` | Count matches | + +--- + +## Mathematical Functions + +### Basic Math (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `ABS` | `ABS(x)` | Absolute value | +| `ROUND` | `ROUND(x, d)` | Round to d decimal places | +| `FLOOR` | `FLOOR(x)` | Round down to integer | +| `CEIL` / `CEILING` | `CEIL(x)` | Round up to integer | +| `TRUNC` / `TRUNCATE` | `TRUNC(x, d)` | Truncate to d decimal places | +| `MOD` | `MOD(x, y)` | Modulo (remainder) | +| `SIGN` | `SIGN(x)` | Sign (-1, 0, or 1\) | + +### Advanced Math (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `POWER` | `POWER(x, y)` | x raised to power y | +| `SQRT` | `SQRT(x)` | Square root | +| `EXP` | `EXP(x)` | e raised to power x | +| `LN` | `LN(x)` | Natural logarithm | +| `LOG` | `LOG(base, x)` | Logarithm with specified base | +| `LOG10` | `LOG10(x)` | Base-10 logarithm | + +### Trigonometric (RECOMMENDED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `SIN` | `SIN(x)` | Sine (x in radians) | +| `COS` | `COS(x)` | Cosine | +| `TAN` | `TAN(x)` | Tangent | +| `ASIN` | `ASIN(x)` | Arc sine | +| `ACOS` | `ACOS(x)` | Arc cosine | +| `ATAN` | `ATAN(x)` | Arc tangent | +| `ATAN2` | `ATAN2(y, x)` | Arc tangent of y/x | +| `RADIANS` | `RADIANS(degrees)` | Convert degrees to radians | +| `DEGREES` | `DEGREES(radians)` | Convert radians to degrees | +| `PI` | `PI()` | Value of π | + +### Comparison Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `GREATEST` | `GREATEST(x, y, ...)` | Maximum of arguments | +| `LEAST` | `LEAST(x, y, ...)` | Minimum of arguments | + +--- + +## Conditional Functions + +### CASE Expression (REQUIRED) + +```sql +-- Searched CASE +CASE + WHEN condition1 THEN result1 + WHEN condition2 THEN result2 + ELSE default_result +END + +-- Simple CASE +CASE expression + WHEN value1 THEN result1 + WHEN value2 THEN result2 + ELSE default_result +END +``` + +### Conditional Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `IF` | `IF(condition, true_result, false_result)` | Ternary conditional | +| `IFF` | `IFF(condition, true_result, false_result)` | Alias for IF | +| `NULLIF` | `NULLIF(expr1, expr2)` | Returns NULL if equal | +| `COALESCE` | `COALESCE(expr1, expr2, ...)` | First non-null value | +| `IFNULL` | `IFNULL(expr, default)` | Alias for COALESCE with 2 args | +| `NVL` | `NVL(expr, default)` | Alias for COALESCE with 2 args | +| `NVL2` | `NVL2(expr, not_null_result, null_result)` | Different results for null/not-null | +| `ZeroIfNull` | `ZEROIFNULL(expr)` | Returns 0 if null | +| `NullIfZero` | `NULLIFZERO(expr)` | Returns NULL if zero | + +### Boolean Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `BOOLEAN` | `TRUE`, `FALSE` | Boolean literals | +| `NOT` | `NOT expr` | Logical negation | +| `AND` | `expr1 AND expr2` | Logical AND | +| `OR` | `expr1 OR expr2` | Logical OR | + +--- + +## Window Functions + +Window functions operate over a window frame defined by `OVER()`. This should act consistently with window functions in ANSI SQL. +When, adding the query interface, window functions will be subject to where they are allowed. + +### Syntax + +```sql +function_name(args) OVER ( + [PARTITION BY partition_expr, ...] + [ORDER BY order_expr [ASC|DESC], ...] + [frame_clause] +) +``` + +Frame clause options: + +- `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` +- `ROWS BETWEEN n PRECEDING AND n FOLLOWING` +- `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` + +### Ranking Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `ROW_NUMBER` | `ROW_NUMBER() OVER (...)` | Sequential row number | +| `RANK` | `RANK() OVER (...)` | Rank with gaps for ties | +| `DENSE_RANK` | `DENSE_RANK() OVER (...)` | Rank without gaps | +| `NTILE` | `NTILE(n) OVER (...)` | Divide into n buckets | +| `PERCENT_RANK` | `PERCENT_RANK() OVER (...)` | Relative rank (0-1) | +| `CUME_DIST` | `CUME_DIST() OVER (...)` | Cumulative distribution | + +### Offset Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `LAG` | `LAG(expr, offset, default) OVER (...)` | Value from previous row | +| `LEAD` | `LEAD(expr, offset, default) OVER (...)` | Value from next row | +| `FIRST_VALUE` | `FIRST_VALUE(expr) OVER (...)` | First value in window | +| `LAST_VALUE` | `LAST_VALUE(expr) OVER (...)` | Last value in window | +| `NTH_VALUE` | `NTH_VALUE(expr, n) OVER (...)` | Nth value in window | + +### Window Aggregations (REQUIRED) + +All standard aggregation functions can be used as window functions: + +```sql +-- Running total +SUM(amount) OVER (ORDER BY order_date) + +-- Running average +AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) + +-- Partition totals +SUM(amount) OVER (PARTITION BY region) + +-- Percent of total +amount / SUM(amount) OVER () * 100 +``` + +--- + +## Type Conversion Functions + +### CAST (REQUIRED) + +```sql +CAST(expression AS target_type) +``` + +Supported target types: + +- `VARCHAR` / `STRING` \- Character string +- `INTEGER` / `INT` / `BIGINT` \- Integer +- `DECIMAL` / `NUMERIC` \- Fixed-point decimal +- `FLOAT` / `DOUBLE` \- Floating-point +- `BOOLEAN` \- Boolean +- `DATE` \- Date +- `TIMESTAMP` \- Timestamp +- `TIME` \- Time + +### TRY\_CAST (RECOMMENDED) + +```sql +TRY_CAST(expression AS target_type) -- Returns NULL on failure +``` +--- + +### Null-Safe Comparison + +```sql +-- Standard comparison (returns NULL if either side is NULL) +a = b + +-- Null-safe comparison (treats NULLs as equal) +a IS NOT DISTINCT FROM b -- TRUE if both are NULL +a IS DISTINCT FROM b -- TRUE if one is NULL and other isn't +``` + +--- + +## Dialect Extensions + +Ossie implementations MAY support additional functions through dialect-specific extensions. When using dialect extensions, the expression must specify the dialect. + +The Ossie dialect should always be supported. Other dialects MAY be ignored. There is no guarantee that all different dialects for an expression will act the same, so implementations should be consistent with their dialect handling. This means that if an Ossie model has an expression written in two dialects, the implementation should deterministically choose which dialect to use. + +### Declaring Dialect-Specific Expressions + +``` +expression: + dialects: + - dialect: ANSI_SQL + expression: DATE_TRUNC('month', order_date) + - dialect: SNOWFLAKE + expression: DATE_TRUNC('month', order_date) + - dialect: BIGQUERY + expression: DATE_TRUNC(order_date, MONTH) +``` + +### Common Dialect Variations + +| Function | ANSI\_SQL | Snowflake | BigQuery | Databricks | PostgreSQL | +| :---- | :---- | :---- | :---- | :---- | :---- | +| Date truncation | `DATE_TRUNC('month', d)` | `DATE_TRUNC('month', d)` | `DATE_TRUNC(d, MONTH)` | `DATE_TRUNC('month', d)` | `DATE_TRUNC('month', d)` | +| Date add | `DATEADD(day, 7, d)` | `DATEADD(day, 7, d)` | `DATE_ADD(d, INTERVAL 7 DAY)` | `DATE_ADD(d, 7)` | `d + INTERVAL '7 days'` | +| String concat | `CONCAT(a, b)` | `CONCAT(a, b)` | `CONCAT(a, b)` | `CONCAT(a, b)` | `a || b` | +| Null coalesce | `COALESCE(a, b)` | `COALESCE(a, b)` or `NVL(a, b)` | `COALESCE(a, b)` or `IFNULL(a, b)` | `COALESCE(a, b)` | `COALESCE(a, b)` | +| Current timestamp | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP` | +| Substring | `SUBSTRING(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTRING(s, start, len)` | `SUBSTRING(s, start, len)` | + +### + +### Dialect-Specific Extensions + +Vendors may expose their own feature through extensions, however the default for Ossie should be to pass unknown values through.: +--- + +## Cross-Reference: Tool Mappings + +This section maps Ossie standard functions to their equivalents in popular BI tools. + +### Aggregation Function Mapping + +| Ossie Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `SUM(x)` | `SUM(x)` | `SUM(X)` | `SUM(x)` | +| `COUNT(x)` | `COUNT(x)` | `COUNT(X)` | `COUNT(x)` | +| `COUNT(DISTINCT x)` | `COUNTD(x)` | `COUNT_DISTINCT(X)` | `DISTINCTCOUNT(x)` | +| `AVG(x)` | `AVG(x)` | `AVG(X)` | `AVERAGE(x)` | +| `MIN(x)` | `MIN(x)` | `MIN(X)` | `MIN(x)` | +| `MAX(x)` | `MAX(x)` | `MAX(X)` | `MAX(x)` | +| `STDDEV(x)` | `STDEV(x)` | `STDDEV(X)` | `STDEV.S(x)` | +| `STDDEV_POP(x)` | `STDEVP(x)` | `STDDEV(X)` | `STDEV.P(x)` | +| `VARIANCE(x)` | `VAR(x)` | `VARIANCE(X)` | `VAR.S(x)` | +| `MEDIAN(x)` | `MEDIAN(x)` | `MEDIAN(X)` | `MEDIAN(x)` | +| `PERCENTILE_CONT(x, 0.75)` | `PERCENTILE(x, 0.75)` | `PERCENTILE(X, 75)` | `PERCENTILE.INC(x, 0.75)` | + +### Date Function Mapping + +| Ossie Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `YEAR(d)` | `YEAR(d)` | `YEAR(Date)` | `YEAR(d)` | +| `MONTH(d)` | `MONTH(d)` | `MONTH(Date)` | `MONTH(d)` | +| `DAY(d)` | `DAY(d)` | `DAY(Date)` | `DAY(d)` | +| `DATE_TRUNC('month', d)` | `DATETRUNC('month', d)` | `TODATE(d, "YYYYMM01", "YYYYMMDD")` | `DATE(YEAR(d), MONTH(d), 1)` | +| `DATEADD(day, n, d)` | `DATEADD('day', n, d)` | `DATE_ADD(d, n)` (days only) | `DATE(d) + n` or `DATEADD(d, n, DAY)` | +| `DATEDIFF(day, d1, d2)` | `DATEDIFF('day', d1, d2)` | `DATE_DIFF(d1, d2)` | `DATEDIFF(d1, d2, DAY)` | +| `CURRENT_DATE` | `TODAY()` | `TODAY()` | `TODAY()` | + +### String Function Mapping + +| Ossie Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `CONCAT(a, b)` | `a + b` | `CONCAT(X, Y)` | `CONCATENATE(a, b)` or `a & b` | +| `LENGTH(s)` | `LEN(s)` | `LENGTH(X)` | `LEN(s)` | +| `LOWER(s)` | `LOWER(s)` | `LOWER(X)` | `LOWER(s)` | +| `UPPER(s)` | `UPPER(s)` | `UPPER(X)` | `UPPER(s)` | +| `TRIM(s)` | `TRIM(s)` | `TRIM(X)` | `TRIM(s)` | +| `LEFT(s, n)` | `LEFT(s, n)` | `LEFT_TEXT(X, n)` | `LEFT(s, n)` | +| `RIGHT(s, n)` | `RIGHT(s, n)` | `RIGHT_TEXT(X, n)` | `RIGHT(s, n)` | +| `SUBSTRING(s, start, len)` | `MID(s, start, len)` | `SUBSTR(X, start, len)` | `MID(s, start, len)` | +| `REPLACE(s, from, to)` | `REPLACE(s, from, to)` | `REPLACE(X, Y, Z)` | `SUBSTITUTE(s, from, to)` | +| `CONTAINS(s, sub)` | `CONTAINS(s, sub)` | `CONTAINS_TEXT(X, text)` | `CONTAINSSTRING(s, sub)` | + +### Conditional Function Mapping + +| Ossie Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `CASE WHEN...` | `CASE WHEN...` or `IF...` | `CASE WHEN...` | `SWITCH(TRUE(), ...)` | +| `IF(cond, t, f)` | `IF cond THEN t ELSE f END` | N/A (use CASE) | `IF(cond, t, f)` | +| `COALESCE(a, b)` | `IFNULL(a, b)` or `ZN(a)` | `COALESCE(...)` | `COALESCE(a, b)` | +| `NULLIF(a, b)` | `IF a = b THEN NULL ELSE a END` | N/A | `IF(a = b, BLANK(), a)` | + +### Window Function Mapping + +| Ossie Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `ROW_NUMBER() OVER(...)` | `INDEX()` | N/A | `RANKX(...)` with DENSE | +| `RANK() OVER(...)` | `RANK(expr)` | N/A | `RANKX(...)` | +| `SUM(...) OVER(PARTITION BY...)` | `{FIXED [...]: SUM(...)}` | N/A (blending only) | Context-dependent | +| `LAG(x, 1) OVER(ORDER BY...)` | `LOOKUP(x, -1)` | N/A | `CALCULATE(x, PREVIOUSDAY(...))` | +| `RUNNING_SUM(...)` | `RUNNING_SUM(SUM(...))` | N/A | `CALCULATE(SUM(...), FILTER(...))` | + +--- + +## Compliance Levels + +### MUST Support (Core) + +Implementations MUST support all functions marked as **REQUIRED** in this specification. These represent the minimum portable expression language. + +### SHOULD Support (Recommended) + +Implementations SHOULD support functions marked as **RECOMMENDED**. These are common analytical functions that may not be available in all databases. + +### MAY Support (Extensions) + +Implementations MAY support additional functions through dialect extensions. These should be documented as dialect-specific. + +--- + +## Version History + +| Version | Date | Changes | +|:----------|:-----------| :---- | +| 0.2.0.dev | 2026-07-15 | Initial draft | + +--- + +## References + +- [SQL:2003 Standard](https://www.iso.org/standard/34132.html) (ISO/IEC 9075-2:2003) +- [Tableau Functions Reference](https://help.tableau.com/current/pro/desktop/en-us/functions.htm) +- [Looker Studio Function List](https://support.google.com/looker-studio/table/6379764) +- [DAX Function Reference](https://learn.microsoft.com/en-us/dax/dax-function-reference) +- [Snowflake SQL Reference](https://docs.snowflake.com/en/sql-reference-functions) +- [BigQuery Standard SQL Reference](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) +- [Databricks SQL Functions](https://docs.databricks.com/sql/language-manual/sql-ref-functions.html) +- [PostgreSQL Functions](https://www.postgresql.org/docs/current/functions.html) diff --git a/core-spec/img/ossie_layers.png b/core-spec/img/ossie_layers.png new file mode 100644 index 0000000..7887c9f Binary files /dev/null and b/core-spec/img/ossie_layers.png differ diff --git a/core-spec/osi-schema.json b/core-spec/osi-schema.json index 72cb164..163db20 100644 --- a/core-spec/osi-schema.json +++ b/core-spec/osi-schema.json @@ -1,14 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json", - "title": "OSI Core Metadata Specification", - "description": "JSON Schema for validating OSI (Open Semantic Interoperability) semantic model definitions", + "$id": "https://github.com/apache/ossie/core-spec/osi-schema.json", + "title": "Apache Ossie Core Metadata Specification", + "description": "JSON Schema for validating Apache Ossie semantic model definitions", "type": "object", "properties": { "version": { "type": "string", "const": "0.2.0.dev0", - "description": "OSI specification version" + "description": "Apache Ossie specification version" }, "semantic_model": { "type": "array", @@ -23,12 +23,12 @@ "$defs": { "Dialect": { "type": "string", - "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL"], + "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "BIGQUERY"], "description": "Supported SQL and expression language dialects" }, "Vendor": { "type": "string", - "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA"], + "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM"], "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 ea7e775..b7c285e 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -1,4 +1,23 @@ -# OSI - Core Metadata Specification + + +# Apache Ossie - Core Metadata Specification > **DRAFT version** — in development, schema may change before 0.2.0 is released. @@ -38,6 +57,7 @@ Supported SQL and expression language dialects for metrics and field definitions | `TABLEAU` | Tableau calculations | | `DATABRICKS` | Databricks SQL | | `MAQL` | GoodData MAQL (Metric Analysis and Query Language) | +| `BIGQUERY` | Google BigQuery (GoogleSQL) | ## Semantic Model @@ -52,7 +72,7 @@ The top-level container that represents a complete semantic model, including dat | `ai_context` | string/object | No | Additional context for AI tools (e.g., custom instructions) | | `datasets` | array | Yes | Collection of logical datasets (fact and dimension tables) | | `relationships` | array | No | Defines how logical datasets are connected | -| `metrics` | array | No | Quantifiable measures defined as aggregate expessions on fields from logical datsets | +| `metrics` | array | No | Quantifiable measures defined as aggregate expressions on fields from logical datasets | | `custom_extensions` | array | No | Vendor-specific attributes for extensibility | ### Example @@ -232,7 +252,7 @@ expression: - dialect: ANSI_SQL expression: customer_id description: Customer identifier - dimension: + dimension: is_time: false ``` @@ -278,6 +298,8 @@ expression: expression: LOWER(email) - dialect: SNOWFLAKE expression: LOWER(email)::VARCHAR + - dialect: BIGQUERY + expression: SAFE_CAST(LOWER(email) AS STRING) description: Normalized email address ``` @@ -366,6 +388,8 @@ The following are well-known examples: | `DBT` | dbt-specific attributes | | `DATABRICKS` | Databricks-specific attributes | | `GOODDATA` | GoodData-specific attributes | +| `HONEYDEW` | Honeydew-specific attributes | +| `WISDOM` | WisdomAI-specific attributes | ### Examples @@ -440,14 +464,14 @@ semantic_model: - dialect: ANSI_SQL expression: order_id description: Order identifier - + - name: customer_id expression: dialects: - dialect: ANSI_SQL expression: customer_id description: Customer identifier - + - name: order_date expression: dialects: @@ -456,7 +480,7 @@ semantic_model: dimension: is_time: true description: Order date - + - name: amount expression: dialects: @@ -475,7 +499,7 @@ semantic_model: - dialect: ANSI_SQL expression: id description: Customer identifier - + - name: email expression: dialects: diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index ed46203..2c9c73e 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -1,7 +1,24 @@ -# OSI - Core Metadata Spec (YAML Schema) +# 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. + +# Apache Ossie - Core Metadata Spec (YAML Schema) # DRAFT version - in development, schema may change before 0.2.0 is released version: 0.2.0.dev0 -# +# # Goals: # - Standardization: Establish uniform language and structure for semantic model definitions # - Extensibility: Support domain-specific extensions while maintaining core compatibility @@ -19,14 +36,15 @@ dialects: - "TABLEAU" # Tableau - "DATABRICKS" # Databricks SQL - "MAQL" # GoodData MAQL (Multi-Dimensional Analytical Query Language) + - "BIGQUERY" # Google BigQuery GoogleSQL # Vendor name for custom extensions (free-form string) -# Examples: "COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA" +# Examples: "COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM" vendor_name: string - + # Top-level semantic model definition semantic_model: # Required: Unique identifier for the semantic model @@ -46,7 +64,7 @@ semantic_model: # See Relationships section below for detailed structure relationships: [] - # Optional: + # Optional: # These metrics can span one or more logical datasets and use relationships # See Metrics section below for detailed structure metrics: [] @@ -68,7 +86,7 @@ datasets: # Required: Reference to the underlying physical table/view or query # Format should be either database_name.schema_name.table_name or query source: string - + # Optional: Primary key definition that uniquely identifies rows in this dataset # Can be a single column or a composite of multiple columns # This is the preferred unique identifier for this dataset and is used in relationships to determine many-to-one or one-to-one. @@ -89,8 +107,8 @@ datasets: # - [column2, column3] # # unique_keys: - # - [column1, column2] - # - [column3, column4] + # - [column1, column2] + # - [column3, column4] unique_keys: - [] # Array of column names (single or composite) @@ -180,7 +198,7 @@ fields: # Helps LLMs understand the field meaning and generate better queries ai_context: string - # Optional: Vendor-specific attributes for extensibility + # Optional: Vendor-specific attributes for extensibility custom_extensions: - vendor_name: string # Free-form string identifying the vendor data: string @@ -209,7 +227,7 @@ metrics: # Helps LLMs understand the metric meaning and suggest it appropriately ai_context: string - # Optional: Vendor-specific attributes for extensibility + # Optional: Vendor-specific attributes for extensibility custom_extensions: - vendor_name: string # Free-form string identifying the vendor data: string diff --git a/docs/index.md b/docs/index.md index 435631c..0a46482 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,31 @@ -# Open Semantic Interchange (OSI) + + +# Apache Ossie ## Overview -The [Open Semantic Interchange (OSI)](https://open-semantic-interchange.org/) initiative is a collaborative, open-source effort dedicated to standardizing and streamlining semantic model exchange and utilization across the data analytics, AI, and BI ecosystem. Our shared vision is to establish a common, vendor-agnostic semantic model specification, promoting interoperability, efficiency, and collaboration among all participants. +The [Apache Ossie](https://ossie.apache.org/) initiative is a collaborative, open-source effort dedicated to standardizing and streamlining semantic model exchange and utilization across the data analytics, AI, and BI ecosystem. Our shared vision is to establish a common, vendor-agnostic semantic model specification, promoting interoperability, efficiency, and collaboration among all participants. -By providing a single, consistent source of truth, the OSI standard ensures that your data's definitions and value remain consistent as they are interchanged between AI agents, BI platforms, and all other tools in your ecosystem — eliminating inconsistencies across your different tools. +Apache Ossie was formerly known as **Open Semantic Interchange (OSI)**. + +By providing a single, consistent source of truth, the Ossie standard ensures that your data's definitions and value remain consistent as they are interchanged between AI agents, BI platforms, and all other tools in your ecosystem — eliminating inconsistencies across your different tools. ### The Problem: Semantic Fragmentation @@ -15,18 +36,18 @@ Today's data ecosystem is fragmented. Organizations rely on a wide array of tool - **AI Hallucinations**: When AI agents encounter conflicting or incomplete business logic across tools, they produce unreliable outputs grounded in inconsistent data definitions. - **Integration Debt**: Every new tool added to the stack requires custom integration work, creating a web of brittle, point-to-point connectors that are costly to maintain. -### How OSI Solves It +### How Apache Ossie Solves It -OSI addresses semantic fragmentation by providing: +Ossie addresses semantic fragmentation by providing: - **Single Source of Truth**: A unified specification for semantic and metric definitions that all tools can read and write, ensuring consistency across the entire data stack. -- **Native Interoperability**: A hub-and-spoke model where tools exchange semantic models through OSI as a common format — enabling direct platform-to-platform exchange without custom connectors. +- **Native Interoperability**: A hub-and-spoke model where tools exchange semantic models through Ossie as a common format — enabling direct platform-to-platform exchange without custom connectors. - **Trusted AI Grounding**: Consistent business logic and rich AI context annotations ensure that AI agents and LLMs can reliably interpret and query data. - **Reduced Total Cost of Ownership**: Automated model exchange eliminates manual reconciliation work and reduces the engineering effort needed to integrate new tools. ### Specification at a Glance -The OSI core specification (current version: **0.2.0.dev0**, latest released: **0.1.1**) defines a YAML-based format for describing semantic models. The key constructs are: +The Ossie core specification (current version: **0.2.0.dev0**, latest released: **0.1.1**) defines a YAML-based format for describing semantic models. The key constructs are: | Construct | Description | |-----------|-------------| @@ -44,22 +65,22 @@ For the full specification, see [core-spec/spec.md](../core-spec/spec.md). For v ### Participating Organizations -OSI is supported by a broad coalition of 50+ organizations across the data ecosystem, including: +Ossie is supported by a broad coalition of 50+ organizations across the data ecosystem, including: Alation, Anomalo, Atlan, AtScale, Bigeye, BlackRock, Blue Yonder, Carto, Cloudera, Coalesce, Collate, Collibra, Cogniti, Count, Credible, Cube, Databricks, DataHub, Denodo, dbt Labs, Dremio, Domo, Elementum AI, Firebolt, GoodData, Hex, Honeydew, Informatica, Instacart, JetBrains, Lightdash, Mistral AI, Omni, Oracle, Preset, Qlik, RelationalAI, Salesforce, Select Star, Sigma, Snowflake, Starburst Data, Strategy, Sundial, ThoughtSpot, and more. ### Converters -OSI converters follow a **hub-and-spoke** architecture: the OSI core specification acts as the central, vendor-neutral format, and each converter handles translation to or from a specific vendor format (e.g., Snowflake, dbt, Salesforce, Databricks). This avoids the need for point-to-point converters between every pair of vendors. +Ossie converters follow a **hub-and-spoke** architecture: the Ossie core specification acts as the central, vendor-neutral format, and each converter handles translation to or from a specific vendor format (e.g., Snowflake, dbt, Salesforce, Databricks). This avoids the need for point-to-point converters between every pair of vendors. -For details on implementing a converter, see the [Converters Guide](../converters/index.md). +For details on implementing a converter, see the [Converters Guide](../converters/README.md). --- ## Project Governance -We wanted the OSI project to be a collaborative effort from the start. The purpose is to grow a community of developers, contributors, and users who are actively involved in shaping the OSI Specification as it moves along. -Open Semantic Interchange (OSI) project governance is inspired by the governance model from [The ASF](https://www.apache.org). +We wanted the Ossie project to be a collaborative effort from the start. The purpose is to grow a community of developers, contributors, and users who are actively involved in shaping the Ossie Specification as it moves along. +Apache Ossie project governance is inspired by the governance model from [The ASF](https://www.apache.org). ### The Apache Way @@ -82,47 +103,43 @@ All technical discussions, design decisions, and specification changes happen in The community collectively ensures that the specification and its associated tooling remain high quality, secure, and aligned with the project's mission of vendor-agnostic semantic interoperability. 6. **Independence** -The OSI project operates independently of any single vendor or organization. While contributors may be employed by companies that have a stake in semantic interoperability, the project's direction is determined by the community as a whole. +The Ossie project operates independently of any single vendor or organization. While contributors may be employed by companies that have a stake in semantic interoperability, the project's direction is determined by the community as a whole. + +### Governing Bodies -### Technical Governing Bodies +As an incubating project, Apache Ossie is governed by its **Podling Project Management Committee (PPMC)**, working alongside the project's **Mentors** and under the oversight of the **Apache Incubator PMC (IPMC)**. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full description of roles and process. -The **Technical Steering Committee (TSC)** is responsible for the overall technical direction of the OSI Specification. The TSC: +The **PPMC** is responsible for the overall direction and health of the podling. It: - Reviews and approves significant changes to the core specification -- Resolves technical disputes that cannot be settled through normal consensus +- Guides the roadmap for new specification features and extensions - Ensures backward compatibility and coherence across specification versions -- Oversees the roadmap for new specification features and extensions -- Has binding vote on the Specification Change vote - -New TSC members are elected by the TSC members. +- Casts binding votes and votes on committer/PPMC nominations and releases +- Resolves technical disputes that cannot be settled through normal consensus -The TSC members are: +New committers and PPMC members are nominated and voted on by the PPMC following the standard [ASF process](https://www.apache.org/dev/pmc.html). During incubation, releases are additionally approved by the Incubator PMC. -| Name | Affiliation | -|------|-------------| -| Khushboo Bhatia | Snowflake | -| Lior Ebel | Salesforce | -| Quigley Malcom | dbt Labs | -| JB Onofré | The ASF | +Current PPMC members and Mentors are listed on the [podling status page](https://incubator.apache.org/projects/ossie.html). -### Non-Governing Bodies +### Roles -- **Contributors**: Anyone who contributes to the project in any form — code, documentation, bug reports, specification feedback, or community support. Contributors do not have binding vote rights but are encouraged to participate in all discussions. -- **Committers**: Contributors who have earned write access to the repository through sustained contributions. Committers can merge pull requests and have binding votes on non-specification matters. -- **Specification Reviewers**: Community members with domain expertise (e.g., in BI, AI, data engineering) who review proposed specification changes for correctness, completeness, and practical applicability. +- **Contributors**: Anyone who contributes to the project in any form — code, documentation, bug reports, specification feedback, or community support. Contributor votes are non-binding, but everyone is encouraged to participate in all discussions and votes. +- **Committers**: Contributors who have earned write access to the repository through sustained contributions. Committers merge pull requests and have binding votes on the project's technical decisions. All committers have an ICLA on file. +- **PPMC Members**: Committers who also help steer the podling — growing the community, overseeing releases, and mentoring contributors. PPMC votes are binding. +- **Mentors**: Experienced ASF members assigned by the Incubator to guide the podling and shepherd release votes to the IPMC. -### Vote on Specification Changes +### Voting on Specification Changes -Changes to the OSI Specification follow a structured voting process: +Changes to the Apache Ossie specification follow the ASF voting model, held on the `dev@ossie.apache.org` mailing list: -1. **Proposal**: A specification change is submitted as a GitHub pull request with a clear description of the motivation, the change itself, and its impact on existing implementations. -2. **Discussion period**: The community has a minimum of 7 days to review and discuss the proposed change. Complex changes may require a longer review window. -3. **Vote**: Once the discussion period has elapsed, a vote is called. TSC members and committers cast votes using the following convention: - - **+1** (Yes): In favor of the change - - **0** (Abstain): No opinion or willing to go with the majority - - **-1** (Veto): Against the change — must be accompanied by a technical justification - The votes from the TSC members are considered as binding votes. -4. **Resolution**: A specification change passes with at least 2 binding +1 votes and no vetoes. A veto can only be overridden by addressing the stated concern or by a supermajority (two-thirds) vote of the TSC. +1. **Proposal**: Announce the change on `dev@ossie.apache.org` and open a GitHub pull request describing the motivation, the change, and its impact on existing implementations. +2. **Discussion period**: The community has a minimum of 7 days to review and discuss. Complex changes may require a longer window. +3. **Vote**: A `[VOTE]` thread is called. Voters cast: + - **+1**: In favor + - **0**: Abstain / no strong opinion + - **-1**: Veto — must include a technical justification; a valid veto is resolved only by addressing the stated concern + PPMC member votes are binding. +4. **Resolution**: A specification change passes with at least **three binding +1 votes** and no vetoes. ### Working Groups @@ -137,7 +154,7 @@ Community meetings are open to all participants and provide a forum for discussi - **Global Community Meetings**: Regular meetings open to all community members for cross-cutting discussions, roadmap reviews, and community announcements. - **Working Group Meetings**: Each working group holds its own meetings to drive focused progress on their specific area of the specification. -Meeting schedules, agendas, and notes are published on the [OSI website](https://open-semantic-interchange.org/) and the project's GitHub repository. All community members are welcome to attend and participate. +Meeting schedules, agendas, and notes are published on the [Ossie website](https://ossie.apache.org/) and the project's GitHub repository. All community members are welcome to attend and participate. **Google Calendar**: _link TBD_ @@ -147,7 +164,7 @@ Meeting schedules, agendas, and notes are published on the [OSI website](https:/ ### Hub-and-Spoke Model -OSI is designed around a hub-and-spoke architecture that dramatically simplifies the integration landscape. Instead of requiring every tool to build custom connectors to every other tool, OSI acts as the universal interchange format at the center. +Ossie is designed around a hub-and-spoke architecture that dramatically simplifies the integration landscape. Instead of requiring every tool to build custom connectors to every other tool, Ossie acts as the universal interchange format at the center. ``` ┌─────────────┐ @@ -155,7 +172,7 @@ OSI is designed around a hub-and-spoke architecture that dramatically simplifies └──────┬──────┘ │ ┌─────────────┐ ┌─────┴─────┐ ┌─────────────┐ - │ dbt ├────┤ OSI ├────┤ Salesforce │ + │ dbt ├────┤ Ossie ├────┤ Salesforce │ └─────────────┘ └─────┬─────┘ └─────────────┘ │ ┌──────┴──────┐ @@ -163,22 +180,22 @@ OSI is designed around a hub-and-spoke architecture that dramatically simplifies └─────────────┘ ``` -With N vendors, a point-to-point strategy would require **N×(N-1)** converters. With OSI as the hub, only **2×N** converters are needed (one import and one export per vendor), and interoperability with all other vendors comes for free. +With N vendors, a point-to-point strategy would require **N×(N-1)** converters. With Ossie as the hub, only **2×N** converters are needed (one import and one export per vendor), and interoperability with all other vendors comes for free. ### How It Flows -A typical OSI-based workflow looks like this: +A typical Ossie-based workflow looks like this: -1. **Author**: A semantic model is authored in one tool (e.g., dbt) or directly in the OSI YAML format. -2. **Import**: If authored in a vendor tool, the vendor's import converter translates it into an OSI model, preserving vendor-specific metadata in `custom_extensions`. -3. **Validate**: The OSI model is validated against the [JSON Schema](../core-spec/osi-schema.json) and the [validation script](../validation/validate.py) to ensure correctness. -4. **Exchange**: The OSI model is shared — via Git, a data catalog, or a sync API — with other teams and tools. -5. **Export**: Each consuming tool's export converter translates the OSI model into its native format, selecting the appropriate SQL dialect and applying vendor-specific extensions. -6. **Round-Trip**: When changes are made in a downstream tool, they can be imported back into the OSI model, preserving all metadata for lossless round-tripping. +1. **Author**: A semantic model is authored in one tool (e.g., dbt) or directly in the Ossie YAML format. +2. **Import**: If authored in a vendor tool, the vendor's import converter translates it into an Ossie model, preserving vendor-specific metadata in `custom_extensions`. +3. **Validate**: The Ossie model is validated against the [JSON Schema](../core-spec/osi-schema.json) and the [validation script](../validation/validate.py) to ensure correctness. +4. **Exchange**: The Ossie model is shared — via Git, a data catalog, or a sync API — with other teams and tools. +5. **Export**: Each consuming tool's export converter translates the Ossie model into its native format, selecting the appropriate SQL dialect and applying vendor-specific extensions. +6. **Round-Trip**: When changes are made in a downstream tool, they can be imported back into the Ossie model, preserving all metadata for lossless round-tripping. ### Multi-Dialect Expression System -A key architectural feature of OSI is its multi-dialect expression system. Fields and metrics can carry expressions in multiple SQL dialects simultaneously: +A key architectural feature of Ossie is its multi-dialect expression system. Fields and metrics can carry expressions in multiple SQL dialects simultaneously: ```yaml expression: @@ -202,32 +219,32 @@ This allows a single semantic model to be consumed natively by different platfor **What is a semantic model?** A semantic model is a structured description of business data that defines what datasets exist, what their fields mean, how datasets relate to each other, and what metrics (KPIs) can be computed from the data. It serves as a shared vocabulary between data producers and consumers — whether those consumers are humans using BI tools or AI agents generating queries. -**How is OSI different from existing standards?** -Most existing standards focus on data formats (e.g., Parquet, Arrow), query interfaces (e.g., ODBC, JDBC), or catalog metadata (e.g., Hive Metastore, OpenMetadata). OSI focuses specifically on the *semantic layer* — the business meaning, metric definitions, and relationships that sit on top of raw data. It is complementary to these other standards rather than a replacement. +**How is Ossie different from existing standards?** +Most existing standards focus on data formats (e.g., Parquet, Arrow), query interfaces (e.g., ODBC, JDBC), or catalog metadata (e.g., Hive Metastore, OpenMetadata). Ossie focuses specifically on the *semantic layer* — the business meaning, metric definitions, and relationships that sit on top of raw data. It is complementary to these other standards rather than a replacement. -**Is OSI tied to any specific vendor?** -No. OSI is vendor-agnostic by design. The specification is developed and governed by a community of contributors from many organizations. While vendor-specific metadata can be carried via `custom_extensions`, the core specification is neutral. +**Is Ossie tied to any specific vendor?** +No. Ossie is vendor-agnostic by design. The specification is developed and governed by a community of contributors from many organizations. While vendor-specific metadata can be carried via `custom_extensions`, the core specification is neutral. ### Adoption -**Can I use OSI with my existing BI tool?** -Yes, as long as a converter exists (or is built) for your tool. The hub-and-spoke model means that adding OSI support to a single tool gives it interoperability with every other OSI-compatible tool. Check the [Converters Guide](../converters/index.md) for currently supported vendors. +**Can I use Ossie with my existing BI tool?** +Yes, as long as a converter exists (or is built) for your tool. The hub-and-spoke model means that adding Ossie support to a single tool gives it interoperability with every other Ossie-compatible tool. Check the [Converters Guide](../converters/README.md) for currently supported vendors. **What if my vendor isn't supported yet?** -You can contribute a converter. The [Converters Guide](../converters/index.md) provides a step-by-step guide for implementing import and export converters for new vendors. The community is happy to help with design reviews and testing. +You can contribute a converter. The [Converters Guide](../converters/README.md) provides a step-by-step guide for implementing import and export converters for new vendors. The community is happy to help with design reviews and testing. **Do I need to rewrite my existing semantic models?** -No. Import converters translate existing vendor-specific models into the OSI format automatically. Your existing models remain intact — OSI provides an additional interchange layer on top of them. +No. Import converters translate existing vendor-specific models into the Ossie format automatically. Your existing models remain intact — Ossie provides an additional interchange layer on top of them. -**How do I validate an OSI model?** +**How do I validate an Ossie model?** Use the [validation script](../validation/validate.py) included in the repository. It checks your model against the [JSON Schema](../core-spec/osi-schema.json), validates SQL expressions across dialects, and ensures referential integrity between datasets and relationships. ### Technical **Why YAML and not JSON?** -YAML is more human-readable and easier to author by hand, which is important for a specification that teams may edit directly. The OSI JSON Schema is available for programmatic validation, and converters can work with either format. +YAML is more human-readable and easier to author by hand, which is important for a specification that teams may edit directly. The Ossie JSON Schema is available for programmatic validation, and converters can work with either format. -**How does OSI handle vendor-specific features?** +**How does Ossie handle vendor-specific features?** Through `custom_extensions`. Each vendor can store arbitrary JSON metadata in extension blocks tagged with their vendor name. This metadata is preserved during round-trip conversions and ignored by tools that don't understand it — ensuring that no information is lost. **Can metrics reference multiple datasets?** @@ -242,7 +259,7 @@ The current specification supports `ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`, `MDX`, ### Specification Versioning -The OSI specification follows [Semantic Versioning](https://semver.org/) (SemVer): +The Ossie specification follows [Semantic Versioning](https://semver.org/) (SemVer): - **Major version** (e.g., 1.0.0 → 2.0.0): Breaking changes that are not backward compatible. Existing valid models may not be valid under the new version. Major version bumps are rare and go through an extended review and migration period. - **Minor version** (e.g., 0.1.0 → 0.2.0): New features or constructs added in a backward-compatible way. Existing valid models remain valid. @@ -264,32 +281,32 @@ Custom extensions (`custom_extensions`) are explicitly outside the scope of core ## Adoption Guide -A practical guide for organizations looking to adopt OSI. +A practical guide for organizations looking to adopt Ossie. ### Phase 1: Evaluate - **Inventory your semantic layer**: Identify which tools in your organization define semantic models — BI platforms, data modeling tools, AI/ML pipelines, metrics stores. - **Map your pain points**: Determine where semantic fragmentation causes the most friction — conflicting metric definitions, manual reconciliation, onboarding new tools. -- **Check converter availability**: Review the [Converters Guide](../converters/index.md) to see if converters exist for your tools. If not, assess the effort to build one. +- **Check converter availability**: Review the [Converters Guide](../converters/README.md) to see if converters exist for your tools. If not, assess the effort to build one. ### Phase 2: Pilot -- **Start with one model**: Choose a well-understood semantic model (e.g., a core sales or finance model) and express it in the OSI format. +- **Start with one model**: Choose a well-understood semantic model (e.g., a core sales or finance model) and express it in the Ossie format. - **Validate**: Run the [validation script](../validation/validate.py) to ensure the model conforms to the specification. -- **Test round-tripping**: If converters are available, export the OSI model to your vendor format and compare with the original. Identify any gaps or lossy conversions. +- **Test round-tripping**: If converters are available, export the Ossie model to your vendor format and compare with the original. Identify any gaps or lossy conversions. - **Gather feedback**: Share the pilot results with your data team and collect feedback on the experience. ### Phase 3: Expand -- **Convert additional models**: Gradually bring more semantic models into the OSI format, prioritizing those shared across multiple tools. -- **Integrate into workflows**: Add OSI validation to your CI/CD pipeline. Store OSI models in version control alongside your data code. +- **Convert additional models**: Gradually bring more semantic models into the Ossie format, prioritizing those shared across multiple tools. +- **Integrate into workflows**: Add Ossie validation to your CI/CD pipeline. Store Ossie models in version control alongside your data code. - **Automate synchronization**: Use converters (and eventually the Sync API) to automate the propagation of semantic model changes across your tool ecosystem. ### Phase 4: Govern - **Establish ownership**: Define who owns each semantic model and who is responsible for approving changes. - **Implement review processes**: Use the voting and review processes described in this document (or your own governance model) to manage specification changes. -- **Monitor consistency**: Regularly validate that the semantic models consumed by each tool are in sync with the authoritative OSI model. +- **Monitor consistency**: Regularly validate that the semantic models consumed by each tool are in sync with the authoritative Ossie model. --- @@ -300,15 +317,15 @@ A practical guide for organizations looking to adopt OSI. | **Semantic Model** | A structured description of business data that defines datasets, fields, relationships, and metrics. It provides a shared vocabulary for interpreting data across tools and teams. | | **Dataset** | A logical representation of a business entity, typically corresponding to a fact table or dimension table in a data warehouse. | | **Field** | A row-level attribute within a dataset, used for grouping, filtering, or as part of metric expressions. Fields can be simple column references or computed expressions. | -| **Dimension** | A categorical attribute used to slice and filter data (e.g., region, product category, date). In OSI, dimensions are represented as fields with optional metadata such as `is_time`. | +| **Dimension** | A categorical attribute used to slice and filter data (e.g., region, product category, date). In Ossie, dimensions are represented as fields with optional metadata such as `is_time`. | | **Metric** | A quantitative measure computed by aggregating data across one or more datasets (e.g., total revenue, average order value). Metrics are defined at the semantic model level. | | **Relationship** | A foreign key connection between two datasets, defining how they can be joined. Relationships are always many-to-one (from the referencing dataset to the referenced dataset). | -| **Dialect** | A specific SQL or expression language variant (e.g., `ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`). OSI supports multiple dialects so expressions can be tailored to each platform. | -| **Custom Extension** | Vendor-specific metadata attached to any OSI construct as a JSON string. Extensions allow platforms to carry additional information without modifying the core specification. | -| **AI Context** | Optional annotations on any OSI construct (model, dataset, field, relationship, metric) that provide additional context for AI tools — including natural language instructions, synonyms, and example queries. | -| **Converter** | A tool that translates between the OSI format and a specific vendor's semantic model format. Converters come in pairs: import (vendor → OSI) and export (OSI → vendor). | -| **Hub-and-Spoke** | The architectural pattern used by OSI, where the specification acts as the central format (hub) and vendor converters act as spokes, avoiding the need for point-to-point integrations. | -| **Round-Trip Fidelity** | The ability to convert a model from one format to OSI and back without losing information. Achieved by preserving vendor-specific metadata in `custom_extensions`. | +| **Dialect** | A specific SQL or expression language variant (e.g., `ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`). Ossie supports multiple dialects so expressions can be tailored to each platform. | +| **Custom Extension** | Vendor-specific metadata attached to any Ossie construct as a JSON string. Extensions allow platforms to carry additional information without modifying the core specification. | +| **AI Context** | Optional annotations on any Ossie construct (model, dataset, field, relationship, metric) that provide additional context for AI tools — including natural language instructions, synonyms, and example queries. | +| **Converter** | A tool that translates between the Ossie format and a specific vendor's semantic model format. Converters come in pairs: import (vendor → Ossie) and export (Ossie → vendor). | +| **Hub-and-Spoke** | The architectural pattern used by Ossie, where the specification acts as the central format (hub) and vendor converters act as spokes, avoiding the need for point-to-point integrations. | +| **Round-Trip Fidelity** | The ability to convert a model from one format to Ossie and back without losing information. Achieved by preserving vendor-specific metadata in `custom_extensions`. | | **Fact Table** | A dataset that records business events or transactions (e.g., sales, clicks, shipments). Fact tables typically contain numeric measures and foreign keys to dimension tables. | | **Dimension Table** | A dataset that describes business entities referenced by fact tables (e.g., customers, products, dates). Dimension tables provide the context for analyzing facts. | | **Semantic Layer** | The abstraction layer between raw data and business users/tools. It defines the business meaning of data, standardizes metric calculations, and provides a consistent query interface. | @@ -317,15 +334,15 @@ A practical guide for organizations looking to adopt OSI. ## Related Resources -- **Website**: [open-semantic-interchange.org](https://open-semantic-interchange.org/) -- **GitHub**: [github.com/open-semantic-interchange](https://github.com/open-semantic-interchange) +- **Website**: [ossie.apache.org](https://ossie.apache.org/) +- **GitHub**: [github.com/apache/ossie](https://github.com/apache/ossie) - **Slack**: [join slack](https://join.slack.com/t/opensemanticx/shared_invite/zt-3yuad6c0h-MaoPgVSD1g9MEOf1_QeaiQ) - **Core Specification**: [core-spec/spec.md](../core-spec/spec.md) - **JSON Schema**: [core-spec/osi-schema.json](../core-spec/osi-schema.json) - **YAML Schema**: [core-spec/spec.yaml](../core-spec/spec.yaml) - **TPC-DS Example Model**: [examples/tpcds_semantic_model.yaml](../examples/tpcds_semantic_model.yaml) - **Validation Script**: [validation/validate.py](../validation/validate.py) -- **Converters Guide**: [converters/index.md](../converters/index.md) +- **Converters Guide**: [converters/README.md](../converters/README.md) --- @@ -345,13 +362,13 @@ We welcome contributions from everyone — whether you are a developer, a data e 1. **Read the Specification**: Familiarize yourself with the [core specification](../core-spec/spec.md) to understand the semantic model format. 2. **Explore the Examples**: Review the [TPC-DS example](../examples/tpcds_semantic_model.yaml) to see a complete semantic model in practice. -3. **Join the Conversation**: Open or participate in [GitHub Issues](https://github.com/open-semantic-interchange) and Discussions to share ideas and feedback. +3. **Join the Conversation**: Open or participate in [GitHub Issues](https://github.com/apache/ossie) and Discussions to share ideas and feedback. 4. **Submit a Pull Request**: Fork the repository, make your changes, and submit a pull request. All contributions go through the standard review process described in the governance section above. -5. **Join the Slack workspace**: You can chat directly with the community by [joining Slack](https://join.slack.com/t/opensemanticx/shared_invite/zt-3yuad6c0h-MaoPgVSD1g9MEOf1_QeaiQ). +5. **Join the Slack workspace**: You can chat directly with the community by [joining Slack](https://join.slack.com/t/apache-ossie/shared_invite/zt-42i1xkgy8-7YQtKEDq7v~mceFmdiLhkA). ### Code of Conduct -All participants in the OSI community are expected to treat each other with respect and professionalism. We are committed to providing a welcoming and inclusive environment for everyone, regardless of background or experience level. +All participants in the Ossie community are expected to treat each other with respect and professionalism. We are committed to providing a welcoming and inclusive environment for everyone, regardless of background or experience level. --- diff --git a/docs/working_groups.md b/docs/working_groups.md index a21626b..3e8a3a9 100644 --- a/docs/working_groups.md +++ b/docs/working_groups.md @@ -1,4 +1,23 @@ -# OSI Working Groups + + +# Apache Ossie Working Groups Join the community on Slack: [join.slack.com/t/opensemanticx](https://join.slack.com/t/opensemanticx/shared_invite/zt-3yuad6c0h-MaoPgVSD1g9MEOf1_QeaiQ) diff --git a/examples/flights.yaml b/examples/flights.yaml index 8c9d4a1..f0f64d1 100644 --- a/examples/flights.yaml +++ b/examples/flights.yaml @@ -1,1092 +1,1155 @@ +# 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. + version: 0.2.0.dev0 -name: flights -description: Ontology for flight-related concepts and relationships +name: Flights +description: Ontology of flights into and out of airports. +requires: + - COUNT[Airport] > 0 # there must be at least one Airport + - COUNT[Carrier] > 0 # there must be at least one Carrier ontology: - - concept: - name: Example_Runway - type: EntityType - identify_by: [ id ] - relationships: - - name: id - roles: - - concept: String - verbalizes: [ '{Example_Runway} id {String}' ] - multiplicity: ManyToOne - - name: shapelength - roles: - - concept: Float - verbalizes: [ '{Example_Runway} shapeLength {Float}' ] - multiplicity: ManyToOne - - name: airportid - roles: - - concept: String - verbalizes: [ '{Example_Runway} airportId {String}' ] - multiplicity: ManyToOne +- concept: + name: NrFeet + description: "Unit of measure for distance in feet" + type: ValueType + extends: [ Decimal ] +- concept: + name: NrPounds + description: "Unit of measure for weight in pounds" + type: ValueType + extends: [ Integer ] +- concept: + name: NrMiles + description: "Unit of measure for distance in miles" + type: ValueType + extends: [ Decimal ] +- concept: + name: NrMinutes + description: "Unit of measure for time in minutes" + type: ValueType + extends: [ Decimal ] +- concept: + name: CancelationCode + description: "The single character code that identifies the reason a flight is canceled" + type: ValueType + extends: [ String ] + requires: [ CancelationCode == 'A' OR CancelationCode == 'B' OR CancelationCode == 'C' OR CancelationCode == 'D' ] +- concept: + name: Capacity + description: "The capacity of an aircraft, measured in pounds." + type: ValueType + extends: [ NrPounds ] +- concept: + name: DegreesLatitude + type: ValueType + extends: [ Decimal ] + requires: [ DegreesLatitude <= 90, DegreesLatitude >= -90 ] +- concept: + name: DegreesLongitude + type: ValueType + extends: [ Decimal ] + requires: [ DegreesLongitude <= 180, DegreesLongitude >= -180 ] +- concept: + name: Polygon + description: "A polygon represented as a list of vertices, where each vertex is a pair of latitude and longitude coordinates." + type: ValueType + extends: [ String ] +- concept: + name: CityName + type: ValueType + extends: [ String ] +- concept: + name: Delay + type: ValueType + extends: [ NrMinutes ] +- concept: + name: Distance + type: ValueType + extends: [ NrMiles ] +- concept: + name: DistanceGroup + description: "A number used to group distances of different lengths, where 1 groups the shortest and 10 the longest." + type: ValueType + extends: [ Integer ] + requires: [ 1 <= DistanceGroup, DistanceGroup <= 10 ] +- concept: + name: RunwayGeometry + description: "A polygon that models the shape of a runway." + type: ValueType + extends: [ Polygon ] +- concept: + name: RunwayLength + description: "The unit for measuring the lengths of runways in American airports." + type: ValueType + extends: [ NrFeet ] +- concept: + name: RunwayDesignator + description: "Used to distinguish runways within a given airport." + type: ValueType + extends: [ String ] +- concept: + name: SerialNr + type: ValueType + extends: [ String ] +- concept: + name: StateCode + type: ValueType + extends: [ String ] +- concept: + name: StateName + type: ValueType + extends: [ String ] +- concept: + name: TailNr + type: ValueType + extends: [ String ] +- concept: + name: Year + type: ValueType + extends: [ String ] +- concept: + name: CancelationReason + description: "A curated set of reasons that explain why a flight is canceled" + type: EntityType + identify_by: [ code ] + relationships: + - name: code + roles: + - concept: CancelationCode + verbalizes: [ '{CancelationReason} is identified by {CancelationCode}'] + multiplicity: OneToOne + - name: description + roles: + - concept: String + verbalizes: [ '{CancelationReason} has description- {String}' ] # The hyphen after "description" is significant when verbalizing constraints + multiplicity: ManyToOne # Each CancelationReason has at most one description String +- concept: + name: State + type: EntityType + identify_by: [ code ] + relationships: + - name: code + roles: + - concept: StateCode + verbalizes: + - '{State} is identified by {StateCode}' + multiplicity: OneToOne + - name: name + roles: + - concept: StateName + verbalizes: + - '{State} has {StateName}' + multiplicity: ManyToOne +- concept: + name: City + type: EntityType + identify_by: [ name, state ] + relationships: + - name: name + roles: + - concept: CityName + verbalizes: + - '{City} has {CityName}' + multiplicity: ManyToOne + - name: state + roles: + - concept: State + verbalizes: + - '{City} is located in {State}' + multiplicity: ManyToOne +- concept: + name: Runway + type: EntityType + identify_by: [ designator, airport ] + relationships: + - name: airport + roles: + - concept: Airport + verbalizes: + - '{Runway} belongs to {Airport}' + multiplicity: ManyToOne + - name: designator + roles: + - concept: RunwayDesignator + verbalizes: + - '{Runway} uses {RunwayDesignator}' + multiplicity: ManyToOne + - name: length + roles: + - concept: RunwayLength + verbalizes: + - '{Runway} has {RunwayLength}' + multiplicity: ManyToOne + - name: geometry + roles: + - concept: RunwayGeometry + verbalizes: + - '{Runway} has {RunwayGeometry}' + multiplicity: ManyToOne +- concept: + name: ManufacturerName + type: ValueType + extends: [ String ] +- concept: + name: Manufacturer + type: EntityType + identify_by: [ name ] + relationships: + - name: name + roles: + - concept: ManufacturerName + verbalizes: + - '{Manufacturer} is identified by {ManufacturerName}' + multiplicity: OneToOne +- concept: + name: ModelName + type: ValueType + extends: [ String ] +- concept: + name: Model + type: EntityType + identify_by: [ name, manufacturer ] + relationships: + - name: name + roles: + - concept: ModelName + verbalizes: + - '{Model} has {ModelName}' + multiplicity: ManyToOne + - name: manufacturer + roles: + - concept: Manufacturer + verbalizes: + - '{Model} is manufactured by {Manufacturer}' + multiplicity: ManyToOne +- concept: + name: Aircraft + type: EntityType + identify_by: [ tailnum ] + relationships: + - name: serial_number + roles: + - concept: SerialNr + verbalizes: + - '{Aircraft} has {SerialNr}' + multiplicity: ManyToOne + - name: name + roles: + - concept: String + verbalizes: + - '{Aircraft} has name {String}' + multiplicity: ManyToOne + - name: number_of_seats + roles: + - concept: Integer + verbalizes: + - '{Aircraft} has {Integer} seats' + multiplicity: ManyToOne + - name: tailnum + roles: + - concept: TailNr + verbalizes: + - '{Aircraft} is identified by {TailNr}' + multiplicity: OneToOne + - name: model + roles: + - concept: Model + verbalizes: + - '{Aircraft} has {Model}' + multiplicity: ManyToOne + - name: year_manufactured + roles: + - concept: Year + verbalizes: + - '{Aircraft} was manufactured in {Year}' + multiplicity: ManyToOne + - name: capacity + roles: + - concept: Capacity + verbalizes: + - '{Aircraft} has {Capacity}' + multiplicity: ManyToOne + - name: carrier + roles: + - concept: Carrier + verbalizes: + - '{Aircraft} is operated by {Carrier}' + multiplicity: ManyToOne +- concept: + name: AirportName + type: ValueType + extends: [ String ] +- concept: + name: AirportCode + description: "The three-letter IATA code for the airport." + type: ValueType + extends: [ String ] +- concept: + name: AirportId + description: "Five digit number used as an alternate identifier for airports." + type: ValueType + extends: [ String ] +- concept: + name: Airport + type: EntityType + identify_by: [ code ] + requires: [ Airport.latitude, Airport.longitude ] + relationships: + - name: city + roles: + - concept: City + verbalizes: + - '{Airport} is located in {City}' + multiplicity: ManyToOne + - name: serves + roles: + - concept: Market + verbalizes: + - '{Airport} serves {Market}' + multiplicity: ManyToOne + - name: average_departure_delay + roles: + - concept: Delay + verbalizes: + - '{Airport} has average- departure {Delay}' # The hyphen after "average" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Airport has at most one average departure Delay + derived_by: [ 'Delay == AVG[Flight.departure_delay WHERE Airport == Flight.route.departure GROUP BY Airport]' ] + - name: longitude + roles: + - concept: DegreesLongitude + verbalizes: + - '{Airport} centers at {DegreesLongitude}' + multiplicity: ManyToOne + - name: average_arrival_delay + roles: + - concept: Delay + verbalizes: + - '{Airport} has average- arrival {Delay}' # The hyphen after "average" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Airport has at most one average arrival Delay + derived_by: [ 'Delay == AVG[Flight.arrival_delay WHERE Airport == Flight.route.destination GROUP BY Airport]' ] + - name: name + roles: + - concept: AirportName + verbalizes: + - '{Airport} has {AirportName}' + multiplicity: ManyToOne + - name: code + roles: + - concept: AirportCode + verbalizes: + - '{Airport} has {AirportCode}' + multiplicity: OneToOne + - name: latitude + roles: + - concept: DegreesLatitude + verbalizes: + - '{Airport} centers at {DegreesLatitude}' + multiplicity: ManyToOne +- concept: + name: MarketName + type: ValueType + extends: [ String ] +- concept: + name: Market + type: EntityType + identify_by: [name] + relationships: + - name: name + roles: [ { concept: MarketName }] + verbalizes: [ '{Market} is identified by {MarketName}' ] + multiplicity: OneToOne +- concept: + name: FlightNr + description: "The IATA flight number, which is typically a combination of the airline's IATA code and a numeric code (e.g., 'AA1234')." + type: ValueType + extends: [ String ] +- concept: + name: FlightId + description: "A unique identifier for an instance of a flight" + type: ValueType + extends: [ String ] +- concept: + name: Flight + type: EntityType + identify_by: [ id ] + relationships: + - name: departure_delay + roles: + - concept: Delay + verbalizes: + - '{Flight} has departure- {Delay}' # The hyphen after "departure" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Flight has at most one departure Delay + - name: number + roles: + - concept: FlightNr + verbalizes: + - '{Flight} has {FlightNr}' + multiplicity: ManyToOne + - name: scheduled_departure + roles: [ { concept: DateTime } ] + verbalizes: + - '{Flight} is scheduled to depart at {DateTime}' + multiplicity: ManyToOne + requires: [ Flight.scheduled_departure < Flight.scheduled_arrival ] + - name: arrival_delay + roles: + - concept: Delay + verbalizes: + - '{Flight} has arrival- {Delay}' # The hyphen after "arrival" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Flight has at most one arrival Delay + - name: canceled + verbalizes: + - '{Flight} was canceled' + - name: canceled_due_to + roles: + - concept: CancelationReason + verbalizes: + - '{Flight} was canceled due to {CancelationReason}' + multiplicity: ManyToOne + - name: distance + roles: + - concept: Distance + verbalizes: + - '{Flight} spans actual- {Distance}' # The hyphen after "actual" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Flight spans at most one actual Distance + - name: id + roles: + - concept: FlightId + verbalizes: + - '{Flight} is identified by {FlightId}' + multiplicity: OneToOne + - name: scheduled_arrival + roles: + - concept: DateTime + verbalizes: + - '{Flight} is scheduled to arrive at {DateTime}' + multiplicity: ManyToOne + - name: registers_longitude_series + roles: + - concept: DateTime + - concept: DegreesLongitude + verbalizes: + - '{Flight} at {DateTime} registers {DegreesLongitude}' + multiplicity: ManyToOne + - name: registers_latitude_series + roles: + - concept: DateTime + - concept: DegreesLatitude + verbalizes: + - '{Flight} at {DateTime} registers {DegreesLatitude}' + multiplicity: ManyToOne + - name: date + roles: + - concept: Date + verbalizes: + - '{Flight} is scheduled to depart on {Date}' + multiplicity: ManyToOne + - name: departs_at + roles: + - concept: DateTime + verbalizes: + - '{Flight} departs at {DateTime}' + multiplicity: ManyToOne + requires: [ Flight.departs_at < Flight.arrives_at ] + - name: diverted + verbalizes: + - '{Flight} was diverted' + - name: arrives_at + roles: + - concept: DateTime + verbalizes: + - '{Flight} arrives at {DateTime}' + multiplicity: ManyToOne + - name: route + roles: + - concept: Route + verbalizes: + - '{Flight} traverses {Route}' + multiplicity: ManyToOne + - name: aircraft + roles: + - concept: Aircraft + verbalizes: + - '{Flight} uses {Aircraft}' + multiplicity: ManyToOne + requires: [ Flight.operated_by(Aircraft.carrier) ] + - name: operated_by + roles: + - concept: Carrier + verbalizes: + - '{Flight} is operated by {Carrier}' + multiplicity: ManyToOne +- concept: + name: CarrierCode + description: "The two-letter IATA code for the airline carrier." + type: ValueType + extends: [ String ] +- concept: + name: CarrierName + type: ValueType + extends: [ String ] +- concept: + name: Carrier + type: EntityType + identify_by: [ code ] + relationships: + - name: code + roles: + - concept: CarrierCode + verbalizes: + - '{Carrier} uses {CarrierCode}' + multiplicity: OneToOne + - name: name + roles: + - concept: CarrierName + verbalizes: + - '{Carrier} has {CarrierName}' + multiplicity: ManyToOne +- concept: + name: RouteId + type: ValueType + description: "A unique identifier for a route between two airports. Constructed by concatenating the IATA codes of the departure and destination airports (e.g., 'ATL -> DCA')" + extends: [ String ] +- concept: + name: Route + type: EntityType + identify_by: [ id ] + relationships: + - name: average_departure_delay + roles: + - concept: Delay + verbalizes: + - '{Route} has average- departure {Delay}' # The hyphen after "average" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Route has at most one average departure Delay + derived_by: [ 'Delay == AVG[Flight.departure_delay WHERE Flight.route(Route) GROUP BY Route]' ] + - name: average_arrival_delay + roles: + - concept: Delay + verbalizes: + - '{Route} has average- arrival {Delay}' # The hyphen after "average" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Route has at most one average arrival Delay + derived_by: [ 'Delay == AVG[Flight.arrival_delay WHERE Flight.route(Route) GROUP BY Route]' ] + - name: distance + roles: + - concept: Distance + verbalizes: + - '{Route} spans {Distance}' + multiplicity: ManyToOne + - name: lies_in + roles: + - concept: DistanceGroup + verbalizes: + - '{Route} has {DistanceGroup}' + multiplicity: ManyToOne + - name: id + roles: + - concept: RouteId + verbalizes: + - '{Route} is identified by {RouteId}' + multiplicity: OneToOne + - name: route_name + roles: + - concept: String + verbalizes: + - '{Route} has name- {String}' # The hyphen after "name" has significance when verbalizing constraints + multiplicity: ManyToOne + - name: destination + roles: + - concept: Airport + verbalizes: + - '{Route} connects to destination- {Airport}' # The hyphen after "destination" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Route connects to at most one destination Airport + requires: [ 'NOT Route.departure(Airport)' ] + - name: departure + roles: + - concept: Airport + verbalizes: + - '{Route} connects to departure- {Airport}' # The hyphen after "departure" has significance when verbalizing constraints + multiplicity: ManyToOne # Each Route connects to at most one departure Airport + requires: [ 'NOT Route.destination(Airport)' ] +ontology_mappings: +- name: flights_mapping + semantic_model: + name: Flights semantic model + datasets: + - name: RUNWAY + source: DATABASE.SCHEMA.RUNWAYS + fields: + - name: airport_code + expression: + dialects: + - dialect: ANSI_SQL + expression: airport_code - name: length - roles: - - concept: Float - verbalizes: [ '{Example_Runway} length {Float}' ] - multiplicity: ManyToOne - - name: geometry - roles: - - concept: String - verbalizes: [ '{Example_Runway} geometry {String}' ] - multiplicity: ManyToOne + expression: + dialects: + - dialect: ANSI_SQL + expression: length + - name: shape + expression: + dialects: + - dialect: ANSI_SQL + expression: shape - name: designator - roles: - - concept: String - verbalizes: [ '{Example_Runway} designator {String}' ] - multiplicity: ManyToOne - - name: airports - roles: - - concept: Example_Airport - verbalizes: [ '{Example_Runway} airports {Example_Airport}' ] - multiplicity: ManyToOne - derived_by: [ 'Example_Runway.airportid == Example_Airport.airportid' ] - - concept: - name: Example_RouteAlertComment - type: EntityType - identify_by: [ "id" ] - relationships: + expression: + dialects: + - dialect: ANSI_SQL + expression: designator + - name: AIRCRAFT + source: DATABASE.SCHEMA.AIRCRAFT + description: "An airplane, helicopter, or other machine capable of flight" + fields: + - name: serial_nr + expression: + dialects: + - dialect: ANSI_SQL + expression: serial_nr + - name: name + expression: + dialects: + - dialect: ANSI_SQL + expression: name + - name: nr_seats + expression: + dialects: + - dialect: ANSI_SQL + expression: nr_seats + - name: tail_nr + expression: + dialects: + - dialect: ANSI_SQL + expression: tail_nr + - name: carrier_code + expression: + dialects: + - dialect: ANSI_SQL + expression: carrier_code + - name: manufacturer + expression: + dialects: + - dialect: ANSI_SQL + expression: manufacturer + - name: model + expression: + dialects: + - dialect: ANSI_SQL + expression: model + - name: year + expression: + dialects: + - dialect: ANSI_SQL + expression: year + - name: capacity + expression: + dialects: + - dialect: ANSI_SQL + expression: capacity + - name: AIRPORT + source: DATABASE.SCHEMA.AIRPORTS + description: "An airport that is identified by an IATA code." + fields: + - name: state_code + expression: + dialects: + - dialect: ANSI_SQL + expression: state_code + - name: market_nm + expression: + dialects: + - dialect: ANSI_SQL + expression: market_nm + - name: longitude + expression: + dialects: + - dialect: ANSI_SQL + expression: longitude + - name: opened + expression: + dialects: + - dialect: ANSI_SQL + expression: opened + - name: state_nm + expression: + dialects: + - dialect: ANSI_SQL + expression: state_nm + - name: city_nm + expression: + dialects: + - dialect: ANSI_SQL + expression: city_nm + - name: name + expression: + dialects: + - dialect: ANSI_SQL + expression: name + - name: code + expression: + dialects: + - dialect: ANSI_SQL + expression: code + - name: latitude + expression: + dialects: + - dialect: ANSI_SQL + expression: latitude + - name: FLIGHT + source: DATABASE.SCHEMA.FLIGHTS + description: "A commercial passenger flight." + fields: + - name: dep_delay + expression: + dialects: + - dialect: ANSI_SQL + expression: dep_delay + - name: air_time + expression: + dialects: + - dialect: ANSI_SQL + expression: air_time + - name: nr + expression: + dialects: + - dialect: ANSI_SQL + expression: nr + - name: carrier_code + expression: + dialects: + - dialect: ANSI_SQL + expression: carrier_code + - name: duration + expression: + dialects: + - dialect: ANSI_SQL + expression: duration + - name: scheduled_departure + expression: + dialects: + - dialect: ANSI_SQL + expression: scheduled_departure + - name: arr_delay + expression: + dialects: + - dialect: ANSI_SQL + expression: arr_delay + - name: cancelled + expression: + dialects: + - dialect: ANSI_SQL + expression: cancelled + - name: cancel_code + expression: + dialects: + - dialect: ANSI_SQL + expression: cancel_code + - name: distance + expression: + dialects: + - dialect: ANSI_SQL + expression: distance - name: id - roles: - - concept: String - verbalizes: [ '{Example_RouteAlertComment} id {String}' ] - multiplicity: ManyToOne - - name: routealertid - roles: - - concept: String - verbalizes: [ '{Example_RouteAlertComment} routeAlertId {String}' ] - multiplicity: ManyToOne - - name: comment - roles: - - concept: String - verbalizes: [ '{Example_RouteAlertComment} comment {String}' ] - multiplicity: ManyToOne - - name: routealert - roles: - - concept: Example_RouteAlert - verbalizes: [ '{Example_RouteAlertComment} routeAlerts {Example_RouteAlert}' ] - multiplicity: ManyToOne - derived_by: [ 'Example_RouteAlertComment.routealertid == Example_RouteAlert.routealertid' ] - - concept: - name: Example_Airport - type: EntityType - identify_by: [ "airportid" ] - relationships: - - name: airportid - roles: - - concept: String - verbalizes: [ '{Example_Airport} airportId {String}' ] - multiplicity: ManyToOne - - name: displayairportname - roles: - - concept: String - verbalizes: [ '{Example_Airport} displayAirportName {String}' ] - multiplicity: ManyToOne - - name: displaycitymarketnamefull - roles: - - concept: String - verbalizes: [ '{Example_Airport} displayCityMarketNameFull {String}' ] - multiplicity: ManyToOne - - name: flights - roles: - - concept: Example_Flight - verbalizes: [ '{Example_Airport} flights {Example_Flight}' ] - derived_by: [ 'Example_Airport.airportid == Example_Flight.originairportid OR Example_Airport.airportid == Example_Flight.destairportid' ] -ontology_mappings: - - name: flights_map - description: Example mapping of logical fields to ontology concepts and relationships - semantic_model: - name: Logical_Flights_Model - description: Logical model for flight data - datasets: - - name: Example_Runway_example_runway - source: PBACON_ATT_DB.GANDALF.EXAMPLE_RUNWAY - fields: - - name: id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(id AS VARCHAR) - - name: shape_length - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(shape_length AS DOUBLE) - - name: airport_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airport_id AS VARCHAR) - - name: length - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(length AS DOUBLE) - - name: geometry - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(geometry AS VARCHAR) - - name: designator - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(designator AS VARCHAR) - - name: Example_RouteAlertComment_example_route_alert_comment - source: PBACON_ATT_DB.GANDALF.EXAMPLE_ROUTE_ALERT_COMMENT - fields: - - name: id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(id AS VARCHAR) - - name: alert_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(alert_id AS VARCHAR) - - name: type - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(type AS VARCHAR) - - name: created_by - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(created_by AS VARCHAR) - - name: title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(title AS VARCHAR) - - name: comment - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(comment AS VARCHAR) - - name: created_at - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(created_at AS TIMESTAMP) - - name: Example_Aircraft_example_aircraft - source: PBACON_ATT_DB.GANDALF.EXAMPLE_AIRCRAFT - description: Aircraft data with basic properties. Full 2023 flight history available - for a subset of aircraft where the "Complete Flight History" property is "true". - fields: - - name: flight_count - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_count AS INTEGER) - - name: serial_number - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(serial_number AS VARCHAR) - - name: mode_scode_hex - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(mode_scode_hex AS VARCHAR) - - name: title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(title AS VARCHAR) - - name: number_of_seats - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(number_of_seats AS INTEGER) - - name: tail_num - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(tail_num AS VARCHAR) - - name: op_carrier_airline_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(op_carrier_airline_id AS VARCHAR) - - name: carrier_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier_name AS VARCHAR) - - name: mode_scode - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(mode_scode AS INTEGER) - - name: complete_flight_history - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(complete_flight_history AS BOOLEAN) - - name: manufacturer - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(manufacturer AS VARCHAR) - - name: latest_flight_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(latest_flight_id AS VARCHAR) - - name: model - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(model AS VARCHAR) - - name: carrier_iata_code - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier_iata_code AS VARCHAR) - - name: year_mfr - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(year_mfr AS VARCHAR) - - name: acquisition_date - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(acquisition_date AS TIMESTAMP) - - name: capacity_in_pounds - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(capacity_in_pounds AS INTEGER) - - name: Example_Airport_example_airport - source: PBACON_ATT_DB.GANDALF.EXAMPLE_AIRPORT - description: Airports with various geospatial properties. Useful for map examples. - Full 2023 flight history available for a subset of airports where the "Complete - Flight History" property is "true". - fields: - - name: airport_state_code - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airport_state_code AS VARCHAR) - - name: arriving_flight_count - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(arriving_flight_count AS INTEGER) - - name: display_city_market_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(display_city_market_name_full AS VARCHAR) - - name: average_dep_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(average_dep_delay AS DOUBLE) - - name: longitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(longitude AS DOUBLE) - - name: airport_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airport_id AS VARCHAR) - - name: airport_start_date - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airport_start_date AS TIMESTAMP) - - name: airport_state_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airport_state_name AS VARCHAR) - - name: daily_avg_dep_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_avg_dep_delay AS VARCHAR) - - name: average_arr_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(average_arr_delay AS DOUBLE) - - name: display_airport_city_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(display_airport_city_name_full AS VARCHAR) - - name: daily_avg_arr_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_avg_arr_delay AS VARCHAR) - - name: daily_count_of_flights - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_count_of_flights AS VARCHAR) - - name: complete_flight_history - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(complete_flight_history AS BOOLEAN) - - name: display_airport_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(display_airport_name AS VARCHAR) - - name: airport - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airport AS VARCHAR) - - name: departing_flight_count - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(departing_flight_count AS INTEGER) - - name: geopoint - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(geopoint AS VARCHAR) - - name: latitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(latitude AS DOUBLE) - - name: Example_Flight_example_flight - source: PBACON_ATT_DB.GANDALF.EXAMPLE_FLIGHT - description: Represents each individual commercial passenger flight in the US - in 2023. With geospatial and timeseries property examples. - fields: - - name: dep_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(dep_delay AS INTEGER) - - name: air_time - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(air_time AS DOUBLE) - - name: flight_number - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_number AS VARCHAR) - - name: airline_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airline_id AS VARCHAR) - - name: late_aircraft_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(late_aircraft_delay AS INTEGER) - - name: actual_elapsed_time - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(actual_elapsed_time AS DOUBLE) - - name: taxi_in - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(taxi_in AS INTEGER) - - name: scheduled_departure_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(scheduled_departure_timestamp AS TIMESTAMP) - - name: dest_airport_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(dest_airport_id AS VARCHAR) - - name: arr_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(arr_delay AS INTEGER) - - name: cancelled - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(cancelled AS BOOLEAN) - - name: carrier_code - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier_code AS VARCHAR) - - name: destination_display_airport_city_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_display_airport_city_name_full AS VARCHAR) - - name: carrier_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier_delay AS INTEGER) - - name: cancellation_code - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(cancellation_code AS VARCHAR) - - name: origin_latitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_latitude AS DOUBLE) - - name: destination_geopoint - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_geopoint AS VARCHAR) - - name: flights - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flights AS DOUBLE) - - name: destination_display_city_market_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_display_city_market_name_full AS VARCHAR) - - name: distance - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(distance AS DOUBLE) - - name: flight_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_id AS VARCHAR) - - name: origin_geopoint - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_geopoint AS VARCHAR) - - name: scheduled_arrival_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(scheduled_arrival_timestamp AS TIMESTAMP) - - name: destination_latitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_latitude AS DOUBLE) - - name: wheels_on_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(wheels_on_timestamp AS TIMESTAMP) - - name: longitude_sensor_series_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(longitude_sensor_series_id AS VARCHAR) - - name: nas_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(nas_delay AS INTEGER) - - name: latitude_sensor_series_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(latitude_sensor_series_id AS VARCHAR) - - name: destination_longitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_longitude AS DOUBLE) - - name: origin_airport_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_airport_id AS VARCHAR) - - name: origin_display_airport_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_display_airport_name AS VARCHAR) - - name: security_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(security_delay AS INTEGER) - - name: origin_display_airport_city_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_display_airport_city_name_full AS VARCHAR) - - name: origin_longitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_longitude AS DOUBLE) - - name: taxi_out - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(taxi_out AS INTEGER) - - name: origin_display_city_market_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_display_city_market_name_full AS VARCHAR) - - name: destination_airport - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_airport AS VARCHAR) - - name: origin_airport - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_airport AS VARCHAR) - - name: date - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(date AS DATE) - - name: destination_display_airport_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_display_airport_name AS VARCHAR) - - name: wheels_off_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(wheels_off_timestamp AS TIMESTAMP) - - name: scheduled_elapsed_time - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(scheduled_elapsed_time AS INTEGER) - - name: tail_num - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(tail_num AS VARCHAR) - - name: departure_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(departure_timestamp AS TIMESTAMP) - - name: carrier_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier_name AS VARCHAR) - - name: route_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(route_id AS VARCHAR) - - name: weather_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(weather_delay AS INTEGER) - - name: diverted - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(diverted AS BOOLEAN) - - name: arrival_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(arrival_timestamp AS TIMESTAMP) - - name: flight_title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_title AS VARCHAR) - - name: route_title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(route_title AS VARCHAR) - - name: Example_Carrier_example_carrier - source: PBACON_ATT_DB.GANDALF.EXAMPLE_CARRIER - description: Carriers with derived fleet metrics from associated aircraft. Full - 2023 flight history available for a subset of carriers where the "Complete Flight - History" property is "true". - fields: - - name: carrier - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier AS VARCHAR) - - name: average_manufacture_year - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(average_manufacture_year AS DOUBLE) - - name: std_dev_manufacture_year - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(std_dev_manufacture_year AS DOUBLE) - - name: carrier_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(carrier_name AS VARCHAR) - - name: daily_avg_arr_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_avg_arr_delay AS VARCHAR) - - name: daily_count_of_flights - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_count_of_flights AS VARCHAR) - - name: complete_flight_history - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(complete_flight_history AS BOOLEAN) - - name: total_capacity_in_pounds - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(total_capacity_in_pounds AS INTEGER) - - name: airline_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(airline_id AS VARCHAR) - - name: total_seats - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(total_seats AS INTEGER) - - name: aircraft_count - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(aircraft_count AS INTEGER) - - name: daily_avg_dep_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_avg_dep_delay AS VARCHAR) - - name: Example_FlightSensor_example_flight_sensor - source: PBACON_ATT_DB.GANDALF.EXAMPLE_FLIGHT_SENSOR - description: 'The flight sensor holds any sensor readings for the flight object. - It is linked to flights as a sensor object and therefore analyses on the flight - object can automatically access time series on the flight sensor. ' - fields: - - name: series_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(series_name AS VARCHAR) - - name: units - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(units AS VARCHAR) - - name: flight_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_id AS VARCHAR) - - name: title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(title AS VARCHAR) - - name: flight_sensor_series_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_sensor_series_id AS VARCHAR) - - name: unique_sensor_flight_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(unique_sensor_flight_id AS VARCHAR) - - name: Example_RouteAlert_example_route_alert - source: PBACON_ATT_DB.GANDALF.EXAMPLE_ROUTE_ALERT - description: Alerts generated based on historical route performance, structured - as an operational object type to demonstrate basic status and assignment patterns. - fields: - - name: normalized_deviation_metric - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(normalized_deviation_metric AS DOUBLE) - - name: normalized_weighted_change_metric - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(normalized_weighted_change_metric AS DOUBLE) - - name: percent_change - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(percent_change AS DOUBLE) - - name: priority - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(priority AS VARCHAR) - - name: assignee - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(assignee AS VARCHAR) - - name: id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(id AS VARCHAR) - - name: flight_count - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flight_count AS INTEGER) - - name: alert_type - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(alert_type AS VARCHAR) - - name: risk_level - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(risk_level AS INTEGER) - - name: deviation_metric - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(deviation_metric AS DOUBLE) - - name: floor_threshold - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(floor_threshold AS DOUBLE) - - name: description_markdown - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(description_markdown AS VARCHAR) - - name: status - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(status AS VARCHAR) - - name: title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(title AS VARCHAR) - - name: alert_period_length - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(alert_period_length AS INTEGER) - - name: percent_change_threshold - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(percent_change_threshold AS DOUBLE) - - name: route_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(route_id AS VARCHAR) - - name: metric_description - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(metric_description AS VARCHAR) - - name: description - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(description AS VARCHAR) - - name: previous_deviation_metric - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(previous_deviation_metric AS DOUBLE) - - name: investigation_notes - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(investigation_notes AS VARCHAR) - - name: alert_period_start - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(alert_period_start AS DATE) - - name: percent_change_display - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(percent_change_display AS DOUBLE) - - name: Example_Explainer_example_explainer - source: PBACON_ATT_DB.GANDALF.EXAMPLE_EXPLAINER - description: Stores re-usable callout references to integrate into Workshop and - other one state examples - fields: - - name: id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(id AS VARCHAR) - - name: link_url - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(link_url AS VARCHAR) - - name: text - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(text AS VARCHAR) - - name: title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(title AS VARCHAR) - - name: link_text - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(link_text AS VARCHAR) - - name: Example_Log_UpdateRouteAlertStatus_example_log_update_route_alert_status - source: PBACON_ATT_DB.GANDALF.EXAMPLE_LOG_UPDATE_ROUTE_ALERT_STATUS - description: 'This object type was automatically created when logging was enabled - for the Action # type: Update Route Alert Status' - fields: - - name: action_triggerer - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(action_triggerer AS VARCHAR) - - name: status - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(status AS VARCHAR) - - name: action_rid - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(action_rid AS VARCHAR) - - name: action_type_rid - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(action_type_rid AS VARCHAR) - - name: action_type_version - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(action_type_version AS VARCHAR) - - name: action_summary - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(action_summary AS VARCHAR) - - name: action_timestamp - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(action_timestamp AS TIMESTAMP) - - name: Example_Route_example_route - source: PBACON_ATT_DB.GANDALF.EXAMPLE_ROUTE - description: Derived from flights between pairs of departure and arrival airports. - Demonstrates the use of deriving "intermediate" object types from granular data - as well as pre-calculating default metrics in a pipeline. Full 2023 flight history - available for a subset of routes where the "Complete Flight History" property - is "true". - fields: - - name: average_dep_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(average_dep_delay AS DOUBLE) - - name: destination_longitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_longitude AS DOUBLE) - - name: origin_airport_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_airport_id AS VARCHAR) - - name: destination_airport_1 - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_airport_1 AS VARCHAR) - - name: origin_display_airport_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_display_airport_name AS VARCHAR) - - name: daily_avg_dep_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_avg_dep_delay AS VARCHAR) - - name: origin_display_airport_city_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_display_airport_city_name_full AS VARCHAR) - - name: average_arr_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(average_arr_delay AS DOUBLE) - - name: origin_longitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_longitude AS DOUBLE) - - name: daily_count_of_flights - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_count_of_flights AS VARCHAR) - - name: daily_avg_arr_delay - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(daily_avg_arr_delay AS VARCHAR) - - name: dest_airport_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(dest_airport_id AS VARCHAR) - - name: origin_airport - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_airport AS VARCHAR) - - name: destination_display_airport_city_name_full - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_display_airport_city_name_full AS VARCHAR) - - name: origin_latitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_latitude AS DOUBLE) - - name: actual_elapsed_time_standard_deviation - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(actual_elapsed_time_standard_deviation AS DOUBLE) - - name: destination_display_airport_name - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_display_airport_name AS VARCHAR) - - name: destination_geopoint - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_geopoint AS VARCHAR) - - name: average_actual_elapsed_time - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(average_actual_elapsed_time AS DOUBLE) - - name: flights_count - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(flights_count AS INTEGER) - - name: distance - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(distance AS DOUBLE) - - name: distance_group - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(distance_group AS INTEGER) - - name: route_id - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(route_id AS VARCHAR) - - name: origin_geopoint - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(origin_geopoint AS VARCHAR) - - name: complete_flight_history - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(complete_flight_history AS BOOLEAN) - - name: destination_latitude - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(destination_latitude AS DOUBLE) - - name: route_title - expression: - dialects: - - dialect: ANSI_SQL - expression: CAST(route_title AS VARCHAR) - concept_mappings: - - concept: Example_Runway - object_mappings: - - referent_mappings: - - relationship: id - expression: Example_Runway_example_runway.id - link_mappings: - - object_mapping: - referent_mappings: - - relationship: id - expression: Example_Runway_example_runway.id - children: - - object_mapping: - concept: Float - expression: Example_Runway_example_runway.length - relationship: length - - object_mapping: - concept: String - expression: Example_Runway_example_runway.geometry - relationship: geometry - - object_mapping: - concept: String - expression: Example_Runway_example_runway.designator - relationship: designator - - object_mapping: - concept: Float - expression: Example_Runway_example_runway.shape_length - relationship: shape_length - - object_mapping: - concept: String - expression: Example_Runway_example_runway.airport_id - relationship: airport_id + expression: + dialects: + - dialect: ANSI_SQL + expression: id + - name: scheduled_arrival + expression: + dialects: + - dialect: ANSI_SQL + expression: scheduled_arrival + - name: wheels_on + expression: + dialects: + - dialect: ANSI_SQL + expression: wheels_on + - name: date + expression: + dialects: + - dialect: ANSI_SQL + expression: date + - name: wheels_off + expression: + dialects: + - dialect: ANSI_SQL + expression: wheels_off + - name: scheduled_duration + expression: + dialects: + - dialect: ANSI_SQL + expression: scheduled_duration + - name: tail_nr + expression: + dialects: + - dialect: ANSI_SQL + expression: tail_nr + - name: departure + expression: + dialects: + - dialect: ANSI_SQL + expression: departure + - name: route_id + expression: + dialects: + - dialect: ANSI_SQL + expression: route_id + - name: diverted + expression: + dialects: + - dialect: ANSI_SQL + expression: diverted + - name: arrival + expression: + dialects: + - dialect: ANSI_SQL + expression: arrival + - name: CARRIER + source: DATABASE.SCHEMA.CARRIERS + description: "An airline, such as Delta, United, or American." + fields: + - name: code + expression: + dialects: + - dialect: ANSI_SQL + expression: code + - name: name + expression: + dialects: + - dialect: ANSI_SQL + expression: name + - name: ROUTE + source: DATABASE.SCHEMA.ROUTES + description: "Represents the existence of one or more flights between a pair of departure + arrival airports." + fields: + - name: orig_airport_code + expression: + dialects: + - dialect: ANSI_SQL + expression: orig_airport_code + - name: dest_airport_code + expression: + dialects: + - dialect: ANSI_SQL + expression: dest_airport_code + - name: distance + expression: + dialects: + - dialect: ANSI_SQL + expression: distance + - name: dist_grp + expression: + dialects: + - dialect: ANSI_SQL + expression: dist_grp + - name: id + expression: + dialects: + - dialect: ANSI_SQL + expression: id + - name: name + expression: + dialects: + - dialect: ANSI_SQL + expression: name + concept_mappings: + - concept: Runway + object_mappings: + - referent_mappings: + - relationship: designator + expression: RUNWAY.designator + - relationship: airport + referent_mappings: + - relationship: code + expression: RUNWAY.airport_code + link_mappings: + - object_mapping: + referent_mappings: + - relationship: designator + expression: RUNWAY.designator + - relationship: airport + referent_mappings: + - relationship: code + expression: RUNWAY.airport_code + children: + - object_mapping: + concept: RunwayLength + expression: RUNWAY.length + relationship: length + - object_mapping: + concept: RunwayGeometry + expression: RUNWAY.shape + relationship: geometry + - concept: Manufacturer + object_mappings: + - referent_mappings: + - relationship: name + expression: AIRCRAFT.manufacturer + - concept: Model + object_mappings: + - referent_mappings: + - relationship: name + expression: AIRCRAFT.model + - relationship: manufacturer + referent_mappings: + - relationship: name + expression: AIRCRAFT.manufacturer + - concept: Aircraft + object_mappings: + - referent_mappings: + - relationship: tailnum + expression: AIRCRAFT.tail_nr + link_mappings: + - object_mapping: + referent_mappings: + - relationship: tailnum + expression: AIRCRAFT.tail_nr + children: + - object_mapping: + concept: SerialNr + expression: AIRCRAFT.serial_nr + relationship: serial_number + - object_mapping: + concept: String + expression: AIRCRAFT.name + relationship: name + - object_mapping: + concept: Integer + expression: AIRCRAFT.nr_seats + relationship: number_of_seats + - object_mapping: + concept: Model + referent_mappings: + - relationship: name + expression: AIRCRAFT.model + - relationship: manufacturer + referent_mappings: + - relationship: name + expression: AIRCRAFT.manufacturer + relationship: model + - object_mapping: + concept: Year + expression: AIRCRAFT.year + relationship: year_manufactured + - object_mapping: + concept: Capacity + expression: AIRCRAFT.capacity + relationship: capacity + - object_mapping: + concept: Carrier + referent_mappings: + - relationship: code + expression: AIRCRAFT.carrier_code + relationship: carrier + - concept: State + object_mappings: + - referent_mappings: + - relationship: code + expression: AIRPORT.state_code + link_mappings: + - object_mapping: + referent_mappings: + - relationship: code + expression: AIRPORT.state_code + children: + - object_mapping: + concept: StateName + expression: AIRPORT.state_nm + relationship: name + - concept: City + object_mappings: + - referent_mappings: + - relationship: name + expression: AIRPORT.city_nm + - relationship: state + referent_mappings: + - relationship: code + expression: AIRPORT.state_code + - concept: Market + object_mappings: + - referent_mappings: + - relationship: name + expression: AIRPORT.market_nm + - concept: Airport + object_mappings: + - referent_mappings: + - relationship: code + expression: AIRPORT.code + link_mappings: + - object_mapping: + referent_mappings: + - relationship: code + expression: AIRPORT.code + children: + - object_mapping: + concept: City + referent_mappings: + - relationship: name + expression: AIRPORT.city_nm + - relationship: state + referent_mappings: + - relationship: code + expression: AIRPORT.state_code + relationship: city + - object_mapping: + concept: Market + referent_mappings: + - relationship: name + expression: AIRPORT.market_nm + relationship: serves + - object_mapping: + concept: DegreesLongitude + expression: AIRPORT.longitude + relationship: longitude + - object_mapping: + concept: AirportName + expression: AIRPORT.name + relationship: name + - object_mapping: + concept: DegreesLatitude + expression: AIRPORT.latitude + relationship: latitude + - concept: Flight + object_mappings: + - referent_mappings: + - relationship: id + expression: FLIGHT.id + link_mappings: + - object_mapping: + referent_mappings: + - relationship: id + expression: FLIGHT.id WHERE ( FLIGHT.diverted == TRUE ) + relationship: diverted + - object_mapping: + referent_mappings: + - relationship: id + expression: FLIGHT.id WHERE ( FLIGHT.cancelled == TRUE ) + relationship: canceled + - object_mapping: + referent_mappings: + - relationship: id + expression: FLIGHT.id + children: + - object_mapping: + concept: Delay + expression: FLIGHT.arr_delay + relationship: arrival_delay + - object_mapping: + concept: Delay + expression: FLIGHT.dep_delay + relationship: departure_delay + - object_mapping: + concept: FlightNr + expression: FLIGHT.nr + relationship: number + - object_mapping: + concept: DateTime + expression: FLIGHT.scheduled_departure + relationship: scheduled_departure + - object_mapping: + concept: CancelationReason + referent_mappings: + - relationship: code + expression: FLIGHT.cancel_code + relationship: canceled_due_to + - object_mapping: + concept: Distance + expression: FLIGHT.distance + relationship: distance + - object_mapping: + concept: DateTime + expression: FLIGHT.scheduled_arrival + relationship: scheduled_arrival + - object_mapping: + concept: Date + expression: FLIGHT.date + relationship: date + - object_mapping: + concept: DateTime + expression: FLIGHT.departure + relationship: departs_at + - object_mapping: + concept: DateTime + expression: FLIGHT.arrival + relationship: arrives_at + - object_mapping: + concept: Route + referent_mappings: + - relationship: id + expression: FLIGHT.route_id + relationship: route + - object_mapping: + concept: Aircraft + referent_mappings: + - relationship: tailnum + expression: FLIGHT.tail_nr + relationship: aircraft + - object_mapping: + concept: Carrier + referent_mappings: + - relationship: code + expression: FLIGHT.carrier_code + relationship: operated_by + - concept: Carrier + object_mappings: + - referent_mappings: + - relationship: code + expression: CARRIER.code + link_mappings: + - object_mapping: + referent_mappings: + - relationship: code + expression: CARRIER.code + children: + - object_mapping: + concept: CarrierName + expression: CARRIER.name + relationship: name + - concept: Route + object_mappings: + - referent_mappings: + - relationship: id + expression: ROUTE.id + link_mappings: + - object_mapping: + referent_mappings: + - relationship: id + expression: ROUTE.id + children: + - object_mapping: + concept: Distance + expression: ROUTE.distance + relationship: distance + - object_mapping: + concept: DistanceGroup + expression: ROUTE.dist_grp + relationship: lies_in + - object_mapping: + concept: String + expression: ROUTE.name + relationship: route_name + - object_mapping: + concept: Airport + referent_mappings: + - relationship: code + expression: ROUTE.dest_airport_code + relationship: destination + - object_mapping: + concept: Airport + referent_mappings: + - relationship: code + expression: ROUTE.orig_airport_code + relationship: departure diff --git a/examples/tpcds_semantic_model.yaml b/examples/tpcds_semantic_model.yaml index 7dd2785..c20f288 100644 --- a/examples/tpcds_semantic_model.yaml +++ b/examples/tpcds_semantic_model.yaml @@ -1,7 +1,23 @@ # yaml-language-server: $schema=../core-spec/osi-schema.json +# 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. # TPC-DS Semantic Model Example -# This example demonstrates the OSI Core Metadata Spec using the TPC-DS benchmark schema +# This example demonstrates the Ossie Core Metadata Spec using the TPC-DS benchmark schema # TPC-DS is a decision support benchmark with a realistic retail business model version: "0.2.0.dev0" diff --git a/impl/python/README.md b/impl/python/README.md new file mode 100644 index 0000000..755b306 --- /dev/null +++ b/impl/python/README.md @@ -0,0 +1 @@ +Python implemention diff --git a/ontology/ontology.json b/ontology/ontology.json index 473f69b..943f6aa 100644 --- a/ontology/ontology.json +++ b/ontology/ontology.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json", - "title": "OSI Ontology Metadata Specification", - "description": "JSON Schema for validating OSI (Open Semantic Interoperability) ontology definitions", + "$id": "https://github.com/apache/ossie/core-spec/osi-schema.json", + "title": "Apache Ossie Ontology Metadata Specification", + "description": "JSON Schema for validating Apache Ossie ontology definitions", "type": "object", "properties": { "version": { @@ -19,7 +19,14 @@ "description": "Human-readable description" }, "ai_context": { - "$ref": "https://raw.githubusercontent.com/open-semantic-interchange/OSI/main/core-spec/osi-schema.json#/$defs/AIContext" + "$ref": "https://raw.githubusercontent.com/apache/ossie/main/core-spec/osi-schema.json#/$defs/AIContext" + }, + "requires": { + "type": "array", + "items": { + "$ref": "#/$defs/Expression" + }, + "description": "Expressions that constrain the population of this ontology" }, "ontology": { "type": "array", @@ -51,7 +58,7 @@ "concept": { "$ref": "#/$defs/Concept" }, - "relationships": { + "relationships": { "type": "array", "items": { "$ref": "#/$defs/Relationship" @@ -95,6 +102,13 @@ }, "description": "Expressions that define how this concept is derived" }, + "requires": { + "type": "array", + "items": { + "$ref": "#/$defs/Expression" + }, + "description": "Expressions that constrain the population of this relationship" + }, "verbalizes": { "type": "array", "items": { @@ -167,7 +181,7 @@ "$ref": "#/$defs/ObjectMapping" }, "description": "Mappings from logical constructs that populate the concept in this component. Valid only when the concept is an entity type" - }, + }, "link_mappings": { "type": "array", "items": { @@ -282,7 +296,7 @@ "description": "Human-readable description of this ontology map" }, "semantic_model": { - "$ref": "https://raw.githubusercontent.com/open-semantic-interchange/OSI/main/core-spec/osi-schema.json#/$defs/SemanticModel" + "$ref": "https://raw.githubusercontent.com/apache/ossie/main/core-spec/osi-schema.json#/$defs/SemanticModel" }, "concept_mappings": { "type": "array", diff --git a/ontology/ontology.md b/ontology/ontology.md index 921e061..3e9593f 100644 --- a/ontology/ontology.md +++ b/ontology/ontology.md @@ -1,4 +1,23 @@ -# OSI - Ontology Specification + + +# Apache Ossie - Ontology Specification **Version:** 0.2.0.dev0 @@ -29,7 +48,7 @@ e-mail address. In some modeling languages these are called either entities or o A value type is a concept that represents instances of some data type (i.e SQL types like Integer or String) with additional semantics. For instance, a social-security number is a string or positive -integer that comprises exactly nine digits. In some modeling langauges these are called data types +integer that comprises exactly nine digits. In some modeling languages these are called data types or domains. ### Built-in concepts @@ -82,7 +101,7 @@ concept plays the first role: Concepts represent the types of things that have meaning in a business setting, e.g., person, company, or salary. Every ontology implicitly includes all of the [built-in concepts](#built-in-concepts) and -may refer to them by name without declaring them. +may refer to them by name without declaring them. Concepts have the following schema: @@ -168,7 +187,7 @@ ontology: the relationship is identified by the string `Person.earns`. This convention naturally supports expressions that navigate over the links of relationships using the “dot-join” operator in a manner that is familiar to object-oriented programming languages. This relationship links -`Person` and `Salary` objects and verbalizes each link as “Person earns Salary.” +`Person` and `Salary` objects and verbalizes each link as “Person earns Salary.” #### Roles @@ -201,7 +220,7 @@ ontology: - concept: Vehicle - concept: Date multiplicity: ManyToOne - verbalizes: [ "{Person} puchased {Vehicle} on {Date}" ] + verbalizes: [ "{Person} purchased {Vehicle} on {Date}" ] ``` the unary relationship `Person.files_married_joint` has an empty roles list, while the @@ -209,7 +228,7 @@ ternary relationship `Person.purchased_on` declares two additional roles played `Vehicle` and `Date` respectively, The role player often suffices to distinguish the role within its relationship, but when -the same concept plays more than one role, the user must declare a distinguising name for +the same concept plays more than one role, the user must declare a distinguishing name for any additional role whose player's name does not distinguish it from other roles in the same relationship. For instance, in: @@ -234,13 +253,13 @@ this relationship. Expressions that are used to define derived_by rules and requires constraints will refer to roles by name -- the name defaulting to the concept that plays the role unless an explicit role name is provided. In any expression that involving links of the `Store.ships_to_in_days` -relationship can then use the variables `Store` and `destination` to refer to objecs that +relationship can then use the variables `Store` and `destination` to refer to objects that play these two `Store`-playing roles without ambiguity. #### Multiplicities If a relationship comprises more than one role, objects that play the last role could be functionally -dermined by a tuple of objects that play the other roles. This knowledge is declared using a `ManyToOne` +determined by a tuple of objects that play the other roles. This knowledge is declared using a `ManyToOne` multiplicity constraint. In the examples above, the constraint declares that each person earns at most one salary and that for each pair of stores, the former ships to the latter in at most one number of days. For relationships of ternary and higher arity, the multiplicity applies to the n-th role, meaning @@ -266,7 +285,7 @@ idnetifier of a concept. ### Derivation expressions -Concepts and relationships may be derived using expressions. Think of a derived concept or +Concepts and relationships may be derived using expressions. Think of a derived concept or relationship as a view whose objects or links are derived from those of other concepts or relationships. For instance: @@ -287,7 +306,7 @@ ontology: name: "descendant" derived_by: - "Person.parent_of(descendant)" - "Person.ancestor_of.parent_of(descendant)" + "Person.ancestor_of.parent_of(descendant)" - name: taxed_at roles: - concept: TaxRate @@ -382,7 +401,7 @@ mappings that group by some concept. Each concept mapping declares how to populate a concept with objects and how to populate the relationships that group under that concept with links. These declarations are formed from patterns of expressions that -reference fields in a logical model that is declared using the OSI core semantic model spec. +reference fields in a logical model that is declared using the Ossie core semantic model spec. Concept mappings have the following schema: @@ -403,7 +422,7 @@ An object mapping has the following schema: |---------------|---------|-----|-------| | `concept` | string | No | Names the concept being mapped to using this object map | | `expression` | string | if no `referent_mappings` | SQL expression that computes a value from fields | -| `referent_mappings` | list | if no `expression` | Referent mappings that find entity objects using identifying realtionships | +| `referent_mappings` | list | if no `expression` | Referent mappings that find entity objects using identifying relationships | When the concept is a value type or an entity type with a simple identifier, then an object mapping is just a SQL expression. For instance, given this ontology snippet: @@ -439,7 +458,7 @@ then mapping those values to `Person` objects using the declared identifier. The concept_mappings: - concept: Person object_mappings: - - expression: PERSONS.SSN + - expression: PERSONS.SSN ... ``` maps values from the `SSN` field of dataset `PERSONS` into `Person` objects. @@ -454,7 +473,7 @@ Referent mappings have the following schema: |----------------|--------|-----|-------| | `relationship` | string | Yes | Name of an identifying relationship | | `expression` | string | if no `referent_mappings` | SQL expression that computes a value from fields | -| `referent_mappings` | list | if no `expression` | Referent mappings that find entity objects using identifying realtionships | +| `referent_mappings` | list | if no `expression` | Referent mappings that find entity objects using identifying relationships | For instance, consider this ontology snippet: diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..38af97d --- /dev/null +++ b/python/README.md @@ -0,0 +1,38 @@ + + +# Apache Ossie Python Package + +The Apache Ossie Python package provides Pydantic v2 models for the Apache Ossie semantic model specification. It is the shared fundation used by Apache Ossie converters to parse, construct, validate, and serialize OSI documents from Python application. + +## Development + +### Prerequisites +- Python 3.11 or later +- uv >= 0.9.0 + +### Installation +```bash +uv sync +``` + +### Generating package distributions +```bash +uv build +``` \ No newline at end of file diff --git a/python/pyproject.toml b/python/pyproject.toml index baf099b..7dab9b6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,24 +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. + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "osi-python" +name = "apache-ossie" version = "0.2.0.dev0" -description = "Python types for the Open Semantic Interchange (OSI) specification" +description = "Python types for the Apache Ossie semantic model specification" +authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] requires-python = ">=3.11" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "Apache Ossie", + "Ossie", + "Open Semantic Interchange", +] dependencies = [ "pydantic>=2.0", "PyYAML>=6.0", ] -[project.license] -text = "Apache-2.0" +[project.urls] +homepage = "https://ossie.apache.org/" +repository = "https://github.com/apache/ossie/" [tool.hatch.build.targets.wheel] -packages = ["src/osi"] +packages = ["src/ossie"] [tool.uv] -dev-dependencies = [ - "pytest>=8.0", -] +required-version = ">=0.9.0" \ No newline at end of file diff --git a/python/src/osi/__init__.py b/python/src/osi/__init__.py deleted file mode 100644 index 00970fd..0000000 --- a/python/src/osi/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from osi.models import ( - OSIAIContext, - OSIAIContextObject, - OSICustomExtension, - OSIDataset, - OSIDialect, - OSIDialectExpression, - OSIDimension, - OSIDocument, - OSIExpression, - OSIField, - OSIMetric, - OSIRelationship, - OSISemanticModel, - OSIVendor, -) - -__all__ = [ - "OSIAIContext", - "OSIAIContextObject", - "OSICustomExtension", - "OSIDataset", - "OSIDialect", - "OSIDialectExpression", - "OSIDimension", - "OSIDocument", - "OSIExpression", - "OSIField", - "OSIMetric", - "OSIRelationship", - "OSISemanticModel", - "OSIVendor", -] diff --git a/python/src/ossie/__init__.py b/python/src/ossie/__init__.py new file mode 100644 index 0000000..cb105fa --- /dev/null +++ b/python/src/ossie/__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 ossie.models import ( + OSIAIContext, + OSIAIContextObject, + OSICustomExtension, + OSIDataset, + OSIDialect, + OSIDialectExpression, + OSIDimension, + OSIDocument, + OSIExpression, + OSIField, + OSIMetric, + OSIRelationship, + OSISemanticModel, + OSIVendor, +) + +__all__ = [ + "OSIAIContext", + "OSIAIContextObject", + "OSICustomExtension", + "OSIDataset", + "OSIDialect", + "OSIDialectExpression", + "OSIDimension", + "OSIDocument", + "OSIExpression", + "OSIField", + "OSIMetric", + "OSIRelationship", + "OSISemanticModel", + "OSIVendor", +] diff --git a/python/src/osi/models.py b/python/src/ossie/models.py similarity index 78% rename from python/src/osi/models.py rename to python/src/ossie/models.py index 766b6e0..9be74a6 100644 --- a/python/src/osi/models.py +++ b/python/src/ossie/models.py @@ -1,3 +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 enum import Enum from typing import Any, Optional, Union @@ -14,10 +31,11 @@ class OSIDialect(str, Enum): MAQL = "MAQL" TABLEAU = "TABLEAU" DATABRICKS = "DATABRICKS" + BIGQUERY = "BIGQUERY" class OSIVendor(str, Enum): - """Vendors with supported custom extensions.""" + """Well-known vendor names for custom extensions.""" COMMON = "COMMON" SNOWFLAKE = "SNOWFLAKE" @@ -25,6 +43,8 @@ class OSIVendor(str, Enum): DBT = "DBT" DATABRICKS = "DATABRICKS" GOODDATA = "GOODDATA" + SEMANTIDO = "SEMANTIDO" + WISDOM = "WISDOM" class OSIAIContextObject(BaseModel): @@ -45,7 +65,7 @@ class OSICustomExtension(BaseModel): model_config = ConfigDict(frozen=True) - vendor_name: OSIVendor + vendor_name: str data: str @@ -144,7 +164,7 @@ class OSISemanticModel(BaseModel): class OSIDocument(BaseModel): - """Root OSI document.""" + """Root Ossie document.""" model_config = ConfigDict(frozen=True) @@ -154,10 +174,10 @@ class OSIDocument(BaseModel): semantic_model: list[OSISemanticModel] def to_osi_yaml(self, **kwargs: Any) -> str: - """Serialize to OSI-compliant YAML (uses field aliases and excludes None values).""" + """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) def to_osi_json(self, **kwargs: Any) -> str: - """Serialize to OSI-compliant JSON (uses field aliases and excludes None values).""" + """Serialize to Ossie-compliant JSON (uses field aliases and excludes None values).""" return self.model_dump_json(by_alias=True, exclude_none=True, **kwargs) diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..17d1769 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,220 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "apache-ossie" +version = "0.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[[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 = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/validation/validate.py b/validation/validate.py index a33cf76..258d34f 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -1,8 +1,35 @@ #!/usr/bin/env python3 +# +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "jsonschema>=4.26.0", +# "pyyaml>=6.0.3", +# "sqlglot>=30.12.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. + """ -OSI Semantic Model Validator +Ossie Semantic Model Validator -Validates OSI YAML files against: +Validates Ossie YAML files against: 1. JSON Schema (structure, types, enums) 2. Unique names (datasets, fields, metrics, relationships) 3. Valid relationship references @@ -28,16 +55,17 @@ try: import sqlglot - from sqlglot.errors import ParseError + from sqlglot.errors import ParseError, TokenError SQLGLOT_AVAILABLE = True except ImportError: SQLGLOT_AVAILABLE = False -# Map OSI dialects to sqlglot dialects +# Map Ossie dialects to sqlglot dialects DIALECT_MAP = { "ANSI_SQL": None, # sqlglot default "SNOWFLAKE": "snowflake", "DATABRICKS": "databricks", + "BIGQUERY": "bigquery", "MDX": None, # Not supported by sqlglot, skip validation "TABLEAU": None, # Not supported by sqlglot, skip validation "MAQL": None, # Not supported by sqlglot, skip validation @@ -114,9 +142,9 @@ def validate_references(data: dict) -> list[str]: to_ds = rel.get("to") if from_ds and from_ds not in dataset_names: - errors.append(f"[Reference] Relationship '{rel_name}' references unknown dataset '{from_ds}'") + errors.append(f"[Reference] Relationship '{rel_name}' in model '{model_name}' references unknown dataset '{from_ds}'") if to_ds and to_ds not in dataset_names: - errors.append(f"[Reference] Relationship '{rel_name}' references unknown dataset '{to_ds}'") + errors.append(f"[Reference] Relationship '{rel_name}' in model '{model_name}' references unknown dataset '{to_ds}'") return errors @@ -135,14 +163,14 @@ def validate_sql_expression(expr: str, dialect: str, context: str) -> str | None # Try parsing as expression first (for field expressions like "column_name") sqlglot.parse_one(expr, dialect=sqlglot_dialect) return None - except ParseError: + except (ParseError, TokenError): pass try: # Try wrapping in SELECT for simple column references sqlglot.parse_one(f"SELECT {expr}", dialect=sqlglot_dialect) return None - except ParseError as e: + except (ParseError, TokenError) as e: return f"[SQL] {context}: {str(e).split(chr(10))[0]}" @@ -170,7 +198,7 @@ def validate_sql(data: dict) -> list[str]: dialect = dialect_expr.get("dialect", "ANSI_SQL") expr = dialect_expr.get("expression", "") if expr: - context = f"Field '{dataset_name}.{field_name}' ({dialect})" + context = f"Field '{dataset_name}.{field_name}' in model '{model_name}' ({dialect})" error = validate_sql_expression(expr, dialect, context) if error: errors.append(error) @@ -183,7 +211,7 @@ def validate_sql(data: dict) -> list[str]: dialect = dialect_expr.get("dialect", "ANSI_SQL") expr = dialect_expr.get("expression", "") if expr: - context = f"Metric '{metric_name}' ({dialect})" + context = f"Metric '{metric_name}' in model '{model_name}' ({dialect})" error = validate_sql_expression(expr, dialect, context) if error: errors.append(error)