diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a47a4b1..717b9a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,36 @@ jobs: - name: Build and validate distribution run: task build + + leak-guard-new-commits: + name: task leak-guard:history (new commits) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout with full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install uv with cache + uses: astral-sh/setup-uv@v6 + with: + version: "0.10.12" + python-version: "3.13.3" + enable-cache: true + cache-dependency-glob: | + uv.lock + pyproject.toml + + # History published before the leak guard existed cannot be cleaned, + # so CI scans only the commits this pull request or push adds. + - name: Scan the commits this pull request or push adds + env: + REVISIONS: ${{ github.event_name == 'pull_request' && format('origin/{0}..HEAD', github.base_ref) || format('{0}..{1}', github.event.before, github.sha) }} + run: task leak-guard:history -- "$REVISIONS" diff --git a/.gitignore b/.gitignore index dcb1266..35d37da 100644 --- a/.gitignore +++ b/.gitignore @@ -219,5 +219,7 @@ __marimo__/ # custom private/ +# Leak guard: maintainers' private patterns, which name the material they guard +/.leak-patterns.private notebooks/forecast.csv config.yaml \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7baf21c..4b2124c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,6 +25,17 @@ repos: entry: uv run cz check --commit-msg-file language: system stages: [commit-msg] + - id: leak-guard-message + name: leak guard (commit message) + entry: uv run python scripts/leak_guard.py message + language: system + stages: [commit-msg] + - id: leak-guard + name: leak guard (working tree) + entry: task leak-guard + language: system + pass_filenames: false + always_run: true - id: ruff-check name: ruff check entry: uv run ruff check . diff --git a/.vscode/settings.json b/.vscode/settings.json index f426aa2..6c8c5d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "chat.editing.revealNextChangeOnResolve": false, - "python.defaultInterpreterPath": ".venv/bin/python", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "python-envs.pythonProjects": [ { "path": ".", diff --git a/AGENTS.md b/AGENTS.md index faeeb9d..9fe9340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1 @@ -Agents must ground their thoughts in facts, not assumptions: before planning, claiming, or editing, read the relevant material — source, configs, data, docs, test output — and only act on beliefs backed by something just read or run. Agents must run ad-hoc Python and CLI commands through `task run -- ` (which wraps `uv run` with the project's canonical parameters), never bare `python` or hand-written `uv run` invocations — this keeps every invocation from accidentally re-resolving or mutating the `.venv` and `uv.lock`. Agents must explain what each command will do and why it is being run before running it. Before asking the user a question, agents must first explain the corresponding context and terminology — what the question concerns, why it arises, and what any project-specific terms mean — so the user can answer without digging through the code themselves. Agents must run `task pre-commit` and fix all reported issues before reporting success to the user. After `task pre-commit` succeeds, show the diff and explain why each change is necessary before reporting success to the user. Agents must lead a response with its outcome — what now works, what the answer is, and anything the user must decide or act on — within the first few lines, so a reader who stops there misses nothing that changes what they do next; the evidence and the mechanism follow underneath, ordered by what the user cannot reconstruct alone, and what the user can already see is left out: no restatement of the request, no narration of the steps taken or the files visited, no walk through a diff that is shown anyway, and no closing summary that repeats the opening. That focus governs only what is *not* written — it never removes a `module.py:120` citation, a measured number, a side-effect or migration impact, an unrelated defect the agent noticed, or a question the user must answer; when focus and justification collide, the prose goes and the evidence stays. When what an agent reports is a set of items measured or classified along the same few dimensions — files and their status, config keys and their values, metrics before and after, options and their trade-offs — it must be presented as a Markdown table instead of prose or a bullet list, with short cells and only the columns that carry information; prose stays the right form for a single item, a narrative explanation, or an ordered procedure, because a one-row table, or one whose cells are sentences, is harder to read than the paragraph it replaced. Agents must never perform destructive or state-changing git operations unless the user explicitly instructs the agent to run that specific operation — no `git push --force`, no `git reset --hard`, no `git stash` (which hides uncommitted work), no branch/tag deletion, no history rewrites (`rebase`, `commit --amend` on published commits, `filter-branch`), no `git clean -fdx`, no `--no-verify` to bypass hooks, and no discarding of uncommitted work. Read-only inspection commands (`git status`, `git diff`, `git log`, `git show`) are always allowed. If a task seems to require a state-changing git operation and the user has not explicitly asked for it, stop and ask the user to run it. Agents must never mention temporary planning identifiers — phase names or numbers such as "Phase 3g", milestone, sprint, or ticket codes — in docstrings, comments, help or description strings, error messages, or test docstrings; those labels are deleted when the plan is retired and leave readers with a dangling reference nobody can decode, so describe the concept by its lasting behavior or stable configuration key instead (phase names belong only in roadmap and planning docs, and linking to such a doc by its actual filename is fine). Agents must not mention or recommend key rotation (rotating API keys, tokens, or other credentials) — the user takes care of key rotation themselves. Agents must keep comments in configuration files value-independent: a comment attached to a configuration parameter — in `config.yaml`/`config.sample.yaml`, a `Taskfile`, `pyproject.toml`, or any other settings file — may only state what remains true whatever the value is (what the parameter controls, what its options mean, which invariant or trade-off it participates in), and must never restate the current value, derive from it, or do arithmetic on it (`# 64 of 576 rows`, `# 2x the 288-step horizon`, `# keeps 90% of draws at full context`); configuration is retuned constantly, so such a comment turns false the moment somebody edits the value on the line below it and nothing catches the drift, so express a needed derivation as a rule over the parameter or record concrete numbers in a dated notes doc where they read as a historical observation. ripgrep (`rg`) is available (installed by `task setup` via `task install:ripgrep`); prefer it for fast code and text search. +Agents must ground their thoughts in facts, not assumptions: before planning, claiming, or editing, read the relevant material — source, configs, data, docs, test output — and only act on beliefs backed by something just read or run. Agents must run ad-hoc Python and CLI commands through `task run -- ` (which wraps `uv run` with the project's canonical parameters), never bare `python` or hand-written `uv run` invocations — this keeps every invocation from accidentally re-resolving or mutating the `.venv` and `uv.lock`. Agents must explain what each command will do and why it is being run before running it. Before asking the user a question, agents must first explain the corresponding context and terminology — what the question concerns, why it arises, and what any project-specific terms mean — so the user can answer without digging through the code themselves. Agents must run `task pre-commit` and fix all reported issues before reporting success to the user. After `task pre-commit` succeeds, show the diff and explain why each change is necessary before reporting success to the user. Agents must lead a response with its outcome — what now works, what the answer is, and anything the user must decide or act on — within the first few lines, so a reader who stops there misses nothing that changes what they do next; the evidence and the mechanism follow underneath, ordered by what the user cannot reconstruct alone, and what the user can already see is left out: no restatement of the request, no narration of the steps taken or the files visited, no walk through a diff that is shown anyway, and no closing summary that repeats the opening. That focus governs only what is *not* written — it never removes a `module.py:120` citation, a measured number, a side-effect or migration impact, an unrelated defect the agent noticed, or a question the user must answer; when focus and justification collide, the prose goes and the evidence stays. When what an agent reports is a set of items measured or classified along the same few dimensions — files and their status, config keys and their values, metrics before and after, options and their trade-offs — it must be presented as a Markdown table instead of prose or a bullet list, with short cells and only the columns that carry information; prose stays the right form for a single item, a narrative explanation, or an ordered procedure, because a one-row table, or one whose cells are sentences, is harder to read than the paragraph it replaced. Agents must never perform destructive or state-changing git operations unless the user explicitly instructs the agent to run that specific operation — no `git push --force`, no `git reset --hard`, no `git stash` (which hides uncommitted work), no branch/tag deletion, no history rewrites (`rebase`, `commit --amend` on published commits, `filter-branch`), no `git clean -fdx`, no `--no-verify` to bypass hooks, and no discarding of uncommitted work. Read-only inspection commands (`git status`, `git diff`, `git log`, `git show`) are always allowed. If a task seems to require a state-changing git operation and the user has not explicitly asked for it, stop and ask the user to run it. Agents must never mention temporary planning identifiers — phase names or numbers, milestone, sprint, or ticket codes — in docstrings, comments, help or description strings, error messages, or test docstrings; those labels are deleted when the plan is retired and leave readers with a dangling reference nobody can decode, so describe the concept by its lasting behavior or stable configuration key instead (phase names belong only in roadmap and planning docs, and linking to such a doc by its actual filename is fine). Agents must not mention or recommend key rotation (rotating API keys, tokens, or other credentials) — the user takes care of key rotation themselves. Agents must keep comments in configuration files value-independent: a comment attached to a configuration parameter — in `config.yaml`/`config.sample.yaml`, a `Taskfile`, `pyproject.toml`, or any other settings file — may only state what remains true whatever the value is (what the parameter controls, what its options mean, which invariant or trade-off it participates in), and must never restate the current value, derive from it, or do arithmetic on it (`# 64 of 576 rows`, `# 2x the 288-step horizon`, `# keeps 90% of draws at full context`); configuration is retuned constantly, so such a comment turns false the moment somebody edits the value on the line below it and nothing catches the drift, so express a needed derivation as a rule over the parameter or record concrete numbers in a dated notes doc where they read as a historical observation. ripgrep (`rg`) is available (installed by `task setup` via `task install:ripgrep`); prefer it for fast code and text search. This repository is public: `task leak-guard` (a pre-commit hook), a `commit-msg` hook, and a CI scan of every commit a pull request or push adds fail on private material, adding a maintainer's git-ignored `.leak-patterns.private` rules when present (see the README's *Leak guard* section); a passing guard does not make a change safe to publish, and agents must never weaken a rule to get a change through but remove the material instead. diff --git a/README.md b/README.md index 88dc6a2..194efc5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The SDK targets the DataRobot-hosted unstructured prediction route and the same - Import namespace: `jointfm_client` - Supported Python: `>=3.11` - Current SDK package version: `0.12.0` -- Current JointFM service schema: `schema_version="v5"` +- Current JointFM service schema: `schema_version="v6"` The public API shape is a synchronous low-level `JointFMClient` with `health()`, `health_instances()`, and `predict(payload)` methods plus high-level `forecast(...)`, `forecast_mean(...)`, `forecast_samples(...)`, and `forecast_quantiles(...)` helpers. The SDK is not a proxy service; callers use it as a local Python library that talks to the hosted or local JointFM endpoint. @@ -50,7 +50,7 @@ Example deployment configuration: deployment: datarobot_endpoint: https://app.datarobot.com/api/v2 datarobot_api_token: - schema_version: v5 + schema_version: v6 deployment_id: # Optional model-version pin; the SDK discovers it from /healthz when unset: # model_version: jointfm-inference:+ckpt. @@ -68,7 +68,7 @@ Equivalent `.env` deployment configuration: ```dotenv DATAROBOT_ENDPOINT=https://app.datarobot.com/api/v2 DATAROBOT_API_TOKEN= -JOINTFM_SCHEMA_VERSION=v5 +JOINTFM_SCHEMA_VERSION=v6 JOINTFM_DEPLOYMENT_ID= # Optional drift-detection pin; the SDK discovers the model version from /healthz when unset: # JOINTFM_MODEL_VERSION=jointfm-inference:+ckpt. @@ -78,7 +78,7 @@ Equivalent configuration for a JointFM inference service you run yourself: ```dotenv JOINTFM_LOCAL_BASE_URL=http://127.0.0.1:8080 -JOINTFM_SCHEMA_VERSION=v5 +JOINTFM_SCHEMA_VERSION=v6 # Optional drift-detection pin; the SDK discovers the model version from /healthz when unset: # JOINTFM_MODEL_VERSION=jointfm-inference:+ckpt. ``` @@ -176,7 +176,7 @@ The bootstrap helper resolves the nearest src-layout Python project root, switch The current forecast request contract is: -- `schema_version`: exactly `"v5"`, configured as `JOINTFM_SCHEMA_VERSION` for `from_env()` clients +- `schema_version`: exactly `"v6"`, configured as `JOINTFM_SCHEMA_VERSION` for `from_env()` clients - `model_version`: exact model version advertised by `/healthz` or otherwise selected by the caller. Optional for `from_env()` clients: when `JOINTFM_MODEL_VERSION` is unset the SDK reads it from `/healthz` on first use; when set it acts as a drift-detection pin - `query_mode`: `"forecast"` for the unconditional forecast, or `"condition"` for the forecast given conditions on some columns; the high-level helpers set it from whether a `condition` was passed - `return_mode`: one of `"mean"`, `"samples"`, or `"quantiles"` @@ -232,7 +232,7 @@ Successful forecast responses preserve `schema_version`, `image_version`, `model ```json { - "schema_version": "v5", + "schema_version": "v6", "errors": [ { "code": "VALIDATION_ERROR", @@ -247,7 +247,7 @@ Known error codes are `VALIDATION_ERROR`, `UNSUPPORTED_HEAD_QUERY_COMBINATION`, ## Compatibility Policy -The SDK supports only `schema_version="v5"`. `validate_service_metadata()` checks `/healthz` metadata and raises typed compatibility errors before prediction if the service advertises a different schema, an unexpected model version, mode capabilities outside the recorded service contract, or an unsupported `decoding_strategy`. Return modes and time-index modes must match the SDK's lists exactly. Query modes and condition kinds are derived by the service from the mounted head, so a deployment may advertise fewer of them than the SDK knows; it must advertise at least one query mode and nothing the SDK does not know. +The SDK supports only `schema_version="v6"`. `validate_service_metadata()` checks `/healthz` metadata and raises typed compatibility errors before prediction if the service advertises a different schema, an unexpected model version, mode capabilities outside the recorded service contract, or an unsupported `decoding_strategy`. Return modes and time-index modes must match the SDK's lists exactly. Query modes and condition kinds are derived by the service from the mounted head, so a deployment may advertise fewer of them than the SDK knows; it must advertise at least one query mode and nothing the SDK does not know. Callers should pass an expected `model_version` when they already know which deployment artifact they intend to use. A mismatch is treated as a hard compatibility error rather than silently downgrading, guessing, or retrying another model. @@ -289,7 +289,7 @@ Create `.env` from `.env.sample` or set the same values in your shell. A hosted ```dotenv DATAROBOT_ENDPOINT=https://app.datarobot.com/api/v2 DATAROBOT_API_TOKEN= -JOINTFM_SCHEMA_VERSION=v5 +JOINTFM_SCHEMA_VERSION=v6 JOINTFM_DEPLOYMENT_ID= # Or: JOINTFM_DEPLOYMENT_IDS=, # Optional drift-detection pin; the SDK discovers the model version from /healthz when unset: @@ -354,6 +354,8 @@ Use `forecast_samples(...)` for sampled trajectories or `forecast_quantiles(...) - `task release`: cut a SemVer release with Commitizen (writes `CHANGELOG.md`, bumps versions, creates tag) - `task release:publish`: push the release commit and its tag to `origin`, which triggers the PyPI publish workflow - `task pre-commit`: run every configured pre-commit hook +- `task leak-guard`: fail when a file in the working tree carries private material in its path or content +- `task leak-guard:history`: fail when a commit carries private material in its message, paths, or files; pass revisions to narrow the scan (`task leak-guard:history -- origin/main..HEAD`) Contributors do not need to add copyright or license headers manually. The `insert-license` pre-commit hook runs [skywalking-eyes](https://github.com/apache/skywalking-eyes) (via the `apache/skywalking-eyes` Docker image, so a running Docker daemon is required) to stamp the standard Apache-2.0 header (`Copyright 2026 DataRobot, Inc. and its affiliates.` followed by the standard "Licensed under the Apache License, Version 2.0" notice) into every `.py` file, and the companion `insert-license-notebooks` hook stamps the same notice into a leading markdown cell of every notebook the first time you run `task pre-commit`. Verify the headers are present at any time with `task license-check`. @@ -365,6 +367,12 @@ sudo usermod -aG docker "$USER" Group membership is captured when a process starts, so it must be in effect **before** the editor or its language server launches — start a fresh login session (or reboot) after adding yourself to the group, otherwise the commit hook inherits the old groups and fails with a Docker permission error. +### Leak guard + +This repository is public, so `scripts/leak_guard.py` fails on material that must stay private: proprietary file headers, DataRobot object IDs, internal hosts and private addresses, links to non-public repositories, planning labels, and credential shapes. It runs as a pre-commit hook over the working tree, as a `commit-msg` hook over each message, and in CI over every commit a pull request or push adds. Commits published before the guard existed cannot be cleaned, so CI does not scan the full history. + +Some rules cannot be published because they name what they guard. Maintainers keep those in a git-ignored `.leak-patterns.private` at the repository root: one Python regular expression per line, blank lines and `#` comment lines skipped. The guard reports whether it loaded the file and runs the public rules alone when it is absent, as in CI. A dangling link fails. A passing guard does not prove a change safe to publish. + ## Versioning & Commits The package follows strict [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are cut with [Commitizen](https://commitizen-tools.github.io/commitizen/), driven by [Conventional Commits](https://www.conventionalcommits.org/), so the commit log is the source of truth for what a release contains. diff --git a/Taskfile.yaml b/Taskfile.yaml index 1deb6d8..fb3cbc9 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -48,6 +48,26 @@ tasks: desc: Run ty static type checks cmd: uv run ty check --output-format concise --error-on-warning + leak-guard: + desc: Fail when a tracked or untracked, non-ignored file carries private material in its path or content + cmd: | + if uv run python scripts/leak_guard.py tree; then + echo "OK: leak guard found no private material in the working tree" + else + echo "FAIL: leak guard found private material in the working tree" + exit 1 + fi + + leak-guard:history: + desc: "Fail when a commit carries private material in its message, paths, or file contents; scans every ref unless given revisions (task leak-guard:history -- origin/main..HEAD)" + cmd: | + if uv run python scripts/leak_guard.py history {{.CLI_ARGS}}; then + echo "OK: leak guard found no private material in the scanned history" + else + echo "FAIL: leak guard found private material in the scanned history" + exit 1 + fi + test: desc: Run the unit test suite cmd: uv run pytest -n auto @@ -208,6 +228,7 @@ tasks: if [ -s .git/COMMIT_EDITMSG ]; then uv run cz check --commit-msg-file .git/COMMIT_EDITMSG MSG_STATUS=$? + uv run python scripts/leak_guard.py message .git/COMMIT_EDITMSG || MSG_STATUS=1 fi if [ "$PRE_COMMIT_STATUS" -ne 0 ] || [ "$MSG_STATUS" -ne 0 ]; then exit 1 diff --git a/config.sample.yaml b/config.sample.yaml index 9c6c822..645c19a 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -54,7 +54,7 @@ transport: - X-DataRobot-Execution-ID user_agent_header: User-Agent forecast: - schema_version: v5 + schema_version: v6 query_mode: forecast return_mode: mean time_index_mode: ordinal diff --git a/docs/api-reference.md b/docs/api-reference.md index 01b30f6..6787ab8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,7 +26,7 @@ This reference covers the supported public Python surface exported by `jointfm_c | `HealthMetadata` | Typed service-health payload with service status, schema and model versions, checkpoint metadata, device, head, `decoding_strategy`, advertised query modes, `supported_condition_kinds` (empty when the deployment cannot condition), return modes, time-index modes, time-index encoding, `max_sample_count`, `max_concurrent_requests` (how many requests the container serves at once; a further request waits for a free slot), an optional `data_generation` block carrying advertised capacity limits, and `supported_metadata_traits` (the expert-hint traits the mounted model reads; empty when the field is absent). The container exposes it on `GET /healthz` for direct local access and as the response to `POST {"request_type": "health"}` on the unstructured prediction route for DataRobot-hosted deployments. Each endpoint reports only its own capabilities. | | `InstanceHealth` | One configured instance's probe outcome: `instance_id` (the deployment ID of a hosted instance, the normalized base URL of a local one), optional `metadata` (`HealthMetadata` when reachable), and optional `error` when the peer was skipped. | | `HealthInstances` | Client aggregation of `health_instances()`: `instances` (one `InstanceHealth` per configured instance), `max_sample_count` (sum of reachable caps, one request per instance), `topology` as `(count, cap)` pairs sorted by descending cap, and `topology_label` such as `2x5000` or `1x7000, 1x3000`. Unavailable peers stay in `instances` but are omitted from the sum and topology. | -| `DataGenerationCapabilities` | Optional service-health block describing the deployed checkpoint's data-generation capacity. Fields are `sampler_type`, `min_series`, `max_series`, `t_input`, `t_output`, `n_input`, and `n_output`. `min_series` and `max_series` bound the total number of series in one request, features and targets counted together — there is no separate per-role cap. | +| `DataGenerationCapabilities` | Optional service-health block describing the deployed checkpoint's data-generation capacity. Fields are `min_series`, `max_series`, `t_input`, `t_output`, `n_input`, and `n_output`. `min_series` and `max_series` bound the total number of series in one request, features and targets counted together — there is no separate per-role cap. | | `UtilizationStage` | The burst of `run_max_utilization_check`: `name`, the total requested `n_samples`, the `request_count` of maximal requests sent at once, `wallclock_seconds` (an upper bound on each request's duration), and `errors`, one entry per request that ended in a JointFM error or a wrong sample count. `passed` is true without errors. | | `MaxUtilizationReport` | Result of `run_max_utilization_check`: the `envelope` the requests were sized from, `decoding_strategy`, `topology_label`, `request_slots` (the sum of `max_concurrent_requests` over reachable endpoints), `unavailable_instances`, `read_timeout_seconds`, `health_seconds`, and the `concurrent_saturation` stage. `passed` requires every endpoint to be reachable and the stage to pass; `format_lines()` renders the report ending in a `VERDICT:` line. | | `ForecastPlan` | Validated forecast plan returned by `plan_forecast_columns`. Fields are `columns` (ordered `ColumnSpec` tuple), `feature_columns`, `target_columns` (both as the caller declared them), and `requested_columns` (the caller's target list). | @@ -82,7 +82,7 @@ All SDK-specific exceptions inherit from `JointFMError`. | `JointFMHTTPStatusError` | The service returns an HTTP error status. | | `JointFMServiceError` | A response body contains non-empty JointFM `errors`, including the case where HTTP status unexpectedly succeeded. | | `JointFMCompatibilityError` | Base class for fail-fast service compatibility failures. | -| `UnsupportedSchemaVersionError` | The service or response advertises a schema version other than `v5`. | +| `UnsupportedSchemaVersionError` | The service or response advertises a schema version other than `v6`. | | `UnsupportedModelVersionError` | The service or response model version differs from the configured or requested version. | | `UnsupportedServiceContractError` | The service-health payload advertises mode capabilities or a `decoding_strategy` outside the recorded service contract, or a condition request targets a deployment that does not advertise the `condition` mode or one of the request's condition kinds. | @@ -131,7 +131,7 @@ All SDK-specific exceptions inherit from `JointFMError`. | --- | --- | --- | | `DATAROBOT_ENDPOINT` | Hosted calls | HTTPS DataRobot API v2 endpoint, normalized without a trailing slash and required to end in `/api/v2`. | | `DATAROBOT_API_TOKEN` | Hosted calls | Non-empty, whitespace-free API token used in the hosted bearer authorization header. | -| `JOINTFM_SCHEMA_VERSION` | Hosted calls | Request schema pin. The SDK supports only `v5`. | +| `JOINTFM_SCHEMA_VERSION` | Hosted calls | Request schema pin. The SDK supports only `v6`. | | `JOINTFM_MODEL_VERSION` | Optional | Exact JointFM deployment model version expected from the service-health payload and prediction responses. When unset, the SDK discovers the version from the service-health payload on first use; when set, a mismatch is a hard compatibility error. | | `JOINTFM_DEPLOYMENT_ID` | One selector | Deployment ID used to build hosted health and prediction URLs. | | `JOINTFM_DEPLOYMENT_IDS` | One selector | Comma-separated hosted deployment IDs for round-robin load balancing (at least two unique IDs). Mutually exclusive with other selectors. Peers must share `model_version` and `checkpoint_version`. `health()` uses the minimum reachable `max_sample_count` as the sample-batch cap; `health_instances()` sums reachable caps, one request per instance, and reports topology. Sample forecasts are split evenly across all reachable instances. | @@ -163,7 +163,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE` | Field | Required | Description | | --- | --- | --- | | `request_type` | Optional | One of `"predict"` (default) or `"health"`. Forecast requests omit this field or set it to `"predict"`. | -| `schema_version` | Yes | Must be `"v5"`. | +| `schema_version` | Yes | Must be `"v6"`. | | `model_version` | Yes | Exact deployed model version expected by the caller. | | `query_mode` | Yes | `"forecast"` for the unconditional forecast or `"condition"` for the forecast given the request's `conditions`. | | `return_mode` | Yes | One of `"mean"`, `"samples"`, `"quantiles"`, or `"log_prob"`, covered by the `forecast_mean`, `forecast_samples`, `forecast_quantiles`, and `forecast_log_prob` helpers. | @@ -207,7 +207,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE` | Field | Description | | --- | --- | -| `schema_version` | Response schema, expected to be `"v5"`. | +| `schema_version` | Response schema, expected to be `"v6"`. | | `image_version` | Service image version that produced the response. | | `model_version` | Model version that produced the response. | | `checkpoint_version` | Checkpoint version that produced the response. | @@ -233,7 +233,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE` | Field | Description | | --- | --- | | `status` | Service status string. | -| `schema_version` | Advertised schema version. The SDK requires `v5`. | +| `schema_version` | Advertised schema version. The SDK requires `v6`. | | `image_version` | Running service image version. | | `model_version` | Running model version. | | `checkpoint_version` | Loaded checkpoint version. | @@ -250,7 +250,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE` | `supported_metadata_traits` | Expert-hint traits the mounted model reads; empty when it reads none. A deployment that does not send the field predates hints and parses as supporting none. Forecasts with a `traits` entry outside this list are refused locally. | | `max_sample_count` | Maximum sample-count budget the service accepts in a single prediction. The client reads it during health probes and batches oversized sample requests locally, so the service never has to reject them. | | `max_concurrent_requests` | How many prediction requests the container serves at once; a further request waits for a free slot. `run_max_utilization_check` fills every slot; the forecast helpers send at most one request per endpoint at a time. | -| `data_generation` | Optional capability block describing the deployed checkpoint's advertised data-generation capacity. Absent on legacy checkpoints; present payloads expose `sampler_type`, `min_series`, `max_series`, `t_input`, `t_output`, `n_input`, and `n_output`. | +| `data_generation` | Optional capability block describing the deployed checkpoint's advertised data-generation capacity. Absent on legacy checkpoints; present payloads expose `min_series`, `max_series`, `t_input`, `t_output`, `n_input`, and `n_output`. | ## Docstring Enforcement diff --git a/notebooks/forecast_condition.ipynb b/notebooks/forecast_condition.ipynb index e1078a3..21012f8 100644 --- a/notebooks/forecast_condition.ipynb +++ b/notebooks/forecast_condition.ipynb @@ -552,9 +552,9 @@ ], "metadata": { "kernelspec": { - "display_name": "jointfm-client (3.13.x)", + "display_name": "Python (joint-client-python)", "language": "python", - "name": "python3" + "name": "joint-client-python" }, "language_info": { "codemirror_mode": { @@ -566,7 +566,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.-1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/notebooks/forecast_csv.ipynb b/notebooks/forecast_csv.ipynb index 17d0a56..7147220 100644 --- a/notebooks/forecast_csv.ipynb +++ b/notebooks/forecast_csv.ipynb @@ -152,9 +152,9 @@ ], "metadata": { "kernelspec": { - "display_name": "jointfm-client (3.13.x)", + "display_name": "Python (joint-client-python)", "language": "python", - "name": "python3" + "name": "joint-client-python" }, "language_info": { "codemirror_mode": { @@ -166,7 +166,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.-1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/notebooks/forecast_mean.ipynb b/notebooks/forecast_mean.ipynb index 3572706..bf0d473 100644 --- a/notebooks/forecast_mean.ipynb +++ b/notebooks/forecast_mean.ipynb @@ -113,9 +113,9 @@ ], "metadata": { "kernelspec": { - "display_name": "jointfm-client (3.13.x)", + "display_name": "Python (joint-client-python)", "language": "python", - "name": "python3" + "name": "joint-client-python" }, "language_info": { "codemirror_mode": { @@ -127,7 +127,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.-1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/notebooks/forecast_quantiles.ipynb b/notebooks/forecast_quantiles.ipynb index 16b83c0..97c9227 100644 --- a/notebooks/forecast_quantiles.ipynb +++ b/notebooks/forecast_quantiles.ipynb @@ -115,9 +115,9 @@ ], "metadata": { "kernelspec": { - "display_name": "jointfm-client (3.13.x)", + "display_name": "Python (joint-client-python)", "language": "python", - "name": "python3" + "name": "joint-client-python" }, "language_info": { "codemirror_mode": { @@ -129,7 +129,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.-1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/notebooks/forecast_samples.ipynb b/notebooks/forecast_samples.ipynb index 9e325f3..d8dd9d7 100644 --- a/notebooks/forecast_samples.ipynb +++ b/notebooks/forecast_samples.ipynb @@ -115,9 +115,9 @@ ], "metadata": { "kernelspec": { - "display_name": "jointfm-client (3.13.x)", + "display_name": "Python (joint-client-python)", "language": "python", - "name": "python3" + "name": "joint-client-python" }, "language_info": { "codemirror_mode": { @@ -129,7 +129,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.-1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/notebooks/service_health.ipynb b/notebooks/service_health.ipynb index a104b1d..17b3d6d 100644 --- a/notebooks/service_health.ipynb +++ b/notebooks/service_health.ipynb @@ -124,9 +124,9 @@ ], "metadata": { "kernelspec": { - "display_name": "jointfm-client (3.13.x)", + "display_name": "Python (joint-client-python)", "language": "python", - "name": "python3" + "name": "joint-client-python" }, "language_info": { "codemirror_mode": { @@ -138,7 +138,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.-1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index 7a625ad..6e23d3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,9 @@ include = [ "/src/jointfm_client", ] +[tool.pytest.ini_options] +pythonpath = ["scripts"] + [tool.ruff] extend-exclude = ["private"] @@ -118,6 +121,7 @@ convention = "google" [tool.ty] environment.python-version = "3.11" +environment.extra-paths = ["scripts"] [tool.ty.src] exclude = ["build/", "dist/", "private/"] diff --git a/scripts/leak_guard.py b/scripts/leak_guard.py new file mode 100644 index 0000000..c2c2fd7 --- /dev/null +++ b/scripts/leak_guard.py @@ -0,0 +1,364 @@ +# Copyright 2026 DataRobot, Inc. and its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fail when material that must stay private is about to be published. + +The guard matches file paths, file contents, and commit messages line by line +against two rule sets. The public rules below are safe to publish. The private +rules name the very material they guard, so maintainers keep them outside +version control in the git-ignored ``PRIVATE_RULES_FILE``; the guard reports +whether it loaded them and runs the public rules alone when the file is absent. + +Modes: + +- ``tree``: every tracked file and every untracked file that is not ignored, as + it is in the working tree. +- ``message ``: one commit message, as the ``commit-msg`` hook passes it. +- ``history [ ...]``: every commit message, path, and file version + reachable from any ref, because a published history cannot be cleaned + afterwards. Revisions such as ``origin/main..HEAD`` narrow the scan to the + commits they select and the file versions those commits introduce, for a + repository whose history was published before the guard existed. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path + +PRIVATE_RULES_FILE = ".leak-patterns.private" +BINARY_SNIFF_BYTES = 8192 +EVERY_REF = ("--all",) +PUBLIC_DATAROBOT_REPOSITORIES = ("joint-client-python", "joint-inference-python") +PUBLIC_DATAROBOT_HOSTS = ("app", "app.eu", "app.jp", "docs", "www") + + +@dataclass(frozen=True) +class Rule: + """One named pattern that marks a line as private material.""" + + name: str + pattern: re.Pattern[str] + + +@dataclass(frozen=True) +class Finding: + """One rule match: where it is, which rule matched, and the matched text.""" + + location: str + line: int | None + rule: str + excerpt: str + + def render(self) -> str: + """Return the finding as one report line.""" + position = ( + self.location if self.line is None else f"{self.location}:{self.line}" + ) + return f"{position}: [{self.rule}] {self.excerpt}" + + +def _alternatives(names: Iterable[str]) -> str: + return "|".join(re.escape(name) for name in names) + + +# Every pattern is written so that its own source text does not match it, which +# lets the guard scan this file without an exemption. +PUBLIC_RULES = ( + Rule( + "proprietary-header", + re.compile( + r"(?i)\bproprietary\s+and\s+confidential\b|LicenseRef-\s*Proprietary" + ), + ), + Rule("datarobot-object-id", re.compile(r"\b[0-9a-f]{24}\b")), + Rule( + "internal-host", + re.compile( + r"(?i)\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:internal|corp|intranet|lan)\b" + r"|\bip(?:-\d{1,3}){4}\b" + r"|\b[a-z0-9.-]+\.amazonaws\.com\b" + ), + ), + Rule( + "private-ip-address", + re.compile( + r"\b(?:10(?:\.\d{1,3}){3}" + r"|192\.168(?:\.\d{1,3}){2}" + r"|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})\b" + ), + ), + Rule( + "datarobot-host", + re.compile( + rf"(?i)\b(?!(?:{_alternatives(PUBLIC_DATAROBOT_HOSTS)})\.datarobot\.com\b)" + r"[a-z0-9-]+(?:\.[a-z0-9-]+)*\.datarobot\.com\b" + ), + ), + Rule( + "private-repository-link", + re.compile( + rf"(?i)github\.com/datarobot/(?!(?:{_alternatives(PUBLIC_DATAROBOT_REPOSITORIES)})(?![\w.-]))[\w.-]+" + ), + ), + Rule( + "planning-label", + re.compile(r"(?i)\b(?:phase|milestone|sprint)[\s_-]*\d+[a-z0-9]*\b"), + ), + Rule( + "credential", + re.compile( + r"\bgh[pousr]_[A-Za-z0-9]{36,}\b" + r"|\bgithub_pat_\w{22,}" + r"|\bAKIA[0-9A-Z]{16}\b" + r"|\bhf_[A-Za-z0-9]{30,}\b" + r"|-----BEGIN [A-Z ]*PRIVATE KEY-----" + ), + ), +) +PRIVATE_RULES_FILE_RULE = Rule( + "private-rules-file-published", + re.compile(rf"(?:^|/){re.escape(PRIVATE_RULES_FILE)}$"), +) + + +def load_private_rules(path: Path) -> tuple[Rule, ...]: + """Read the private rules: one Python regular expression per line. + + Blank lines and lines starting with ``#`` are skipped. Each rule is named + after its line number, so a report never repeats the private pattern itself. + + Raises: + OSError: when ``path`` cannot be read, including a dangling symbolic link. + re.error: when a line is not a valid regular expression. + ValueError: when the file holds no pattern. + """ + rules: list[Rule] = [] + for number, raw in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + text = raw.strip() + if not text or text.startswith("#"): + continue + rules.append(Rule(f"private:{number}", re.compile(text))) + if not rules: + raise ValueError(f"{path} holds no patterns; delete it or add patterns") + return tuple(rules) + + +def resolve_rules(root: Path) -> tuple[tuple[Rule, ...], str]: + """Return the content rules for ``root`` and a line saying which sets are active. + + A dangling ``PRIVATE_RULES_FILE`` link is not treated as absent: loading it + raises, so a moved private list never silently drops the private rules. + """ + path = root / PRIVATE_RULES_FILE + if not path.is_symlink() and not path.exists(): + return ( + PUBLIC_RULES, + f"leak guard: public rules only; {PRIVATE_RULES_FILE} is absent", + ) + private = load_private_rules(path) + status = f"leak guard: public rules and {len(private)} private rules from {PRIVATE_RULES_FILE}" + return PUBLIC_RULES + private, status + + +def scan_text(location: str, text: str, rules: Sequence[Rule]) -> list[Finding]: + """Match every line of ``text`` against every rule.""" + findings: list[Finding] = [] + for number, line in enumerate(text.splitlines(), start=1): + for rule in rules: + match = rule.pattern.search(line) + if match is not None: + findings.append(Finding(location, number, rule.name, match.group(0))) + return findings + + +def scan_path(path: str, rules: Sequence[Rule]) -> list[Finding]: + """Match a repository-relative path against the rules and the private-file rule.""" + findings: list[Finding] = [] + for rule in (*rules, PRIVATE_RULES_FILE_RULE): + match = rule.pattern.search(path) + if match is not None: + findings.append(Finding(path, None, rule.name, match.group(0))) + return findings + + +def _decode_text(data: bytes) -> str | None: + """Return ``data`` as text, or ``None`` for binary content.""" + if b"\0" in data[:BINARY_SNIFF_BYTES]: + return None + return data.decode("utf-8", errors="replace") + + +def _git(root: Path, *args: str, stdin: bytes | None = None) -> bytes: + completed = subprocess.run( + ["git", "-C", str(root), *args], input=stdin, capture_output=True, check=True + ) + return completed.stdout + + +def scan_tree(root: Path, rules: Sequence[Rule]) -> list[Finding]: + """Scan every tracked and every untracked, non-ignored file in the working tree. + + A symbolic link is scanned by its target path, which is what git stores. A + tracked file deleted from the working tree contributes only its path. + """ + listing = _git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard") + paths = sorted({path for path in listing.decode("utf-8").split("\0") if path}) + findings: list[Finding] = [] + for relative in paths: + findings.extend(scan_path(relative, rules)) + full = root / relative + if full.is_symlink(): + text: str | None = os.readlink(full) + elif full.exists(): + text = _decode_text(full.read_bytes()) + else: + continue + if text is not None: + findings.extend(scan_text(relative, text, rules)) + return findings + + +def scan_message(path: Path, rules: Sequence[Rule]) -> list[Finding]: + """Scan a commit message file, skipping the ``#`` comment lines git strips.""" + lines = path.read_text(encoding="utf-8").splitlines() + kept = "\n".join("" if line.startswith("#") else line for line in lines) + return scan_text("commit message", kept, rules) + + +def _reachable_blobs(root: Path, revisions: Sequence[str]) -> dict[str, str]: + """Map every blob ``revisions`` reach to the first path it was seen at.""" + blobs: dict[str, str] = {} + listing = _git(root, "rev-list", "--objects", *revisions).decode("utf-8") + entries = [line.split(" ", 1) for line in listing.splitlines()] + named = [(entry[0], entry[1]) for entry in entries if len(entry) == 2] + if not named: + return blobs + request = "".join(f"{sha}\n" for sha, _ in named).encode("ascii") + kinds = _git( + root, "cat-file", "--batch-check=%(objectname) %(objecttype)", stdin=request + ) + for (sha, path), line in zip( + named, kinds.decode("ascii").splitlines(), strict=True + ): + if line.split(" ")[1] == "blob": + blobs.setdefault(sha, path) + return blobs + + +def _read_blobs(root: Path, shas: Sequence[str]) -> dict[str, bytes]: + """Read many blobs through one ``git cat-file --batch`` call.""" + output = _git( + root, + "cat-file", + "--batch", + stdin="".join(f"{sha}\n" for sha in shas).encode("ascii"), + ) + contents: dict[str, bytes] = {} + offset = 0 + for sha in shas: + header_end = output.index(b"\n", offset) + size = int(output[offset:header_end].split(b" ")[2]) + start = header_end + 1 + contents[sha] = output[start : start + size] + offset = start + size + 1 + return contents + + +def scan_history( + root: Path, rules: Sequence[Rule], revisions: Sequence[str] = EVERY_REF +) -> list[Finding]: + """Scan every commit message, path, and blob that ``revisions`` select. + + ``revisions`` are passed to ``git log`` and ``git rev-list --objects``; the + default selects everything reachable from any ref. A range such as + ``base..HEAD`` selects the commits after ``base`` and only the blobs they + reach that ``base`` does not. An unknown revision fails in git. + """ + findings: list[Finding] = [] + log = _git(root, "log", *revisions, "--format=%H%x00%B%x00").decode( + "utf-8", errors="replace" + ) + fields = log.split("\0") + for sha, message in zip(fields[0::2], fields[1::2], strict=False): + commit = sha.strip() + if commit: + findings.extend(scan_text(f"commit {commit[:12]} message", message, rules)) + blobs = _reachable_blobs(root, revisions) + for path in sorted(set(blobs.values())): + findings.extend(scan_path(path, rules)) + contents = _read_blobs(root, sorted(blobs)) + for sha, path in sorted(blobs.items(), key=lambda item: item[1]): + text = _decode_text(contents[sha]) + if text is not None: + findings.extend(scan_text(f"{path} (blob {sha[:12]})", text, rules)) + return findings + + +def repository_root() -> Path: + """Return the top level of the git repository the guard runs in.""" + return Path( + _git(Path.cwd(), "rev-parse", "--show-toplevel").decode("utf-8").strip() + ) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the guard and return 1 when any rule matched.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + modes = parser.add_subparsers(dest="mode", required=True) + modes.add_parser( + "tree", help="scan the working tree's tracked and untracked, non-ignored files" + ) + history = modes.add_parser( + "history", help="scan everything reachable from any ref, or the given revisions" + ) + history.add_argument( + "revisions", + nargs="*", + help="git revisions or ranges to scan instead of every ref, e.g. origin/main..HEAD", + ) + message = modes.add_parser("message", help="scan one commit message file") + message.add_argument("message_file", type=Path) + args = parser.parse_args(argv) + + root = repository_root() + rules, status = resolve_rules(root) + print(status) + if args.mode == "tree": + findings = scan_tree(root, rules) + elif args.mode == "history": + findings = scan_history(root, rules, args.revisions or EVERY_REF) + else: + findings = scan_message(args.message_file, rules) + for finding in findings: + print(finding.render()) + if findings: + print( + f"leak guard: {len(findings)} finding(s); remove the material before it is committed or pushed" + ) + return 1 + print("leak guard: no findings") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/jointfm_client/contract.py b/src/jointfm_client/contract.py index 21a2b73..8931ed7 100644 --- a/src/jointfm_client/contract.py +++ b/src/jointfm_client/contract.py @@ -39,7 +39,7 @@ # pyproject.toml the way a hand-maintained literal here did. PACKAGE_VERSION: Final = importlib.metadata.version(DISTRIBUTION_NAME) -SCHEMA_VERSION: Final = "v5" +SCHEMA_VERSION: Final = "v6" # The service sums and averages its log densities in the model's own precision, # so its summaries differ from a recomputation in the last few digits. DERIVED_SCORE_TOLERANCE: Final = 1e-6 @@ -759,9 +759,10 @@ class DataGenerationCapabilities: is admissible on width alone. ``n_input`` is the longest history and ``n_output`` the longest forecast horizon the deployment was trained to handle; both are upper bounds, and smaller requests are accepted. + ``t_input`` and ``t_output`` are the simulated time spans of that history + and horizon during training, which give the time scale the model expects. """ - sampler_type: str min_series: int max_series: int t_input: float @@ -790,10 +791,6 @@ def from_payload(cls, payload: Mapping[str, Any]) -> Self: f"data_generation.max_series; got {min_series} > {max_series}" ) return cls( - sampler_type=_require_string( - payload.get("sampler_type"), - field="data_generation.sampler_type", - ), min_series=min_series, max_series=max_series, t_input=_require_positive_float( diff --git a/tests/fixtures/condition_interval_response.json b/tests/fixtures/condition_interval_response.json index d076318..58b740f 100644 --- a/tests/fixtures/condition_interval_response.json +++ b/tests/fixtures/condition_interval_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/condition_log_prob_request.json b/tests/fixtures/condition_log_prob_request.json index c3e5c74..e43e930 100644 --- a/tests/fixtures/condition_log_prob_request.json +++ b/tests/fixtures/condition_log_prob_request.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "condition", "return_mode": "log_prob", diff --git a/tests/fixtures/condition_log_prob_response.json b/tests/fixtures/condition_log_prob_response.json index a78c15c..7bb105c 100644 --- a/tests/fixtures/condition_log_prob_response.json +++ b/tests/fixtures/condition_log_prob_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/condition_mean_request.json b/tests/fixtures/condition_mean_request.json index ecd9608..0d9c688 100644 --- a/tests/fixtures/condition_mean_request.json +++ b/tests/fixtures/condition_mean_request.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "condition", "return_mode": "mean", diff --git a/tests/fixtures/condition_mean_response.json b/tests/fixtures/condition_mean_response.json index db2d7ca..656b93b 100644 --- a/tests/fixtures/condition_mean_response.json +++ b/tests/fixtures/condition_mean_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/forecast_log_prob_request.json b/tests/fixtures/forecast_log_prob_request.json index ccd069d..e9bf94a 100644 --- a/tests/fixtures/forecast_log_prob_request.json +++ b/tests/fixtures/forecast_log_prob_request.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "forecast", "return_mode": "log_prob", diff --git a/tests/fixtures/forecast_log_prob_response.json b/tests/fixtures/forecast_log_prob_response.json index 548207b..33868b4 100644 --- a/tests/fixtures/forecast_log_prob_response.json +++ b/tests/fixtures/forecast_log_prob_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/forecast_mean_request.json b/tests/fixtures/forecast_mean_request.json index 18d43ac..d61352f 100644 --- a/tests/fixtures/forecast_mean_request.json +++ b/tests/fixtures/forecast_mean_request.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "forecast", "return_mode": "mean", diff --git a/tests/fixtures/forecast_mean_response.json b/tests/fixtures/forecast_mean_response.json index dab2ecd..11f99a5 100644 --- a/tests/fixtures/forecast_mean_response.json +++ b/tests/fixtures/forecast_mean_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/forecast_quantiles_request.json b/tests/fixtures/forecast_quantiles_request.json index a1551df..eeeb0b2 100644 --- a/tests/fixtures/forecast_quantiles_request.json +++ b/tests/fixtures/forecast_quantiles_request.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "forecast", "return_mode": "quantiles", diff --git a/tests/fixtures/forecast_quantiles_response.json b/tests/fixtures/forecast_quantiles_response.json index c61c0da..63809ad 100644 --- a/tests/fixtures/forecast_quantiles_response.json +++ b/tests/fixtures/forecast_quantiles_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/forecast_samples_request.json b/tests/fixtures/forecast_samples_request.json index 7b06d74..6234939 100644 --- a/tests/fixtures/forecast_samples_request.json +++ b/tests/fixtures/forecast_samples_request.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "forecast", "return_mode": "samples", diff --git a/tests/fixtures/forecast_samples_response.json b/tests/fixtures/forecast_samples_response.json index 45a10b4..6df8a34 100644 --- a/tests/fixtures/forecast_samples_response.json +++ b/tests/fixtures/forecast_samples_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", diff --git a/tests/fixtures/health_metadata.json b/tests/fixtures/health_metadata.json index 5ede7e2..1a79260 100644 --- a/tests/fixtures/health_metadata.json +++ b/tests/fixtures/health_metadata.json @@ -1,6 +1,6 @@ { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", @@ -21,7 +21,6 @@ "max_sample_count": 4096, "max_concurrent_requests": 1, "data_generation": { - "sampler_type": "studentt", "min_series": 1, "max_series": 16, "t_input": 10.0, diff --git a/tests/fixtures/input_size_exceeded_response.json b/tests/fixtures/input_size_exceeded_response.json index ab5be10..e29c49d 100644 --- a/tests/fixtures/input_size_exceeded_response.json +++ b/tests/fixtures/input_size_exceeded_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "errors": [ { "code": "INPUT_SIZE_EXCEEDED", diff --git a/tests/fixtures/model_version_mismatch_response.json b/tests/fixtures/model_version_mismatch_response.json index 321c7fa..51d58ae 100644 --- a/tests/fixtures/model_version_mismatch_response.json +++ b/tests/fixtures/model_version_mismatch_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "errors": [ { "code": "MODEL_VERSION_MISMATCH", diff --git a/tests/fixtures/schema_version_mismatch_response.json b/tests/fixtures/schema_version_mismatch_response.json index 5cd59f7..5c9bde2 100644 --- a/tests/fixtures/schema_version_mismatch_response.json +++ b/tests/fixtures/schema_version_mismatch_response.json @@ -3,7 +3,7 @@ "errors": [ { "code": "SCHEMA_VERSION_MISMATCH", - "message": "Unsupported schema_version: expected 'v5', got 'v4'", + "message": "Unsupported schema_version: expected 'v6', got 'v4'", "field": "schema_version", "detail": {} } diff --git a/tests/fixtures/validation_error_response.json b/tests/fixtures/validation_error_response.json index 226105d..ffdb5ea 100644 --- a/tests/fixtures/validation_error_response.json +++ b/tests/fixtures/validation_error_response.json @@ -1,5 +1,5 @@ { - "schema_version": "v5", + "schema_version": "v6", "errors": [ { "code": "VALIDATION_ERROR", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 78e6c09..351f8e2 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -34,7 +34,6 @@ def _capabilities(**overrides: Any) -> DataGenerationCapabilities: """Build a permissive capabilities object that tests override per case.""" defaults: dict[str, Any] = { - "sampler_type": "studentt", "min_series": 1, "max_series": 16, "t_input": 10.0, @@ -124,7 +123,6 @@ def test_plan_forecast_columns_spends_one_budget_across_both_roles( health = _health( json_fixture_loader, data_generation={ - "sampler_type": "studentt", "min_series": 1, "max_series": 5, "t_input": 10.0, @@ -159,7 +157,6 @@ def test_plan_forecast_columns_raises_when_combined_width_exceeds_max_series( health = _health( json_fixture_loader, data_generation={ - "sampler_type": "studentt", "min_series": 1, "max_series": 3, "t_input": 10.0, @@ -253,7 +250,6 @@ def test_plan_forecast_columns_enforces_min_series( health = _health( json_fixture_loader, data_generation={ - "sampler_type": "studentt", "min_series": 4, "max_series": 12, "t_input": 10.0, diff --git a/tests/test_cli.py b/tests/test_cli.py index 5af4aef..8517450 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -48,7 +48,7 @@ class FakeHealthClient: "deployment-id/predictionsUnstructured" ), deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) @@ -58,7 +58,7 @@ def health(self, *, cache: bool = False, refresh: bool = False) -> HealthMetadat del cache, refresh return HealthMetadata( status="ok", - schema_version="v5", + schema_version="v6", image_version="0.3.0", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", checkpoint_version="sdk-test", @@ -175,7 +175,7 @@ def test_predict_command_writes_response_file(monkeypatch, tmp_path: Path) -> No request_file.write_text( json.dumps( { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", } ), diff --git a/tests/test_condition_mode.py b/tests/test_condition_mode.py index d66af7f..796043a 100644 --- a/tests/test_condition_mode.py +++ b/tests/test_condition_mode.py @@ -100,7 +100,7 @@ def _health( """Build one advertisement without going through a transport.""" return HealthMetadata( status="ok", - schema_version="v5", + schema_version="v6", image_version="0.3.0", model_version=_MODEL_VERSION, checkpoint_version="sdk-test", @@ -516,7 +516,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: assert isinstance(sample_count, int) start = sum(cast(int, earlier["n_samples"]) for earlier in self.payloads[:-1]) return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test", @@ -556,7 +556,7 @@ def _health_payload( """Build one health advertisement as the service serializes it.""" return { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test", diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 22882ef..289575f 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -47,7 +47,7 @@ class _HealthTransport: _METADATA: dict[str, object] = { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.yaml", "checkpoint_version": "yaml", @@ -149,7 +149,7 @@ def test_load_settings_layers_config_below_dotenv_and_environment( "datarobot_endpoint": "https://app.datarobot.com/api/v2", "datarobot_api_token": "yaml-token", "deployment_id": "yaml-deployment-id", - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.yaml", } }, @@ -193,7 +193,7 @@ def test_client_from_env_uses_transport_defaults_from_config( "datarobot_endpoint": "https://app.datarobot.com/api/v2", "datarobot_api_token": "yaml-token", "deployment_id": "yaml-deployment-id", - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.yaml", }, "transport": { diff --git a/tests/test_contract.py b/tests/test_contract.py index d50ebc5..48bb122 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -76,7 +76,7 @@ def _health_metadata() -> dict[str, object]: """Health metadata.""" return { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -97,7 +97,6 @@ def _health_metadata() -> dict[str, object]: "max_sample_count": 4096, "max_concurrent_requests": 1, "data_generation": { - "sampler_type": "studentt", "min_series": 1, "max_series": 16, "t_input": 10.0, @@ -113,7 +112,7 @@ def test_package_identity_contract() -> None: assert DISTRIBUTION_NAME == "jointfm-client" assert IMPORT_NAMESPACE == "jointfm_client" assert FIRST_SUPPORTED_PYTHON_VERSION == "3.11" - assert SCHEMA_VERSION == "v5" + assert SCHEMA_VERSION == "v6" def test_package_version_matches_installed_distribution() -> None: @@ -269,7 +268,7 @@ def test_forecast_payload_matches_service_contract_without_mutating_inputs() -> ) assert payload == { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "quantiles", @@ -364,7 +363,7 @@ def test_dataframe_payload_matches_service_forecast_request_shape() -> None: ) assert payload == { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "mean", @@ -779,7 +778,7 @@ def test_health_and_response_models_parse_current_payloads() -> None: health = HealthMetadata.from_payload(_health_metadata()) response = ForecastResponse.from_payload( { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -876,7 +875,7 @@ def test_forecast_result_conversion_helpers_cover_mean_samples_and_quantiles() - """Forecast result conversion helpers cover mean samples and quantiles.""" mean_result = ForecastResponse.from_payload( { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -896,7 +895,7 @@ def test_forecast_result_conversion_helpers_cover_mean_samples_and_quantiles() - ) sample_result = ForecastResponse.from_payload( { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -919,7 +918,7 @@ def test_forecast_result_conversion_helpers_cover_mean_samples_and_quantiles() - ) quantile_result = ForecastResponse.from_payload( { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -993,7 +992,7 @@ def test_forecast_result_conversion_helpers_cover_mean_samples_and_quantiles() - def test_forecast_response_validates_request_scoped_shapes() -> None: """Forecast response validates request scoped shapes.""" request_payload = { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "samples", @@ -1002,7 +1001,7 @@ def test_forecast_response_validates_request_scoped_shapes() -> None: "n_samples": 3, } response_payload = { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -1032,7 +1031,7 @@ def test_forecast_response_raises_typed_error_for_success_payload_errors() -> No with pytest.raises(JointFMServiceError) as exc_info: ForecastResponse.from_payload( { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -1198,7 +1197,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: def _forecast_response_payload(*, return_mode: str) -> dict[str, object]: """Forecast response payload.""" return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", diff --git a/tests/test_contract_models.py b/tests/test_contract_models.py index 8d43da2..e79068b 100644 --- a/tests/test_contract_models.py +++ b/tests/test_contract_models.py @@ -76,7 +76,7 @@ def test_request_models_serialize_direct_payloads_without_mutating_inputs() -> N ) assert metadata.to_payload() == { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "quantiles", @@ -91,7 +91,7 @@ def test_request_models_serialize_direct_payloads_without_mutating_inputs() -> N "timezone": "UTC", } assert request.to_payload() == { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "quantiles", @@ -303,7 +303,7 @@ def test_response_models_reject_direct_validation_edges() -> None: def test_forecast_response_rejects_request_scoped_metadata_mismatches() -> None: """Forecast response rejects request scoped metadata mismatches.""" request_payload = { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "mean", @@ -354,7 +354,7 @@ def test_forecast_response_rejects_request_scoped_metadata_mismatches() -> None: def test_forecast_response_rejects_sample_bound_violations() -> None: """Forecast response rejects sample bound violations.""" request_payload = { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "query_mode": "forecast", "return_mode": "samples", @@ -397,7 +397,7 @@ def test_forecast_response_rejects_sample_bound_violations() -> None: def _mean_response_payload() -> dict[str, Any]: """Mean response payload.""" return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -419,7 +419,7 @@ def _mean_response_payload() -> dict[str, Any]: def _sample_response_payload() -> dict[str, Any]: """Sample response payload.""" return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", diff --git a/tests/test_feature_importance.py b/tests/test_feature_importance.py index 273d84a..70abea9 100644 --- a/tests/test_feature_importance.py +++ b/tests/test_feature_importance.py @@ -47,7 +47,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: self.payloads.append(dict(payload)) samples = self.sample_batches[len(self.payloads) - 1] return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test", diff --git a/tests/test_leak_guard.py b/tests/test_leak_guard.py new file mode 100644 index 0000000..f597867 --- /dev/null +++ b/tests/test_leak_guard.py @@ -0,0 +1,258 @@ +# Copyright 2026 DataRobot, Inc. and its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the leak guard that keeps private material out of this repository.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +import leak_guard + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Samples are assembled at runtime so this file's own source never matches a rule. +OBJECT_ID = "0123456789ab" + "cdef01234567" +HEADER = "proprietary and " + "confidential to the owner" +SAMPLES = { + "proprietary-header": HEADER, + "datarobot-object-id": f"deployment {OBJECT_ID}", + "internal-host": "gpu-7.cluster" + ".internal", + "private-ip-address": "10.2" + ".3.4", + "datarobot-host": "tenant-example" + ".datarobot.com", + "private-repository-link": "github.com/datarobot/" + "secret-repo", + "planning-label": "Phase" + " 3g", + "credential": "AKIA" + "ABCDEFGHIJKLMNOP", +} +PUBLIC_TEXTS = ( + "https://app" + ".datarobot.com/api/v2", + "https://github.com/datarobot/" + "joint-client-python", + "contact: someone@" + "datarobot.com", + "JointFM" + " 1 serves 2 horizons", + '"generation": "jointfm-2"', + "sha256 " + "ab" * 32, + "phase shift of the seasonal component", +) + + +def _git(root: Path, *args: str) -> None: + subprocess.run( + [ + "git", + "-C", + str(root), + "-c", + "user.name=t", + "-c", + "user.email=t@example.com", + *args, + ], + check=True, + capture_output=True, + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """Return an empty git repository that ignores the private rules file.""" + _git(tmp_path, "init", "-q", "-b", "main") + (tmp_path / ".gitignore").write_text( + f"/{leak_guard.PRIVATE_RULES_FILE}\n", encoding="utf-8" + ) + return tmp_path + + +def _rules_hit(findings: list[leak_guard.Finding]) -> set[str]: + return {finding.rule for finding in findings} + + +@pytest.mark.parametrize(("rule", "text"), sorted(SAMPLES.items())) +def test_each_public_rule_flags_its_sample(rule: str, text: str) -> None: + """Each public rule flags its own sample and no other rule does.""" + findings = leak_guard.scan_text("sample", text, leak_guard.PUBLIC_RULES) + assert _rules_hit(findings) == {rule} + + +@pytest.mark.parametrize("text", PUBLIC_TEXTS) +def test_public_rules_pass_publishable_text(text: str) -> None: + """Publishable text, including the client's generation names, passes the public rules.""" + assert leak_guard.scan_text("sample", text, leak_guard.PUBLIC_RULES) == [] + + +def test_this_repository_passes_its_own_guard() -> None: + """This repository's working tree passes the rules the guard resolves here.""" + rules, _ = leak_guard.resolve_rules(REPO_ROOT) + assert [ + finding.render() for finding in leak_guard.scan_tree(REPO_ROOT, rules) + ] == [] + + +def test_private_rules_are_absent_by_default_and_reported(repo: Path) -> None: + """Without a private rules file the guard runs the public rules and says so.""" + rules, status = leak_guard.resolve_rules(repo) + assert rules == leak_guard.PUBLIC_RULES + assert "public rules only" in status + + +def test_private_rules_load_and_name_rules_by_line(repo: Path) -> None: + """Private rules load from the file and are named by line, never by pattern.""" + (repo / leak_guard.PRIVATE_RULES_FILE).write_text( + "# comment\n\n\\bwidget\\b\n", encoding="utf-8" + ) + rules, status = leak_guard.resolve_rules(repo) + assert "1 private rules" in status + findings = leak_guard.scan_text("sample", "a widget here", rules) + assert [(finding.rule, finding.excerpt) for finding in findings] == [ + ("private:3", "widget") + ] + + +def test_dangling_private_rules_link_fails(repo: Path) -> None: + """A dangling private rules link fails instead of dropping the private rules.""" + (repo / leak_guard.PRIVATE_RULES_FILE).symlink_to(repo / "moved-away.txt") + with pytest.raises(FileNotFoundError): + leak_guard.resolve_rules(repo) + + +def test_private_rules_file_without_patterns_fails(repo: Path) -> None: + """A private rules file without any pattern fails.""" + (repo / leak_guard.PRIVATE_RULES_FILE).write_text( + "# only a comment\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="holds no patterns"): + leak_guard.resolve_rules(repo) + + +def test_tree_scans_paths_contents_untracked_files_and_link_targets(repo: Path) -> None: + """The tree scan covers paths, contents, untracked files, and link targets, not binaries.""" + (repo / "tracked.md").write_text(f"line one\n{HEADER}\n", encoding="utf-8") + _git(repo, "add", "tracked.md") + notes = "phase" + "2-notes.md" + (repo / notes).write_text("clean\n", encoding="utf-8") + (repo / "binary.bin").write_bytes(b"\0" + HEADER.encode("utf-8")) + (repo / "link").symlink_to("/srv/build" + ".internal/data") + findings = leak_guard.scan_tree(repo, leak_guard.PUBLIC_RULES) + assert sorted( + (finding.location, finding.line, finding.rule) for finding in findings + ) == [ + ("link", 1, "internal-host"), + (notes, None, "planning-label"), + ("tracked.md", 2, "proprietary-header"), + ] + + +def test_tree_skips_ignored_private_rules_file(repo: Path) -> None: + """The git-ignored private rules file is not scanned as content.""" + (repo / leak_guard.PRIVATE_RULES_FILE).write_text( + "\\bwidget\\b\n", encoding="utf-8" + ) + assert leak_guard.scan_tree(repo, leak_guard.PUBLIC_RULES) == [] + + +def test_tracked_private_rules_file_is_a_finding(repo: Path) -> None: + """A tracked private rules file is itself a finding.""" + (repo / leak_guard.PRIVATE_RULES_FILE).write_text( + "\\bwidget\\b\n", encoding="utf-8" + ) + _git(repo, "add", "--force", leak_guard.PRIVATE_RULES_FILE) + findings = leak_guard.scan_tree(repo, leak_guard.PUBLIC_RULES) + assert _rules_hit(findings) == {"private-rules-file-published"} + + +def test_message_skips_git_comment_lines(tmp_path: Path) -> None: + """A commit message scan skips the comment lines git strips.""" + message = tmp_path / "COMMIT_EDITMSG" + message.write_text( + f"feat: Add loader\n\nRefs {OBJECT_ID}\n# {HEADER}\n", encoding="utf-8" + ) + findings = leak_guard.scan_message(message, leak_guard.PUBLIC_RULES) + assert [(finding.line, finding.rule) for finding in findings] == [ + (3, "datarobot-object-id") + ] + + +def test_history_finds_material_deleted_in_a_later_commit(repo: Path) -> None: + """The history scan finds material a later commit removed from the tree.""" + (repo / "notes.md").write_text(f"{HEADER}\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", f"docs: Add notes for deployment {OBJECT_ID}") + (repo / "notes.md").write_text("clean\n", encoding="utf-8") + _git(repo, "commit", "-q", "-am", "docs: Clean notes") + assert leak_guard.scan_tree(repo, leak_guard.PUBLIC_RULES) == [] + findings = leak_guard.scan_history(repo, leak_guard.PUBLIC_RULES) + rendered = sorted(finding.rule for finding in findings) + assert rendered == ["datarobot-object-id", "proprietary-header"] + assert any(finding.location.startswith("notes.md (blob ") for finding in findings) + + +def test_history_of_a_clean_repository_passes(repo: Path) -> None: + """A clean history passes.""" + (repo / "README.md").write_text("clean\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", "docs: Add readme") + assert leak_guard.scan_history(repo, leak_guard.PUBLIC_RULES) == [] + + +def test_main_returns_failure_on_findings( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The command line returns 1 on findings and 0 once they are removed.""" + (repo / "notes.md").write_text(f"{HEADER}\n", encoding="utf-8") + monkeypatch.chdir(repo) + assert leak_guard.main(["tree"]) == 1 + output = capsys.readouterr().out + assert "public rules only" in output + assert "notes.md:1: [proprietary-header]" in output + (repo / "notes.md").write_text("clean\n", encoding="utf-8") + assert leak_guard.main(["tree"]) == 0 + + +def test_history_range_scans_only_the_commits_it_selects(repo: Path) -> None: + """A revision range scans only the commits it selects and the blobs they add.""" + (repo / "notes.md").write_text(f"{HEADER}\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", f"docs: Add notes for deployment {OBJECT_ID}") + _git(repo, "tag", "published") + (repo / "notes.md").write_text("clean\n", encoding="utf-8") + _git(repo, "commit", "-q", "-am", "docs: Clean notes") + assert ( + leak_guard.scan_history(repo, leak_guard.PUBLIC_RULES, ["published..HEAD"]) + == [] + ) + (repo / "new.md").write_text("Phase" + " 7\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", "docs: Add new notes") + findings = leak_guard.scan_history( + repo, leak_guard.PUBLIC_RULES, ["published..HEAD"] + ) + assert [(finding.rule, finding.excerpt) for finding in findings] == [ + ("planning-label", "Phase" + " 7") + ] + + +def test_main_history_accepts_revisions( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The history command narrows its scan to the revisions it is given.""" + (repo / "notes.md").write_text(f"{HEADER}\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", "docs: Add notes") + _git(repo, "tag", "published") + monkeypatch.chdir(repo) + assert leak_guard.main(["history"]) == 1 + assert leak_guard.main(["history", "published..HEAD"]) == 0 + assert "leak guard: no findings" in capsys.readouterr().out diff --git a/tests/test_metadata_traits.py b/tests/test_metadata_traits.py index ac00682..8a24545 100644 --- a/tests/test_metadata_traits.py +++ b/tests/test_metadata_traits.py @@ -36,7 +36,7 @@ def _health(traits: list[str] | None) -> dict[str, Any]: """Health payload that advertises ``traits``, or omits the field for ``None``.""" payload: dict[str, Any] = { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "smoke-1", @@ -65,7 +65,7 @@ def _health(traits: list[str] | None) -> dict[str, Any]: def _mean_response(request: Mapping[str, Any]) -> dict[str, Any]: """Mean response that answers ``request`` with zeros.""" return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "smoke-1", diff --git a/tests/test_pool.py b/tests/test_pool.py index a3be42a..ed683f9 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -59,7 +59,7 @@ def _health( """Health.""" return { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": model_version, "checkpoint_version": checkpoint_version, @@ -139,7 +139,7 @@ def test_pool_retries_next_instance_on_470() -> None: pool = _pool(transport=_Transport(fail_ids=frozenset({"a"}))) assert pool.next_instance().instance_id == "a" assert pool.next_instance().instance_id == "b" - assert pool.post_json({"schema_version": "v5"}) == { + assert pool.post_json({"schema_version": "v6"}) == { "ok": True, "deployment_id": "b", } @@ -149,7 +149,7 @@ def test_pool_raises_when_all_instances_unavailable() -> None: """Pool raises when all instances unavailable.""" pool = _pool(transport=_Transport(fail_ids=frozenset({"a", "b"}))) with pytest.raises(JointFMHTTPStatusError, match="unavailable"): - pool.post_json({"schema_version": "v5"}) + pool.post_json({"schema_version": "v6"}) def test_pool_health_rejects_mismatch_and_aligns_sample_cap() -> None: @@ -280,7 +280,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: executor.submit( pool.post_json_to, pool.instance_at(index), - {"schema_version": "v5"}, + {"schema_version": "v6"}, ) for index in range(2) ] @@ -322,7 +322,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: with ThreadPoolExecutor(max_workers=batch_count) as executor: futures = [ executor.submit( - pool.post_json_to, pool.instance_at(index), {"schema_version": "v5"} + pool.post_json_to, pool.instance_at(index), {"schema_version": "v6"} ) for index in range(batch_count) ] @@ -344,7 +344,7 @@ def test_pool_health_routes_only_reachable_peers() -> None: assert pool.instance_at(0).instance_id == "b" assert pool.instance_at(1).instance_id == "b" assert pool.next_instance().instance_id == "b" - assert pool.post_json({"schema_version": "v5"})["deployment_id"] == "b" + assert pool.post_json({"schema_version": "v6"})["deployment_id"] == "b" def test_pool_failover_retries_health_excluded_peer() -> None: @@ -358,7 +358,7 @@ def test_pool_failover_retries_health_excluded_peer() -> None: assert pool.instance_at(0).instance_id == "b" transport.fail_ids = frozenset({"b"}) - assert pool.post_json({"schema_version": "v5"})["deployment_id"] == "a" + assert pool.post_json({"schema_version": "v6"})["deployment_id"] == "a" assert pool.instance_at(0).instance_id == "a" @@ -378,7 +378,7 @@ def test_pool_health_skips_incompatible_peer_when_another_matches_pin() -> None: assert metadata.model_version == pinned assert pool.instance_at(0).instance_id == "a" assert pool.instance_at(1).instance_id == "a" - assert pool.post_json({"schema_version": "v5"})["deployment_id"] == "a" + assert pool.post_json({"schema_version": "v6"})["deployment_id"] == "a" def test_pool_cooldown_restores_peer_after_transient_failure( @@ -390,7 +390,7 @@ def test_pool_cooldown_restores_peer_after_transient_failure( transport = _Transport(fail_ids=frozenset({"a"})) pool = _pool(transport=transport, peer_cooldown_seconds=10.0) - assert pool.post_json({"schema_version": "v5"})["deployment_id"] == "b" + assert pool.post_json({"schema_version": "v6"})["deployment_id"] == "b" assert pool.instance_at(0).instance_id == "b" assert pool.instance_at(1).instance_id == "b" @@ -400,4 +400,4 @@ def test_pool_cooldown_restores_peer_after_transient_failure( clock["now"] = 110.0 assert {pool.instance_at(i).instance_id for i in range(2)} == {"a", "b"} - assert pool.post_json({"schema_version": "v5"})["deployment_id"] in {"a", "b"} + assert pool.post_json({"schema_version": "v6"})["deployment_id"] in {"a", "b"} diff --git a/tests/test_result_arrays.py b/tests/test_result_arrays.py index 4f9c856..120e35f 100644 --- a/tests/test_result_arrays.py +++ b/tests/test_result_arrays.py @@ -45,7 +45,7 @@ def _payload(return_mode: str, outputs: dict[str, Any], query_times: Any) -> dict: """One forecast response over three horizons and two requested columns.""" return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", @@ -301,7 +301,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: _HEALTH_PAYLOAD: dict[str, Any] = { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.smoke-1", "checkpoint_version": "smoke-1", diff --git a/tests/test_sample_batch_merge.py b/tests/test_sample_batch_merge.py index a334e87..dff0263 100644 --- a/tests/test_sample_batch_merge.py +++ b/tests/test_sample_batch_merge.py @@ -52,7 +52,7 @@ def _health_payload(*, max_sample_count: int) -> dict[str, object]: """Health payload advertising ``max_sample_count``.""" return { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test", @@ -88,7 +88,7 @@ def _samples_response( ) -> dict[str, object]: """One sample batch response answering ``seed`` and ``sample_count``.""" return { - "schema_version": "v5", + "schema_version": "v6", "image_version": image_version, "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test", @@ -165,7 +165,7 @@ def _single_endpoint_client(transport: _BatchTransport) -> JointFMClient: health_url="http://127.0.0.1:8080/healthz", predict_url="http://127.0.0.1:8080/predict", deployment_selector="local_service", - schema_version="v5", + schema_version="v6", model_version=_MODEL_VERSION, local_base_url="http://127.0.0.1:8080", ) @@ -213,7 +213,7 @@ def _pool_client(transport: _BatchTransport) -> JointFMClient: health_url=_PRIMARY, predict_url=_PRIMARY, deployment_selector="deployment_ids", - schema_version="v5", + schema_version="v6", instances=( JointFMInstanceSettings(instance_id="primary-id", predict_url=_PRIMARY), JointFMInstanceSettings(instance_id="backup-id", predict_url=_BACKUP), diff --git a/tests/test_settings.py b/tests/test_settings.py index 160b75b..58074fa 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -55,7 +55,7 @@ def _hosted_env(**overrides: str) -> dict[str, str]: DATAROBOT_ENDPOINT_ENV: "https://app.datarobot.com/api/v2/", DATAROBOT_API_TOKEN_ENV: "secret-token", JOINTFM_DEPLOYMENT_ID_ENV: "deployment-id", - JOINTFM_SCHEMA_VERSION_ENV: "v5", + JOINTFM_SCHEMA_VERSION_ENV: "v6", JOINTFM_MODEL_VERSION_ENV: "jointfm-inference:0.3.0+ckpt.sdk-test", } env.update(overrides) @@ -67,7 +67,7 @@ def test_load_settings_from_environment_with_deployment_id_builds_hosted_url() - settings = load_settings(env=_hosted_env(), dotenv_path=None) assert settings.datarobot_endpoint == "https://app.datarobot.com/api/v2" - assert settings.schema_version == "v5" + assert settings.schema_version == "v6" assert settings.model_version == "jointfm-inference:0.3.0+ckpt.sdk-test" assert settings.deployment_id == "deployment-id" assert settings.predict_url == ( @@ -83,7 +83,7 @@ def test_load_settings_with_local_service_base_url_builds_direct_urls() -> None: settings = load_settings( env={ JOINTFM_LOCAL_BASE_URL_ENV: "http://127.0.0.1:8080/", - JOINTFM_SCHEMA_VERSION_ENV: "v5", + JOINTFM_SCHEMA_VERSION_ENV: "v6", JOINTFM_MODEL_VERSION_ENV: "jointfm-inference:0.3.0+ckpt.local-test", }, dotenv_path=None, @@ -159,7 +159,7 @@ def test_load_settings_reads_dotenv_without_overriding_environment(tmp_path) -> "DATAROBOT_ENDPOINT=https://app.datarobot.com/api/v2", "DATAROBOT_API_TOKEN=file-token", "JOINTFM_DEPLOYMENT_ID=file-deployment-id", - "JOINTFM_SCHEMA_VERSION=v5", + "JOINTFM_SCHEMA_VERSION=v6", "JOINTFM_MODEL_VERSION=jointfm-inference:0.3.0+ckpt.sdk-test", ] ), @@ -210,7 +210,7 @@ def test_load_settings_rejects_missing_credentials_without_defaults() -> None: env={ DATAROBOT_API_TOKEN_ENV: "secret-token", JOINTFM_DEPLOYMENT_ID_ENV: "deployment-id", - JOINTFM_SCHEMA_VERSION_ENV: "v5", + JOINTFM_SCHEMA_VERSION_ENV: "v6", JOINTFM_MODEL_VERSION_ENV: "jointfm-inference:0.3.0+ckpt.sdk-test", }, dotenv_path=None, @@ -221,7 +221,7 @@ def test_load_settings_rejects_missing_credentials_without_defaults() -> None: env={ DATAROBOT_ENDPOINT_ENV: "https://app.datarobot.com/api/v2", JOINTFM_DEPLOYMENT_ID_ENV: "deployment-id", - JOINTFM_SCHEMA_VERSION_ENV: "v5", + JOINTFM_SCHEMA_VERSION_ENV: "v6", JOINTFM_MODEL_VERSION_ENV: "jointfm-inference:0.3.0+ckpt.sdk-test", }, dotenv_path=None, @@ -550,7 +550,7 @@ def _local_pool_env(base_urls: str) -> dict[str, str]: """Credential-free env selecting a local service pool.""" return { JOINTFM_LOCAL_BASE_URLS_ENV: base_urls, - JOINTFM_SCHEMA_VERSION_ENV: "v5", + JOINTFM_SCHEMA_VERSION_ENV: "v6", } diff --git a/tests/test_surfaces.py b/tests/test_surfaces.py index 067182d..f3852d8 100644 --- a/tests/test_surfaces.py +++ b/tests/test_surfaces.py @@ -71,7 +71,7 @@ def test_hosted_surface_uses_datarobot_routes_and_auth_headers( health_url=predict_url, predict_url=predict_url, deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version=request_payload["model_version"], deployment_id="deployment-id", ) @@ -210,7 +210,7 @@ def test_hosted_surface_auto_discovers_model_version_when_settings_unpinned( health_url=predict_url, predict_url=predict_url, deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", deployment_id="deployment-id", ) assert settings.model_version is None diff --git a/tests/test_transport.py b/tests/test_transport.py index ffe92f7..791a624 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -158,7 +158,7 @@ def _health_payload( """Health payload.""" return { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": model_version, "checkpoint_version": checkpoint_version, @@ -196,7 +196,7 @@ def _forecast_response_payload(*, return_mode: str = "mean") -> dict[str, object else None, } return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "checkpoint_version": "sdk-test", @@ -240,7 +240,7 @@ def test_transport_posts_json_with_headers_timeout_and_user_agent() -> None: session.mount("https://", adapter) result = transport.post_json( - "https://example.com/predict", {"schema_version": "v5"} + "https://example.com/predict", {"schema_version": "v6"} ) assert result == {"ok": True} @@ -254,7 +254,7 @@ def test_transport_posts_json_with_headers_timeout_and_user_agent() -> None: assert adapter.kwargs[0]["timeout"] == (1.5, 2.5) request_body = request.body assert isinstance(request_body, bytes) - assert json.loads(request_body.decode("utf-8")) == {"schema_version": "v5"} + assert json.loads(request_body.decode("utf-8")) == {"schema_version": "v6"} def test_transport_from_settings_attaches_hosted_auth_headers_and_closes_session() -> ( @@ -271,7 +271,7 @@ def test_transport_from_settings_attaches_hosted_auth_headers_and_closes_session "deployment-id/predictionsUnstructured" ), deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) @@ -299,7 +299,7 @@ def test_transport_from_local_settings_omits_hosted_auth_headers() -> None: health_url="http://127.0.0.1:8080/healthz", predict_url="http://127.0.0.1:8080/predict", deployment_selector="local_service", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.local-test", local_base_url="http://127.0.0.1:8080", ) @@ -325,7 +325,7 @@ def test_transport_retries_retryable_server_responses() -> None: retry_config=JointFMRetryConfig(max_attempts=2) ) - result = transport.post_json(_server_url(server), {"schema_version": "v5"}) + result = transport.post_json(_server_url(server), {"schema_version": "v6"}) assert result == {"ok": True} assert handler.request_count == 2 @@ -357,7 +357,7 @@ def test_transport_retries_html_bodied_gateway_errors() -> None: ), ) - result = transport.post_json(_server_url(server), {"schema_version": "v5"}) + result = transport.post_json(_server_url(server), {"schema_version": "v6"}) assert result == {"ok": True} assert handler.request_count == 2 @@ -386,7 +386,7 @@ def test_transport_raises_status_error_when_gateway_html_persists() -> None: ) with pytest.raises(JointFMHTTPStatusError) as exc_info: - transport.post_json(_server_url(server), {"schema_version": "v5"}) + transport.post_json(_server_url(server), {"schema_version": "v6"}) assert exc_info.value.status_code == HTTPStatus.BAD_GATEWAY assert "502 Bad Gateway" in exc_info.value.response_body_excerpt @@ -450,7 +450,7 @@ def request(self, *args: Any, **kwargs: Any) -> requests.Response: ) result = transport.post_json( - "https://example.com/predict", {"schema_version": "v5"} + "https://example.com/predict", {"schema_version": "v6"} ) assert result == {"ok": True} @@ -469,7 +469,7 @@ def test_status_error_carries_parsed_retry_after_seconds() -> None: ) with pytest.raises(JointFMHTTPStatusError) as exc_info: - transport.post_json(_server_url(server), {"schema_version": "v5"}) + transport.post_json(_server_url(server), {"schema_version": "v6"}) assert exc_info.value.retry_after_seconds == 0.5 assert handler.request_count == 1 @@ -487,7 +487,7 @@ def test_transport_does_not_retry_validation_errors() -> None: ) with pytest.raises(JointFMHTTPStatusError) as exc_info: - transport.post_json(_server_url(server), {"schema_version": "v5"}) + transport.post_json(_server_url(server), {"schema_version": "v6"}) assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST assert exc_info.value.datarobot_request_id == "request-id-1" @@ -513,7 +513,7 @@ def test_transport_rejects_non_json_serializable_payloads() -> None: with pytest.raises(JointFMRequestEncodingError, match="JSON-serializable"): transport.post_json( "https://example.com/predict", - {"schema_version": "v5", "bad": object()}, + {"schema_version": "v6", "bad": object()}, ) @@ -624,14 +624,14 @@ def test_client_predict_uses_configured_transport_and_settings() -> None: health_url="https://app.datarobot.com/api/v2/deployments/deployment-id/healthz", predict_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) transport = RecordingTransport() client = JointFMClient(settings=settings, transport=transport) payload = { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", } @@ -650,7 +650,7 @@ def test_client_health_returns_typed_metadata_and_caches_only_when_requested() - health_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", predict_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) @@ -681,7 +681,7 @@ def test_client_health_instances_returns_one_entry_for_single_endpoint() -> None health_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", predict_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) @@ -743,7 +743,7 @@ def capture_transport( "DATAROBOT_ENDPOINT": "https://app.datarobot.com/api/v2", "DATAROBOT_API_TOKEN": "secret-token", "JOINTFM_DEPLOYMENT_ID": "deployment-id", - "JOINTFM_SCHEMA_VERSION": "v5", + "JOINTFM_SCHEMA_VERSION": "v6", "JOINTFM_MODEL_VERSION": "jointfm-inference:0.3.0+ckpt.sdk-test", }, dotenv_path=None, @@ -765,7 +765,7 @@ def test_client_health_rejects_cached_model_mismatch() -> None: health_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", predict_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) @@ -791,7 +791,7 @@ def test_client_hosted_health_posts_request_type_health_to_predict_url() -> None health_url=predict_url, predict_url=predict_url, deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) @@ -815,7 +815,7 @@ def test_client_local_health_keeps_get_request_to_healthz_route() -> None: health_url="http://127.0.0.1:8080/healthz", predict_url="http://127.0.0.1:8080/predict", deployment_selector="local_service", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", local_base_url="http://127.0.0.1:8080", ) @@ -857,7 +857,7 @@ def test_client_forecast_builds_payload_from_rows_and_returns_typed_response() - np.testing.assert_array_equal(result.outputs.mean, ((12.0,),)) assert transport.predict_url == "http://localhost:8080/predict" assert transport.payload == { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", "query_mode": "forecast", "return_mode": "mean", @@ -1038,7 +1038,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: health_url="http://127.0.0.1:8080/healthz", predict_url="http://127.0.0.1:8080/predict", deployment_selector="local_service", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", local_base_url="http://127.0.0.1:8080", ) @@ -1093,13 +1093,13 @@ def test_client_predict_raises_typed_service_error_for_success_payload_errors() health_url="https://app.datarobot.com/api/v2/deployments/deployment-id/healthz", predict_url="https://app.datarobot.com/api/v2/deployments/deployment-id/predictionsUnstructured", deployment_selector="deployment_id", - schema_version="v5", + schema_version="v6", model_version="jointfm-inference:0.3.0+ckpt.sdk-test", deployment_id="deployment-id", ) transport = RecordingTransport() transport.predict_payload = { - "schema_version": "v5", + "schema_version": "v6", "errors": [ { "code": "VALIDATION_ERROR", @@ -1113,7 +1113,7 @@ def test_client_predict_raises_typed_service_error_for_success_payload_errors() with pytest.raises(JointFMServiceError) as exc_info: client.predict( { - "schema_version": "v5", + "schema_version": "v6", "model_version": settings.model_version, } ) @@ -1202,7 +1202,7 @@ def do_POST(self) -> None: payload = {"ok": True} else: payload = { - "schema_version": "v5", + "schema_version": "v6", "errors": [ { "code": "VALIDATION_ERROR", @@ -1286,7 +1286,7 @@ def _pool_settings(primary: str, backup: str) -> JointFMSettings: health_url=primary, predict_url=primary, deployment_selector="deployment_ids", - schema_version="v5", + schema_version="v6", instances=( JointFMInstanceSettings(instance_id="primary-id", predict_url=primary), JointFMInstanceSettings(instance_id="backup-id", predict_url=backup), @@ -1328,7 +1328,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: transport = PoolTransport() client = JointFMClient(settings=settings, transport=transport) payload = { - "schema_version": "v5", + "schema_version": "v6", "model_version": "jointfm-inference:0.3.0+ckpt.sdk-test", } client.predict(payload) @@ -1496,7 +1496,7 @@ def _local_pool_settings() -> JointFMSettings: return load_settings( env={ JOINTFM_LOCAL_BASE_URLS_ENV: f"{_LOCAL_PRIMARY},{_LOCAL_BACKUP}", - JOINTFM_SCHEMA_VERSION_ENV: "v5", + JOINTFM_SCHEMA_VERSION_ENV: "v6", }, dotenv_path=None, ) diff --git a/tests/test_utilization.py b/tests/test_utilization.py index ccb749e..d1eaea4 100644 --- a/tests/test_utilization.py +++ b/tests/test_utilization.py @@ -38,7 +38,6 @@ _MODEL_VERSION = "jointfm-inference:0.3.0+ckpt.sdk-test" _DATA_GENERATION = { - "sampler_type": "studentt", "min_series": 1, "max_series": 3, "t_input": 10.0, @@ -69,7 +68,7 @@ def _settings(*deployment_ids: str) -> JointFMSettings: deployment_selector=( "deployment_id" if len(deployment_ids) == 1 else "deployment_ids" ), - schema_version="v5", + schema_version="v6", instances=tuple( JointFMInstanceSettings( instance_id=deployment_id, predict_url=_url(deployment_id) @@ -92,7 +91,7 @@ def _health_payload( """Health payload advertising a small capacity envelope.""" payload: dict[str, Any] = { "status": "ok", - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test", @@ -175,7 +174,7 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: for sample in range(cast(int, payload["n_samples"])) ] return { - "schema_version": "v5", + "schema_version": "v6", "image_version": "0.3.0", "model_version": _MODEL_VERSION, "checkpoint_version": "sdk-test",