diff --git a/.github/workflows/interop-matrix-ci.yml b/.github/workflows/interop-matrix-ci.yml new file mode 100644 index 00000000..6b54d981 --- /dev/null +++ b/.github/workflows/interop-matrix-ci.yml @@ -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. +# + +name: Cross-converter Interop Matrix + +on: + push: + branches: [ "main" ] + paths: + - 'interop/**' + - 'converters/**' + - 'examples/**' + - 'core-spec/**' + - 'python/**' + - 'validation/**' + - '.github/workflows/interop-matrix-ci.yml' + pull_request: + branches: [ "main" ] + paths: + - 'interop/**' + - 'converters/**' + - 'examples/**' + - 'core-spec/**' + - 'python/**' + - 'validation/**' + - '.github/workflows/interop-matrix-ci.yml' + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + + steps: + - name: Checkout project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.12 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Lint interop harness + working-directory: interop + run: | + uv sync --frozen + uv run ruff check runner.py adapters tests + + - name: Test interop harness + working-directory: interop + run: | + uv sync --frozen + uv run pytest + + - name: Generate report-only interoperability matrix + run: | + uv run --project interop python interop/runner.py \ + --report interop-report.md \ + --report-only + cat interop-report.md >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload interoperability report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cross-converter-interop-report + path: interop-report.md + if-no-files-found: error diff --git a/interop/README.md b/interop/README.md new file mode 100644 index 00000000..45189e3c --- /dev/null +++ b/interop/README.md @@ -0,0 +1,48 @@ + + +# Cross-converter interoperability + +The `interop` harness tests the hub-and-spoke contract across converter boundaries rather than only testing each converter's own round trip. + +## Tiers + +**Tier A** sends the canonical Ossie example through every declared export capability. A successful process is not sufficient: the manifest also declares a converter-specific non-empty assertion, so a loadable vendor document with no meaningful members is graded `EMPTY` rather than `OK`. + +**Tier B** starts from each declared native fixture and evaluates `source native -> Ossie -> target native -> Ossie`. The final Ossie document is validated with `validation/validate.py`. A target must declare both export and import capabilities to participate. + +The report grades cells as `OK`, `LOSSY`, `EMPTY`, `FAIL`, or `SKIP`. Warning counts are retained so report-only CI can expose known loss before individual cells are promoted to blocking gates. Build/setup warnings are deliberately excluded from semantic loss grading. + +**Tier C** is available for converters that expose an independent native compiler or validator. A manifest `native_gate` can be unconditional or guarded by an environment variable; the tier is emitted when at least one selected converter declares a gate. + +## Manifest + +`manifest.yaml` is the compatibility boundary between the harness and converter-specific command lines. Each converter directory must have an entry, even when it is currently skipped. Executable capabilities declare their command, output shape, and non-empty assertion. + +This keeps CLI differences out of the runner and makes unsupported toolchains explicit. GoodData is wired through a thin Python API adapter. The ontology converter participates asymmetrically through its real Palantir import boundary: a committed minimal Palantir export feeds Tier B as a source, while Tier A and ontology-as-target cells remain `SKIP` because no Ossie-to-Palantir exporter exists. Salesforce runs through its shaded executable JAR. Polaris runs its public CLI against an in-process Iceberg REST catalog, which exercises the real HTTP boundary without requiring an external service. Wisdom is installed transiently with `uv --with`, so its missing converter-local lockfile does not mutate the checkout. The legacy `converters/gsf` compatibility directory is the only intentional hard skip; the executable NVIDIA GSF converter is represented by `nvidia`. + +## Running + +```bash +cd interop +uv sync --frozen +uv run pytest +cd .. +uv run --project interop python interop/runner.py --report interop-report.md --report-only +``` + +Use `--include databricks,nvidia` for a focused run, `--tier a`, `--tier b`, or `--tier c` to select one tier, and omit `--report-only` when failures should produce a non-zero exit code. Tier C internally materializes the Tier B target output before invoking native gates. diff --git a/interop/adapters/gooddata.py b/interop/adapters/gooddata.py new file mode 100755 index 00000000..9e3dde11 --- /dev/null +++ b/interop/adapters/gooddata.py @@ -0,0 +1,50 @@ +#!/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. + +"""CLI shim exposing GoodData's Python API to the interop harness.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import yaml +from ossie_gooddata import gooddata_to_ossie, ossie_to_gooddata +from ossie_gooddata.models import gd_model_from_dict, gd_model_to_dict + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("direction", choices=("export", "import")) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + if args.direction == "export": + ossie = yaml.safe_load(args.input.read_text(encoding="utf-8")) + native = gd_model_to_dict(ossie_to_gooddata(ossie)) + args.output.write_text(json.dumps(native, indent=2) + "\n", encoding="utf-8") + return 0 + + native = json.loads(args.input.read_text(encoding="utf-8")) + ossie = gooddata_to_ossie(gd_model_from_dict(native)) + args.output.write_text(yaml.safe_dump(ossie, sort_keys=False), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/interop/adapters/java_build.py b/interop/adapters/java_build.py new file mode 100755 index 00000000..e583f99b --- /dev/null +++ b/interop/adapters/java_build.py @@ -0,0 +1,84 @@ +#!/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. + +"""Build a Java converter in an isolated interop workspace.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +SUPPORTED = {"polaris", "salesforce"} + + +def _copy_module(module: str, output: Path) -> Path: + sandbox = output / "repo" + module_source = REPO / "converters" / module + module_target = sandbox / "converters" / module + shutil.copytree( + module_source, + module_target, + ignore=shutil.ignore_patterns("target", ".idea", "*.iml"), + ) + if module == "salesforce": + shutil.copytree(REPO / "core-spec", sandbox / "core-spec") + return module_target + + +def build(module: str, output: Path) -> None: + if module not in SUPPORTED: + raise ValueError(f"unsupported Java converter: {module}") + if output.exists(): + shutil.rmtree(output) + output.parent.mkdir(parents=True, exist_ok=True) + module_root = _copy_module(module, output) + + command = ["mvn", "-q", "-DskipTests", "package"] + if module == "polaris": + command.extend( + [ + "dependency:build-classpath", + "-Dmdep.outputFile=target/interop-classpath.txt", + ] + ) + process = subprocess.run( + command, + cwd=module_root, + capture_output=True, + text=True, + check=False, + ) + if process.stdout: + print(process.stdout, end="") + if process.stderr: + print(process.stderr, end="", file=__import__("sys").stderr) + if process.returncode != 0: + raise SystemExit(process.returncode) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--module", required=True, choices=sorted(SUPPORTED)) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + build(args.module, args.output) + + +if __name__ == "__main__": + main() diff --git a/interop/adapters/ontology.py b/interop/adapters/ontology.py new file mode 100755 index 00000000..36b8d7bf --- /dev/null +++ b/interop/adapters/ontology.py @@ -0,0 +1,56 @@ +#!/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. + +"""Interop adapter for the ontology converter's Palantir import boundary.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "converters" / "ontology" / "src")) + +from ossie_ontology.converter.ossie_to_spec.converter import ( + OssieToSpecConverter, +) +from ossie_ontology.converter.palantir_to_ossie.converter import ( + PalantirToOssieConverter, +) +from ossie_ontology.external.palantir.parser import PalantirParser + + +def import_palantir(input_path: Path, output_path: Path) -> None: + palantir = PalantirParser().parse(input_path) + ontology = PalantirToOssieConverter().convert(palantir, "PALANTIR", "PALANTIR") + spec = OssieToSpecConverter.convert(ontology) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(spec.dump_yaml(), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("import",)) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + import_palantir(args.input, args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/interop/adapters/polaris.py b/interop/adapters/polaris.py new file mode 100755 index 00000000..a096d605 --- /dev/null +++ b/interop/adapters/polaris.py @@ -0,0 +1,215 @@ +#!/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. + +"""Run the Polaris CLI against a deterministic in-process Iceberg REST catalog.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlparse + +REPO = Path(__file__).resolve().parents[2] +CATALOG = "interop" + + +class CatalogState: + def __init__(self, bundle: dict | None = None) -> None: + self.bundle = bundle or {"catalog": CATALOG, "namespaces": []} + + def namespace(self, parts: list[str], *, create: bool = False) -> dict | None: + for item in self.bundle.get("namespaces", []): + if item.get("namespace") == parts: + return item + if not create: + return None + item = {"namespace": parts, "properties": {}, "tables": []} + self.bundle.setdefault("namespaces", []).append(item) + return item + + def table(self, parts: list[str], name: str) -> dict | None: + namespace = self.namespace(parts) + if not namespace: + return None + return next((table for table in namespace.get("tables", []) if table.get("name") == name), None) + + +def _segments(path: str) -> list[str]: + return [unquote(part) for part in urlparse(path).path.split("/") if part] + + +def _namespace_parts(value: str) -> list[str]: + return value.split("\x1f") if value else [] + + +def _metadata_response(table: dict) -> dict: + schema = table.get("schema") or {"type": "struct", "schema-id": 0, "fields": []} + return { + "metadata": { + "current-schema-id": schema.get("schema-id", 0), + "schemas": [schema], + "properties": table.get("properties") or {}, + } + } + + +def _handler(state: CatalogState): + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return + + def _send(self, status: int, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + parts = _segments(self.path) + try: + ns_index = parts.index("namespaces") + except ValueError: + self._send(404, {"error": "unknown endpoint"}) + return + + if len(parts) == ns_index + 1: + namespace = payload.get("namespace") or [] + item = state.namespace(namespace, create=True) + assert item is not None + item["properties"] = payload.get("properties") or {} + self._send(201, {}) + return + + if len(parts) == ns_index + 3 and parts[-1] == "tables": + namespace = _namespace_parts(parts[ns_index + 1]) + item = state.namespace(namespace, create=True) + assert item is not None + tables = item.setdefault("tables", []) + tables[:] = [table for table in tables if table.get("name") != payload.get("name")] + tables.append(payload) + self._send(201, {}) + return + + self._send(404, {"error": "unknown endpoint"}) + + def do_GET(self) -> None: + parts = _segments(self.path) + try: + ns_index = parts.index("namespaces") + except ValueError: + self._send(404, {"error": "unknown endpoint"}) + return + + if len(parts) == ns_index + 1: + self._send(200, {"namespaces": [item["namespace"] for item in state.bundle.get("namespaces", [])]}) + return + + namespace = _namespace_parts(parts[ns_index + 1]) + if len(parts) == ns_index + 3 and parts[-1] == "tables": + item = state.namespace(namespace) + identifiers = [] if item is None else [ + {"namespace": namespace, "name": table["name"]} + for table in item.get("tables", []) + ] + self._send(200, {"identifiers": identifiers}) + return + + if len(parts) == ns_index + 4 and parts[ns_index + 2] == "tables": + table = state.table(namespace, parts[-1]) + if table is None: + self._send(404, {"error": "table not found"}) + else: + self._send(200, _metadata_response(table)) + return + + self._send(404, {"error": "unknown endpoint"}) + + return Handler + + +def _java_command(build_root: Path) -> list[str]: + classpath_file = build_root / "target" / "interop-classpath.txt" + if not classpath_file.is_file(): + raise RuntimeError("Polaris classpath is missing; run the manifest prepare command") + classpath = os.pathsep.join( + [str(build_root / "target" / "classes"), classpath_file.read_text(encoding="utf-8").strip()] + ) + return ["java", "-cp", classpath, "org.apache.ossie.converter.polaris.OssiePolarisConverter"] + + +def _run_cli(state: CatalogState, args: list[str], build_root: Path) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _handler(state)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}" + process = subprocess.run( + [*_java_command(build_root), *args, "--url", url, "--catalog", CATALOG], + cwd=build_root, + capture_output=True, + text=True, + check=False, + ) + finally: + server.shutdown() + thread.join() + server.server_close() + if process.stdout: + print(process.stdout, end="") + if process.stderr: + print(process.stderr, end="", file=__import__("sys").stderr) + if process.returncode != 0: + raise SystemExit(process.returncode) + + +def export_model(source: Path, output: Path, build_root: Path) -> None: + state = CatalogState() + _run_cli(state, ["export", str(source.resolve())], build_root) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(state.bundle, indent=2) + "\n", encoding="utf-8") + + +def import_model(source: Path, output: Path, build_root: Path) -> None: + bundle = json.loads(source.read_text(encoding="utf-8")) + state = CatalogState(bundle) + output.parent.mkdir(parents=True, exist_ok=True) + _run_cli(state, ["import", "-o", str(output.resolve())], build_root) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("export", "import")) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--build-root", required=True, type=Path) + args = parser.parse_args() + if args.mode == "export": + export_model(args.input, args.output, args.build_root) + else: + import_model(args.input, args.output, args.build_root) + + +if __name__ == "__main__": + main() diff --git a/interop/adapters/salesforce.py b/interop/adapters/salesforce.py new file mode 100755 index 00000000..3c0b0ba3 --- /dev/null +++ b/interop/adapters/salesforce.py @@ -0,0 +1,124 @@ +#!/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. + +"""Interop shim around the Salesforce converter's shaded executable JAR.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[2] + + +def _jar(build_root: Path) -> Path: + jars = sorted( + path + for path in (build_root / "target").glob("ossie-salesforce-converter-*.jar") + if not path.name.startswith("original-") + ) + if len(jars) != 1: + raise RuntimeError(f"expected one Salesforce executable JAR, found {len(jars)}") + return jars[0] + + +def _run(direction: str, source: Path, staging: Path, build_root: Path) -> list[Path]: + staged_input = staging / ("input" + source.suffix) + shutil.copy2(source, staged_input) + process = subprocess.run( + ["java", "-jar", str(_jar(build_root)), direction, str(staged_input)], + cwd=staging, + capture_output=True, + text=True, + check=False, + ) + if process.stdout: + print(process.stdout, end="") + if process.stderr: + print(process.stderr, end="", file=__import__("sys").stderr) + if process.returncode != 0: + raise SystemExit(process.returncode) + return sorted(path for path in staging.iterdir() if path.is_file() and path != staged_input) + + +def export_model(source: Path, output: Path, build_root: Path) -> None: + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="ossie-salesforce-export-") as temp_dir: + generated = _run("toSF", source, Path(temp_dir), build_root) + json_files = [path for path in generated if path.suffix.lower() == ".json"] + if not json_files: + raise RuntimeError("Salesforce export produced no JSON models") + for path in json_files: + shutil.copy2(path, output / path.name) + + +def import_model(source: Path, output: Path, build_root: Path) -> None: + sources = [source] if source.is_file() else sorted(source.rglob("*.json")) + if not sources: + raise RuntimeError("Salesforce import input contains no JSON models") + + version: str | None = None + semantic_models: list[object] = [] + for native in sources: + with tempfile.TemporaryDirectory(prefix="ossie-salesforce-import-") as temp_dir: + generated = _run("toOssie", native, Path(temp_dir), build_root) + yaml_files = [path for path in generated if path.suffix.lower() in {".yaml", ".yml"}] + if not yaml_files: + raise RuntimeError(f"Salesforce import produced no Ossie YAML for {native.name}") + for path in yaml_files: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise TypeError(f"Salesforce import produced invalid YAML: {path.name}") + current_version = document.get("version") + if version is None: + version = current_version + elif current_version != version: + raise RuntimeError("Salesforce import produced inconsistent Ossie versions") + models = document.get("semantic_model") + if not isinstance(models, list): + raise TypeError(f"Salesforce import omitted semantic_model: {path.name}") + semantic_models.extend(models) + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + yaml.safe_dump( + {"version": version or "0.2.0.dev0", "semantic_model": semantic_models}, + sort_keys=False, + ), + encoding="utf-8", + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("export", "import")) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--build-root", required=True, type=Path) + args = parser.parse_args() + if args.mode == "export": + export_model(args.input, args.output, args.build_root) + else: + import_model(args.input, args.output, args.build_root) + + +if __name__ == "__main__": + main() diff --git a/interop/fixtures/ontology_palantir/data_sets/ds.json b/interop/fixtures/ontology_palantir/data_sets/ds.json new file mode 100644 index 00000000..0ecf76c9 --- /dev/null +++ b/interop/fixtures/ontology_palantir/data_sets/ds.json @@ -0,0 +1,9 @@ +[ + { + "mainDatasetId": "ri.ot.1", + "datasetName": "widget", + "datasetSchema": [ + {"name": "widget_id", "type": "STRING"} + ] + } +] diff --git a/interop/fixtures/ontology_palantir/ontology.json b/interop/fixtures/ontology_palantir/ontology.json new file mode 100644 index 00000000..372ca538 --- /dev/null +++ b/interop/fixtures/ontology_palantir/ontology.json @@ -0,0 +1,14 @@ +{ + "objectTypes": [ + { + "rid": "ri.ot.1", + "id": "widget", + "displayName": "Widget", + "properties": [ + {"rid": "ri.p.1", "id": "widget_id", "baseType": {"type": "STRING"}} + ], + "primaryKeys": ["widget_id"] + } + ], + "relations": [] +} diff --git a/interop/fixtures/polaris_catalog.json b/interop/fixtures/polaris_catalog.json new file mode 100644 index 00000000..a47a3315 --- /dev/null +++ b/interop/fixtures/polaris_catalog.json @@ -0,0 +1,31 @@ +{ + "catalog": "interop", + "namespaces": [ + { + "namespace": ["sales"], + "properties": { + "description": "Interop fixture", + "ossie.source": "true" + }, + "tables": [ + { + "name": "orders", + "schema": { + "type": "struct", + "schema-id": 0, + "fields": [ + {"id": 1, "name": "order_id", "type": "long", "required": true}, + {"id": 2, "name": "order_amount", "type": "decimal(18,2)", "required": false}, + {"id": 3, "name": "order_ts", "type": "timestamptz", "required": false} + ], + "identifier-field-ids": [1] + }, + "properties": { + "comment": "Orders", + "ossie.source": "interop.sales.orders" + } + } + ] + } + ] +} diff --git a/interop/manifest.yaml b/interop/manifest.yaml new file mode 100644 index 00000000..84292a37 --- /dev/null +++ b/interop/manifest.yaml @@ -0,0 +1,171 @@ +# 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 +canonical_model: examples/tpcds_semantic_model.yaml +validation: + command: [uv, run, --script, "{repo}/validation/validate.py", "{input}"] +warning_pattern: '(?i)\bwarning\b' +foreign_extension_pattern: '(?i)(foreign.*custom_extensions|custom_extensions.*foreign)' + +converters: + databricks: + root: converters/databricks + prepare: [uv, sync, --frozen] + fixture: converters/databricks/tests/fixtures/fixtureA_metric_view.yaml + export: + command: [uv, run, ossie-databricks, export, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [dimensions, measures, joins]} + import: + command: [uv, run, ossie-databricks, import, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + dbt: + root: converters/dbt + prepare: [uv, sync, --frozen] + export: + command: [uv, run, ossie-dbt, ossie-to-msi, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_models, metrics]} + import: + command: [uv, run, ossie-dbt, msi-to-ossie, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + gooddata: + root: converters/gooddata + prepare: [uv, sync, --frozen] + fixture: converters/gooddata/tests/fixtures/gooddata_tpcds.json + export: + command: [uv, run, python, "{repo}/interop/adapters/gooddata.py", export, --input, "{input}", --output, "{output}"] + output: file + nonempty: {type: structured_any, paths: [ldm.datasets, ldm.dateInstances]} + import: + command: [uv, run, python, "{repo}/interop/adapters/gooddata.py", import, --input, "{input}", --output, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + gsf: + root: converters/gsf + skip_reason: legacy compatibility directory; NVIDIA GSF is the executable converter + + honeydew: + root: converters/honeydew + prepare: [uv, sync, --frozen] + export: + command: [uv, run, honeydew-ossie, ossie-to-honeydew, -i, "{input}", -o, "{output}"] + output: directory + nonempty: {type: directory_any, patterns: ['**/*.yaml', '**/*.yml']} + import: + command: [uv, run, honeydew-ossie, honeydew-to-ossie, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + nvidia: + root: converters/nvidia + prepare: [uv, sync, --frozen] + fixture: converters/nvidia/tests/fixtures/sales.gsf.yaml + export: + command: [uv, run, ossie-nvidia-gsf, export, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [data_layer.databases, semantic_layer.terms]} + import: + command: [uv, run, ossie-nvidia-gsf, import, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + ontology: + root: converters/ontology + fixture: interop/fixtures/ontology_palantir + import: + command: [uv, run, --project, "{repo}/interop", --with-requirements, "{repo}/converters/ontology/requirements.lock", python, "{repo}/interop/adapters/ontology.py", import, --input, "{input}", --output, "{output}"] + output: file + nonempty: {type: structured_any, paths: [ontology, semantic_model, ontology_mappings]} + + omni: + root: converters/omni + prepare: [uv, sync, --frozen] + fixture: converters/omni/tests/fixtures/fixtureB_omni + export: + command: [uv, run, ossie-omni, export, -i, "{input}", -o, "{output}"] + output: directory + nonempty: {type: directory_any, patterns: ['**/*.yaml', '**/*.yml']} + import: + command: [uv, run, ossie-omni, import, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + orionbelt: + root: converters/orionbelt + prepare: [uv, sync, --frozen] + fixture: converters/orionbelt/tests/fixtures/tpcds_as_obml.yaml + export: + command: [uv, run, ossie-orionbelt, ossie-to-obml, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [dataObjects, dimensions, measures, metrics]} + import: + command: [uv, run, ossie-orionbelt, obml-to-ossie, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + polaris: + root: converters/polaris + prepare: [uv, run, --project, "{repo}/interop", python, "{repo}/interop/adapters/java_build.py", --module, polaris, --output, "{workspace}/java/polaris"] + fixture: interop/fixtures/polaris_catalog.json + export: + command: [uv, run, --project, "{repo}/interop", python, "{repo}/interop/adapters/polaris.py", export, --input, "{input}", --output, "{output}", --build-root, "{workspace}/java/polaris/repo/converters/polaris"] + output: file + nonempty: {type: structured_any, paths: [namespaces]} + import: + command: [uv, run, --project, "{repo}/interop", python, "{repo}/interop/adapters/polaris.py", import, --input, "{input}", --output, "{output}", --build-root, "{workspace}/java/polaris/repo/converters/polaris"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + salesforce: + root: converters/salesforce + prepare: [uv, run, --project, "{repo}/interop", python, "{repo}/interop/adapters/java_build.py", --module, salesforce, --output, "{workspace}/java/salesforce"] + fixture: converters/salesforce/src/test/resources/examples/salesforceToOssie.json + export: + command: [uv, run, --project, "{repo}/interop", python, "{repo}/interop/adapters/salesforce.py", export, --input, "{input}", --output, "{output}", --build-root, "{workspace}/java/salesforce/repo/converters/salesforce"] + output: directory + nonempty: {type: directory_any, patterns: ['**/*.json']} + import: + requires_file: "{repo}/converters/salesforce/src/main/resources/schemas/salesforce-semantic-model-schema.json" + skip_reason: Salesforce import requires the externally supplied semantic-model schema documented in converters/salesforce/README.md + command: [uv, run, --project, "{repo}/interop", python, "{repo}/interop/adapters/salesforce.py", import, --input, "{input}", --output, "{output}", --build-root, "{workspace}/java/salesforce/repo/converters/salesforce"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} + + snowflake: + root: converters/snowflake + prepare: [uv, sync, --frozen] + export: + command: [uv, run, ossie-snowflake, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [tables, relationships, metrics]} + + wisdom: + root: converters/wisdom + fixture: converters/wisdom/tests/fixtures/sample_export.json + export: + command: [uv, run, --project, "{repo}/interop", --with, "{repo}/python", --with, "{repo}/converters/wisdom", ossie-wisdom, ossie-to-wisdom, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [tables, table_metadata]} + import: + command: [uv, run, --project, "{repo}/interop", --with, "{repo}/python", --with, "{repo}/converters/wisdom", ossie-wisdom, wisdom-to-ossie, -i, "{input}", -o, "{output}"] + output: file + nonempty: {type: structured_any, paths: [semantic_model]} diff --git a/interop/pyproject.toml b/interop/pyproject.toml new file mode 100644 index 00000000..7e91c015 --- /dev/null +++ b/interop/pyproject.toml @@ -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. + +[project] +name = "apache-ossie-interop" +version = "0.1.0" +description = "Cross-converter interoperability harness for Apache Ossie" +requires-python = ">=3.11" +dependencies = [ + "pyyaml>=6.0.3", +] + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "ruff>=0.12.12", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/interop/runner.py b/interop/runner.py new file mode 100755 index 00000000..bbadbb48 --- /dev/null +++ b/interop/runner.py @@ -0,0 +1,717 @@ +#!/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. + +"""Repository-level cross-converter interoperability runner.""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +OK = "OK" +LOSSY = "LOSSY" +EMPTY = "EMPTY" +FAIL = "FAIL" +SKIP = "SKIP" +SELF = "—" +FAILURE_STATUSES = {EMPTY, FAIL} + + +@dataclass(frozen=True) +class StepResult: + status: str + detail: str = "" + warnings: int = 0 + foreign_extension_warnings: int = 0 + output: Path | None = None + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: str + stderr: str + + @property + def combined_output(self) -> str: + return "\n".join(part for part in (self.stdout, self.stderr) if part) + + +def load_manifest(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise TypeError("interop manifest root must be a mapping") + if data.get("version") != 1: + raise ValueError("interop manifest version must be 1") + converters = data.get("converters") + if not isinstance(converters, dict) or not converters: + raise ValueError("interop manifest must define converters") + return data + + +def render_command(command: list[str], **values: str) -> list[str]: + return [str(part).format(**values) for part in command] + + +def lookup_path(data: Any, dotted_path: str) -> Any: + current = data + for part in dotted_path.split("."): + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + +def _is_nonempty_value(value: Any) -> bool: + if value is None or value is False: + return False + if isinstance(value, (str, bytes, list, tuple, dict, set)): + return bool(value) + return True + + +def check_output(path: Path, check: dict[str, Any] | None) -> tuple[str, str]: + if not path.exists(): + return EMPTY, f"expected output was not created: {path.name}" + + check = check or {"type": "file_nonempty"} + check_type = check.get("type", "file_nonempty") + + if check_type == "file_nonempty": + if not path.is_file() or path.stat().st_size == 0: + return EMPTY, "output file is empty" + return OK, "" + + if check_type == "directory_any": + if not path.is_dir(): + return FAIL, "expected a directory output" + patterns = check.get("patterns") or ["**/*"] + candidates = [] + for pattern in patterns: + candidates.extend(p for p in path.glob(pattern) if p.is_file()) + if not any(candidate.stat().st_size > 0 for candidate in candidates): + return EMPTY, "output directory contains no matching non-empty files" + return OK, "" + + if check_type == "structured_any": + if not path.is_file() or path.stat().st_size == 0: + return EMPTY, "structured output file is empty" + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + return FAIL, f"structured output could not be parsed: {exc}" + paths = check.get("paths") or [] + if not paths: + return FAIL, "structured_any output check requires paths" + if any(_is_nonempty_value(lookup_path(data, item)) for item in paths): + return OK, "" + return EMPTY, "structured output has no meaningful content at: " + ", ".join(paths) + + return FAIL, f"unknown output check type: {check_type}" + + +def combine_status(*results: StepResult) -> StepResult: + warnings = sum(result.warnings for result in results) + foreign = sum(result.foreign_extension_warnings for result in results) + for status in (FAIL, EMPTY, SKIP): + matching = next((result for result in results if result.status == status), None) + if matching is not None: + return StepResult(status, matching.detail, warnings, foreign, matching.output) + if warnings or foreign or any(result.status == LOSSY for result in results): + return StepResult(LOSSY, "", warnings, foreign, results[-1].output if results else None) + return StepResult(OK, "", warnings, foreign, results[-1].output if results else None) + + +class Harness: + def __init__( + self, + repo: Path, + manifest: dict[str, Any], + workspace: Path, + *, + timeout: int = 180, + no_setup: bool = False, + ) -> None: + self.repo = repo + self.manifest = manifest + self.workspace = workspace + self.timeout = timeout + self.no_setup = no_setup + self.prepared: dict[str, StepResult] = {} + self.pair_native: dict[tuple[str, str], Path] = {} + self.warning_re = re.compile(manifest.get("warning_pattern", r"(?i)\bwarning\b")) + self.foreign_re = re.compile( + manifest.get( + "foreign_extension_pattern", + r"(?i)(foreign.*custom_extensions|custom_extensions.*foreign)", + ) + ) + + @property + def converters(self) -> dict[str, Any]: + return self.manifest["converters"] + + def _warning_counts(self, text: str) -> tuple[int, int]: + lines = text.splitlines() + warning_count = sum(bool(self.warning_re.search(line)) for line in lines) + foreign_count = sum(bool(self.foreign_re.search(line)) for line in lines) + return warning_count, foreign_count + + def _run(self, command: list[str], cwd: Path) -> CommandResult: + env = os.environ.copy() + # The harness itself may run inside an interop virtualenv. Child uv + # commands must resolve the converter's own environment instead of + # inheriting that unrelated VIRTUAL_ENV and emitting setup warnings. + env.pop("VIRTUAL_ENV", None) + try: + completed = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + timeout=self.timeout, + check=False, + env=env, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "") + return CommandResult(124, stdout, f"{stderr}\ncommand timed out after {self.timeout}s") + except OSError as exc: + return CommandResult(127, "", str(exc)) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + def prepare(self, name: str) -> StepResult: + if name in self.prepared: + return self.prepared[name] + + spec = self.converters[name] + if spec.get("skip_reason"): + result = StepResult(SKIP, spec["skip_reason"]) + self.prepared[name] = result + return result + + root = self.repo / spec["root"] + if not root.is_dir(): + result = StepResult(FAIL, f"converter root does not exist: {spec['root']}") + self.prepared[name] = result + return result + + command = spec.get("prepare") + if self.no_setup or not command: + result = StepResult(OK) + self.prepared[name] = result + return result + + rendered = render_command( + command, + repo=str(self.repo), + workspace=str(self.workspace), + input="", + output="", + ) + process = self._run(rendered, root) + if process.returncode != 0: + detail = _last_message(process.combined_output) or f"prepare exited {process.returncode}" + result = StepResult(FAIL, detail) + else: + # Build/install output is environmental setup, not converter semantics. + # Do not let package-manager or compiler warnings pollute LOSSY grading. + result = StepResult(OK) + self.prepared[name] = result + return result + + def capability_prerequisite( + self, + capability: str, + cap: dict[str, object], + ) -> StepResult | None: + required = cap.get("requires_file") + if not required: + return None + required_path = Path( + str(required).format(repo=str(self.repo), workspace=str(self.workspace)) + ) + if required_path.is_file(): + return None + reason = cap.get("skip_reason") or ( + f"{capability} requires file: {required_path}" + ) + return StepResult(SKIP, str(reason)) + + def run_capability( + self, + name: str, + capability: str, + input_path: Path, + output_path: Path, + ) -> StepResult: + spec = self.converters[name] + cap = spec.get(capability) + if not isinstance(cap, dict): + return StepResult(SKIP, f"{capability} capability is not declared") + + prerequisite = self.capability_prerequisite(capability, cap) + if prerequisite is not None: + return prerequisite + + prepare = self.prepare(name) + if prepare.status in {FAIL, SKIP}: + return prepare + + if output_path.exists(): + if output_path.is_dir(): + shutil.rmtree(output_path) + else: + output_path.unlink() + output_path.parent.mkdir(parents=True, exist_ok=True) + + command = render_command( + cap["command"], + repo=str(self.repo), + workspace=str(self.workspace), + input=str(input_path.resolve()), + output=str(output_path.resolve()), + ) + process = self._run(command, self.repo / spec["root"]) + warnings, foreign = self._warning_counts(process.combined_output) + + if process.returncode != 0: + detail = _last_message(process.combined_output) or f"command exited {process.returncode}" + return StepResult(FAIL, detail, warnings, foreign) + + status, detail = check_output(output_path, cap.get("nonempty")) + if status != OK: + return StepResult(status, detail, warnings, foreign, output_path) + if warnings or foreign: + return StepResult(LOSSY, "", warnings, foreign, output_path) + return StepResult(OK, "", warnings, foreign, output_path) + + def validate_ossie(self, input_path: Path) -> StepResult: + validation = self.manifest.get("validation") or {} + command = validation.get("command") + if not command: + return StepResult(SKIP, "validation command is not configured") + rendered = render_command( + command, + repo=str(self.repo), + workspace=str(self.workspace), + input=str(input_path.resolve()), + output="", + ) + process = self._run(rendered, self.repo) + warnings, foreign = self._warning_counts(process.combined_output) + if process.returncode != 0: + return StepResult( + FAIL, + _last_message(process.combined_output) or "Ossie validation failed", + warnings, + foreign, + input_path, + ) + if warnings or foreign: + return StepResult(LOSSY, "", warnings, foreign, input_path) + return StepResult(OK, output=input_path) + + def run_native_gate(self, name: str, input_path: Path) -> StepResult: + spec = self.converters[name] + gate = spec.get("native_gate") + if not isinstance(gate, dict): + return StepResult(SKIP, "native gate is not declared") + + required_env = gate.get("when_env") + if required_env and not os.environ.get(required_env): + return StepResult(SKIP, f"native gate requires environment variable {required_env}") + + command = gate.get("command") + if not isinstance(command, list) or not command: + return StepResult(FAIL, "native gate command is not configured") + rendered = render_command( + command, + repo=str(self.repo), + workspace=str(self.workspace), + input=str(input_path.resolve()), + output="", + ) + process = self._run(rendered, self.repo / spec["root"]) + warnings, foreign = self._warning_counts(process.combined_output) + if process.returncode != 0: + return StepResult( + FAIL, + _last_message(process.combined_output) or "native gate failed", + warnings, + foreign, + input_path, + ) + if warnings or foreign: + return StepResult(LOSSY, "", warnings, foreign, input_path) + return StepResult(OK, output=input_path) + + def run_tier_a(self, names: list[str]) -> dict[str, StepResult]: + canonical = self.repo / self.manifest["canonical_model"] + results: dict[str, StepResult] = {} + for name in names: + spec = self.converters[name] + if spec.get("skip_reason"): + results[name] = StepResult(SKIP, spec["skip_reason"]) + continue + cap = spec.get("export") + if not isinstance(cap, dict): + results[name] = StepResult(SKIP, "export capability is not declared") + continue + suffix = "native" if cap.get("output") == "directory" else "native.yaml" + output = self.workspace / "tier-a" / name / suffix + results[name] = self.run_capability(name, "export", canonical, output) + return results + + def _import_fixture(self, name: str) -> StepResult: + spec = self.converters[name] + fixture = spec.get("fixture") + if not fixture: + return StepResult(SKIP, "no native fixture declared") + import_cap = spec.get("import") + if not isinstance(import_cap, dict): + return StepResult(SKIP, "import capability is not declared") + prerequisite = self.capability_prerequisite("import", import_cap) + if prerequisite is not None: + return prerequisite + fixture_path = self.repo / fixture + if not fixture_path.exists(): + return StepResult(FAIL, f"fixture does not exist: {fixture}") + output = self.workspace / "tier-b" / "imports" / f"{name}.ossie.yaml" + imported = self.run_capability(name, "import", fixture_path, output) + if imported.status in FAILURE_STATUSES | {SKIP}: + return imported + return combine_status(imported, self.validate_ossie(output)) + + def run_tier_b(self, names: list[str]) -> dict[tuple[str, str], StepResult]: + imported = {name: self._import_fixture(name) for name in names} + results: dict[tuple[str, str], StepResult] = {} + + for source in names: + source_result = imported[source] + for target in names: + if source == target: + results[(source, target)] = StepResult(SELF) + continue + if source_result.status in FAILURE_STATUSES | {SKIP}: + results[(source, target)] = source_result + continue + target_spec = self.converters[target] + if target_spec.get("skip_reason"): + results[(source, target)] = StepResult(SKIP, target_spec["skip_reason"]) + continue + export_cap = target_spec.get("export") + import_cap = target_spec.get("import") + if not isinstance(export_cap, dict) or not isinstance(import_cap, dict): + results[(source, target)] = StepResult( + SKIP, "target requires both export and import capabilities" + ) + continue + target_prerequisite = self.capability_prerequisite("import", import_cap) + if target_prerequisite is not None: + results[(source, target)] = target_prerequisite + continue + + native_kind = export_cap.get("output") + native_name = "native" if native_kind == "directory" else "native.out" + pair_root = self.workspace / "tier-b" / "pairs" / source / target + native_output = pair_root / native_name + exported = self.run_capability( + target, + "export", + source_result.output or Path(), + native_output, + ) + if exported.status not in FAILURE_STATUSES | {SKIP}: + self.pair_native[(source, target)] = native_output + if exported.status in FAILURE_STATUSES | {SKIP}: + results[(source, target)] = combine_status(source_result, exported) + continue + + roundtrip = pair_root / "roundtrip.ossie.yaml" + reimported = self.run_capability(target, "import", native_output, roundtrip) + if reimported.status in FAILURE_STATUSES | {SKIP}: + results[(source, target)] = combine_status( + source_result, exported, reimported + ) + continue + + validated = self.validate_ossie(roundtrip) + results[(source, target)] = combine_status( + source_result, exported, reimported, validated + ) + return results + + def run_tier_c( + self, + names: list[str], + tier_b: dict[tuple[str, str], StepResult], + ) -> dict[tuple[str, str], StepResult]: + results: dict[tuple[str, str], StepResult] = {} + for source in names: + for target in names: + if source == target: + results[(source, target)] = StepResult(SELF) + continue + if not isinstance(self.converters[target].get("native_gate"), dict): + results[(source, target)] = StepResult(SKIP, "native gate is not declared") + continue + native = self.pair_native.get((source, target)) + if native is None: + results[(source, target)] = tier_b[(source, target)] + continue + results[(source, target)] = self.run_native_gate(target, native) + return results + + +def _last_message(text: str) -> str: + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + return "" + + stack_tail = re.compile(r"^(?:at\s+|\.\.\.\s+\d+\s+more$)") + error_marker = re.compile(r"(?:^Error:|\bERROR\b|Exception:|Caused by:)") + + for line in reversed(lines): + if error_marker.search(line) and not stack_tail.match(line): + return line + for line in reversed(lines): + if not stack_tail.match(line): + return line + return lines[-1] + + +def _escape_cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") + + +def format_cell(result: StepResult) -> str: + if result.status == SELF: + return SELF + text = result.status + if result.warnings: + text += f" ({result.warnings} warn)" + if result.foreign_extension_warnings: + text += f" ({result.foreign_extension_warnings} foreign-ext)" + return text + + +def build_markdown_report( + names: list[str], + tier_a: dict[str, StepResult] | None, + tier_b: dict[tuple[str, str], StepResult] | None, + tier_c: dict[tuple[str, str], StepResult] | None = None, +) -> str: + lines = [ + "# Apache Ossie Cross-Converter Interoperability Report", + "", + "Statuses: `OK`, `LOSSY`, `EMPTY`, `FAIL`, `SKIP`.", + "", + ] + + if tier_a is not None: + lines.extend( + [ + "## Tier A — canonical Ossie export acceptance", + "", + "| Converter | Status | Warnings | Foreign extension warnings | Detail |", + "| --- | --- | ---: | ---: | --- |", + ] + ) + for name in names: + result = tier_a[name] + lines.append( + "| " + + " | ".join( + ( + name, + result.status, + str(result.warnings), + str(result.foreign_extension_warnings), + _escape_cell(result.detail), + ) + ) + + " |" + ) + lines.append("") + + if tier_b is not None: + lines.extend(["## Tier B — pairwise native → Ossie → target → Ossie", ""]) + header = "| Source \\ Target | " + " | ".join(names) + " |" + separator = "| --- | " + " | ".join("---" for _ in names) + " |" + lines.extend([header, separator]) + for source in names: + cells = [format_cell(tier_b[(source, target)]) for target in names] + lines.append(f"| {source} | " + " | ".join(cells) + " |") + lines.append("") + + failures = [ + (source, target, result) + for (source, target), result in tier_b.items() + if result.status in FAILURE_STATUSES + ] + if failures: + lines.extend(["### Tier B failure details", ""]) + for source, target, result in failures: + lines.append( + f"- `{source} → {target}`: **{result.status}** — " + f"{_escape_cell(result.detail)}" + ) + lines.append("") + + skips: dict[str, int] = {} + for result in tier_b.values(): + if result.status == SKIP and result.detail: + skips[result.detail] = skips.get(result.detail, 0) + 1 + if skips: + lines.extend(["### Tier B skip reasons", ""]) + for detail, count in sorted(skips.items()): + lines.append(f"- **{count} cell(s)** — {_escape_cell(detail)}") + lines.append("") + + if tier_c is not None: + lines.extend(["## Tier C — native compiler / validator gates", ""]) + header = "| Source \\ Target | " + " | ".join(names) + " |" + separator = "| --- | " + " | ".join("---" for _ in names) + " |" + lines.extend([header, separator]) + for source in names: + cells = [format_cell(tier_c[(source, target)]) for target in names] + lines.append(f"| {source} | " + " | ".join(cells) + " |") + lines.append("") + + failures = [ + (source, target, result) + for (source, target), result in tier_c.items() + if result.status in FAILURE_STATUSES + ] + if failures: + lines.extend(["### Tier C failure details", ""]) + for source, target, result in failures: + lines.append( + f"- `{source} → {target}`: **{result.status}** — " + f"{_escape_cell(result.detail)}" + ) + lines.append("") + + skips: dict[str, int] = {} + for result in tier_c.values(): + if result.status == SKIP and result.detail: + skips[result.detail] = skips.get(result.detail, 0) + 1 + if skips: + lines.extend(["### Tier C skip reasons", ""]) + for detail, count in sorted(skips.items()): + lines.append(f"- **{count} cell(s)** — {_escape_cell(detail)}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def has_failures(*groups: dict[Any, StepResult] | None) -> bool: + return any( + result.status in FAILURE_STATUSES + for group in groups + if group is not None + for result in group.values() + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + default="interop/manifest.yaml", + help="manifest path relative to repository root", + ) + parser.add_argument("--report", default="interop-report.md", help="markdown report path") + parser.add_argument("--tier", choices=("a", "b", "c", "all"), default="all") + parser.add_argument( + "--include", + help="comma-separated converter names; defaults to every manifest entry", + ) + parser.add_argument( + "--report-only", + action="store_true", + help="always exit zero after writing the report", + ) + parser.add_argument( + "--no-setup", + action="store_true", + help="do not run converter prepare commands", + ) + parser.add_argument("--timeout", type=int, default=180, help="per-command timeout in seconds") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo = Path(__file__).resolve().parents[1] + manifest_path = repo / args.manifest + manifest = load_manifest(manifest_path) + all_names = sorted(manifest["converters"]) + if args.include: + requested = [item.strip() for item in args.include.split(",") if item.strip()] + unknown = sorted(set(requested) - set(all_names)) + if unknown: + raise SystemExit("unknown converter(s): " + ", ".join(unknown)) + names = requested + else: + names = all_names + + with tempfile.TemporaryDirectory(prefix="ossie-interop-") as temp_dir: + harness = Harness( + repo, + manifest, + Path(temp_dir), + timeout=args.timeout, + no_setup=args.no_setup, + ) + tier_a = harness.run_tier_a(names) if args.tier in {"a", "all"} else None + needs_tier_b = args.tier in {"b", "c", "all"} + tier_b_results = harness.run_tier_b(names) if needs_tier_b else None + tier_b = tier_b_results if args.tier in {"b", "all"} else None + has_native_gates = any( + isinstance(manifest["converters"][name].get("native_gate"), dict) + for name in names + ) + tier_c = ( + harness.run_tier_c(names, tier_b_results or {}) + if args.tier == "c" or (args.tier == "all" and has_native_gates) + else None + ) + report = build_markdown_report(names, tier_a, tier_b, tier_c) + + report_path = Path(args.report) + if not report_path.is_absolute(): + report_path = repo / report_path + report_path.write_text(report, encoding="utf-8") + print(report, end="") + if args.report_only: + return 0 + return 1 if has_failures(tier_a, tier_b, tier_c) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/interop/tests/test_runner.py b/interop/tests/test_runner.py new file mode 100644 index 00000000..42c0b052 --- /dev/null +++ b/interop/tests/test_runner.py @@ -0,0 +1,215 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +import yaml + +from runner import ( + EMPTY, + FAIL, + LOSSY, + OK, + SKIP, + CommandResult, + Harness, + StepResult, + _last_message, + build_markdown_report, + check_output, + combine_status, + load_manifest, +) + +REPO = Path(__file__).resolve().parents[2] +MANIFEST = REPO / "interop" / "manifest.yaml" + + +def test_manifest_covers_every_converter_directory() -> None: + manifest = load_manifest(MANIFEST) + expected = { + path.name + for path in (REPO / "converters").iterdir() + if path.is_dir() and not path.name.startswith(".") + } + assert set(manifest["converters"]) == expected + + +def test_every_active_capability_has_nonempty_assertion() -> None: + manifest = load_manifest(MANIFEST) + for name, converter in manifest["converters"].items(): + if converter.get("skip_reason"): + continue + assert "root" in converter, name + for capability in ("import", "export"): + if capability in converter: + assert converter[capability].get("nonempty"), f"{name}.{capability}" + + + +def test_last_message_prefers_java_exception_over_stacktrace_tail() -> None: + output = """ +ERROR conversion failed +org.example.ConversionException: source is invalid + at org.example.Converter.run(Converter.java:10) + ... 6 more +""" + assert _last_message(output) == "org.example.ConversionException: source is invalid" + + +def test_only_legacy_gsf_is_hard_skipped() -> None: + manifest = load_manifest(MANIFEST) + hard_skips = { + name + for name, converter in manifest["converters"].items() + if converter.get("skip_reason") + } + assert hard_skips == {"gsf"} + + +def test_prepare_warnings_do_not_mark_semantic_loss(tmp_path: Path, monkeypatch) -> None: + converter_root = tmp_path / "converter" + converter_root.mkdir() + harness = Harness( + tmp_path, + {"converters": {"x": {"root": "converter", "prepare": ["fake"]}}}, + tmp_path / "work", + ) + monkeypatch.setattr( + harness, + "_run", + lambda command, cwd: CommandResult(0, "", "WARNING compiler setup noise"), + ) + + result = harness.prepare("x") + + assert result.status == OK + assert result.warnings == 0 + assert result.foreign_extension_warnings == 0 + + +def test_native_gate_runs_and_can_be_env_gated(tmp_path: Path, monkeypatch) -> None: + converter_root = tmp_path / "converter" + converter_root.mkdir() + native = tmp_path / "native.json" + native.write_text("{}", encoding="utf-8") + harness = Harness( + tmp_path, + { + "converters": { + "x": { + "root": "converter", + "native_gate": {"command": ["validator", "{input}"]}, + } + } + }, + tmp_path / "work", + ) + monkeypatch.setattr(harness, "_run", lambda command, cwd: CommandResult(0, "", "")) + assert harness.run_native_gate("x", native).status == OK + + harness.converters["x"]["native_gate"]["when_env"] = "OSSIE_INTEROP_TEST_GATE" + monkeypatch.delenv("OSSIE_INTEROP_TEST_GATE", raising=False) + skipped = harness.run_native_gate("x", native) + assert skipped.status == SKIP + assert "OSSIE_INTEROP_TEST_GATE" in skipped.detail + +def test_structured_output_can_be_nonzero_bytes_but_empty(tmp_path: Path) -> None: + output = tmp_path / "native.yaml" + output.write_text("version: '1.1'\ndimensions: []\nmeasures: []\n", encoding="utf-8") + + status, detail = check_output( + output, + {"type": "structured_any", "paths": ["dimensions", "measures"]}, + ) + + assert status == EMPTY + assert "meaningful content" in detail + + +def test_structured_output_accepts_any_meaningful_path(tmp_path: Path) -> None: + output = tmp_path / "native.yaml" + output.write_text("dimensions:\n - name: order_id\n", encoding="utf-8") + + status, detail = check_output( + output, + {"type": "structured_any", "paths": ["dimensions", "measures"]}, + ) + + assert status == OK + assert detail == "" + + +def test_malformed_structured_output_is_fail_not_empty(tmp_path: Path) -> None: + output = tmp_path / "native.yaml" + output.write_text("dimensions: [\n", encoding="utf-8") + + status, _ = check_output( + output, + {"type": "structured_any", "paths": ["dimensions"]}, + ) + + assert status == FAIL + + +def test_directory_output_requires_matching_nonempty_file(tmp_path: Path) -> None: + output = tmp_path / "native" + output.mkdir() + (output / "README.txt").write_text("not a model", encoding="utf-8") + + status, _ = check_output( + output, + {"type": "directory_any", "patterns": ["**/*.yaml"]}, + ) + + assert status == EMPTY + + +def test_combine_status_preserves_failure_precedence_and_warning_counts() -> None: + result = combine_status( + StepResult(LOSSY, warnings=2), + StepResult(EMPTY, "native model had no members", warnings=1), + StepResult(FAIL, "later failure", foreign_extension_warnings=3), + ) + + assert result.status == FAIL + assert result.detail == "later failure" + assert result.warnings == 3 + assert result.foreign_extension_warnings == 3 + + +def test_report_contains_tier_a_and_pairwise_matrix() -> None: + names = ["a", "b"] + tier_a = {"a": StepResult(OK), "b": StepResult(SKIP, "not wired")} + tier_b = { + ("a", "a"): StepResult("—"), + ("a", "b"): StepResult(SKIP, "not wired"), + ("b", "a"): StepResult(FAIL, "boom"), + ("b", "b"): StepResult("—"), + } + + report = build_markdown_report(names, tier_a, tier_b) + + assert "Tier A" in report + assert "Tier B" in report + assert "SKIP" in report + assert "`b → a`: **FAIL** — boom" in report + assert "Tier B skip reasons" in report + assert "**1 cell(s)** — not wired" in report + + +def test_manifest_is_valid_yaml() -> None: + data = yaml.safe_load(MANIFEST.read_text(encoding="utf-8")) + assert data["version"] == 1 diff --git a/interop/uv.lock b/interop/uv.lock new file mode 100644 index 00000000..05795e17 --- /dev/null +++ b/interop/uv.lock @@ -0,0 +1,167 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "apache-ossie-interop" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6.0.3" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "ruff", specifier = ">=0.12.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 = "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.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +]