Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ repos:
name: ruff format
entry: uv run ruff format
language: system
types: [python]
types_or: [python, jupyter]
- id: ty-check
name: ty check
entry: task typecheck
Expand All @@ -49,7 +49,7 @@ repos:
types: [jupyter]
- id: tests
name: tests with coverage
entry: env COVERAGE_FILE=/tmp/joint-client-python-pre-commit.coverage uv run pytest -q -p no:cacheprovider --cov=jointfm_client --cov-report=term-missing:skip-covered --cov-fail-under=90
entry: env COVERAGE_FILE=/tmp/joint-client-python-pre-commit.coverage uv run pytest -q -n auto -p no:cacheprovider --cov=jointfm_client --cov-report=term-missing:skip-covered --cov-fail-under=90
language: system
pass_filenames: false
always_run: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 explain what each command will do and why it is being run before running it. 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 never perform destructive git operations unless the user explicitly instructs the agent to run that specific operation — no `git push --force`, no `git reset --hard`, 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. If a task seems to require a destructive git operation and the user has not explicitly asked for it, stop and ask the user to run it.
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 -- <command>` (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 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. ripgrep (`rg`) is available (installed by `task setup` via `task install:ripgrep`); prefer it for fast code and text search.
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ transport:
connect_seconds: 5.0
read_seconds: 60.0
retry:
max_attempts: 3
max_attempts: 5
backoff_seconds: 1
```

Expand Down Expand Up @@ -152,6 +152,7 @@ Checked-in example notebooks live under `notebooks/`. Every example starts with:

```python
from jointfm_client import bootstrap_notebook

bootstrap_notebook(add_src_root=True)
```

Expand All @@ -169,7 +170,7 @@ The current V1 forecast request contract is:
- `time_column`: required for `"absolute_datetime"`, and used for ordered ordinal or continuous histories when supplied
- `query_times`: non-empty future forecast times only
- `requested_columns`: optional column names or integer column indices, with duplicates rejected
- `n_samples`: positive sample count for sampled forecasts and quantile estimation. When `return_mode="samples"` exceeds a service-reported sample cap, `forecast_samples(...)` automatically resubmits capped prediction batches and returns one merged `SampleForecastResult`.
- `n_samples`: positive sample count for sampled forecasts and quantile estimation. When `return_mode="samples"` exceeds the `max_sample_count` advertised by the deployment's health metadata, `forecast_samples(...)` splits the request into capped prediction batches up front and returns one merged `SampleForecastResult`.

V1 column descriptors support the server fields `name`, `modality`, `role`, `nullable`, `vocabulary_size`, `level_count`, `mapping`, `lower_bound`, `upper_bound`, `time_value_kind`, `time_value_scale_seconds`, `time_value_use_local_normalized_time`, `time_value_calendar_id`, and `time_value_timezone`.

Expand Down Expand Up @@ -303,10 +304,19 @@ Use `forecast_samples(...)` for sampled trajectories or `forecast_quantiles(...)
- `task check`: run the static code quality gate (typos, lint, format check, type checks)
- `task release:dry`: preview the next SemVer bump without changing any files
- `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

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`.

Your user must belong to the `docker` group so the hook can reach the daemon:

```shell
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.

## 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.
Expand Down Expand Up @@ -384,7 +394,7 @@ After that, every future `task release` finds its base tag automatically.
```bash
task release:dry # preview the next version + CHANGELOG entries
task release # bump, write CHANGELOG.md, create the annotated tag
git push && git push --tags
task release:publish # push the bump commit and the tag
```

`task release` first runs `task release:check` (clean tree, on `main`, in sync with `origin/main`), then calls `cz bump` which:
Expand All @@ -395,7 +405,9 @@ git push && git push --tags
- bumps `version =` in `pyproject.toml`, `__version__` in `src/jointfm_client/__init__.py`, and the "Current SDK package version" line in this README,
- commits the bump and creates the annotated tag.

Pushing is left manual so you can inspect the bump first. Override the inferred bump level only when needed: `task release -- --increment minor`.
Publishing is a separate task on purpose, so you can inspect the inferred bump before anything leaves your machine — Commitizen derives the version from commit messages, and a stray `feat:` where you meant `fix:` is only fixable while the release is still local. Override the inferred bump level when needed: `task release -- --increment minor`.

`task release:publish` re-checks that the working tree is clean, that you are on `main`, that the tag points at `HEAD`, and that `origin` does not already have the tag, prints the commits about to be pushed, asks for confirmation on a terminal, and then pushes `main` and that single tag. It pushes one explicit tag rather than `git push --tags`, so unrelated local tags are never published.

