diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d2bbda..be0cd22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,11 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt, llvm-tools-preview + - name: Setup Python uses: actions/setup-python@v4 with: @@ -29,9 +34,16 @@ jobs: run: | uv sync --extra all - - name: Run with pytest + - name: Run Rust clippy, unit tests, and build extension + run: | + cargo clippy --manifest-path lkr/schema/rust/Cargo.toml -- -D warnings + cargo test --no-default-features --manifest-path lkr/schema/rust/Cargo.toml + make schema-rs + + - name: Run pytest run: | uv run pytest tests/ -v + uv run pytest tests/test_schema.py -v --cov=lkr.schema --cov=scripts.schema --cov-report=term-missing - name: Run ruff run: | @@ -40,4 +52,3 @@ jobs: - name: Run ty run: | uv run ty check - diff --git a/.gitignore b/.gitignore index bc5efd5..44db3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ __pycache__ .venv tmp -.env \ No newline at end of file +.env +lkr/schema/rust/target/ +coverage.xml +.coverage +*.profraw \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aa28ea1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.7.12 + hooks: + - id: uv-lock + args: ["--default-index", "https://pypi.org/simple", "--no-config"] + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run ruff check . + language: system + pass_filenames: false + - id: ty-check + name: ty check + entry: uv run ty check + language: system + pass_filenames: false + - id: test-and-coverage + name: pytest & rust coverage + entry: make coverage + language: system + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38b0b43 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# AGENTS.md + +## Pre-Commit & Testing Workflow + +- **Do NOT run tests, coverage, `ty check`, or `ruff check` after every individual file edit or change.** +- Linting (`uv run ruff check .`), type-checking (`uv run ty check`), Rust unit tests/clippy (`make test-rs`), and Python/Rust test coverage (`make coverage`) are configured via [`.pre-commit-config.yaml`](.pre-commit-config.yaml) (powered by [`astral-sh/uv-pre-commit`](https://github.com/astral-sh/uv-pre-commit)) and run automatically at commit time. +- Focus on completing your logical unit of work first; let `pre-commit` (or a single verification pass right before committing) run the full check and coverage suite. diff --git a/Makefile b/Makefile index 017db40..5dd8443 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: docs test-deps codemode-test codemode-start +.PHONY: docs test-deps codemode-test codemode-start schema-rs test-rs coverage-py coverage-rs coverage docs: uv run typer lkr/main.py utils docs --output lkr.md @@ -9,6 +9,20 @@ test-deps: codemode-test: uv run pytest tests/test_codemode.py +schema-rs: + cargo build --release --manifest-path lkr/schema/rust/Cargo.toml && cp lkr/schema/rust/target/release/lib_schema_rs.so lkr/schema/_schema_rs.so + +test-rs: + cargo clippy --manifest-path lkr/schema/rust/Cargo.toml -- -D warnings + cargo test --no-default-features --manifest-path lkr/schema/rust/Cargo.toml + +coverage-py: + uv run pytest tests/test_schema.py --cov=lkr.schema --cov=scripts.schema --cov-report=term-missing --cov-report=xml:coverage.xml + +coverage-rs: + RUSTFLAGS="-C instrument-coverage" LLVM_PROFILE_FILE="target/cov-%p-%m.profraw" cargo test --no-default-features --manifest-path lkr/schema/rust/Cargo.toml + +coverage: test-rs coverage-py coverage-rs download-swagger: uv run python lkr/codemode/download_swagger.py diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2e43782 --- /dev/null +++ b/TODO.md @@ -0,0 +1,33 @@ +# `lkr schema` Notes & Findings from `system__activity::history` (`vdAt9IRgn89vVzHSLxOczt`) + +## Answered Questions & Implemented Pipeline Steps + +Using `system__activity::history` (starting from query slug `vdAt9IRgn89vVzHSLxOczt`, schema saved in `tmp/system_activity_history.json`), we inspected real completed queries across multiple Looker models and explores and updated `lkr/schema/` accordingly: + +1. **Resolving `system__activity::history` `query.id` to `get_query` (`lkr/schema/history.py`)**: + - In `system__activity::history`, `query.id` is the internal database integer ID (e.g. `314`, `324`, `142769`), whereas Looker API 4.0 `sdk.query(query_id)` (`GET /queries/{query_id}`) requires the string `query.slug` (e.g. `"P2BfdfcRGtgx3QWn48RwnHFSsCPwWPKY"`). + - `resolve_history_queries` starts from `vdAt9IRgn89vVzHSLxOczt` (or custom `--history-slug`), queries `system__activity::history` for `["query.id", "query.slug", "query.model", "query.view"]` filtered by `--model`, `--explore`, and/or `--query-id`, calls `sdk.query(slug)`, strips read-only `Query` fields via `sanitize_read_query_to_write_query`, and validates each query via `validate_query`. + +2. **Real-world `dynamic_fields` Shapes (`lkr/schema/dynamic_fields.py` & `lkr/schema/validator.py`)**: + - **`TableCalculation`**: Can be expression-based (`expression`) OR shortcut-based (`calculation_type` such as `rank_of_column`, `percent_of_column_sum`, `running_total`, `percent_of_previous` + `args: []` + `based_on` / `source_field`, with no `expression`). Both forms and Looker UI metadata (`category`, `_kind_hint`, `_type_hint`, `is_disabled`, `__PARAMETER_LINE_NUMS`) are now supported and validated. + - **`CustomMeasure`**: Often omits `type` when derived from an existing measure (`based_on: ""`, `measure: ""`) and may include `filters` (dict of `: `) or `filter_expression` (Looker custom filter formula). Both `based_on` and custom measure `filters` are now validated against the explore schema. + - **`CustomDimension`**: Supports `dimension`, `expression`, `based_on`, `calculation_type` + `args`, and Looker UI hints. + - **`${view.field}` Formula Reference Validation**: `_validate_expression_field_refs` validates that all `${view.field}` references inside `dynamic_fields[*].expression`, `dynamic_fields[*].filter_expression`, and `body.filter_expression` resolve to non-hidden explore fields or defined `dynamic_fields` `.name`s (ignoring Looker table calculation functions like `row()`, `offset()`, `pivot_index()`, `now()`). + +3. **Real-world `sorts` & Dashboard `filters` (`lkr/schema/fields.py` & `lkr/schema/filter_expressions.py`)**: + - Pivoted column sorts in Looker can omit `asc`/`desc` (`" 0"` as well as `" desc 0"`). + - `type: tier` dimensions in LookML emit companion sort fields `"__sort_"` in `body.sorts` (`users.age_tier__sort_`, `user_order_facts.lifetime_orders_tier__sort_`), which are validated against visible dimensions. + - Unfiltered dashboard tiles frequently pass `""` (empty string) in `body.filters`. + +## TODO Bucket + +- [ ] **Capture `case_sensitive` on string filter properties (`x-case-sensitive: bool`)**: + - **API Limitation**: Looker's REST API (`LookmlModel`, `LookmlModelExplore`, and `LookmlModelExploreField` from `sdk.lookml_model_explore(model, explore)`) does **not** serialize `case_sensitive` at the model, explore, or field level. + - **Sourcing via LookML Parser**: Parse the underlying `.lkml` files (using `project_name` + `source_file` from the explore/field metadata with a LookML parser library) to resolve the effective boolean using Looker's inheritance cascade: + `field.case_sensitive ?? explore.case_sensitive ?? model.case_sensitive ?? True` + - **Schema Representation (Option B)**: In `build_filter_property_schema` (`lkr/schema/filter_expressions.py`), annotate string filter properties in `body.filters.properties.` with: + - `"x-case-sensitive": bool` (machine-readable JSON Schema extension tag) + - Appended `[case_sensitive: true|false]` in `description` (for LLM/agent visibility). + +- [ ] **Strongly type `body.vis_config`**: + - Replace the untyped Swagger passthrough (`"additionalProperties": {"type": "any", "format": "any"}`) with strongly-typed schemas discriminated by `vis_config.type` (e.g., `looker_grid`, `looker_column`, `looker_bar`, `looker_line`, `looker_area`, `looker_pie`, `single_value`, maps, etc.). diff --git a/lkr.md b/lkr.md index 7f1fa58..f26b8db 100644 --- a/lkr.md +++ b/lkr.md @@ -11,14 +11,14 @@ $ lkr [OPTIONS] COMMAND [ARGS]... **Options**: * `--version`: Show the version and exit. -* `--client-id TEXT`: [env var: LOOKERSDK_CLIENT_ID] -* `--client-secret TEXT`: [env var: LOOKERSDK_CLIENT_SECRET] -* `--base-url TEXT`: [env var: LOOKERSDK_BASE_URL] -* `--log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL]`: [env var: LOG_LEVEL] +* `--client-id `: [env var: LOOKERSDK_CLIENT_ID] +* `--client-secret `: [env var: LOOKERSDK_CLIENT_SECRET] +* `--base-url `: [env var: LOOKERSDK_BASE_URL] +* `--log-level `: [env var: LOG_LEVEL] * `--quiet` * `--force-oauth` * `--dev` -* `--oauth-account TEXT`: OAuth account to lookup in DB and use regardless of what's active +* `--oauth-account `: OAuth account to lookup in DB and use regardless of what's active * `--install-completion`: Install completion for the current shell. * `--show-completion`: Show completion for the current shell, to copy it or customize the installation. * `--help`: Show this message and exit. @@ -27,6 +27,7 @@ $ lkr [OPTIONS] COMMAND [ARGS]... * `db-template` * `auth`: Authentication commands for LookML Repository +* `schema`: Generate and validate strongly-typed... * `mcp` * `observability` * `tools` @@ -42,7 +43,7 @@ $ lkr db-template [OPTIONS] **Options**: -* `--import-error TEXT`: [default: No module named 'lkr.db_template.main'] +* `--import-error `: [default: No module named 'lkr.db_template.main'] ## `lkr auth` @@ -77,8 +78,8 @@ $ lkr auth login [OPTIONS] **Options**: -* `-I, --instance-name TEXT`: Name of the Looker instance to login or switch to -* `-p, --port INTEGER RANGE`: Port to run the local OAuth redirect web server on [1<=x<=65535] +* `-I, --instance-name `: Name of the Looker instance to login or switch to +* `-p, --port `: Port to run the local OAuth redirect web server on [1<=x<=65535] * `--help`: Show this message and exit. ### `lkr auth logout` @@ -93,7 +94,7 @@ $ lkr auth logout [OPTIONS] **Options**: -* `--instance-name TEXT`: Name of the Looker instance to logout from. If not provided, logs out from all instances. +* `--instance-name `: Name of the Looker instance to logout from. If not provided, logs out from all instances. * `--all`: Logout from all instances * `--help`: Show this message and exit. @@ -125,6 +126,64 @@ $ lkr auth list [OPTIONS] * `--help`: Show this message and exit. +## `lkr schema` + +Generate and validate strongly-typed Looker Explore query schemas + +**Usage**: + +```console +$ lkr schema [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `generate`: Generate a strongly-typed run_inline_query... +* `validate`: Validate a query (--query or --query-file)... + +### `lkr schema generate` + +Generate a strongly-typed run_inline_query JSON Schema for --model and --explore. + +**Usage**: + +```console +$ lkr schema generate [OPTIONS] +``` + +**Options**: + +* `-m, --model `: LookML model name [required] +* `-e, --explore `: LookML explore name [required] +* `-f, --explore-file `: Path to a local lookml_model_explore JSON file (e.g. tmp/order_items.json) +* `-o, --output `: Optional file path to write the generated JSON schema +* `--help`: Show this message and exit. + +### `lkr schema validate` + +Validate a query (--query or --query-file) against an Explore schema. + +**Usage**: + +```console +$ lkr schema validate [OPTIONS] +``` + +**Options**: + +* `-m, --model `: LookML model name +* `-e, --explore `: LookML explore name +* `-f, --explore-file `: Path to a local lookml_model_explore JSON file (e.g. tmp/order_items.json) +* `--schema `: Inline JSON schema string (optional; if omitted, generates schema from --model and --explore) +* `--schema-file `: Path to a generated JSON schema file (optional; if omitted, generates schema from --model and --explore) +* `-q, --query `: Inline query JSON string (either full run_inline_query payload or body WriteQuery) +* `--query-file `: Path to a query JSON file (either full run_inline_query payload or body WriteQuery) +* `--help`: Show this message and exit. + ## `lkr mcp` **Usage**: @@ -182,10 +241,10 @@ $ lkr observability embed [OPTIONS] **Options**: -* `--host TEXT`: Host to bind to [env var: HOST; default: 0.0.0.0] -* `--port INTEGER`: Port to bind to [env var: PORT; default: 8080] -* `--timeout INTEGER`: Timeout for the health check [env var: TIMEOUT; default: 120] -* `--event-prefix TEXT`: Event prefix [env var: EVENT_PREFIX; default: lkr-observability] +* `--host `: Host to bind to [env var: HOST; default: 0.0.0.0] +* `--port `: Port to bind to [env var: PORT; default: 8080] +* `--timeout `: Timeout for the health check [env var: TIMEOUT; default: 120] +* `--event-prefix `: Event prefix [env var: EVENT_PREFIX; default: lkr-observability] * `--help`: Show this message and exit. ## `lkr tools` @@ -216,8 +275,8 @@ $ lkr tools user-attribute-updater [OPTIONS] **Options**: -* `--host TEXT`: [env var: HOST; default: 127.0.0.1] -* `--port INTEGER`: [env var: PORT; default: 8080] +* `--host `: [env var: HOST; default: 127.0.0.1] +* `--port `: [env var: PORT; default: 8080] * `--help`: Show this message and exit. ### `lkr tools schedule-download-deprecation` @@ -232,10 +291,10 @@ $ lkr tools schedule-download-deprecation [OPTIONS] **Options**: -* `--limit INTEGER`: Search batch size [default: 500] -* `--model-offset INTEGER`: Offset for model columns [default: 0] +* `--limit `: Search batch size [default: 500] +* `--model-offset `: Offset for model columns [default: 0] * `--csv`: Output as CSV instead of a table -* `--csv-file-name TEXT`: Name for the output CSV file (without extension) [default: schedule_download_deprecation] +* `--csv-file-name `: Name for the output CSV file (without extension) [default: schedule_download_deprecation] * `--unfiltered`: Show all rows, including those with no missing permissions * `--email`: Use Email instead of Name * `--help`: Show this message and exit. @@ -268,19 +327,20 @@ If --file / -f is specified (or folder_name is a file), only that single file is **Usage**: ```console -$ lkr tools lookml push [OPTIONS] FOLDER_NAME +$ lkr tools lookml push [OPTIONS] {folder_name} ``` **Arguments**: -* `FOLDER_NAME`: Local folder name / Looker project ID to push [required] +* `folder_name`: Local folder name / Looker project ID to push [required] **Options**: -* `--project-id, --project TEXT`: Looker project ID to push to (if different from folder name) -* `-f, --file TEXT`: Single file relative path (or absolute path) to push +* `--project-id, --project `: Looker project ID to push to (if different from folder name) +* `-f, --file `: Single file relative path (or absolute path) to push * `--deploy`: Commit and deploy to production after push -* `--message TEXT`: Commit message when deploying [default: push from lkr cli] +* `--reset / --no-reset`: Reset developer workspace to production before pushing [default: reset] +* `--message `: Commit message when deploying [default: push from lkr cli] * `--help`: Show this message and exit. #### `lkr tools lookml pull` @@ -291,19 +351,19 @@ If --file / -f is specified, only that single file is pulled without deleting lo **Usage**: ```console -$ lkr tools lookml pull [OPTIONS] FOLDER_NAME +$ lkr tools lookml pull [OPTIONS] {folder_name} ``` **Arguments**: -* `FOLDER_NAME`: Local folder name / Looker project ID to pull into [required] +* `folder_name`: Local folder name / Looker project ID to pull into [required] **Options**: -* `--project-id, --project TEXT`: Looker project ID to pull from (if different from folder name) -* `-f, --file TEXT`: Single file relative path to pull from Looker +* `--project-id, --project `: Looker project ID to pull from (if different from folder name) +* `-f, --file `: Single file relative path to pull from Looker * `--deploy`: Commit and deploy to production on Looker after pull -* `--message TEXT`: Commit message when deploying [default: pull from lkr cli then commit and deploy] +* `--message `: Commit message when deploying [default: pull from lkr cli then commit and deploy] * `--help`: Show this message and exit. #### `lkr tools lookml deploy` @@ -313,17 +373,17 @@ Commit dev workspace and deploy Looker project to production. **Usage**: ```console -$ lkr tools lookml deploy [OPTIONS] [FOLDER_NAME] +$ lkr tools lookml deploy [OPTIONS] [folder_name] ``` **Arguments**: -* `[FOLDER_NAME]`: Local folder name / Looker project ID to deploy +* `folder_name`: Local folder name / Looker project ID to deploy **Options**: -* `--project-id, --project TEXT`: Looker project ID to deploy (if folder_name not specified) -* `--message TEXT`: Commit message [default: commit and deploy from lkr cli] +* `--project-id, --project `: Looker project ID to deploy (if folder_name not specified) +* `--message `: Commit message [default: commit and deploy from lkr cli] * `--help`: Show this message and exit. ## `lkr code-mode` @@ -353,10 +413,11 @@ $ lkr code-mode sandbox [OPTIONS] **Options**: -* `-c, --code TEXT`: Execute Python code directly in the sandbox -* `-f, --file TEXT`: Execute Python code from a file in the sandbox +* `-c, --code `: Execute Python code directly in the sandbox +* `-f, --file `: Execute Python code from a file in the sandbox * `--dev-mode`: Run in dev mode -* `-v, --var TEXT`: Inject variable as key=value pair (e.g. -v project=my_project) +* `--allow-update-session`: Allow calling update_session inside code-mode +* `-v, --var `: Inject variable as key=value pair (e.g. -v project=my_project) * `--help`: Show this message and exit. ### `lkr code-mode run` diff --git a/lkr/main.py b/lkr/main.py index acbcee7..3ddde84 100644 --- a/lkr/main.py +++ b/lkr/main.py @@ -7,6 +7,7 @@ from lkr.classes import LkrCtxObj from lkr.custom_types import LogLevel from lkr.logger import logger +from lkr.schema.main import group as schema_group app = typer.Typer( name="lkr", @@ -16,6 +17,7 @@ ) app.add_typer(auth_group, name="auth") +app.add_typer(schema_group, name="schema") IMPORT_ERROR = None diff --git a/lkr/schema/__init__.py b/lkr/schema/__init__.py new file mode 100644 index 0000000..ad32d24 --- /dev/null +++ b/lkr/schema/__init__.py @@ -0,0 +1,15 @@ +from lkr.schema.builder import build_explore_query_schema +from lkr.schema.main import group +from lkr.schema.validator import ( + ValidationResult, + validate_batch_queries, + validate_query, +) + +__all__ = [ + "ValidationResult", + "build_explore_query_schema", + "group", + "validate_batch_queries", + "validate_query", +] diff --git a/lkr/schema/_schema_rs.so b/lkr/schema/_schema_rs.so new file mode 100755 index 0000000..12b306c Binary files /dev/null and b/lkr/schema/_schema_rs.so differ diff --git a/lkr/schema/builder.py b/lkr/schema/builder.py new file mode 100644 index 0000000..9859474 --- /dev/null +++ b/lkr/schema/builder.py @@ -0,0 +1,163 @@ +import copy +import re +from typing import Any + +from lkr.codemode.type import _get_swagger_data +from lkr.schema.dynamic_fields import build_dynamic_fields_schema +from lkr.schema.fields import ( + build_fields_schema, + build_fill_fields_schema, + build_pivots_schema, + build_sorts_schema, + build_subtotals_schema, + extract_visible_explore_fields, +) +from lkr.schema.filter_expressions import ( + FILTER_EXPRESSION_DEFS, + build_filters_schema, +) + + +def build_query_body_schema( + model: str, + explore: str, + explore_data: dict[str, Any], + base_query_def: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the strongly-typed `body` (`WriteQuery`) object schema for ` `.""" + visible = extract_visible_explore_fields(explore_data) + dim_names = visible.dimension_names + measure_names = visible.measure_names + + raw_props = (base_query_def or {}).get("properties") or {} + body_props = { + k: copy.deepcopy(v) + for k, v in raw_props.items() + if not v.get("readOnly") and k != "client_id" + } + + body_props["model"] = { + "type": "string", + "const": model, + "default": model, + "description": f"LookML Model name (pinned to '{model}')", + } + body_props["view"] = { + "type": "string", + "const": explore, + "default": explore, + "description": f"LookML Explore name (pinned to '{explore}')", + } + body_props["fields"] = build_fields_schema(dim_names, measure_names) + body_props["pivots"] = build_pivots_schema(dim_names) + body_props["fill_fields"] = build_fill_fields_schema(dim_names) + body_props["subtotals"] = build_subtotals_schema(dim_names) + body_props["sorts"] = build_sorts_schema(visible.selectable_field_names) + body_props["filters"] = build_filters_schema( + dimensions=visible.dimensions, + measures=visible.measures, + filters=visible.filters, + parameters=visible.parameters, + ) + body_props["limit"] = { + "type": "string", + "default": "500", + "description": "Row limit (default 500). Set to -1 for unlimited results.", + } + body_props["dynamic_fields"] = build_dynamic_fields_schema( + visible.selectable_field_names + ) + + return { + "type": "object", + "description": "inline query", + "properties": body_props, + "required": ["model", "view"], + "additionalProperties": False, + } + + +def build_explore_query_schema( + model: str, + explore: str, + explore_data: dict[str, Any], + swagger_data: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Rewrite `run_inline_query` into a strongly typed JSON Schema for ` `.""" + swagger = swagger_data if swagger_data is not None else _get_swagger_data() + op = ( + swagger.get("paths", {}) + .get("/queries/run/{result_format}", {}) + .get("post", {}) + ) + query_def = swagger.get("definitions", {}).get("Query", {"properties": {}}) + body_schema = build_query_body_schema( + model, explore, explore_data, query_def + ) + + format_rows = [ + (fmt, fmt_desc) + for fmt, fmt_desc in re.findall( + r"^\|\s*([a-z0-9_]+)\s*\|\s*(.+?)\s*$", + op.get("description", ""), + re.MULTILINE, + ) + if fmt != "result_format" + ] + + param_defaults: dict[str, Any] = { + "limit": 500, + "apply_formatting": True, + "apply_vis": True, + "cache": True, + "server_table_calcs": True, + "generate_drill_links": False, + "force_production": False, + "cache_only": False, + "rebuild_pdts": False, + } + top_props: dict[str, Any] = {} + required_top: list[str] = [] + for param in op.get("parameters", []): + p_name = param["name"] + if param.get("required"): + required_top.append(p_name) + if p_name == "body": + body_with_desc = dict(body_schema) + if "description" in param: + body_with_desc["description"] = param["description"] + top_props["body"] = body_with_desc + else: + p_schema: dict[str, Any] = {"type": param.get("type", "string")} + if "description" in param: + p_schema["description"] = param["description"] + if "format" in param: + p_schema["format"] = param["format"] + if p_name == "result_format" and format_rows: + p_schema["enum"] = [fmt for fmt, _ in format_rows] + p_schema["default"] = "json" + fmt_summary = "; ".join( + f"{fmt}: {desc}" for fmt, desc in format_rows + ) + p_schema["description"] = f"Format of result ({fmt_summary})" + elif p_name in param_defaults: + p_schema["default"] = param_defaults[p_name] + top_props[p_name] = p_schema + + if not top_props: + top_props = { + "result_format": {"type": "string", "default": "json"}, + "body": body_schema, + "limit": {"type": "integer", "default": 500}, + } + required_top = ["result_format", "body"] + + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": f"run_inline_query_{model}_{explore}", + "description": f"Strongly typed run_inline_query schema for {model}::{explore}", + "type": "object", + "$defs": copy.deepcopy(FILTER_EXPRESSION_DEFS), + "properties": top_props, + "required": required_top, + } diff --git a/lkr/schema/dynamic_fields.py b/lkr/schema/dynamic_fields.py new file mode 100644 index 0000000..93d8fee --- /dev/null +++ b/lkr/schema/dynamic_fields.py @@ -0,0 +1,113 @@ +from typing import Any + +COMMON_DYNAMIC_FIELD_PROPERTIES: dict[str, Any] = { + "category": { + "type": "string", + "enum": ["dimension", "measure", "table_calculation"], + }, + "label": {"type": ["string", "null"]}, + "description": {"type": ["string", "null"]}, + "value_format": {"type": ["string", "null"]}, + "value_format_name": {"type": ["string", "null"]}, + "_kind_hint": {"type": ["string", "null"]}, + "_type_hint": {"type": ["string", "null"]}, + "is_disabled": {"type": ["boolean", "null"]}, + "__PARAMETER_LINE_NUMS": {"type": ["object", "null"]}, +} + + +def build_custom_dimension_schema() -> dict[str, Any]: + """Build JSON Schema for a Looker CustomDimension dynamic field (`dimension_like`). + Supports both formula custom dimensions (`expression`) and custom groupings/bins (`calculation_type` + `args`). + """ + return { + "title": "CustomDimension", + "type": "object", + "properties": { + **COMMON_DYNAMIC_FIELD_PROPERTIES, + "dimension": { + "type": "string", + "description": "Unique .name for this custom dimension (dimension_like; valid in fields and pivots).", + }, + "expression": {"type": ["string", "null"]}, + "calculation_type": {"type": ["string", "null"]}, + "args": {"type": ["array", "null"]}, + }, + "required": ["dimension"], + "additionalProperties": False, + } + + +def build_custom_measure_schema( + selectable_field_names: list[str], +) -> dict[str, Any]: + """Build JSON Schema for a Looker CustomMeasure dynamic field (valid in fields, not pivots).""" + return { + "title": "CustomMeasure", + "type": "object", + "properties": { + **COMMON_DYNAMIC_FIELD_PROPERTIES, + "measure": { + "type": "string", + "description": "Unique .name for this custom measure (valid in fields, not pivots).", + }, + "based_on": { + "type": "string", + "enum": selectable_field_names, + }, + "type": {"type": ["string", "null"]}, + "expression": {"type": ["string", "null"]}, + "filter_expression": {"type": ["string", "null"]}, + "filters": { + "type": ["object", "null"], + "additionalProperties": {"type": "string"}, + }, + }, + "required": ["measure", "based_on"], + "additionalProperties": False, + } + + +def build_table_calculation_schema() -> dict[str, Any]: + """Build JSON Schema for a Looker TableCalculation dynamic field (valid in fields, not pivots). + Supports both formula table calculations (`expression`) and shortcut calculations (`calculation_type` + `args`). + """ + return { + "title": "TableCalculation", + "type": "object", + "properties": { + **COMMON_DYNAMIC_FIELD_PROPERTIES, + "table_calculation": { + "type": "string", + "description": "Unique .name for this table calculation (valid in fields, not pivots).", + }, + "expression": {"type": ["string", "null"]}, + "calculation_type": {"type": ["string", "null"]}, + "args": {"type": ["array", "null"]}, + "based_on": {"type": ["string", "null"]}, + "source_field": {"type": ["string", "null"]}, + }, + "required": ["table_calculation"], + "additionalProperties": False, + } + + +def build_dynamic_fields_schema( + selectable_field_names: list[str], +) -> dict[str, Any]: + """Build JSON Schema for `body.dynamic_fields`.""" + return { + "type": "array", + "description": ( + "Dynamic fields (custom dimensions, custom measures, table calculations). " + "Custom dimensions (.dimension) are dimension_like and may be used in fields and pivots; " + "custom measures (.measure) and table calculations (.table_calculation) may only be used in fields." + ), + "items": { + "oneOf": [ + build_custom_dimension_schema(), + build_custom_measure_schema(selectable_field_names), + build_table_calculation_schema(), + ] + }, + } diff --git a/lkr/schema/fields.py b/lkr/schema/fields.py new file mode 100644 index 0000000..127d7f8 --- /dev/null +++ b/lkr/schema/fields.py @@ -0,0 +1,119 @@ +import re +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class VisibleExploreFields: + dimensions: list[dict[str, Any]] + measures: list[dict[str, Any]] + filters: list[dict[str, Any]] + parameters: list[dict[str, Any]] + + @property + def dimension_names(self) -> list[str]: + return [f["name"] for f in self.dimensions] + + @property + def measure_names(self) -> list[str]: + return [f["name"] for f in self.measures] + + @property + def selectable_field_names(self) -> list[str]: + return self.dimension_names + self.measure_names + + +def is_visible_field(field: dict[str, Any]) -> bool: + """Return True if a LookML explore field has a name and is not hidden.""" + return bool(field.get("name")) and not bool(field.get("hidden")) + + +def extract_visible_explore_fields( + explore_data: dict[str, Any], +) -> VisibleExploreFields: + """Extract non-hidden dimensions, measures, filters, and parameters from explore JSON.""" + fields_obj = explore_data.get("fields") or {} + return VisibleExploreFields( + dimensions=[ + f + for f in (fields_obj.get("dimensions") or []) + if is_visible_field(f) + ], + measures=[ + f for f in (fields_obj.get("measures") or []) if is_visible_field(f) + ], + filters=[ + f for f in (fields_obj.get("filters") or []) if is_visible_field(f) + ], + parameters=[ + f + for f in (fields_obj.get("parameters") or []) + if is_visible_field(f) + ], + ) + + +def _string_enum_array(names: list[str], description: str) -> dict[str, Any]: + return { + "type": "array", + "items": {"type": "string", "enum": names}, + "description": description, + } + + +def build_fields_schema( + dimension_names: list[str], measure_names: list[str] +) -> dict[str, Any]: + """Build JSON Schema for `body.fields` (dimensions, measures, or dynamic_fields .name).""" + return _string_enum_array( + dimension_names + measure_names, + "Dimensions, measures, or dynamic_fields .name values to include in the query.", + ) + + +def build_pivots_schema(dimension_names: list[str]) -> dict[str, Any]: + """Build JSON Schema for `body.pivots` (dimensions or dimension_like dynamic_fields .name; no measures or table calcs).""" + return _string_enum_array( + dimension_names, + "Dimensions or dimension_like dynamic_fields .name values to pivot on " + "(measures and table calculations are not allowed).", + ) + + +def build_fill_fields_schema(dimension_names: list[str]) -> dict[str, Any]: + """Build JSON Schema for `body.fill_fields` (constrained to dimensions or dimension_like dynamic_fields .name).""" + return _string_enum_array( + dimension_names, + "Dimensions or dimension_like dynamic_fields .name values for dimension fill.", + ) + + +def build_subtotals_schema(dimension_names: list[str]) -> dict[str, Any]: + """Build JSON Schema for `body.subtotals` (grouping dimensions on which to run subtotals).""" + return _string_enum_array( + dimension_names, + "Fields on which to run subtotals.", + ) + + +def build_sorts_schema(selectable_field_names: list[str]) -> dict[str, Any]: + """Build JSON Schema for `body.sorts` constrained by a regex derived from selectable fields. + Supports ``, ` asc|desc`, ` `, ` asc|desc `, or `__UNSORTED__`. + """ + if selectable_field_names: + escaped = "|".join(re.escape(name) for name in selectable_field_names) + pattern = rf"^(?:__UNSORTED__|(?:{escaped})(?:__sort_)?(?:\s+(?:asc|desc))?(?:\s+\d+)?)$" + else: + pattern = r"^__UNSORTED__$" + + return { + "type": "array", + "items": { + "type": "string", + "pattern": pattern, + }, + "description": ( + "Sorting for the query results (`['view.field']`, `['view.field desc']`, " + "`['view.field 0']`, `['view.field desc 0']`, or `['__UNSORTED__']`)." + ), + } diff --git a/lkr/schema/filter_expressions.py b/lkr/schema/filter_expressions.py new file mode 100644 index 0000000..df8ccbc --- /dev/null +++ b/lkr/schema/filter_expressions.py @@ -0,0 +1,242 @@ +from typing import Any + +NUMERIC_FIELD_TYPES = frozenset( + { + "number", + "int", + "count", + "count_distinct", + "sum", + "sum_distinct", + "average", + "average_distinct", + "min", + "max", + "median", + "percentile", + } +) + +FILTER_EXPRESSION_DEFS: dict[str, Any] = { + "StringFilterExpression": { + "type": "string", + "description": ( + "Looker string filter expression (https://docs.cloud.google.com/looker/docs/filter-expressions#string). " + "Supports exact ('FOO'), OR list ('FOO,BAR'), wildcards ('%FOO%', 'FOO%', '%FOO', '_UF'), " + "negation ('-FOO', '-%FOO%'), and null/empty checks ('EMPTY', 'NULL', '-EMPTY', '-NULL')." + ), + "examples": [ + "FOO", + "FOO,BAR", + "%FOO%", + "FOO%", + "%FOO", + "-FOO", + "EMPTY", + "NULL", + "-EMPTY", + "-NULL", + ], + }, + "DateTimeFilterExpression": { + "type": "string", + "description": ( + "Looker date and time filter expression (https://docs.cloud.google.com/looker/docs/filter-expressions#date_and_time). " + "Supports relative dates ('today', 'yesterday', 'tomorrow', '7 days', '3 days ago', " + "'7 days ago for 7 days', 'this week', 'this month', 'this quarter', 'this year', " + "'last week', 'next month'), ranges ('before 2024-01-01', 'after 2024-01-01', " + "'2024-01-01 to 2024-01-31'), and absolute dates ('2024/05/29', '2024/05', '2024', 'FY2024', 'FY2024-Q1', 'NULL', '-NULL')." + ), + "pattern": ( + r"^(?:|today(?:\s+for\s+\d+\s+(?:second|minute|hour|day|week|month|quarter|year)s?)?|yesterday|tomorrow|NULL|-NULL|NOT NULL" + r"|(?:this|last|next)\s+(?:(?:\d+\s+)?(?:second|minute|hour|day|week|month|quarter|fiscal\s+quarter|year|fiscal\s+year)s?)(?:\s+to\s+(?:second|minute|hour|day|week|month|quarter|year))?" + r"|(?:before|after)\s+.+" + r"|\d+\s+(?:second|minute|hour|day|week|month|quarter|fiscal\s+quarter|year|fiscal\s+year)s?(?:\s+(?:ago|from\s+now))?(?:\s+for\s+\d+\s+(?:second|minute|hour|day|week|month|quarter|fiscal\s+quarter|year|fiscal\s+year)s?)?" + r"|(?:FY)?\d{4}(?:[-/]\d{2}(?:[-/]\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?)?|-Q[1-4])?(?:\s+(?:to\s+.+|for\s+\d+\s+(?:second|minute|hour|day|week|month|quarter|year)s?))?" + r"|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)" + r")(?:,\s*[^,]+)*$" + ), + "examples": [ + "today", + "yesterday", + "7 days", + "7 days ago for 7 days", + "this month", + "last quarter", + "before 2024-01-01", + "after 2024-01-01", + "2024-01-01 to 2024-01-31", + "2024/05", + "NULL", + "-NULL", + ], + }, + "BooleanFilterExpression": { + "type": "string", + "description": ( + "Looker boolean / yesno filter expression (https://docs.cloud.google.com/looker/docs/filter-expressions#boolean)." + ), + "enum": [ + "", + "yes", + "no", + "Yes", + "No", + "TRUE", + "FALSE", + "true", + "false", + "NULL", + "-NULL", + "NOT NULL", + ], + }, + "NumberFilterExpression": { + "type": "string", + "description": ( + "Looker number filter expression (https://docs.cloud.google.com/looker/docs/filter-expressions#number). " + "Supports exact numbers ('5', '1, 3, 5'), relational operators ('>10', '>=5.5 AND <=10', '!=5', '<>5', 'NOT 5'), " + "natural ranges ('5.5 to 10', '1 to', 'to 10'), algebraic intervals ('[5, 90]', '(1, 7)', '(500, inf)', '(-inf, 10]'), " + "and null checks ('NULL', 'NOT NULL')." + ), + "pattern": ( + r"^(?:|NULL|-NULL|NOT NULL" + r"|(?:NOT\s+)?(?:(?:<>|!=|>=|<=|>|<|=)\s*-?\d+(?:\.\d+)?|-?\d+(?:\.\d+)?(?:\s+to(?:\s+-?\d+(?:\.\d+)?)?)?|to\s+-?\d+(?:\.\d+)?|[\[\(](?:-?inf|-?\d+(?:\.\d+)?)?\s*,\s*(?:-?inf|-?\d+(?:\.\d+)?)?[\]\)])" + r"(?:\s+(?:AND|OR)\s+(?:NOT\s+)?(?:(?:<>|!=|>=|<=|>|<|=)\s*-?\d+(?:\.\d+)?|-?\d+(?:\.\d+)?(?:\s+to(?:\s+-?\d+(?:\.\d+)?)?)?|to\s+-?\d+(?:\.\d+)?|[\[\(](?:-?inf|-?\d+(?:\.\d+)?)?\s*,\s*(?:-?inf|-?\d+(?:\.\d+)?)?[\]\)]))*)" + r"(?:,\s*(?:NOT\s+)?[^,]+)*$" + ), + "examples": [ + "5", + "NOT 5", + "!=5", + ">1 AND <100", + ">=5.5 AND <=10", + "5.5 to 10", + "[5, 90]", + "(1, 7)", + "NULL", + "NOT NULL", + ], + }, + "LocationFilterExpression": { + "type": "string", + "description": ( + "Looker location filter expression (https://docs.cloud.google.com/looker/docs/filter-expressions#location). " + "Supports exact coordinates ('36.97, -122.03'), circle radius ('40 miles from 36.97, -122.03' with meters/feet/kilometers/miles), " + "bounding box ('inside box from 72.33, -173.14 to 14.39, -61.70'), and null checks ('NULL', '-NULL', 'NOT NULL')." + ), + "pattern": ( + r"^(?:|NULL|-NULL|NOT NULL" + r"|-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?" + r"|(?:within\s+)?\d+(?:\.\d+)?\s+(?:meters|feet|kilometers|miles)\s+(?:from|of)\s+-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?" + r"|inside\s+box\s+from\s+-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s+to\s+-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?)$" + ), + "examples": [ + "36.97, -122.03", + "40 miles from 36.97, -122.03", + "inside box from 72.33, -173.14 to 14.39, -61.70", + "NULL", + "-NULL", + "NOT NULL", + ], + }, +} + + +def extract_enum_values(field: dict[str, Any]) -> list[Any] | None: + """Extract allowed values from `allowed_values` or `enumerations` on a LookML field.""" + raw_allowed = field.get("allowed_values") + if isinstance(raw_allowed, list) and raw_allowed: + return [ + item["value"] if isinstance(item, dict) and "value" in item else item + for item in raw_allowed + ] + enums = field.get("enumerations") + if isinstance(enums, list) and enums: + return [ + item["value"] + for item in enums + if isinstance(item, dict) and "value" in item + ] + return None + + +def classify_filter_expression_ref(field: dict[str, Any]) -> str: + """Map a LookML field definition to its corresponding `$defs` FilterExpression reference.""" + ftype = (field.get("type") or "").lower() + if ftype == "yesno": + return "#/$defs/BooleanFilterExpression" + if ftype == "location": + return "#/$defs/LocationFilterExpression" + if ( + field.get("is_timeframe") + or field.get("can_time_filter") + or ftype.startswith(("date", "time", "duration")) + ): + return "#/$defs/DateTimeFilterExpression" + if field.get("is_numeric") or ftype in NUMERIC_FIELD_TYPES: + return "#/$defs/NumberFilterExpression" + return "#/$defs/StringFilterExpression" + + +def build_filter_property_schema( + field: dict[str, Any], *, is_parameter: bool = False +) -> dict[str, Any]: + """Return a strongly typed JSON Schema for a single filter property.""" + meta: dict[str, Any] = {} + if field.get("description"): + meta["description"] = field["description"] + if field.get("default_filter_value") is not None: + meta["default"] = field["default_filter_value"] + + allowed = extract_enum_values(field) + is_param = is_parameter or bool(field.get("parameter")) + if is_param and (field.get("has_allowed_values") or allowed): + return { + "type": "string", + "enum": allowed, + **meta, + } + + ref = classify_filter_expression_ref(field) + if allowed and ref != "#/$defs/BooleanFilterExpression": + return { + "anyOf": [ + {"type": "string", "enum": allowed}, + {"$ref": ref}, + ], + **meta, + } + + return { + "$ref": ref, + **meta, + } + + +def build_filters_schema( + dimensions: list[dict[str, Any]], + measures: list[dict[str, Any]], + filters: list[dict[str, Any]], + parameters: list[dict[str, Any]], +) -> dict[str, Any]: + """Build the `body.filters` object schema across all visible filterable fields.""" + filter_properties: dict[str, Any] = {} + for f in dimensions + measures + filters: + filter_properties[f["name"]] = build_filter_property_schema( + f, is_parameter=False + ) + for p in parameters: + filter_properties[p["name"]] = build_filter_property_schema( + p, is_parameter=True + ) + + return { + "type": "object", + "properties": filter_properties, + "additionalProperties": False, + "description": ( + "Filter expressions keyed by dimension, measure, filter, or parameter .name. " + "Values follow Looker filter expression rules (https://docs.cloud.google.com/looker/docs/filter-expressions)." + ), + } diff --git a/lkr/schema/main.py b/lkr/schema/main.py new file mode 100644 index 0000000..8fe9600 --- /dev/null +++ b/lkr/schema/main.py @@ -0,0 +1,202 @@ +import json +from pathlib import Path +from typing import Annotated, Any + +import typer + +from lkr.auth_service import get_auth +from lkr.classes import LkrCtxObj +from lkr.logger import logger +from lkr.schema.builder import build_explore_query_schema +from lkr.schema.validator import validate_batch_queries, validate_query + +__all__ = [ + "generate_command", + "group", + "validate_command", +] + +group = typer.Typer( + name="schema", + help="Generate and validate strongly-typed Looker Explore query schemas", + no_args_is_help=True, +) + + +def _get_sdk(ctx: typer.Context) -> Any: + ctx_lkr = ( + ctx.obj.get("ctx_lkr") + if (ctx and ctx.obj and "ctx_lkr" in ctx.obj) + else LkrCtxObj(force_oauth=False) + ) + return get_auth(ctx_lkr).get_current_sdk(prompt_refresh_invalid_token=True) + + +def _load_explore_data( + ctx: typer.Context, + model: str, + explore: str, + explore_file: Path | None = None, +) -> dict[str, Any]: + if explore_file is not None: + try: + return json.loads(explore_file.read_text(encoding="utf-8")) + except FileNotFoundError: + logger.error(f"Explore file not found: {explore_file}") + raise typer.Exit(1) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in explore file {explore_file}: {e}") + raise typer.Exit(1) + + from lkr.codemode.main import to_primitive + + return to_primitive(_get_sdk(ctx).lookml_model_explore(model, explore)) + + +@group.command(name="generate") +def generate_command( + ctx: typer.Context, + model: Annotated[ + str, + typer.Option("--model", "-m", help="LookML model name"), + ], + explore: Annotated[ + str, + typer.Option("--explore", "-e", help="LookML explore name"), + ], + explore_file: Annotated[ + Path | None, + typer.Option( + "--explore-file", + "-f", + help="Path to a local lookml_model_explore JSON file (e.g. tmp/order_items.json)", + ), + ] = None, + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help="Optional file path to write the generated JSON schema", + ), + ] = None, +) -> None: + """Generate a strongly-typed run_inline_query JSON Schema for --model and --explore.""" + explore_data = _load_explore_data(ctx, model, explore, explore_file) + formatted = json.dumps( + build_explore_query_schema(model, explore, explore_data), indent=2 + ) + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(formatted + "\n", encoding="utf-8") + else: + typer.echo(formatted) + + +@group.command(name="validate") +def validate_command( + ctx: typer.Context, + model: Annotated[ + str | None, + typer.Option("--model", "-m", help="LookML model name"), + ] = None, + explore: Annotated[ + str | None, + typer.Option("--explore", "-e", help="LookML explore name"), + ] = None, + explore_file: Annotated[ + Path | None, + typer.Option( + "--explore-file", + "-f", + help="Path to a local lookml_model_explore JSON file (e.g. tmp/order_items.json)", + ), + ] = None, + schema: Annotated[ + str | None, + typer.Option( + "--schema", + help="Inline JSON schema string (optional; if omitted, generates schema from --model and --explore)", + ), + ] = None, + schema_file: Annotated[ + Path | None, + typer.Option( + "--schema-file", + help="Path to a generated JSON schema file (optional; if omitted, generates schema from --model and --explore)", + ), + ] = None, + query: Annotated[ + str | None, + typer.Option( + "--query", + "-q", + help="Inline query JSON string (either full run_inline_query payload or body WriteQuery)", + ), + ] = None, + query_file: Annotated[ + Path | None, + typer.Option( + "--query-file", + help="Path to a query JSON file (either full run_inline_query payload or body WriteQuery)", + ), + ] = None, +) -> None: + """Validate a query (--query or --query-file) against an Explore schema.""" + if bool(query) == bool(query_file): + logger.error("Specify exactly one of --query or --query-file") + raise typer.Exit(1) + if schema and schema_file: + logger.error("Cannot specify both --schema and --schema-file") + raise typer.Exit(1) + + if query is not None: + query_payload = json.loads(query) + elif query_file is not None: + query_payload = json.loads(query_file.read_text(encoding="utf-8")) + else: + raise typer.Exit(1) + + if schema is not None: + schema_data = json.loads(schema) + elif schema_file is not None: + schema_data = json.loads(schema_file.read_text(encoding="utf-8")) + else: + first_item = ( + query_payload[0] + if isinstance(query_payload, list) and query_payload + else query_payload + ) + body_obj = ( + first_item.get("body") + if isinstance(first_item, dict) and isinstance(first_item.get("body"), dict) + else first_item + ) + resolved_model = model or ( + body_obj.get("model") if isinstance(body_obj, dict) else None + ) + resolved_explore = explore or ( + body_obj.get("view") if isinstance(body_obj, dict) else None + ) + if not resolved_model or not resolved_explore: + logger.error( + "Must provide --model and --explore (or --schema / --schema-file) to generate schema for validation" + ) + raise typer.Exit(1) + explore_data = _load_explore_data( + ctx, resolved_model, resolved_explore, explore_file + ) + schema_data = build_explore_query_schema( + resolved_model, resolved_explore, explore_data + ) + + if isinstance(query_payload, list): + results = validate_batch_queries(query_payload, schema_data) + typer.echo(json.dumps([r.to_dict() for r in results], indent=2)) + if not all(r.valid for r in results): + raise typer.Exit(1) + else: + result = validate_query(query_payload, schema_data) + typer.echo(json.dumps(result.to_dict(), indent=2)) + if not result.valid: + raise typer.Exit(1) diff --git a/lkr/schema/rust/Cargo.lock b/lkr/schema/rust/Cargo.lock new file mode 100644 index 0000000..5e35bea --- /dev/null +++ b/lkr/schema/rust/Cargo.lock @@ -0,0 +1,345 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lkr_schema_rs" +version = "0.1.0" +dependencies = [ + "pyo3", + "rayon", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/lkr/schema/rust/Cargo.toml b/lkr/schema/rust/Cargo.toml new file mode 100644 index 0000000..bc77325 --- /dev/null +++ b/lkr/schema/rust/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "lkr_schema_rs" +version = "0.1.0" +edition = "2021" + +[lib] +name = "_schema_rs" +crate-type = ["cdylib"] + +[features] +default = ["extension-module"] +extension-module = ["pyo3/extension-module"] + +[dependencies] +pyo3 = { version = "0.24", features = ["abi3-py312"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +regex = "1.11" +rayon = "1.10" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/lkr/schema/rust/src/lib.rs b/lkr/schema/rust/src/lib.rs new file mode 100644 index 0000000..aac2604 --- /dev/null +++ b/lkr/schema/rust/src/lib.rs @@ -0,0 +1,1063 @@ +use pyo3::prelude::*; +use rayon::prelude::*; +use regex::Regex; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +pub type ValidationOutcome = (bool, Vec, Vec, Option); + +static FIELD_REF_RE: OnceLock = OnceLock::new(); +static SORT_ITEM_RE: OnceLock = OnceLock::new(); + +fn field_ref_regex() -> &'static Regex { + FIELD_REF_RE.get_or_init(|| Regex::new(r"\$\{([^}]*)\}").unwrap()) +} + +fn sort_item_regex() -> &'static Regex { + SORT_ITEM_RE.get_or_init(|| { + Regex::new(r"^([A-Za-z0-9_.]+)(?:\s+(?:asc|desc))?(?:\s+\d+)?$").unwrap() + }) +} + +fn read_only_query_keys() -> &'static HashSet<&'static str> { + static KEYS: OnceLock> = OnceLock::new(); + KEYS.get_or_init(|| { + HashSet::from([ + "can", + "id", + "slug", + "client_id", + "share_url", + "expanded_share_url", + "url", + "has_table_calculations", + "filter_config", + "visible_ui_sections", + ]) + }) +} + +fn allowed_custom_measure_types() -> &'static HashSet<&'static str> { + static TYPES: OnceLock> = OnceLock::new(); + TYPES.get_or_init(|| { + HashSet::from([ + "average", + "average_distinct", + "count", + "count_distinct", + "list", + "max", + "median", + "median_distinct", + "min", + "number", + "percent_of_previous", + "percent_of_total", + "percentile", + "percentile_distinct", + "running_total", + "sum", + "sum_distinct", + "string", + "yesno", + "date", + ]) + }) +} + +#[derive(Clone)] +enum CompiledFilterRule { + Enum { + allowed: HashSet, + allowed_repr: String, + }, + Pattern { + regex: Regex, + pattern_str: String, + }, + AnyString, +} + +#[pyclass] +pub struct RustExploreValidator { + expected_model: Option, + expected_view: Option, + default_body_limit: String, + default_top_limit: i64, + base_fields_set: HashSet, + base_pivots_set: HashSet, + schema_all_ref_fields: HashSet, + measure_only_set: HashSet, + filter_rules: HashMap, + allowed_top_keys: HashSet, + allowed_body_keys: HashSet, + allowed_result_formats: HashSet, +} + +impl RustExploreValidator { + fn compile_filter_prop( + prop_schema: &Value, + defs_regexes: &HashMap, + ) -> CompiledFilterRule { + if let Some(ref_str) = prop_schema.get("$ref").and_then(|v| v.as_str()) { + if let Some(def_name) = ref_str.strip_prefix("#/$defs/") { + if let Some((re, pat)) = defs_regexes.get(def_name) { + return CompiledFilterRule::Pattern { + regex: re.clone(), + pattern_str: pat.clone(), + }; + } + } + } + if let Some(enum_arr) = prop_schema.get("enum").and_then(|v| v.as_array()) { + let mut allowed = HashSet::new(); + let mut repr_items = Vec::new(); + for item in enum_arr { + if let Some(s) = item.as_str() { + allowed.insert(s.to_string()); + repr_items.push(format!("'{}'", s)); + } + } + return CompiledFilterRule::Enum { + allowed, + allowed_repr: format!("[{}]", repr_items.join(", ")), + }; + } + if let Some(pat_str) = prop_schema.get("pattern").and_then(|v| v.as_str()) { + if let Ok(re) = Regex::new(&format!("^(?:{})$", pat_str)) { + return CompiledFilterRule::Pattern { + regex: re, + pattern_str: pat_str.to_string(), + }; + } + } + CompiledFilterRule::AnyString + } + + fn sanitize_body(body_map: &Map) -> Map { + let ro_keys = read_only_query_keys(); + let mut cleaned = Map::new(); + for (k, v) in body_map { + if ro_keys.contains(k.as_str()) || v.is_null() { + continue; + } + if (k == "limit" || k == "column_limit") && v.is_i64() { + cleaned.insert(k.clone(), Value::String(v.as_i64().unwrap().to_string())); + } else { + cleaned.insert(k.clone(), v.clone()); + } + } + cleaned + } + + fn validate_expression_refs( + expr: &str, + valid_fields: &HashSet, + disallowed_table_calcs: Option<&HashSet>, + self_field_name: Option<&str>, + path: &str, + errors: &mut Vec, + ) { + for cap in field_ref_regex().captures_iter(expr) { + let raw_inner = cap.get(1).map(|m| m.as_str()).unwrap_or("").trim(); + if raw_inner.is_empty() { + errors.push(format!( + "{}: empty field reference '${{}}' is not allowed", + path + )); + continue; + } + let normalized_ref = raw_inner + .strip_suffix(":row_total") + .or_else(|| raw_inner.strip_suffix(":total")) + .unwrap_or(raw_inner) + .trim(); + + if let Some(self_name) = self_field_name { + if normalized_ref == self_name { + errors.push(format!( + "{}: dynamic field '{}' cannot reference itself in '${{{}}}'", + path, self_name, raw_inner + )); + continue; + } + } + if let Some(tc_set) = disallowed_table_calcs { + if tc_set.contains(normalized_ref) { + errors.push(format!( + "{}: SQL expression cannot reference post-query table_calculation '${{{}}}'", + path, raw_inner + )); + continue; + } + } + if !valid_fields.contains(normalized_ref) { + errors.push(format!( + "{}: referenced field '${{{}}}' is not a valid visible explore field or dynamic_field .name", + path, raw_inner + )); + } + } + } + + fn validate_filter_map( + &self, + filters_obj: &Map, + path: &str, + errors: &mut Vec, + ) { + for (k, v) in filters_obj { + let prop_path = format!("{}.{}", path, k); + let Some(rule) = self.filter_rules.get(k) else { + errors.push(format!( + "{}: unknown or hidden field '{}' is not allowed", + prop_path, k + )); + continue; + }; + if v.is_null() { + continue; + } + let Some(s) = v.as_str() else { + errors.push(format!("{}: expected string filter expression", prop_path)); + continue; + }; + match rule { + CompiledFilterRule::Enum { + allowed, + allowed_repr, + } => { + if !allowed.contains(s) { + errors.push(format!( + "{}: value '{}' is not in allowed values {}", + prop_path, s, allowed_repr + )); + } + } + CompiledFilterRule::Pattern { regex, pattern_str } => { + if !regex.is_match(s) { + errors.push(format!( + "{}: value '{}' does not match filter expression pattern '{}'", + prop_path, s, pattern_str + )); + } + } + CompiledFilterRule::AnyString => {} + } + } + } + + fn validate_single_value(&self, raw_val: Value) -> ValidationOutcome { + let mut root_obj = match raw_val { + Value::Object(obj) => obj, + _ => { + return ( + false, + vec!["$: expected query payload to be a JSON object".to_string()], + Vec::new(), + None, + ); + } + }; + + let mut payload: Map = Map::new(); + if let Some(body_val) = root_obj.remove("body") { + payload = root_obj; + payload + .entry("result_format".to_string()) + .or_insert_with(|| Value::String("json".to_string())); + let Some(body_map) = body_val.as_object() else { + return ( + false, + vec!["$.body: expected object".to_string()], + Vec::new(), + None, + ); + }; + payload.insert("body".to_string(), Value::Object(Self::sanitize_body(body_map))); + } else { + payload.insert( + "result_format".to_string(), + Value::String("json".to_string()), + ); + payload.insert("body".to_string(), Value::Object(Self::sanitize_body(&root_obj))); + } + + payload + .entry("limit".to_string()) + .or_insert_with(|| Value::Number(self.default_top_limit.into())); + + let body = payload.get_mut("body").unwrap().as_object_mut().unwrap(); + if !body.contains_key("model") { + if let Some(ref m) = self.expected_model { + body.insert("model".to_string(), Value::String(m.clone())); + } + } + if !body.contains_key("view") { + if let Some(ref v) = self.expected_view { + body.insert("view".to_string(), Value::String(v.clone())); + } + } + body.entry("limit".to_string()) + .or_insert_with(|| Value::String(self.default_body_limit.clone())); + + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut dim_like_dynamic: Vec = Vec::new(); + let mut non_dim_like_dynamic: Vec = Vec::new(); + let mut table_calc_names: HashSet = HashSet::new(); + let mut parsed_dyn_items: Vec = Vec::new(); + + // Step 1: Parse and validate dynamic_fields + let mut dyn_parse_ok = false; + if let Some(raw_dyn) = body.get("dynamic_fields") { + if let Some(dyn_str) = raw_dyn.as_str() { + match serde_json::from_str::(dyn_str) { + Ok(Value::Array(arr)) => { + parsed_dyn_items = arr; + dyn_parse_ok = true; + } + Ok(_) => { + errors.push( + "$.body.dynamic_fields: JSON string must decode to a JSON array" + .to_string(), + ); + } + Err(e) => { + errors.push(format!( + "$.body.dynamic_fields: invalid JSON string ({})", + e + )); + } + } + } else if let Some(arr) = raw_dyn.as_array() { + parsed_dyn_items = arr.clone(); + dyn_parse_ok = true; + } else if !raw_dyn.is_null() { + errors.push( + "$.body.dynamic_fields: expected array or JSON-encoded array string" + .to_string(), + ); + } + + if dyn_parse_ok { + for item in &parsed_dyn_items { + if let Some(obj) = item.as_object() { + if let Some(d) = obj.get("dimension").and_then(|v| v.as_str()) { + if !d.is_empty() { + dim_like_dynamic.push(d.to_string()); + } + } else if let Some(m) = obj.get("measure").and_then(|v| v.as_str()) { + if !m.is_empty() { + non_dim_like_dynamic.push(m.to_string()); + } + } else if let Some(tc) = + obj.get("table_calculation").and_then(|v| v.as_str()) + { + if !tc.is_empty() { + non_dim_like_dynamic.push(tc.to_string()); + table_calc_names.insert(tc.to_string()); + } + } + } + } + } + } + + let mut all_ref_fields = self.schema_all_ref_fields.clone(); + let mut all_valid_fields = self.base_fields_set.clone(); + let mut all_valid_pivots = self.base_pivots_set.clone(); + for d in &dim_like_dynamic { + all_ref_fields.insert(d.clone()); + all_valid_fields.insert(d.clone()); + all_valid_pivots.insert(d.clone()); + } + for nd in &non_dim_like_dynamic { + all_ref_fields.insert(nd.clone()); + all_valid_fields.insert(nd.clone()); + } + let non_dim_dyn_set: HashSet<&str> = + non_dim_like_dynamic.iter().map(|s| s.as_str()).collect(); + + if dyn_parse_ok { + for (idx, item) in parsed_dyn_items.iter().enumerate() { + let item_path = format!("$.body.dynamic_fields[{}]", idx); + let Some(obj) = item.as_object() else { + errors.push(format!("{}: expected object", item_path)); + continue; + }; + let dim_name = obj + .get("dimension") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let meas_name = obj + .get("measure") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let tc_name = obj + .get("table_calculation") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + + let has_dim = dim_name.is_some(); + let has_meas = meas_name.is_some(); + let has_tc = tc_name.is_some(); + + let kind_count = (has_dim as u8) + (has_meas as u8) + (has_tc as u8); + if kind_count != 1 { + errors.push(format!( + "{}: value must match exactly one dynamic field kind (CustomDimension, CustomMeasure, or TableCalculation)", + item_path + )); + continue; + } + + let self_name = dim_name.or(meas_name).or(tc_name); + + if has_dim { + let has_expr = obj + .get("expression") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_calc = obj + .get("calculation_type") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()) + && obj.get("args").is_some_and(|v| v.is_array()); + let has_based_on = obj + .get("based_on") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + if !has_expr && !has_calc && !has_based_on { + errors.push(format!( + "{}: CustomDimension must specify 'expression', 'based_on', or 'calculation_type' + 'args'", + item_path + )); + } + if let Some(bo) = obj.get("based_on").and_then(|v| v.as_str()) { + if !all_ref_fields.contains(bo) { + errors.push(format!( + "{}.based_on: '{}' is not a valid visible explore field or dynamic_field .name", + item_path, bo + )); + } + } + } else if has_meas { + let has_based_on = obj + .get("based_on") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_expr = obj + .get("expression") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + if let Some(mtype) = obj.get("type").and_then(|v| v.as_str()) { + if !allowed_custom_measure_types().contains(mtype) { + errors.push(format!( + "{}.type: value '{}' is not in allowed custom measure types", + item_path, mtype + )); + } + } + if !has_based_on && !has_expr { + errors.push(format!( + "{}: CustomMeasure must specify 'based_on' or 'expression'", + item_path + )); + } + if let Some(bo) = obj.get("based_on").and_then(|v| v.as_str()) { + if !all_ref_fields.contains(bo) { + errors.push(format!( + "{}.based_on: '{}' is not a valid visible explore field or dynamic_field .name", + item_path, bo + )); + } + } + if let Some(filters_val) = obj.get("filters") { + if let Some(f_obj) = filters_val.as_object() { + self.validate_filter_map( + f_obj, + &format!("{}.filters", item_path), + &mut errors, + ); + } else if !filters_val.is_null() { + errors.push(format!("{}.filters: expected object", item_path)); + } + } + } else if has_tc { + let has_expr = obj + .get("expression") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_calc = obj + .get("calculation_type") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()) + && obj.get("args").is_some_and(|v| v.is_array()); + if !has_expr && !has_calc { + errors.push(format!( + "{}: TableCalculation must specify either 'expression' or 'calculation_type' + 'args'", + item_path + )); + } + for ref_key in ["based_on", "source_field"] { + if let Some(ref_val) = obj.get(ref_key).and_then(|v| v.as_str()) { + if !all_ref_fields.contains(ref_val) { + errors.push(format!( + "{}.{}: '{}' is not a valid visible explore field or dynamic_field .name", + item_path, ref_key, ref_val + )); + } + } + } + if let Some(args_arr) = obj.get("args").and_then(|v| v.as_array()) { + for (a_idx, a_val) in args_arr.iter().enumerate() { + if let Some(s) = a_val.as_str() { + if !all_ref_fields.contains(s) { + errors.push(format!( + "{}.args[{}]: '{}' is not a valid visible explore field or dynamic_field .name", + item_path, a_idx, s + )); + } + } + } + } + } + + let disallowed_tcs = if has_tc { + None + } else { + Some(&table_calc_names) + }; + for expr_key in ["expression", "filter_expression"] { + if let Some(expr_str) = obj.get(expr_key).and_then(|v| v.as_str()) { + if !expr_str.is_empty() { + Self::validate_expression_refs( + expr_str, + &all_ref_fields, + disallowed_tcs, + self_name, + &format!("{}.{}", item_path, expr_key), + &mut errors, + ); + } + } + } + } + } + + // Step 2: Validate model and view + if let Some(ref expected_m) = self.expected_model { + let actual_m = body.get("model").and_then(|v| v.as_str()); + if actual_m != Some(expected_m.as_str()) { + errors.push(format!( + "$.body.model: expected const '{}', got {:?}", + expected_m, + body.get("model") + )); + } + } + if let Some(ref expected_v) = self.expected_view { + let actual_v = body.get("view").and_then(|v| v.as_str()); + if actual_v != Some(expected_v.as_str()) { + errors.push(format!( + "$.body.view: expected const '{}', got {:?}", + expected_v, + body.get("view") + )); + } + } + + // Step 3: Validate fields + if let Some(fields_val) = body.get("fields") { + if let Some(arr) = fields_val.as_array() { + for (idx, item) in arr.iter().enumerate() { + let Some(s) = item.as_str() else { + errors.push(format!("$.body.fields[{}]: expected string", idx)); + continue; + }; + if !all_valid_fields.contains(s) { + errors.push(format!( + "$.body.fields[{}]: '{}' is not a valid visible dimension, measure, or dynamic_field .name", + idx, s + )); + } + } + } else if !fields_val.is_null() { + errors.push("$.body.fields: expected array".to_string()); + } + } + + // Step 4: Validate pivots + if let Some(pivots_val) = body.get("pivots") { + if let Some(arr) = pivots_val.as_array() { + for (idx, item) in arr.iter().enumerate() { + let Some(s) = item.as_str() else { + errors.push(format!("$.body.pivots[{}]: expected string", idx)); + continue; + }; + if !all_valid_pivots.contains(s) { + if self.measure_only_set.contains(s) { + errors.push(format!( + "$.body.pivots[{}]: measure '{}' cannot be used as a pivot (pivots must be dimensions or dimension_like dynamic_fields)", + idx, s + )); + } else if non_dim_dyn_set.contains(s) { + errors.push(format!( + "$.body.pivots[{}]: dynamic_field '{}' is a measure or table_calculation and cannot be used as a pivot", + idx, s + )); + } else { + errors.push(format!( + "$.body.pivots[{}]: '{}' is not a valid visible dimension or dimension_like dynamic_field .name", + idx, s + )); + } + } + } + } else if !pivots_val.is_null() { + errors.push("$.body.pivots: expected array".to_string()); + } + } + + // Warning check: column_limit without pivots + if body.get("column_limit").is_some_and(|v| !v.is_null()) + && body + .get("pivots") + .and_then(|v| v.as_array()) + .is_none_or(|arr| arr.is_empty()) + { + warnings.push( + "$.body.column_limit: 'column_limit' is set without 'pivots' (column_limit only applies to pivoted queries)" + .to_string(), + ); + } + + // Step 5: Validate filters + if let Some(filters_val) = body.get("filters") { + if let Some(f_obj) = filters_val.as_object() { + self.validate_filter_map(f_obj, "$.body.filters", &mut errors); + } else if !filters_val.is_null() { + errors.push("$.body.filters: expected object".to_string()); + } + } + + // Step 6: Validate sorts + if let Some(sorts_val) = body.get("sorts") { + if let Some(arr) = sorts_val.as_array() { + let re = sort_item_regex(); + for (idx, item) in arr.iter().enumerate() { + let Some(sort_expr) = item.as_str() else { + errors.push(format!("$.body.sorts[{}]: expected string", idx)); + continue; + }; + if sort_expr == "__UNSORTED__" { + continue; + } + let Some(cap) = re.captures(sort_expr) else { + errors.push(format!( + "$.body.sorts[{}]: invalid sort expression '{}' (expected '', ' desc', ' 0', or '__UNSORTED__')", + idx, sort_expr + )); + continue; + }; + let sort_field = cap.get(1).unwrap().as_str(); + let base_dim = sort_field.strip_suffix("__sort_"); + if !all_valid_fields.contains(sort_field) + && !base_dim.is_some_and(|bd| all_valid_pivots.contains(bd)) + { + errors.push(format!( + "$.body.sorts[{}]: unknown or hidden sort field '{}'", + idx, sort_field + )); + } + } + } else if !sorts_val.is_null() { + errors.push("$.body.sorts: expected array".to_string()); + } + } + + // Step 7: Validate fill_fields and subtotals + if let Some(fill_val) = body.get("fill_fields") { + if let Some(arr) = fill_val.as_array() { + for (idx, item) in arr.iter().enumerate() { + let Some(s) = item.as_str() else { + errors.push(format!( + "$.body.fill_fields[{}]: must be a non-hidden dimension or dimension_like dynamic field", + idx + )); + continue; + }; + if !all_valid_pivots.contains(s) { + errors.push(format!( + "$.body.fill_fields[{}]: '{}' must be a non-hidden dimension or dimension_like dynamic field", + idx, s + )); + } + } + } else if !fill_val.is_null() { + errors.push("$.body.fill_fields: expected array".to_string()); + } + } + + if let Some(sub_val) = body.get("subtotals") { + if let Some(arr) = sub_val.as_array() { + for (idx, item) in arr.iter().enumerate() { + let Some(s) = item.as_str() else { + errors.push(format!( + "$.body.subtotals[{}]: must be a non-hidden dimension or dimension_like dynamic field", + idx + )); + continue; + }; + if !all_valid_pivots.contains(s) { + errors.push(format!( + "$.body.subtotals[{}]: '{}' must be a non-hidden dimension or dimension_like dynamic field", + idx, s + )); + } + } + } else if !sub_val.is_null() { + errors.push("$.body.subtotals: expected array of dimensions".to_string()); + } + } + + // Step 8: Validate other properties & filter_expression + if let Some(fe_val) = body.get("filter_expression") { + if let Some(fe_str) = fe_val.as_str() { + Self::validate_expression_refs( + fe_str, + &all_ref_fields, + Some(&table_calc_names), + None, + "$.body.filter_expression", + &mut errors, + ); + } else if !fe_val.is_null() { + errors.push("$.body.filter_expression: expected string".to_string()); + } + } + + for k in body.keys() { + if !self.allowed_body_keys.contains(k) { + errors.push(format!( + "$.body.{}: unknown or hidden field '{}' is not allowed", + k, k + )); + } + } + for (k, v) in &payload { + if k == "body" { + continue; + } + if !self.allowed_top_keys.contains(k) { + errors.push(format!("$.{}: unknown property '{}' is not allowed", k, k)); + } else if k == "result_format" { + if let Some(rf) = v.as_str() { + if !self.allowed_result_formats.is_empty() + && !self.allowed_result_formats.contains(rf) + { + errors.push(format!( + "$.result_format: value '{}' is not in allowed values", + rf + )); + } + } + } + } + + let is_valid = errors.is_empty(); + let normalized_json = if is_valid { + let mut norm_payload = payload; + let norm_body = norm_payload.get_mut("body").unwrap().as_object_mut().unwrap(); + if let Some(dyn_val) = norm_body.get("dynamic_fields") { + if dyn_val.is_array() { + if let Ok(serialized) = serde_json::to_string(dyn_val) { + norm_body.insert("dynamic_fields".to_string(), Value::String(serialized)); + } + } + } + serde_json::to_string(&Value::Object(norm_payload)).ok() + } else { + None + }; + + (is_valid, errors, warnings, normalized_json) + } +} + +#[pymethods] +impl RustExploreValidator { + #[new] + pub fn from_schema_json(schema_json: &str) -> PyResult { + let schema: Value = serde_json::from_str(schema_json).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Invalid schema JSON: {}", e)) + })?; + + let mut defs_regexes: HashMap = HashMap::new(); + if let Some(defs_obj) = schema.get("$defs").and_then(|v| v.as_object()) { + for (def_name, def_val) in defs_obj { + if let Some(pat_str) = def_val.get("pattern").and_then(|v| v.as_str()) { + if let Ok(re) = Regex::new(&format!("^(?:{})$", pat_str)) { + defs_regexes.insert(def_name.clone(), (re, pat_str.to_string())); + } + } + } + } + + let top_props = schema + .get("properties") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let allowed_top_keys: HashSet = top_props.keys().cloned().collect(); + let allowed_result_formats: HashSet = top_props + .get("result_format") + .and_then(|v| v.get("enum")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let default_top_limit = top_props + .get("limit") + .and_then(|v| v.get("default")) + .and_then(|v| v.as_i64()) + .unwrap_or(500); + + let body_props = top_props + .get("body") + .and_then(|v| v.get("properties")) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let allowed_body_keys: HashSet = body_props.keys().cloned().collect(); + + let expected_model = body_props + .get("model") + .and_then(|v| v.get("const")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let expected_view = body_props + .get("view") + .and_then(|v| v.get("const")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let default_body_limit = body_props + .get("limit") + .and_then(|v| v.get("default")) + .and_then(|v| v.as_str()) + .unwrap_or("500") + .to_string(); + + let base_fields_set: HashSet = body_props + .get("fields") + .and_then(|v| v.get("items")) + .and_then(|v| v.get("enum")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let base_pivots_set: HashSet = body_props + .get("pivots") + .and_then(|v| v.get("items")) + .and_then(|v| v.get("enum")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let measure_only_set: HashSet = base_fields_set + .difference(&base_pivots_set) + .cloned() + .collect(); + + let mut filter_rules: HashMap = HashMap::new(); + if let Some(filter_props) = body_props + .get("filters") + .and_then(|v| v.get("properties")) + .and_then(|v| v.as_object()) + { + for (field_name, prop_schema) in filter_props { + let rule = Self::compile_filter_prop(prop_schema, &defs_regexes); + filter_rules.insert(field_name.clone(), rule); + } + } + + let mut schema_all_ref_fields = base_fields_set.clone(); + for k in filter_rules.keys() { + schema_all_ref_fields.insert(k.clone()); + } + + Ok(Self { + expected_model, + expected_view, + default_body_limit, + default_top_limit, + base_fields_set, + base_pivots_set, + schema_all_ref_fields, + measure_only_set, + filter_rules, + allowed_top_keys, + allowed_body_keys, + allowed_result_formats, + }) + } + + pub fn validate_json(&self, query_json: &str) -> PyResult { + let val: Value = serde_json::from_str(query_json).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e)) + })?; + Ok(self.validate_single_value(val)) + } + + pub fn validate_batch_json( + &self, + py: Python<'_>, + queries_json: Vec, + ) -> PyResult> { + let results = py.allow_threads(|| { + queries_json + .par_iter() + .map(|q_str| match serde_json::from_str::(q_str) { + Ok(val) => self.validate_single_value(val), + Err(e) => ( + false, + vec![format!("Invalid query JSON: {}", e)], + Vec::new(), + None, + ), + }) + .collect() + }); + Ok(results) + } +} + +#[pymodule] +fn _schema_rs(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_schema() -> String { + json!({ + "$defs": { + "StringFilterExpression": { "type": "string" }, + "NumberFilterExpression": { "type": "string", "pattern": "^-?\\d+$" } + }, + "properties": { + "result_format": { "type": "string", "enum": ["json", "csv"] }, + "limit": { "type": "integer", "default": 500 }, + "body": { + "type": "object", + "properties": { + "model": { "type": "string", "const": "thelook" }, + "view": { "type": "string", "const": "order_items" }, + "fields": { "type": "array", "items": { "type": "string", "enum": ["order_items.status", "order_items.total_sale_price"] } }, + "pivots": { "type": "array", "items": { "type": "string", "enum": ["order_items.status"] } }, + "filters": { + "type": "object", + "properties": { + "order_items.status": { "$ref": "#/$defs/StringFilterExpression" }, + "order_items.total_sale_price": { "$ref": "#/$defs/NumberFilterExpression" } + } + }, + "sorts": { "type": "array" }, + "limit": { "type": "string", "default": "500" }, + "column_limit": { "type": "string" }, + "subtotals": { "type": "array" }, + "dynamic_fields": { "type": "array" }, + "filter_expression": { "type": "string" } + } + } + } + }) + .to_string() + } + + #[test] + fn test_sanitize_body_strips_read_only_and_coerces_limits() { + let mut raw = Map::new(); + raw.insert("id".to_string(), json!(123)); + raw.insert("slug".to_string(), json!("abc")); + raw.insert("client_id".to_string(), json!("cid")); + raw.insert("limit".to_string(), json!(100)); + raw.insert("column_limit".to_string(), json!(50)); + raw.insert("model".to_string(), json!("thelook")); + + let cleaned = RustExploreValidator::sanitize_body(&raw); + assert!(!cleaned.contains_key("id")); + assert!(!cleaned.contains_key("slug")); + assert!(!cleaned.contains_key("client_id")); + assert_eq!(cleaned.get("limit"), Some(&json!("100"))); + assert_eq!(cleaned.get("column_limit"), Some(&json!("50"))); + assert_eq!(cleaned.get("model"), Some(&json!("thelook"))); + } + + #[test] + fn test_validate_single_value_and_edge_cases() { + let val = RustExploreValidator::from_schema_json(&sample_schema()).unwrap(); + + // Non-object root payload + let (ok, errs, _, _) = val.validate_single_value(json!(["not_an_object"])); + assert!(!ok); + assert!(errs[0].contains("expected query payload to be a JSON object")); + + // Non-object body payload + let (ok, errs, _, _) = val.validate_single_value(json!({"body": "not_an_object"})); + assert!(!ok); + assert!(errs[0].contains("$.body: expected object")); + + // CustomMeasure with non-object filters rejected + let (ok, errs, _, _) = val.validate_single_value(json!({ + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["cm1"], + "dynamic_fields": [{ + "measure": "cm1", + "based_on": "order_items.total_sale_price", + "filters": "invalid_non_object" + }] + } + })); + assert!(!ok); + assert!(errs.iter().any(|e| e.contains(".filters: expected object"))); + + // column_limit without pivots emits warning and remains valid + let (ok, errs, warns, _) = val.validate_single_value(json!({ + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["order_items.status"], + "column_limit": "50" + } + })); + assert!(ok, "errs: {:?}", errs); + assert_eq!(warns.len(), 1); + assert!(warns[0].contains("$.body.column_limit")); + } +} + diff --git a/lkr/schema/validator.py b/lkr/schema/validator.py new file mode 100644 index 0000000..dec360c --- /dev/null +++ b/lkr/schema/validator.py @@ -0,0 +1,64 @@ +import functools +import importlib +import json +from dataclasses import dataclass, field +from typing import Any + +_schema_rs: Any = importlib.import_module("lkr.schema._schema_rs") + + +@dataclass +class ValidationResult: + valid: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + normalized_query: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + res: dict[str, Any] = { + "valid": self.valid, + "errors": self.errors, + "warnings": self.warnings, + } + if self.normalized_query is not None: + res["normalized_query"] = self.normalized_query + return res + + +@functools.lru_cache(maxsize=64) +def _compile_validator(schema_json: str) -> Any: + return _schema_rs.RustExploreValidator(schema_json) + + +def get_rust_validator(schema: dict[str, Any]) -> Any: + """Return a cached Rust `RustExploreValidator` instance for `schema`.""" + return _compile_validator(json.dumps(schema, sort_keys=True)) + + +def validate_batch_queries( + queries: list[dict[str, Any] | str], + schema: dict[str, Any], +) -> list[ValidationResult]: + """Validate a batch of queries in parallel across CPU cores via Rust `rayon`.""" + rust_val = get_rust_validator(schema) + serialized = [q if isinstance(q, str) else json.dumps(q) for q in queries] + raw_results = rust_val.validate_batch_json(serialized) + return [ + ValidationResult( + valid=is_valid, + errors=list(errs), + warnings=list(warns), + normalized_query=json.loads(norm_json) + if norm_json is not None + else None, + ) + for is_valid, errs, warns, norm_json in raw_results + ] + + +def validate_query( + query_payload: dict[str, Any], + schema: dict[str, Any], +) -> ValidationResult: + """Validate `query_payload` against `schema` using the compiled Rust `RustExploreValidator`.""" + return validate_batch_queries([query_payload], schema)[0] diff --git a/pyproject.toml b/pyproject.toml index 9a1d5d5..408f2e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ "pytest>=9.0.3", + "pytest-cov>=6.1.1", "ruff>=0.12.1", "python-dotenv>=1.2.2", "ty", diff --git a/scripts/schema/README.md b/scripts/schema/README.md new file mode 100644 index 0000000..cfd8ab9 --- /dev/null +++ b/scripts/schema/README.md @@ -0,0 +1,60 @@ +# `scripts/schema` — History Validation & Schema Refinement Harness + +## Purpose + +This directory contains developer tooling for testing and refining `lkr schema generate` and `lkr schema validate` against **real completed Looker queries** stored in `system__activity::history`. + +Instead of relying only on hand-crafted unit tests or over-generalizing from a single query, `scripts/schema/history.py` provides a repeatable pipeline that: + +1. **Queries `system__activity::history`** (starting from the template query slug `vdAt9IRgn89vVzHSLxOczt` or custom filters): + - Filters completed queries (`history.status = "complete"`) for a target `--model` (e.g. `thelook`) and `--explore` (e.g. `order_items`), or for specific `--query-id` integer values. + - Resolves the internal database `query.id` integer (e.g. `142769`, `314`, `324`) to its API 4.0 `query.slug` (e.g. `"P2BfdfcRGtgx3QWn48RwnHFSsCPwWPKY"`), since Looker's `GET /queries/{query_id}` (`sdk.query`) requires the string slug. +2. **Fetches the Full `Query` via `sdk.query(slug)`**: + - Retrieves the complete `Query` object (`fields`, `pivots`, `filters`, `sorts`, `dynamic_fields`, `subtotals`, `fill_fields`, `filter_expression`, `vis_config`, etc.). + - Strips read-only metadata (`can`, `id`, `slug`, `share_url`, `expanded_share_url`, `url`, `has_table_calculations`, `filter_config`) via `sanitize_read_query_to_write_query`. +3. **Batch-Validates in Parallel via Rust (`RustExploreValidator`)**: + - Generates (or loads) the strongly-typed JSON Schema for `::`. + - Runs all resolved queries through `validate_batch_queries` (powered by `lkr/schema/rust` + `rayon`), reporting `valid_queries`, `invalid_queries`, and the full list of validation errors per query without short-circuiting. + +--- + +## How to Run + +### 1. Validate Recent Completed Queries for an Explore +```bash +uv run python scripts/schema/history.py \ + --oauth-account=prod-demowest \ + --model=thelook \ + --explore=order_items \ + --explore-file=tmp/order_items.json \ + --limit=20 +``` + +### 2. Validate Specific Completed `query.id`s from `system__activity::history` +```bash +uv run python scripts/schema/history.py \ + --oauth-account=prod-demowest \ + --model=thelook \ + --explore=order_items \ + --explore-file=tmp/order_items.json \ + --query-id=142769 \ + --query-id=314 \ + --query-id=324 +``` + +### 3. Filter Only Queries Containing `dynamic_fields` +```bash +uv run python scripts/schema/history.py \ + --oauth-account=prod-demowest \ + --model=thelook \ + --explore=order_items \ + --explore-file=tmp/order_items.json \ + --require-dynamic-fields \ + --limit=25 +``` + +### 4. Recompile the Rust Validator (`_schema_rs.so`) +If you modify the validation rules in `lkr/schema/rust/src/lib.rs`: +```bash +make schema-rs +``` diff --git a/scripts/schema/__init__.py b/scripts/schema/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/schema/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/schema/history.py b/scripts/schema/history.py new file mode 100644 index 0000000..29b279e --- /dev/null +++ b/scripts/schema/history.py @@ -0,0 +1,317 @@ +import json +from pathlib import Path +from typing import Annotated, Any + +import typer + +from lkr.auth_service import get_auth +from lkr.classes import LkrCtxObj +from lkr.schema.builder import build_explore_query_schema +from lkr.schema.validator import validate_batch_queries + +DEFAULT_HISTORY_QUERY_SLUG = "eiMJQXpAgoXow2JId7whVy" + + +def _to_dict(obj: Any) -> dict[str, Any]: + if isinstance(obj, dict): + return obj + from lkr.codemode.main import to_primitive + + res = to_primitive(obj) + return res if isinstance(res, dict) else {} + + +def build_history_lookup_query( + *, + base_query: dict[str, Any] | None = None, + model: str | None = None, + explore: str | None = None, + query_ids: list[int | str] | None = None, + require_dynamic_fields: bool = False, + limit: int = 50, +) -> dict[str, Any]: + """Build a system__activity::history WriteQuery starting from base_query (e.g. eiMJQXpAgoXow2JId7whVy).""" + base = dict(base_query) if isinstance(base_query, dict) else {} + raw_fields = base.get("fields") + fields: list[str] = ( + [str(f) for f in raw_fields if isinstance(f, str)] + if isinstance(raw_fields, list) + else ["query.id"] + ) + for required_field in ( + "query.id", + "query.slug", + "query.model", + "query.view", + "query.dynamic_fields", + ): + if required_field not in fields: + fields.append(required_field) + + raw_filters = base.get("filters") + filters: dict[str, Any] = ( + dict(raw_filters) + if isinstance(raw_filters, dict) + else { + "history.status": "complete", + "history.workspace_id": "production", + } + ) + filters.pop("query.dynamic_fields", None) + if model: + filters["query.model"] = model + if explore: + filters["query.view"] = explore + if query_ids: + filters["query.id"] = ",".join(str(qid) for qid in query_ids) + + sorts = ( + base.get("sorts") + if isinstance(base.get("sorts"), list) + else ["query.id desc"] + ) + out: dict[str, Any] = { + "model": base.get("model") or "system__activity", + "view": base.get("view") or "history", + "fields": fields, + "filters": filters, + "sorts": sorts, + "limit": str(limit), + } + if require_dynamic_fields: + out["filter_expression"] = "length(${query.dynamic_fields}) > 2" + elif isinstance(base.get("filter_expression"), str) and base["filter_expression"]: + out["filter_expression"] = base["filter_expression"] + return out + + +def resolve_history_queries( + sdk: Any, + *, + model: str | None = None, + explore: str | None = None, + query_ids: list[int | str] | None = None, + history_slug: str | None = DEFAULT_HISTORY_QUERY_SLUG, + require_dynamic_fields: bool = False, + limit: int = 50, +) -> list[dict[str, Any]]: + """ + Reusable dev/refinement pipeline: + 1. Optionally fetches the template query (default `eiMJQXpAgoXow2JId7whVy`) from `system__activity::history`. + 2. Runs `run_inline_query` on `system__activity::history` filtered by `model`, `explore`, and/or `query_ids` + to resolve completed `query.id` -> `query.slug`. + 3. Calls `sdk.query(slug)` (`get_query`) for each resolved query. + """ + base_query: dict[str, Any] | None = None + if history_slug and hasattr(sdk, "query_for_slug"): + try: + base_query = _to_dict(sdk.query_for_slug(history_slug)) + except Exception: # noqa: BLE001 + base_query = None + + lookup_body = build_history_lookup_query( + base_query=base_query, + model=model, + explore=explore, + query_ids=query_ids, + require_dynamic_fields=require_dynamic_fields, + limit=limit, + ) + + from looker_sdk.sdk.api40 import models as mdls + + raw_rows = sdk.run_inline_query("json", mdls.WriteQuery(**lookup_body)) + rows: list[dict[str, Any]] = ( + json.loads(raw_rows) if isinstance(raw_rows, str) else list(raw_rows or []) + ) + + resolved: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + qid = row.get("query.id") + slug = row.get("query.slug") + dedup_key = str(qid if qid is not None else slug) + if not slug or dedup_key in seen_ids: + continue + seen_ids.add(dedup_key) + + full_query = _to_dict(sdk.query(str(slug))) + resolved.append( + { + "query_id": qid, + "slug": str(slug), + "completed_count": row.get("history.completed_Count"), + "raw_query": full_query, + } + ) + return resolved + + +def validate_history_queries( + queries: list[dict[str, Any]], + schema: dict[str, Any], +) -> dict[str, Any]: + """ + Validate a batch of completed queries (either output of `resolve_history_queries` + or raw `sdk.query(...)` dicts) against an Explore JSON Schema using the Rust batch validator. + """ + results: list[dict[str, Any]] = [] + valid_count = 0 + invalid_count = 0 + + prepared: list[tuple[Any, Any, dict[str, Any]]] = [] + batch_inputs: list[dict[str, Any] | str] = [] + for item in queries: + raw_candidate = ( + item.get("raw_query") + if "raw_query" in item + else item.get("query") + if isinstance(item.get("query"), dict) + else item + ) + raw_q: dict[str, Any] = ( + raw_candidate if isinstance(raw_candidate, dict) else {} + ) + qid = item.get("query_id", item.get("id", raw_q.get("id"))) + slug = item.get("slug", raw_q.get("slug")) + prepared.append((qid, slug, raw_q)) + batch_inputs.append({"body": raw_q}) + + batch_results = validate_batch_queries(batch_inputs, schema) + for (qid, slug, raw_q), val_result in zip( + prepared, batch_results, strict=False + ): + if val_result.valid: + valid_count += 1 + else: + invalid_count += 1 + + results.append( + { + "query_id": qid, + "slug": slug, + "valid": val_result.valid, + "error_count": len(val_result.errors), + "errors": list(val_result.errors), + "warnings": list(val_result.warnings), + "fields": raw_q.get("fields"), + "pivots": raw_q.get("pivots"), + "filters": raw_q.get("filters"), + "has_dynamic_fields": bool(raw_q.get("dynamic_fields")), + } + ) + + body_props = ( + schema.get("properties", {}).get("body", {}).get("properties", {}) + if isinstance(schema.get("properties"), dict) + else {} + ) + return { + "model": body_props.get("model", {}).get("const") + if isinstance(body_props.get("model"), dict) + else None, + "explore": body_props.get("view", {}).get("const") + if isinstance(body_props.get("view"), dict) + else None, + "total_queries": len(results), + "valid_queries": valid_count, + "invalid_queries": invalid_count, + "results": results, + } + + +app = typer.Typer( + help="Developer harness to fetch completed queries from system__activity::history and validate them." +) + + +@app.command() +def main( + oauth_account: Annotated[ + str | None, + typer.Option( + "--oauth-account", help="Looker OAuth account name (e.g. prod-demowest)" + ), + ] = None, + model: Annotated[ + str, + typer.Option("--model", "-m", help="LookML model name"), + ] = "thelook", + explore: Annotated[ + str, + typer.Option("--explore", "-e", help="LookML explore name"), + ] = "order_items", + explore_file: Annotated[ + Path | None, + typer.Option( + "--explore-file", + "-f", + help="Optional local lookml_model_explore JSON file", + ), + ] = None, + schema_file: Annotated[ + Path | None, + typer.Option( + "--schema-file", help="Optional pre-generated JSON schema file" + ), + ] = None, + history_slug: Annotated[ + str, + typer.Option( + "--history-slug", help="Base system__activity::history query slug" + ), + ] = DEFAULT_HISTORY_QUERY_SLUG, + query_id: Annotated[ + list[str] | None, + typer.Option( + "--query-id", + help="Specific completed query.id(s) from system__activity::history", + ), + ] = None, + require_dynamic_fields: Annotated[ + bool, + typer.Option( + "--require-dynamic-fields", + help="Only fetch queries with non-null dynamic_fields", + ), + ] = False, + limit: Annotated[ + int, + typer.Option( + "--limit", "-l", help="Max completed queries to fetch and validate" + ), + ] = 25, +) -> None: + ctx_lkr = LkrCtxObj(oauth_account=oauth_account, force_oauth=bool(oauth_account)) + sdk = get_auth(ctx_lkr).get_current_sdk(prompt_refresh_invalid_token=True) + + if schema_file is not None: + schema_data = json.loads(schema_file.read_text(encoding="utf-8")) + elif explore_file is not None: + explore_data = json.loads(explore_file.read_text(encoding="utf-8")) + schema_data = build_explore_query_schema(model, explore, explore_data) + else: + from lkr.codemode.main import to_primitive + + explore_data = to_primitive(sdk.lookml_model_explore(model, explore)) + schema_data = build_explore_query_schema(model, explore, explore_data) + + resolved = resolve_history_queries( + sdk, + model=model, + explore=explore, + query_ids=list(query_id) if query_id else None, + history_slug=history_slug, + require_dynamic_fields=require_dynamic_fields, + limit=limit, + ) + summary = validate_history_queries(resolved, schema_data) + typer.echo(json.dumps(summary, indent=2)) + if summary["invalid_queries"] > 0: + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..f50536d --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,857 @@ +import json +import re +from pathlib import Path + +from typer.testing import CliRunner + +from lkr.main import app +from lkr.schema import build_explore_query_schema, validate_query +from lkr.schema.builder import build_query_body_schema +from lkr.schema.dynamic_fields import ( + build_custom_dimension_schema, + build_custom_measure_schema, + build_dynamic_fields_schema, + build_table_calculation_schema, +) +from lkr.schema.fields import ( + build_fields_schema, + build_fill_fields_schema, + build_pivots_schema, + build_sorts_schema, + build_subtotals_schema, + extract_visible_explore_fields, + is_visible_field, +) +from lkr.schema.filter_expressions import ( + FILTER_EXPRESSION_DEFS, + build_filter_property_schema, + build_filters_schema, + classify_filter_expression_ref, + extract_enum_values, +) + +runner = CliRunner() + + +def test_is_visible_field_and_extract_visible_explore_fields(): + assert is_visible_field({"name": "users.id", "hidden": False}) is True + assert is_visible_field({"name": "users.id", "hidden": True}) is False + assert is_visible_field({"name": "", "hidden": False}) is False + assert is_visible_field({}) is False + + raw = { + "fields": { + "dimensions": [ + {"name": "d1", "hidden": False}, + {"name": "d_hidden", "hidden": True}, + ], + "measures": [ + {"name": "m1", "hidden": False}, + {"name": "m_hidden", "hidden": True}, + ], + "filters": [ + {"name": "f1", "hidden": False}, + {"name": "f_hidden", "hidden": True}, + ], + "parameters": [ + {"name": "p1", "hidden": False}, + {"name": "p_hidden", "hidden": True}, + ], + } + } + visible = extract_visible_explore_fields(raw) + assert visible.dimension_names == ["d1"] + assert visible.measure_names == ["m1"] + assert visible.selectable_field_names == ["d1", "m1"] + assert [f["name"] for f in visible.filters] == ["f1"] + assert [p["name"] for p in visible.parameters] == ["p1"] + + +def test_build_fields_pivots_sorts_fill_and_subtotals_schema(): + fields_schema = build_fields_schema(["d1", "d2"], ["m1"]) + assert fields_schema["items"]["enum"] == ["d1", "d2", "m1"] + + pivots_schema = build_pivots_schema(["d1", "d2"]) + assert pivots_schema["items"]["enum"] == ["d1", "d2"] + assert "m1" not in pivots_schema["items"]["enum"] + + fill_schema = build_fill_fields_schema(["d1", "d2"]) + assert fill_schema["items"]["enum"] == ["d1", "d2"] + + subtotals_schema = build_subtotals_schema(["d1", "d2"]) + assert subtotals_schema["items"]["enum"] == ["d1", "d2"] + + sorts_schema = build_sorts_schema(["d1", "m1"]) + sort_pat = re.compile(sorts_schema["items"]["pattern"]) + assert sort_pat.match("d1") + assert sort_pat.match("d1 desc") + assert sort_pat.match("m1 desc 0") + assert sort_pat.match("__UNSORTED__") + assert not sort_pat.match("unknown_field desc") + + +def test_extract_enum_values_and_classify_filter_expression_ref(): + assert extract_enum_values( + { + "enumerations": [ + {"label": "A", "value": "a"}, + {"label": "B", "value": "b"}, + ] + } + ) == ["a", "b"] + assert extract_enum_values({"allowed_values": ["x", "y"]}) == ["x", "y"] + assert extract_enum_values({}) is None + + assert ( + classify_filter_expression_ref({"type": "yesno"}) + == "#/$defs/BooleanFilterExpression" + ) + assert ( + classify_filter_expression_ref({"type": "location"}) + == "#/$defs/LocationFilterExpression" + ) + assert ( + classify_filter_expression_ref( + {"type": "date_date", "is_timeframe": True} + ) + == "#/$defs/DateTimeFilterExpression" + ) + assert ( + classify_filter_expression_ref({"type": "number", "is_numeric": True}) + == "#/$defs/NumberFilterExpression" + ) + assert ( + classify_filter_expression_ref({"type": "string"}) + == "#/$defs/StringFilterExpression" + ) + + +def test_filter_expression_patterns(): + dt_pattern = re.compile( + FILTER_EXPRESSION_DEFS["DateTimeFilterExpression"]["pattern"] + ) + for ex in FILTER_EXPRESSION_DEFS["DateTimeFilterExpression"]["examples"]: + assert dt_pattern.match(ex), f"DateTime pattern failed on {ex}" + + num_pattern = re.compile( + FILTER_EXPRESSION_DEFS["NumberFilterExpression"]["pattern"] + ) + for ex in FILTER_EXPRESSION_DEFS["NumberFilterExpression"]["examples"]: + assert num_pattern.match(ex), f"Number pattern failed on {ex}" + + loc_pattern = re.compile( + FILTER_EXPRESSION_DEFS["LocationFilterExpression"]["pattern"] + ) + for ex in FILTER_EXPRESSION_DEFS["LocationFilterExpression"]["examples"]: + assert loc_pattern.match(ex), f"Location pattern failed on {ex}" + + +def test_build_filter_property_and_filters_schema(): + param_prop = build_filter_property_schema( + { + "name": "p1", + "parameter": True, + "has_allowed_values": True, + "enumerations": [ + {"label": "Day", "value": "day"}, + {"label": "Week", "value": "week"}, + ], + "default_filter_value": "day", + }, + is_parameter=True, + ) + assert param_prop == { + "type": "string", + "enum": ["day", "week"], + "default": "day", + } + + dim_enum_prop = build_filter_property_schema( + { + "name": "users.age_tier", + "type": "tier", + "enumerations": [{"label": "0 to 17", "value": "0 to 17"}], + } + ) + assert "anyOf" in dim_enum_prop + + filters_schema = build_filters_schema( + dimensions=[{"name": "d1", "type": "string"}], + measures=[{"name": "m1", "type": "sum", "is_numeric": True}], + filters=[{"name": "f1", "type": "date_time", "can_time_filter": True}], + parameters=[ + { + "name": "p1", + "parameter": True, + "has_allowed_values": True, + "enumerations": [{"label": "One", "value": "1"}], + } + ], + ) + props = filters_schema["properties"] + assert set(props.keys()) == {"d1", "m1", "f1", "p1"} + assert props["d1"]["$ref"] == "#/$defs/StringFilterExpression" + assert props["m1"]["$ref"] == "#/$defs/NumberFilterExpression" + assert props["f1"]["$ref"] == "#/$defs/DateTimeFilterExpression" + assert props["p1"]["enum"] == ["1"] + + +def test_build_dynamic_fields_schema(): + cd = build_custom_dimension_schema() + assert "dimension" in cd["required"] + + cm = build_custom_measure_schema(["d1", "d2"]) + assert cm["properties"]["based_on"]["enum"] == ["d1", "d2"] + assert "measure" in cm["required"] + + tc = build_table_calculation_schema() + assert "table_calculation" in tc["required"] + + df = build_dynamic_fields_schema(["d1"]) + assert len(df["items"]["oneOf"]) == 3 + + +def test_schema_generate_and_validate_pipeline(tmp_path: Path): + order_items_path = Path("tmp/order_items.json") + if not order_items_path.exists(): + order_items_path = tmp_path / "order_items.json" + order_items_path.write_text( + json.dumps( + { + "fields": { + "dimensions": [ + {"name": "order_items.created_year", "type": "date_year", "is_timeframe": True, "hidden": False}, + {"name": "order_items.created_date", "type": "date_date", "is_timeframe": True, "hidden": False}, + {"name": "order_items.status", "type": "string", "hidden": False}, + {"name": "order_items.sale_price", "type": "number", "is_numeric": True, "hidden": False}, + {"name": "users.over_21", "type": "yesno", "hidden": False}, + ], + "measures": [ + {"name": "users.count", "type": "count", "is_numeric": True, "hidden": False}, + ], + "filters": [], + "parameters": [], + } + } + ), + encoding="utf-8", + ) + + data = json.loads(order_items_path.read_text(encoding="utf-8")) + body_schema = build_query_body_schema("thelook", "order_items", data) + assert body_schema["properties"]["model"]["const"] == "thelook" + assert body_schema["properties"]["view"]["const"] == "order_items" + assert body_schema["properties"]["limit"]["default"] == "500" + + schema = build_explore_query_schema("thelook", "order_items", data) + assert schema["properties"]["limit"]["default"] == 500 + + # Valid query with dynamic_fields (both CustomDimension in pivots/fields and TableCalculation in fields) + valid_payload = { + "model": "thelook", + "view": "order_items", + "fields": [ + "order_items.created_year", + "users.count", + "custom_dim_bucket", + "calc_pct", + ], + "pivots": ["order_items.status", "custom_dim_bucket"], + "filters": { + "order_items.status": "Returned", + "order_items.created_date": "7 days", + "order_items.sale_price": ">=10 AND <=50", + "users.over_21": "Yes", + }, + "sorts": ["order_items.created_year desc", "calc_pct asc"], + "dynamic_fields": [ + { + "dimension": "custom_dim_bucket", + "label": "Custom Bucket", + "expression": "${order_items.sale_price} > 20", + }, + { + "table_calculation": "calc_pct", + "label": "Calc Pct", + "expression": "${users.count} / 100", + }, + ], + } + valid_res = validate_query(valid_payload, schema) + assert valid_res.valid is True, f"Unexpected errors: {valid_res.errors}" + assert valid_res.normalized_query is not None + assert isinstance( + valid_res.normalized_query["body"]["dynamic_fields"], str + ) + + # Multi-error payload: pipeline must collect EVERY error without stopping early + invalid_payload = { + "model": "wrong_model", + "view": "wrong_view", + "fields": ["order_items.nonexistent_field"], + "pivots": ["users.count", "calc_pct"], + "filters": { + "order_items.sale_price": "not_a_number_expression", + "hidden_or_fake.field": "abc", + }, + "sorts": ["bad_sort_field desc"], + "subtotals": ["users.count"], + "dynamic_fields": [ + { + "table_calculation": "calc_pct", + "label": "Calc Pct", + "expression": "${users.count} / 100", + } + ], + } + invalid_res = validate_query(invalid_payload, schema) + assert invalid_res.valid is False + # Verify errors from all pipeline stages were collected simultaneously + joined_errors = "\n".join(invalid_res.errors) + assert "$.body.model" in joined_errors + assert "$.body.view" in joined_errors + assert "$.body.fields[0]" in joined_errors + assert "$.body.pivots[0]" in joined_errors + assert "$.body.pivots[1]" in joined_errors + assert "$.body.filters.order_items.sale_price" in joined_errors + assert "$.body.filters.hidden_or_fake.field" in joined_errors + assert "$.body.sorts[0]" in joined_errors + assert "$.body.subtotals[0]" in joined_errors + + # Verify CLI `lkr schema generate` + gen_res = runner.invoke( + app, + [ + "schema", + "generate", + "--model=thelook", + "--explore=order_items", + f"--explore-file={order_items_path}", + ], + ) + assert gen_res.exit_code == 0 + cli_schema = json.loads(gen_res.stdout) + assert cli_schema["title"] == "run_inline_query_thelook_order_items" + + # Verify CLI `lkr schema validate` (auto-generating schema via --model and --explore) + val_res = runner.invoke( + app, + [ + "schema", + "validate", + "--model=thelook", + "--explore=order_items", + f"--explore-file={order_items_path}", + f"--query={json.dumps(valid_payload)}", + ], + ) + assert val_res.exit_code == 0 + assert json.loads(val_res.stdout)["valid"] is True + + # Verify CLI `lkr schema validate` returns exit code 1 and all errors when invalid + val_fail_res = runner.invoke( + app, + [ + "schema", + "validate", + "--model=thelook", + "--explore=order_items", + f"--explore-file={order_items_path}", + f"--query={json.dumps(invalid_payload)}", + ], + ) + assert val_fail_res.exit_code == 1 + fail_out = json.loads(val_fail_res.stdout) + assert fail_out["valid"] is False + assert len(fail_out["errors"]) >= 9 + + +MOCK_EXPLORE = { + "fields": { + "dimensions": [ + {"name": "order_items.status", "type": "string", "hidden": False}, + {"name": "order_items.sale_price", "type": "number", "hidden": False}, + {"name": "order_items.secret_id", "type": "number", "hidden": True}, + ], + "measures": [ + { + "name": "order_items.total_sale_price", + "type": "sum", + "hidden": False, + }, + ], + "filters": [], + "parameters": [], + } +} + + +def test_real_world_dynamic_fields_and_history_shapes() -> None: + schema = build_explore_query_schema("thelook", "order_items", MOCK_EXPLORE) + + # 1. Valid real-world shapes from system__activity::history: + # - TableCalculation with calculation_type + args (no expression) + # - CustomMeasure with based_on + filters + filter_expression (no type) + # - CustomDimension with ${view.field} expression reference + # - Tier sort companion (__sort_) + pivoted sort ( 0) + # - Dashboard empty string "" filter value + payload = { + "body": { + "model": "thelook", + "view": "order_items", + "fields": [ + "order_items.status", + "order_items.total_sale_price", + "custom_bin_dim", + "filtered_revenue", + "rank_calc", + ], + "pivots": ["order_items.status"], + "filters": { + "order_items.status": "", + "order_items.sale_price": "[10, 100]", + }, + "sorts": [ + "order_items.status__sort_ asc", + "order_items.total_sale_price 0", + ], + "dynamic_fields": [ + { + "category": "dimension", + "dimension": "custom_bin_dim", + "label": "High Value Item", + "expression": "${order_items.sale_price} > 50", + "_kind_hint": "dimension", + "_type_hint": "yesno", + }, + { + "category": "measure", + "measure": "filtered_revenue", + "label": "Filtered Revenue", + "based_on": "order_items.total_sale_price", + "filters": {"order_items.status": "Complete"}, + "filter_expression": "${order_items.sale_price} > 20", + "_kind_hint": "measure", + "_type_hint": "number", + }, + { + "category": "table_calculation", + "table_calculation": "rank_calc", + "label": "Rank of Sale Price", + "based_on": "order_items.total_sale_price", + "calculation_type": "rank_of_column", + "args": ["order_items.total_sale_price"], + "is_disabled": False, + }, + ], + } + } + res = validate_query(payload, schema) + assert res.valid is True, f"Unexpected errors: {res.errors}" + + # 2. Invalid ${unknown.field} reference inside expression and invalid custom measure filters + bad_dyn_payload = { + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["bad_dim", "bad_meas"], + "dynamic_fields": [ + { + "dimension": "bad_dim", + "expression": "${order_items.secret_id} + ${nonexistent.col}", + }, + { + "measure": "bad_meas", + "based_on": "order_items.total_sale_price", + "filters": {"order_items.secret_id": "123"}, + }, + ], + } + } + bad_res = validate_query(bad_dyn_payload, schema) + assert bad_res.valid is False + assert any("order_items.secret_id" in e for e in bad_res.errors) + assert any("nonexistent.col" in e for e in bad_res.errors) + + +def test_history_pipeline_and_real_queries() -> None: + from scripts.schema.history import ( + build_history_lookup_query, + resolve_history_queries, + validate_history_queries, + ) + + # 1. Verify build_history_lookup_query starts from base_query and sets model/explore/query_ids + lookup = build_history_lookup_query( + base_query={ + "model": "system__activity", + "view": "history", + "fields": ["query.id", "history.completed_Count"], + "filters": { + "history.status": "complete", + "query.dynamic_fields": "-NULL", + }, + }, + model="thelook", + explore="order_items", + query_ids=[314, 142769], + limit=10, + ) + assert lookup["model"] == "system__activity" + assert lookup["view"] == "history" + assert "query.slug" in lookup["fields"] + assert lookup["filters"]["query.model"] == "thelook" + assert lookup["filters"]["query.view"] == "order_items" + assert lookup["filters"]["query.id"] == "314,142769" + assert "query.dynamic_fields" not in lookup["filters"] + + # 2. Verify resolve_history_queries with a mock SDK + class FakeSDK: + def query_for_slug(self, slug: str) -> dict: + return { + "model": "system__activity", + "view": "history", + "fields": ["query.id"], + "filters": {"history.status": "complete"}, + } + + def run_inline_query(self, result_format: str, body: object) -> str: + return json.dumps( + [ + { + "query.id": 142769, + "query.slug": "slug_142769", + "query.model": "thelook", + "query.view": "order_items", + "history.completed_Count": 5, + } + ] + ) + + def query(self, slug: str) -> dict: + assert slug == "slug_142769" + return { + "id": 142769, + "slug": "slug_142769", + "model": "thelook", + "view": "order_items", + "fields": ["order_items.status", "order_items.total_sale_price"], + "can": {"run": True}, + "share_url": "https://example.looker.com/x/slug_142769", + } + + schema = build_explore_query_schema("thelook", "order_items", MOCK_EXPLORE) + resolved = resolve_history_queries( + FakeSDK(), model="thelook", explore="order_items", query_ids=[142769] + ) + assert len(resolved) == 1 + assert resolved[0]["query_id"] == 142769 + summary = validate_history_queries(resolved, schema) + assert summary["valid_queries"] == 1 + assert summary["invalid_queries"] == 0 + + # 3. Verify against cached real files if present + sys_hist_path = Path("tmp/system_activity_history.json") + if sys_hist_path.exists(): + sys_schema = json.loads(sys_hist_path.read_text(encoding="utf-8")) + assert sys_schema["title"] == "run_inline_query_system__activity_history" + assert ( + sys_schema["properties"]["body"]["properties"]["model"]["const"] + == "system__activity" + ) + + slug_test_path = Path("tmp/slug_query_test.json") + order_items_path = Path("tmp/order_items.json") + if slug_test_path.exists() and order_items_path.exists(): + explore_data = json.loads(order_items_path.read_text(encoding="utf-8")) + real_schema = build_explore_query_schema("thelook", "order_items", explore_data) + real_queries = json.loads(slug_test_path.read_text(encoding="utf-8")) + batch = validate_history_queries(real_queries, real_schema) + assert batch["total_queries"] == 5 + assert batch["valid_queries"] == 5 + assert batch["invalid_queries"] == 0 + + +def test_rust_validator_and_batch() -> None: + from lkr.schema.validator import get_rust_validator, validate_batch_queries + + schema = build_explore_query_schema("thelook", "order_items", MOCK_EXPLORE) + rust_val = get_rust_validator(schema) + assert rust_val is not None + + valid_q = { + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["order_items.status", "order_items.total_sale_price"], + "subtotals": ["order_items.status"], + } + } + invalid_q = { + "body": { + "model": "wrong", + "view": "order_items", + "fields": ["order_items.secret_id"], + "pivots": ["order_items.total_sale_price"], + } + } + + res_ok = validate_query(valid_q, schema) + assert res_ok.valid is True + assert res_ok.normalized_query is not None + assert res_ok.normalized_query["body"]["subtotals"] == ["order_items.status"] + + res_bad = validate_query(invalid_q, schema) + assert res_bad.valid is False + assert len(res_bad.errors) == 3 + + batch_out = validate_batch_queries([valid_q, invalid_q], schema) + assert len(batch_out) == 2 + assert batch_out[0].valid is True + assert batch_out[1].valid is False + + +def test_expression_and_custom_filter_field_validator_on_small_schema() -> None: + # 1. Small JSON Schema for system__activity::history (testing eiMJQXpAgoXow2JId7whVy) + small_history_explore = { + "fields": { + "dimensions": [ + {"name": "query.id", "type": "number", "hidden": False}, + {"name": "query.slug", "type": "string", "hidden": False}, + {"name": "query.model", "type": "string", "hidden": False}, + {"name": "query.view", "type": "string", "hidden": False}, + {"name": "query.dynamic_fields", "type": "string", "hidden": False}, + {"name": "history.status", "type": "string", "hidden": False}, + {"name": "history.workspace_id", "type": "string", "hidden": False}, + ], + "measures": [ + {"name": "history.completed_Count", "type": "count", "hidden": False}, + ], + "filters": [], + "parameters": [], + } + } + small_history_schema = build_explore_query_schema( + "system__activity", "history", small_history_explore + ) + + # Valid query matching eiMJQXpAgoXow2JId7whVy + filter_expression: length(${query.dynamic_fields}) > 2 + valid_history_query = { + "body": { + "model": "system__activity", + "view": "history", + "fields": ["query.id"], + "filters": { + "history.status": "complete", + "history.workspace_id": "production", + }, + "filter_expression": "length(${query.dynamic_fields}) > 2", + "sorts": ["query.id desc"], + "limit": "500", + } + } + res_hist_ok = validate_query(valid_history_query, small_history_schema) + assert res_hist_ok.valid is True, f"Unexpected errors: {res_hist_ok.errors}" + + # Invalid filter_expression referencing unknown field ${query.filter_expression} + invalid_history_query = { + "body": { + "model": "system__activity", + "view": "history", + "fields": ["query.id"], + "filter_expression": "length(${query.dynamic_fields}) > 2 OR length(${query.filter_expression}) > 0", + } + } + res_hist_bad = validate_query(invalid_history_query, small_history_schema) + assert res_hist_bad.valid is False + assert any( + "$.body.filter_expression" in e and "${query.filter_expression}" in e + for e in res_hist_bad.errors + ) + + # 2. Small JSON Schema testing chained dynamic_fields ${...}, :total suffix, self-references, and table_calc in SQL filter_expression + small_order_schema = build_explore_query_schema( + "thelook", "order_items", MOCK_EXPLORE + ) + chained_valid_query = { + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["order_items.status", "current_aov", "pct_of_total"], + "filter_expression": "${order_items.sale_price} > 10 AND ${is_high_value}", + "dynamic_fields": [ + { + "dimension": "is_high_value", + "label": "Is High Value", + "expression": "${order_items.sale_price} >= 25", + }, + { + "measure": "current_revenue", + "label": "Current Revenue", + "based_on": "order_items.total_sale_price", + "filter_expression": "${order_items.sale_price} > 0 AND ${is_high_value}", + }, + { + "table_calculation": "current_aov", + "label": "Current AOV", + "expression": "${current_revenue} / 10", + }, + { + "table_calculation": "pct_of_total", + "label": "Pct of Total", + "expression": "${current_aov} / ${order_items.total_sale_price:total}", + }, + ], + } + } + res_chain_ok = validate_query(chained_valid_query, small_order_schema) + assert res_chain_ok.valid is True, f"Unexpected errors: {res_chain_ok.errors}" + + # Invalid ${...} cases: empty ${}, self-reference, unknown field, and table_calculation inside body.filter_expression + bad_refs_query = { + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["order_items.status"], + "filter_expression": "${tc_after_sql} > 0 AND ${} > 1 AND ${unknown.dim} = 1", + "dynamic_fields": [ + { + "table_calculation": "tc_after_sql", + "label": "Self Ref TC", + "expression": "${tc_after_sql} + ${order_items.secret_id}", + } + ], + } + } + res_bad_refs = validate_query(bad_refs_query, small_order_schema) + assert res_bad_refs.valid is False + err_text = "\n".join(res_bad_refs.errors) + assert "empty field reference '${}'" in err_text + assert "cannot reference itself in '${tc_after_sql}'" in err_text + assert "post-query table_calculation '${tc_after_sql}'" in err_text + assert "${order_items.secret_id}" in err_text + assert "${unknown.dim}" in err_text + + # 3. column_limit without pivots emits a warning (does not fail valid) + col_limit_warn_res = validate_query( + { + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["order_items.status", "order_items.total_sale_price"], + "column_limit": "50", + } + }, + small_order_schema, + ) + assert col_limit_warn_res.valid is True + assert len(col_limit_warn_res.warnings) == 1 + assert "$.body.column_limit" in col_limit_warn_res.warnings[0] + + +def test_vgr_edge_cases(tmp_path: Path) -> None: + # 1. Missing --explore-file exits with code 1 cleanly + missing_res = runner.invoke( + app, + [ + "schema", + "generate", + "--model=thelook", + "--explore=order_items", + f"--explore-file={tmp_path / 'does_not_exist.json'}", + ], + ) + assert missing_res.exit_code == 1 + + # 2. Invalid JSON in --explore-file exits with code 1 cleanly + bad_file = tmp_path / "corrupt.json" + bad_file.write_text("{not valid json", encoding="utf-8") + bad_json_res = runner.invoke( + app, + [ + "schema", + "generate", + "--model=thelook", + "--explore=order_items", + f"--explore-file={bad_file}", + ], + ) + assert bad_json_res.exit_code == 1 + + # 3. Non-object CustomMeasure.filters rejected with explicit error + schema = build_explore_query_schema("thelook", "order_items", MOCK_EXPLORE) + non_obj_filters_res = validate_query( + { + "body": { + "model": "thelook", + "view": "order_items", + "fields": ["cm_bad_filters"], + "dynamic_fields": [ + { + "measure": "cm_bad_filters", + "based_on": "order_items.total_sale_price", + "filters": "not_an_object", + } + ], + } + }, + schema, + ) + assert non_obj_filters_res.valid is False + assert any( + "$.body.dynamic_fields[0].filters: expected object" in e + for e in non_obj_filters_res.errors + ) + + # 4. DateTimeFilterExpression multi-comma input completes without backtracking + dt_pat = re.compile( + FILTER_EXPRESSION_DEFS["DateTimeFilterExpression"]["pattern"] + ) + assert dt_pat.match("today, yesterday, 7 days, this month") + assert not dt_pat.match("invalid_prefix," + "a," * 40 + "!") + + # 5. CLI lkr schema validate accepts a JSON array of queries (batch validation) + explore_path = tmp_path / "mock_explore.json" + explore_path.write_text(json.dumps(MOCK_EXPLORE), encoding="utf-8") + valid_arr = [ + {"model": "thelook", "view": "order_items", "fields": ["order_items.status"]}, + {"model": "thelook", "view": "order_items", "fields": ["order_items.total_sale_price"]}, + ] + arr_ok_res = runner.invoke( + app, + [ + "schema", + "validate", + f"--explore-file={explore_path}", + f"--query={json.dumps(valid_arr)}", + ], + ) + assert arr_ok_res.exit_code == 0 + arr_ok_data = json.loads(arr_ok_res.stdout) + assert isinstance(arr_ok_data, list) + assert len(arr_ok_data) == 2 + assert all(item["valid"] is True for item in arr_ok_data) + + mixed_arr = [ + {"model": "thelook", "view": "order_items", "fields": ["order_items.status"]}, + {"model": "thelook", "view": "order_items", "fields": ["order_items.secret_id"]}, + ] + arr_fail_res = runner.invoke( + app, + [ + "schema", + "validate", + f"--explore-file={explore_path}", + f"--query={json.dumps(mixed_arr)}", + ], + ) + assert arr_fail_res.exit_code == 1 + arr_fail_data = json.loads(arr_fail_res.stdout) + assert arr_fail_data[0]["valid"] is True + assert arr_fail_data[1]["valid"] is False + + + + + + diff --git a/uv.lock b/uv.lock index 85de67c..3a54ec4 100644 --- a/uv.lock +++ b/uv.lock @@ -315,6 +315,105 @@ 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.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/ae/16/e11addf5322d98e86307da9e00c94644b97cb72c534c74cda89705bebeef/coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", size = 223544, upload-time = "2026-09-13T19:11:09.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/d9/7ccd6484f615961d94b09c75904f87d86375109596069ae3a495a205dbbe/coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", size = 223882, upload-time = "2026-09-13T19:11:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/59/37/2fd78152a557df4a7e4ad78d6851ede90c211838d526e4916c6016f45144/coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", size = 254987, upload-time = "2026-09-13T19:11:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/58e2f9b8b13cc422aa7f474a498945ec7f446ef473b5b6bae18d2981f2a9/coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", size = 257902, upload-time = "2026-09-13T19:11:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/63f8f6a67c03b86455c726be273b2aa5b57fd1122f22350d141bbb142e91/coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", size = 259522, upload-time = "2026-09-13T19:11:18.206Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/1f2261da7483e345fe55de11e41ffc16a543511e37c0912134c0dccc4f6c/coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", size = 261723, upload-time = "2026-09-13T19:11:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8a/7eb1a361e044b7293f49c921a974b532627ed37f3bef5e7e9f58bcd0d5a2/coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", size = 255462, upload-time = "2026-09-13T19:11:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/67/66/6d94f7ea87e99c519def5cfa5d4a1ab20247d6b403eb6e6b5b63d6aa5985/coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", size = 257617, upload-time = "2026-09-13T19:11:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/3d/52/c3de0a3868589a1463a4f1cb0222eca0e5fa9f91da865ccf20d2e19ec19c/coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", size = 255495, upload-time = "2026-09-13T19:11:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a3/ea10e75f20fc1e7be10b166a494826a965d19b1dc9c8a81c511a01153311/coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", size = 259728, upload-time = "2026-09-13T19:11:29.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/96/9d8020d97de46f0390739beda196322586cdd87e1fc357223e6a28bc4135/coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", size = 254904, upload-time = "2026-09-13T19:11:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/5b/21/2ff8867d4560f4f639a82d8fcf13c27eea88a96193daba096027c48bb929/coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", size = 256828, upload-time = "2026-09-13T19:11:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4b/feacad51cb5163e3baa13281f8a3098f15d7934cc1f28cfe656557eb4e46/coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", size = 225722, upload-time = "2026-09-13T19:11:36.314Z" }, + { url = "https://files.pythonhosted.org/packages/39/34/66bbc6ffd3c51fa67604c566920e772c5e3baa57f88dcf7b109c7f18b2cb/coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", size = 226195, upload-time = "2026-09-13T19:11:38.493Z" }, + { url = "https://files.pythonhosted.org/packages/f7/96/4bad920d4caed127c37c136a64b0730fa495517d2eb58809e0d81e6e7dff/coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", size = 225625, upload-time = "2026-09-13T19:11:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2b/a88c7906eeb7da4394e932060d7008e153b3a2cd8d11782d82faa03bb7a3/coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", size = 224271, upload-time = "2026-09-13T19:11:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1f/98db59c595680d16f553890deac6d72610e04f192c1964ec9b69cbe790ab/coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", size = 224564, upload-time = "2026-09-13T19:11:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/699e236d4cb97ec282b0655afc85acf12349e71a02fff0f1a7d0fb643079/coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", size = 265423, upload-time = "2026-09-13T19:11:47.666Z" }, + { url = "https://files.pythonhosted.org/packages/96/1b/6eaa21912863b3e3bf23cfe605fb62929bb1a41dc33d2bfa8e92000232bd/coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", size = 268503, upload-time = "2026-09-13T19:11:50.12Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4eb46bffa98c28a80559a58c12f17e19308d52b31174af1bd49decf76606/coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", size = 271059, upload-time = "2026-09-13T19:11:52.363Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d4/17a51ef1f6a084c9abbead522cfbee0fe9d29a593128781e724b7a0795b3/coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", size = 272039, upload-time = "2026-09-13T19:11:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/9fdeba8b8aa0848030f45f869ddedc306795a8c6e53e901b8553afbf5415/coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", size = 265869, upload-time = "2026-09-13T19:11:57.423Z" }, + { url = "https://files.pythonhosted.org/packages/95/eb/ab3eb506b2e4dd278440fbbbc7ed18424a86d8a981db0b90fdba66f5d34d/coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", size = 268883, upload-time = "2026-09-13T19:11:59.71Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/f4edefdce33f01954a74237df5ee83ccef5dcd1165e04abf0adf47543fca/coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", size = 265359, upload-time = "2026-09-13T19:12:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/df/86/ce5ed885fc7ce2adc9a5971134ace257d0f84f19c3d117da89c179b9662e/coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", size = 270056, upload-time = "2026-09-13T19:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/5dbd5f95070cece25c6df9e6ea4ded2a87dc695ce82a49bcb2116452b342/coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", size = 265498, upload-time = "2026-09-13T19:12:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ff/254b26f3300f87c7c53ac97da41ae75f0e2ac066aa964bc073c0f3750b38/coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", size = 267459, upload-time = "2026-09-13T19:12:09.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/20/bf9958f8ddbbf6a2d39da3d4009f33e0fcf19c2d2fd99715c6494cdc8e8c/coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", size = 226021, upload-time = "2026-09-13T19:12:11.933Z" }, + { url = "https://files.pythonhosted.org/packages/35/8f/12d46948704d9e437c8dca2718e5d54ecc2f85396b64b500c29d8dbe10db/coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", size = 226853, upload-time = "2026-09-13T19:12:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7f/1ca5fd0601054fce5a3539375b997e67683646e2bf62f60c41e70b529fcc/coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", size = 226163, upload-time = "2026-09-13T19:12:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + [[package]] name = "cryptography" version = "50.0.1" @@ -761,6 +860,10 @@ all = [ { name = "pydantic-monty" }, { name = "selenium" }, ] +code-mode = [ + { name = "mcp", extra = ["cli"] }, + { name = "pydantic-monty" }, +] codemode = [ { name = "mcp", extra = ["cli"] }, { name = "pydantic-monty" }, @@ -781,6 +884,7 @@ tools = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, { name = "python-dotenv" }, { name = "ruff" }, { name = "ty" }, @@ -797,11 +901,13 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'tools'", specifier = ">=0.136.3" }, { name = "looker-sdk", specifier = ">=26.12.0" }, { name = "mcp", extras = ["cli"], marker = "extra == 'all'", specifier = ">=1.27.2,<2" }, + { name = "mcp", extras = ["cli"], marker = "extra == 'code-mode'", specifier = ">=1.27.2,<2" }, { name = "mcp", extras = ["cli"], marker = "extra == 'codemode'", specifier = ">=1.27.2,<2" }, { name = "mcp", extras = ["cli"], marker = "extra == 'mcp'", specifier = ">=1.27.2,<2" }, { name = "pydantic", specifier = ">=2.11.7" }, - { name = "pydantic-monty", marker = "extra == 'all'" }, - { name = "pydantic-monty", marker = "extra == 'codemode'" }, + { name = "pydantic-monty", marker = "extra == 'all'", specifier = "==0.0.23" }, + { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.23" }, + { name = "pydantic-monty", marker = "extra == 'codemode'", specifier = "==0.0.23" }, { name = "pydash", specifier = ">=8.0.5" }, { name = "questionary", specifier = ">=2.1.0" }, { name = "requests", specifier = ">=2.34.2" }, @@ -810,11 +916,12 @@ requires-dist = [ { name = "structlog", specifier = ">=25.4.0" }, { name = "typer", specifier = ">=0.16.0" }, ] -provides-extras = ["mcp", "codemode", "observability", "tools", "all"] +provides-extras = ["mcp", "codemode", "code-mode", "observability", "tools", "all"] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "ruff", specifier = ">=0.12.1" }, { name = "ty" }, @@ -1263,6 +1370,20 @@ 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 = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.3"