Pushing the `v*` tag triggers the [`Publish to PyPI`](.github/workflows/publish.yml) workflow, which rebuilds and validates the distribution with `task build` and uploads it to PyPI via [`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish) — keeping the git tag, the wheel filename, `jointfm_client.__version__`, and the PyPI version all in lockstep.

Expand Down
87 changes: 84 additions & 3 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ tasks:
default:
cmd: task --list --sort alphanumeric

run:
desc: "Run a command inside the project environment (usage: task run -- python ...)"
cmd: uv run {{.CLI_ARGS}}

setup:
desc: Create or reuse the local Python environment and install repo tooling
cmds:
- uv venv --allow-existing --python 3.13.3
- uv sync --all-groups --extra notebooks
- uv run python -m ipykernel install --sys-prefix --name joint-client-python --display-name "Python (joint-client-python)"
- uv run pre-commit install --hook-type pre-commit --hook-type commit-msg
- task: install:ripgrep
- task: install:typos
- cmd: |
if [ -f .env ]; then
Expand Down Expand Up @@ -45,11 +50,11 @@ tasks:

test:
desc: Run the unit test suite
cmd: uv run pytest
cmd: uv run pytest -n auto

coverage:
desc: Run tests with coverage enforcement
cmd: uv run pytest --cov=jointfm_client --cov-report=term-missing --cov-fail-under=91
cmd: uv run pytest -n auto --cov=jointfm_client --cov-report=term-missing --cov-fail-under=90

build:
desc: Build and validate the source distribution and wheel
Expand Down Expand Up @@ -129,7 +134,59 @@ tasks:
echo " bumped version in: pyproject.toml, src/jointfm_client/__init__.py, README.md"
echo " updated: CHANGELOG.md, uv.lock"
echo " created tag: v$NEXT"
echo " publish with: git push && git push --tags"
echo " publish with: task release:publish"

release:publish:
desc: Push the release commit and its tag to origin (triggers the PyPI publish workflow)
cmds:
- task: release:require-tag
- |
TAG="v$(uv run cz version --project)"
echo "[1/5] checking working tree is clean..."
if [ -n "$(git status --porcelain)" ]; then
echo "FAIL: working tree has uncommitted changes; commit them first"
exit 1
fi
echo "[2/5] checking current branch is main..."
BRANCH="$(git symbolic-ref --short HEAD)"
if [ "$BRANCH" != "main" ]; then
echo "FAIL: releases are published from main (currently on $BRANCH)"
exit 1
fi
echo "[3/5] checking $TAG points at HEAD..."
if [ "$(git rev-parse "$TAG^{commit}")" != "$(git rev-parse HEAD)" ]; then
echo "FAIL: $TAG does not point at HEAD."
echo "The wheel published from this tag must match the commit on main."
echo "This usually means the release PR was squashed or rebased instead"
echo "of merged, so the tagged commit is no longer part of main."
exit 1
fi
echo "[4/5] checking $TAG is not already published..."
git fetch --quiet origin main
if [ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]; then
echo "FAIL: origin already has $TAG; PyPI does not allow reusing a version."
echo "Cut a new release instead of republishing this one."
exit 1
fi
echo "[5/5] commits to be pushed to origin/main:"
git --no-pager log --oneline origin/main..HEAD
echo
if [ -t 0 ]; then
printf 'Push main and %s to origin? This publishes %s to PyPI. [y/N] ' "$TAG" "$TAG"
read -r CONFIRMATION
case "$CONFIRMATION" in
y|Y|yes|YES) ;;
*)
echo "OK: aborted, nothing was pushed"
exit 1
;;
esac
fi
git push origin main
git push origin "refs/tags/$TAG"
echo
echo "OK: pushed main and $TAG to origin"
echo " the Publish to PyPI workflow now builds and uploads $TAG"

typos:
desc: Run the spelling checker
Expand All @@ -152,6 +209,30 @@ tasks:
exit 1
fi

install:ripgrep:
desc: Install ripgrep using the available system package manager
cmds:
- |
if command -v rg >/dev/null 2>&1; then
exit 0
fi
if command -v apt >/dev/null 2>&1; then
sudo apt update && sudo apt install -y ripgrep
elif command -v dnf >/dev/null 2>&1; then
rpm -q spal-release >/dev/null 2>&1 || sudo dnf install -y spal-release
sudo dnf install -y ripgrep
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y ripgrep
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -Sy --noconfirm ripgrep
elif command -v brew >/dev/null 2>&1; then
brew install ripgrep
else
echo "No supported package manager found for automatic ripgrep installation."
echo "Install ripgrep manually, then rerun: task setup"
exit 1
fi

install:typos:
desc: Install typos using Homebrew when available, otherwise download a release binary with curl
cmds:
Expand Down
4 changes: 2 additions & 2 deletions config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ transport:
connect_seconds: 5.0
read_seconds: 60.0
retry:
max_attempts: 3
max_attempts: 5
backoff_seconds: 1
max_backoff_seconds: 30.0
max_backoff_seconds: 60.0
status_codes:
- 408
- 429
Expand Down
6 changes: 3 additions & 3 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This reference covers the supported public Python surface exported by `jointfm_c
| --- | --- |
| `JointFMClient` | Synchronous client for hosted or local JointFM endpoints. Use `from_env()` for `.env` and `config.yaml` backed hosted settings, `health()` for typed service metadata, `predict(payload)` for low-level JSON prediction, `forecast(...)` for validated tabular forecasts, and the `forecast_mean(...)`, `forecast_samples(...)`, and `forecast_quantiles(...)` convenience methods for typed forecast results. `health()` probes `GET /healthz` for local deployments and POSTs `{"request_type": "health"}` to `predict_url` for hosted DataRobot deployments because the DataRobot deployment gateway only proxies the unstructured prediction route. |

`JointFMClient.from_env()` loads `config.yaml`, optional `.env` values, and process environment variables. `JointFMClient.health(cache=True)` caches health metadata only when requested. `JointFMClient.predict(payload)` requires `payload["model_version"]`; high-level forecast helpers resolve the configured model version when the caller does not pass one explicitly. When `forecast_samples(...)` requests more samples than the service cap allows, the client discovers the cap from the structured service error, resubmits capped prediction batches, and returns one merged `SampleForecastResult`.
`JointFMClient.from_env()` loads `config.yaml`, optional `.env` values, and process environment variables. `JointFMClient.health(cache=True)` caches health metadata only when requested. `JointFMClient.predict(payload)` requires `payload["model_version"]`; high-level forecast helpers resolve the configured model version when the caller does not pass one explicitly. When `forecast_samples(...)` requests an explicit `n_samples`, the client learns the deployment's `max_sample_count` from health metadata before the first prediction, splits oversized requests into capped prediction batches, and returns one merged `SampleForecastResult`. Clients configured without a reachable health route fall back to discovering the cap from the structured service error.

## Contract Classes

Expand Down Expand Up @@ -156,7 +156,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE`
| `query_times` | Yes | Non-empty future forecast horizon values. Absolute datetimes are encoded timezone-stably. |
| `time_column` | For absolute datetime, optional otherwise | Name of the history time column. It must not duplicate a modeled column name. |
| `requested_columns` | Optional | Output column names or integer indices. Duplicates are rejected. Defaults to all modeled columns. |
| `n_samples` | Samples and quantiles controls | Positive sample count when sampling controls are needed. Oversized sample forecasts are batched automatically after the service reports its cap. |
| `n_samples` | Samples and quantiles controls | Positive sample count when sampling controls are needed. Oversized sample forecasts are batched automatically against the cap advertised in health metadata. |
| `quantiles` | Quantiles mode | Quantile levels in `(0, 1)`, required for `return_mode="quantiles"`. |
| `seed` | Optional | Integer random seed for reproducible stochastic outputs. |
| `time_scale_seconds` | Optional | Positive scale for continuous time indexes. |
Expand Down Expand Up @@ -220,7 +220,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE`
| `supported_return_modes` | Must match the SDK V1 return modes (`mean`, `samples`, `quantiles`, `log_prob`). |
| `supported_time_index_modes` | Must match the SDK V1 time-index modes. |
| `time_index_encoding` | Time-index encoding advertised by the service. |
| `max_sample_count` | Maximum sample-count budget the service accepts in a single prediction. Oversized requests are batched automatically by the client. |
| `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. |
| `data_generation` | Optional capability block describing the deployed checkpoint's advertised data-generation capacity. Absent on legacy checkpoints; present payloads expose `sampler_type`, `min_features`, `max_features`, `min_targets`, `max_targets`, `t_input`, `t_output`, `n_input`, and `n_output`. |

## Docstring Enforcement
Expand Down
Loading
Loading