From 1e87b8a8f87b674bdf71e03373adf73c9ff9bebb Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Wed, 19 Aug 2026 16:51:40 -0400 Subject: [PATCH] feat(ess-langsmith-client): replace langsmith-client Brings in the renamed package and retires the older, smaller copy it supersedes. The new version is a superset: 42 files against 14, adding the `agent_test` deployment smoke-tester, per-subcommand docs, a README, and tests. Two behavioral differences matter. `merge_secrets` no longer harvests credentials from the environment implicitly -- it reads only the keys a caller names via `auto_detect_keys`, so a deploy cannot ship a secret nobody asked for. And `pyproject.toml` now declares `readme` and `license = "Apache-2.0"`, so the built wheel carries a description and a license instead of omitting both; that needs `hatchling>=1.27`, which is the version understanding the bare SPDX string form. Workspace member swapped into the alphabetical `ess-*` block and the lock regenerated: `langsmith-client` out, `ess-langsmith-client` in. --- .../python/ess-langsmith-client/.env.example | 19 + .../python/ess-langsmith-client/.gitignore | 1 + .../python/ess-langsmith-client/README.md | 87 ++++ .../ess-langsmith-client/docs/README.md | 11 + .../ess-langsmith-client/docs/api-keys.md | 65 +++ .../docs/building-images.md | 41 ++ .../docs/deploying-agents.md | 105 ++++ .../ess-langsmith-client/docs/listeners.md | 20 + .../ess-langsmith-client/docs/projects.md | 61 +++ .../docs/testing-agents.md | 43 ++ .../ess-langsmith-client/docs/workspaces.md | 33 ++ .../ess-langsmith-client/pyproject.toml | 29 ++ .../src/ess_langsmith_client/__init__.py | 80 +++ .../src/ess_langsmith_client/_version.py | 11 + .../agent_test/__init__.py | 16 + .../ess_langsmith_client/agent_test/cli.py | 252 ++++++++++ .../agent_test/resolution.py | 112 +++++ .../ess_langsmith_client/agent_test/runner.py | 110 ++++ .../agent_test/test_resolution.py | 117 +++++ .../src/ess_langsmith_client/cli.py | 222 +++++++++ .../src/ess_langsmith_client/client.py | 351 +++++++++++++ .../src/ess_langsmith_client/naming.py | 157 ++++++ .../src/ess_langsmith_client}/project.py | 0 .../src/ess_langsmith_client/secrets.py | 57 +++ .../src/ess_langsmith_client/test_cli.py | 74 +++ .../src/ess_langsmith_client/test_client.py | 128 +++++ .../test_control_plane.py | 142 ++++++ .../src/ess_langsmith_client/test_naming.py | 107 ++++ .../src/ess_langsmith_client/test_projects.py | 104 ++++ .../src/ess_langsmith_client/test_secrets.py | 51 ++ .../ess_langsmith_client}/tools/__init__.py | 0 .../src/ess_langsmith_client}/tools/build.py | 37 +- .../tools/control_plane.py | 147 ++++++ .../tools/deploy_docker.py | 249 ++++++++-- .../tools/deploy_github.py | 162 ++++-- .../src/ess_langsmith_client/tools/keys.py | 425 ++++++++++++++++ .../tools/list_listeners.py | 6 +- .../tools/list_workspaces.py | 21 +- .../src/ess_langsmith_client/tools/main.py | 65 +++ .../ess_langsmith_client/tools/projects.py | 468 ++++++++++++++++++ .../ess_langsmith_client/tools/test_keys.py | 203 ++++++++ .../ess_langsmith_client/tools/test_main.py | 43 ++ packages/python/langsmith-client/.env.example | 9 - .../python/langsmith-client/pyproject.toml | 27 - .../src/langsmith_client/__init__.py | 51 -- .../src/langsmith_client/cli.py | 96 ---- .../src/langsmith_client/client.py | 221 --------- .../src/langsmith_client/secrets.py | 46 -- .../src/langsmith_client/tools/projects.py | 291 ----------- pyproject.toml | 2 +- uv.lock | 231 ++++++++- 51 files changed, 4533 insertions(+), 873 deletions(-) create mode 100644 packages/python/ess-langsmith-client/.env.example create mode 100644 packages/python/ess-langsmith-client/.gitignore create mode 100644 packages/python/ess-langsmith-client/README.md create mode 100644 packages/python/ess-langsmith-client/docs/README.md create mode 100644 packages/python/ess-langsmith-client/docs/api-keys.md create mode 100644 packages/python/ess-langsmith-client/docs/building-images.md create mode 100644 packages/python/ess-langsmith-client/docs/deploying-agents.md create mode 100644 packages/python/ess-langsmith-client/docs/listeners.md create mode 100644 packages/python/ess-langsmith-client/docs/projects.md create mode 100644 packages/python/ess-langsmith-client/docs/testing-agents.md create mode 100644 packages/python/ess-langsmith-client/docs/workspaces.md create mode 100644 packages/python/ess-langsmith-client/pyproject.toml create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/__init__.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/_version.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/__init__.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/cli.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/resolution.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/runner.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/test_resolution.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/cli.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/naming.py rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/project.py (100%) create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/test_control_plane.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/test_naming.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/test_projects.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/tools/__init__.py (100%) rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/tools/build.py (82%) create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/control_plane.py rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/tools/deploy_docker.py (56%) rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/tools/deploy_github.py (67%) create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/tools/list_listeners.py (96%) rename packages/python/{langsmith-client/src/langsmith_client => ess-langsmith-client/src/ess_langsmith_client}/tools/list_workspaces.py (95%) create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/projects.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_main.py delete mode 100644 packages/python/langsmith-client/.env.example delete mode 100644 packages/python/langsmith-client/pyproject.toml delete mode 100644 packages/python/langsmith-client/src/langsmith_client/__init__.py delete mode 100644 packages/python/langsmith-client/src/langsmith_client/cli.py delete mode 100644 packages/python/langsmith-client/src/langsmith_client/client.py delete mode 100644 packages/python/langsmith-client/src/langsmith_client/secrets.py delete mode 100644 packages/python/langsmith-client/src/langsmith_client/tools/projects.py diff --git a/packages/python/ess-langsmith-client/.env.example b/packages/python/ess-langsmith-client/.env.example new file mode 100644 index 0000000..2c861a5 --- /dev/null +++ b/packages/python/ess-langsmith-client/.env.example @@ -0,0 +1,19 @@ +# Copy to .env and fill in. Every value is read from the environment, so +# exporting these instead of using a file works too. + +# LangSmith API key. Key and tracing-project operations need an admin key. +LANGSMITH_API_KEY= + +# Workspace to scope tenant-specific calls (keys, projects). Find yours with +# `langsmith-client workspaces list`. +LANGSMITH_WORKSPACE_ID= + +# Environment suffix in canonical `-` deployment names. Consulted +# only when --env is not passed; falls back to `dev`. +APP_ENV= + +# Default for `deploy docker --listener-id` (hybrid/self-hosted deployments). +LANGSMITH_LISTENER_ID= + +# Default for `deploy github --integration-id`. +GITHUB_INTEGRATION_ID= diff --git a/packages/python/ess-langsmith-client/.gitignore b/packages/python/ess-langsmith-client/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/packages/python/ess-langsmith-client/.gitignore @@ -0,0 +1 @@ +.env diff --git a/packages/python/ess-langsmith-client/README.md b/packages/python/ess-langsmith-client/README.md new file mode 100644 index 0000000..4bf97ba --- /dev/null +++ b/packages/python/ess-langsmith-client/README.md @@ -0,0 +1,87 @@ +# ess-langsmith-client + +A shared LangSmith Control Plane client and CLI. One `langsmith-client` command manages API keys, workspaces, deployments, images, listeners, and tracing projects. + +```bash +uv sync --all-packages + +# List your workspaces, then list the keys in one of them +uv run langsmith-client workspaces list +uv run langsmith-client keys list --workspace-id +``` + +## Installation + +In a `uv` workspace, declare the dependency in your `pyproject.toml`: + +```toml +[project] +dependencies = ["ess-langsmith-client"] + +[tool.uv.sources] +ess-langsmith-client = { workspace = true } +``` + +Then run `uv sync --all-packages` from the workspace root and use the CLI via +`uv run langsmith-client ...`. + +The `test-deployed` command needs the `agent-test` extra: +`ess-langsmith-client[agent-test]`. + +## Configuration + +Most commands read credentials from the environment (a `.env` file in this package is auto-loaded). Copy [`.env.example`](.env.example) to `.env` and fill it in: + +| Variable | Description | Required | +| --- | --- | --- | +| `LANGSMITH_API_KEY` | LangSmith API key. Key and project operations need an admin key. | Yes | +| `LANGSMITH_WORKSPACE_ID` | Workspace to scope tenant-specific calls (keys, projects). | For workspace-scoped commands | +| `APP_ENV` | Environment suffix in canonical `-` deployment names. Consulted only when `--env` is not passed; falls back to `dev`. | No | +| `LANGSMITH_LISTENER_ID` | Default for `deploy docker --listener-id`. See [docs/listeners.md](docs/listeners.md). | For hybrid deployments | +| `GITHUB_INTEGRATION_ID` | Default for `deploy github --integration-id`. | For GitHub deployments | + +Most commands also accept `--region` (defaults to the US control plane). + +> **Workspace scoping gotcha:** API keys and tracing projects are per-workspace. If you run `keys list` or `projects list` without `--workspace-id` (or `LANGSMITH_WORKSPACE_ID`), you may get zero results even when keys exist. Run `langsmith-client workspaces list` first to find the ID. + +## Commands + +Each subcommand has its own `--help` and a detailed guide in [`docs/`](docs/): + +- `keys` — manage API keys — [docs/api-keys.md](docs/api-keys.md) +- `workspaces` — query workspaces and their IDs — [docs/workspaces.md](docs/workspaces.md) +- `deploy docker` / `deploy github` — deploy agents — [docs/deploying-agents.md](docs/deploying-agents.md) +- `build` — build LangGraph Docker images — [docs/building-images.md](docs/building-images.md) +- `listeners` — list hybrid (self-hosted) listeners — [docs/listeners.md](docs/listeners.md) +- `projects` / `control-plane` — tracing projects and control-plane records — [docs/projects.md](docs/projects.md) +- `test-deployed` — smoke-test a deployed agent — [docs/testing-agents.md](docs/testing-agents.md) + +## Library + +`ControlPlaneClient` and the naming, secrets, and project helpers import directly from `ess_langsmith_client`: + +```python +from ess_langsmith_client import ControlPlaneClient, get_project_info +``` + +### Deployment secrets + +`deploy docker` and `deploy github` send only the secrets you name with +`--secret NAME=VALUE`; nothing is read from your environment implicitly. To layer +on convenience defaults, pass your own key list to `merge_secrets`: + +```python +from ess_langsmith_client import merge_secrets + +secrets = merge_secrets(cli_secrets, auto_detect_keys=["OPENAI_API_KEY"]) +``` + +## Running Tests + +```bash +uv run pytest packages/python/ess-langsmith-client +``` + +## License + +Apache License 2.0. diff --git a/packages/python/ess-langsmith-client/docs/README.md b/packages/python/ess-langsmith-client/docs/README.md new file mode 100644 index 0000000..f46c8cb --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/README.md @@ -0,0 +1,11 @@ +# langsmith-client docs + +Detailed guides for each `langsmith-client` subcommand. Start with the [package README](../README.md) for install and configuration, then dive into the area you need: + +- [API keys](api-keys.md) — `keys` list/create/delete, the `--all` duplicate safeguard, workspace scoping. +- [Workspaces](workspaces.md) — `workspaces` list/get; find the workspace IDs other commands need. +- [Deploying agents](deploying-agents.md) — `deploy docker` and `deploy github`: canonical naming, idempotent upsert, git-SHA rescue, secrets, scale. +- [Building images](building-images.md) — `build`: LangGraph image build, tagging, `--push`. +- [Listeners](listeners.md) — `listeners list` for hybrid (self-hosted) deployments. +- [Projects](projects.md) — `projects` (tracing) vs `control-plane` (control-plane records). +- [Testing agents](testing-agents.md) — `test-deployed` resolution flags. diff --git a/packages/python/ess-langsmith-client/docs/api-keys.md b/packages/python/ess-langsmith-client/docs/api-keys.md new file mode 100644 index 0000000..a8b06f8 --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/api-keys.md @@ -0,0 +1,65 @@ +# API keys + +Manage LangSmith API keys with `langsmith-client keys`. Listing, creating, and deleting keys all require an admin `LANGSMITH_API_KEY`. + +```bash +# List keys in a workspace +uv run langsmith-client keys list --workspace-id + +# Create a service key (the full value is shown only once) +uv run langsmith-client keys create "LangSmith Deployment: hello-world-graph" + +# Delete a key by its description (name) +uv run langsmith-client keys delete "my-old-key" +``` + +> **Workspace scoping:** keys are per-workspace. Pass `--workspace-id` (or set `LANGSMITH_WORKSPACE_ID`); otherwise you may see zero results. Run `langsmith-client workspaces list` to find the ID. + +## `keys list` + +List API keys for the current workspace. + +| Option | Description | +| --- | --- | +| `--api-key` | LangSmith API key (defaults to `LANGSMITH_API_KEY`). | +| `--workspace-id` | Target workspace (defaults to `LANGSMITH_WORKSPACE_ID`). | +| `--expired` | Show only expired keys. | +| `--older-than N` | Show only keys older than `N` days (by `created_at`). | +| `--format {table,json}` | Output format (default: `table`). | + +The table shows description, short key, age in days, expiry (with an `EXPIRED` marker), and the key ID. + +## `keys create` + +Create a new service API key. + +```bash +uv run langsmith-client keys create "my-service-key" --format json +``` + +- Argument: `DESCRIPTION` — the human-readable name shown in the LangSmith UI. +- The **full key value is displayed once, at creation time only.** Copy it immediately. + +## `keys delete` + +Delete one or more keys by exact description. + +```bash +# Delete several keys +uv run langsmith-client keys delete "key-1" "key-2" "key-3" + +# Skip the confirmation prompt +uv run langsmith-client keys delete "key-1" --yes + +# Delete every key sharing a duplicated name +uv run langsmith-client keys delete "duplicated-name" --all +``` + +| Option | Description | +| --- | --- | +| `--all` | Delete every key matching a description, even when one description matches multiple keys. | +| `--yes` | Skip the confirmation prompt. | + +### Duplicate safeguard + +By default, if any description matches **more than one** key, `delete` refuses to act and lists the conflicting keys (short key, ID, expiry) so you can inspect them. This prevents accidentally wiping multiple keys that happen to share a name. Re-run with `--all` to delete every matching key deliberately. diff --git a/packages/python/ess-langsmith-client/docs/building-images.md b/packages/python/ess-langsmith-client/docs/building-images.md new file mode 100644 index 0000000..7d17291 --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/building-images.md @@ -0,0 +1,41 @@ +# Building images + +Build a Docker image for a LangGraph agent with `langsmith-client build`. It wraps `langgraph build`, deriving a consistent image tag from `pyproject.toml` and the current git SHA. + +```bash +# Build with defaults from pyproject.toml (tag: name:version-) +uv run langsmith-client build + +# Build and push to a registry +uv run langsmith-client build --push --registry gcr.io/my-project +``` + +## Tagging + +When `--tag` is not given, the tag is `name:version-` (falling back to `name:version` when the SHA is unavailable), read from `[project]` in `pyproject.toml`. A changing tag per commit is what makes each deploy roll out a fresh image. + +## Options + +| Option | Description | +| --- | --- | +| `-t, --tag` | Override the image tag (default: `name:version` from pyproject). | +| `--push` | Push to the registry after build. | +| `--registry` | Registry prefix for push (e.g. `gcr.io/my-project`). | +| `--platform` | Docker platform (default: `linux/amd64`; use `linux/arm64` for local Apple Silicon testing). | +| `-C, --project-dir` | Project dir containing `pyproject.toml` and `langgraph.json` (default: `.`). | +| `LANGGRAPH_ARGS` | Any trailing arguments are passed straight through to `langgraph build`. | + +## Examples + +```bash +# Build a specific project directory +uv run langsmith-client build -C path/to/my-agent + +# Custom tag +uv run langsmith-client build -t my-image:v2 + +# Build for local ARM testing +uv run langsmith-client build --platform linux/arm64 +``` + +Once pushed, deploy the image with [`deploy docker`](deploying-agents.md). diff --git a/packages/python/ess-langsmith-client/docs/deploying-agents.md b/packages/python/ess-langsmith-client/docs/deploying-agents.md new file mode 100644 index 0000000..9146066 --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/deploying-agents.md @@ -0,0 +1,105 @@ +# Deploying agents + +Deploy LangGraph agents to LangSmith with `langsmith-client deploy`. Two sources are supported: + +- `deploy docker` — deploy a prebuilt Docker image (self-hosted / hybrid clusters). +- `deploy github` — deploy from a GitHub repo (LangSmith Cloud). + +Both require `LANGSMITH_API_KEY` and `LANGSMITH_WORKSPACE_ID`. + +```bash +# Docker: build & push first, then deploy +uv run langsmith-client build --push --registry +uv run langsmith-client deploy docker create --listener-id --wait + +# GitHub: deploy from a repo (one-time integration setup required) +uv run langsmith-client deploy github create --repo-url --integration-id +``` + +## Canonical naming and idempotent upsert + +Deployments use a stable canonical name of the form `-`: + +- `service` defaults to `[project].name` in `pyproject.toml` (override with `--name`). +- `env` comes from `--env`, then `$APP_ENV`, then `dev`. +- For Docker you can also pass `--deployment` to use a full base name as-is (e.g. `my-agent-prod-dev`) without the service/env split. + +Re-running `create` is an **idempotent upsert**: + +- updates the live deployment in place if one exists, +- creates the canonical name if none exists, +- creates a **git-SHA rescue name** (`--`) only when the canonical name is stuck/orphaned. + +## `deploy docker` + +### `create` + +Deploy (or upsert) a Docker image. + +| Option | Description | +| --- | --- | +| `--name` | Service name for `-` (default: project name). | +| `--deployment` | Full base name used as-is (skips service/env split). | +| `--env` | Environment component (default: `$APP_ENV` or `dev`). | +| `-C, --project-dir` | Project dir containing `pyproject.toml` (default: `.`). | +| `--image-uri` | Docker image URI (default: `name:version` from pyproject). | +| `--listener-id` | Listener for hybrid deployments (or `LANGSMITH_LISTENER_ID`). See [listeners](listeners.md). | +| `--namespace` | Kubernetes namespace (default: `default`). | +| `--secret NAME=VALUE` | Secret (repeatable); `NAME=$ENV_VAR` reads from the environment. | +| `--min-scale` / `--max-scale` | Instance bounds (default: 1 / 3). | +| `--cpu` / `--memory` | CPU cores / memory MB per instance (default: 1 / 1024). | +| `--wait` | Wait for the deployment to complete. | + +### `update` + +Update a specific deployment by ID with a new image (and secrets). + +| Option | Description | +| --- | --- | +| `--deployment-id` | **Required.** Deployment to update. | +| `-C, --project-dir` | Project dir for deriving the image URI. | +| `--image-uri` | New image URI (default: `name:version` from pyproject). | +| `--secret NAME=VALUE` | Secret (repeatable). | +| `--wait` | Wait for completion. | + +### `list` + +List deployments; `--filter` matches names (contains), `--docker-only` shows only Docker deployments. + +### `delete` + +Delete by `--deployment-id`, or by the resolved `-` base name (via `--name`/`--deployment`/`-C` + `--env`). `--if-exists` exits 0 when nothing matches; `--yes`/`-y` skips confirmation. + +## `deploy github` + +### `create` + +Deploy (or upsert) from a GitHub repository. Requires a one-time GitHub integration (LangSmith UI → Deployments → Import from GitHub), then a `GITHUB_INTEGRATION_ID`. + +| Option | Description | +| --- | --- | +| `--name` | Service name for `-` (default: project name). | +| `--env` | Environment component (default: `$APP_ENV` or `dev`). | +| `-C, --project-dir` | Project dir containing `pyproject.toml`. | +| `--repo-url` | **Required.** GitHub repository URL. | +| `--branch` | Branch to deploy (default: `main`). | +| `--config-path` | Path to `langgraph.json` (default: `langgraph.json`). | +| `--integration-id` | GitHub integration ID (or `GITHUB_INTEGRATION_ID`). | +| `--type {dev,prod}` | Deployment type (default: `dev`). | +| `--auto-build/--no-auto-build` | Rebuild on push (default: on). | +| `--shareable` | Make shareable via Studio. | +| `--secret NAME=VALUE` | Secret (repeatable). | +| `--min-scale` / `--max-scale` / `--cpu` / `--memory` | Resource spec. | +| `--wait` | Wait for completion. | + +### `update` + +Update a deployment by `--deployment-id` (creates a new revision). Optional `--branch`, `--config-path`, `--auto-build/--no-auto-build`, `--wait`. + +### `list` + +List deployments; `--filter` matches names, `--github-only` shows only GitHub deployments. + +### `delete` + +Delete by `--deployment-id` (prompts for confirmation). diff --git a/packages/python/ess-langsmith-client/docs/listeners.md b/packages/python/ess-langsmith-client/docs/listeners.md new file mode 100644 index 0000000..023beaf --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/listeners.md @@ -0,0 +1,20 @@ +# Listeners + +List LangSmith listeners with `langsmith-client listeners`. Listeners connect the LangSmith control plane to a self-hosted Kubernetes cluster; their ID is required when creating Docker-based deployments against that cluster. + +```bash +uv run langsmith-client listeners list +``` + +Requires `LANGSMITH_API_KEY` and `LANGSMITH_WORKSPACE_ID`. + +## `listeners list` + +| Option | Description | +| --- | --- | +| `--api-key` | LangSmith API key (defaults to `LANGSMITH_API_KEY`). | +| `--workspace-id` | Target workspace (defaults to `LANGSMITH_WORKSPACE_ID`). | +| `--region {us,eu}` | Control plane region (default: `us`). | +| `--format {table,json}` | Output format (default: `table`). | + +The table shows each listener's ID, name, and status. Feed the ID into [`deploy docker create --listener-id `](deploying-agents.md) for hybrid deployments. diff --git a/packages/python/ess-langsmith-client/docs/projects.md b/packages/python/ess-langsmith-client/docs/projects.md new file mode 100644 index 0000000..c8a046b --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/projects.md @@ -0,0 +1,61 @@ +# Projects + +There are two distinct "project" concepts in LangSmith, each with its own command: + +- **Tracing projects** (sessions) — where traces land. Managed with `langsmith-client projects`. +- **Control-plane projects** — the control plane's backing records for deployments. Managed with `langsmith-client control-plane projects`. + +Deleting a control-plane project does **not** delete traces unless you explicitly opt in. Use `projects` to manage traces, `control-plane` to manage deployment-backing records. + +## Tracing projects — `projects` + +Tracing projects are created automatically when LangGraph deployments are created. Only `LANGSMITH_API_KEY` is required. + +```bash +uv run langsmith-client projects list --prefix hello-agent-dev +uv run langsmith-client projects info --name hello-agent-dev +uv run langsmith-client projects delete --name hello-agent-auth +``` + +### `projects list` + +| Option | Description | +| --- | --- | +| `--api-key` | LangSmith API key (defaults to `LANGSMITH_API_KEY`). | +| `--name` | Filter by exact project name. | +| `--prefix` | Match `` and `-*` — finds a service's live project regardless of any git-SHA rescue suffix. | +| `--format {table,json}` | Output format (default: `table`). | + +`--name` and `--prefix` are mutually exclusive. The table shows ID, name, run count, and the linked deployment ID. + +### `projects info` + +Show metadata and trace stats for one project by exact `--name` (required): run count, last run time, and deployment ID. + +### `projects delete` + +Delete by `--id` (single project) or `--name` (all projects with that exact name); the two are mutually exclusive. Prompts for confirmation. + +`--force`: +- deletes projects that still contain traces (**data loss**), and +- clears stale deployment references (orphaned deployments) that would otherwise block deletion with a 409. + +Without `--force`, projects that still hold traces are skipped and reported. + +## Control-plane projects — `control-plane projects` + +These records belong to the control plane's `/api-host` API and are distinct from tracing projects. Requires `LANGSMITH_API_KEY` (Admin) and `LANGSMITH_WORKSPACE_ID`. + +```bash +uv run langsmith-client control-plane projects delete --id +uv run langsmith-client control-plane projects delete --id --id --yes +``` + +### `control-plane projects delete` + +| Option | Description | +| --- | --- | +| `--id` | Control-plane project ID to delete (repeatable, required). | +| `--force/--no-force` | Clear stale references that block deletion (default: on). | +| `--delete-tracing-project` | Also delete the paired tracing project and its traces (off by default, so traces are preserved). | +| `--yes` | Skip the confirmation prompt. | diff --git a/packages/python/ess-langsmith-client/docs/testing-agents.md b/packages/python/ess-langsmith-client/docs/testing-agents.md new file mode 100644 index 0000000..995e15f --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/testing-agents.md @@ -0,0 +1,43 @@ +# Testing agents + +Smoke-test a deployed LangGraph agent with `langsmith-client test-deployed`. It resolves the agent's base URL, sends a message, and prints the response. + +```bash +# Resolve the deployment via the control plane by service + env +uv run langsmith-client test-deployed --service hello-agent --env prod -m "What is 2+2?" +``` + +## `test-deployed` + +| Option | Description | +| --- | --- | +| `--service` | **Required.** Service name for control-plane lookup. | +| `-d, --deployment` | Explicit deployment name (default: `-`). | +| `-e, --env` | Environment for the canonical name (default: `$APP_ENV` or `dev`). | +| `--region {us,eu}` | Control plane region (default: `us`). | +| `--url` | Friendly ingress host for resolution (e.g. `https://agents.example.com`), or the base URL when using `--prefix`/`--kubectl`. | +| `--prefix` | Mount prefix (e.g. `/lgp/`) joined onto `--url`; skips control-plane resolution. | +| `--kubectl` | Auto-detect the mount prefix from a running pod via `kubectl`, joined onto `--url`. | +| `--assistant-id` | Assistant/graph ID to invoke (default: `agent`). | +| `-m, --message` | Message to send (default: `Hello! What can you do?`). | +| `--stream` | Stream the response instead of waiting for completion. | + +## Resolution modes + +The base URL is resolved in one of three ways, in priority order: + +1. **`--prefix`** — join the mount prefix onto `--url` directly (no control-plane call). +2. **`--kubectl`** — auto-detect the prefix from a live pod, joined onto `--url`. +3. **Control plane (default)** — resolve `-` (or `--deployment`) through the control plane; use `--url` to override the ingress host. + +```bash +# Friendly dev ingress host +uv run langsmith-client test-deployed --service hello-agent \ + --url https://agents.example.com -d hello-agent-prod-dev + +# Manual prefix (port-forward) +uv run langsmith-client test-deployed --service hello-agent --prefix /lgp/abc + +# kubectl auto-detect, streaming +uv run langsmith-client test-deployed --service hello-agent --kubectl --stream +``` diff --git a/packages/python/ess-langsmith-client/docs/workspaces.md b/packages/python/ess-langsmith-client/docs/workspaces.md new file mode 100644 index 0000000..be37e59 --- /dev/null +++ b/packages/python/ess-langsmith-client/docs/workspaces.md @@ -0,0 +1,33 @@ +# Workspaces + +Query the LangSmith workspaces available to your API key with `langsmith-client workspaces`. This is usually the **first** command you run — most other commands need a workspace ID, and keys/projects are scoped per workspace. + +```bash +uv run langsmith-client workspaces list +``` + +Only `LANGSMITH_API_KEY` is required. + +## `workspaces list` + +List every workspace the API key can access. + +| Option | Description | +| --- | --- | +| `--api-key` | LangSmith API key (defaults to `LANGSMITH_API_KEY`). | +| `--format {table,json}` | Output format (default: `table`). | +| `--role NAME` | Filter by role name (contains match). | +| `--active-only` | Show only non-deleted workspaces. | + +The table shows display name, workspace **ID**, your role, and creation date. Copy the ID for use as `--workspace-id` (or `LANGSMITH_WORKSPACE_ID`) elsewhere. + +## `workspaces get` + +Show full JSON details for a single workspace. + +```bash +uv run langsmith-client workspaces get +``` + +- Argument: `WORKSPACE_ID` — the workspace UUID. +- Errors if no accessible workspace matches the ID. diff --git a/packages/python/ess-langsmith-client/pyproject.toml b/packages/python/ess-langsmith-client/pyproject.toml new file mode 100644 index 0000000..eaa4ded --- /dev/null +++ b/packages/python/ess-langsmith-client/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "ess-langsmith-client" +version = "0.1.0" +description = "LangSmith Control Plane API client and CLI" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.12,<3.13" +dependencies = [ + "requests>=2.31.0", + "click>=8.1.0", + "python-dotenv>=1.1.0", + "python-decouple>=3.8", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +agent-test = ["langgraph-sdk>=0.1.0"] + +[project.scripts] +langsmith-client = "ess_langsmith_client.tools.main:cli" + +[build-system] +# >=1.27 understands the bare SPDX string form of `license`. +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_langsmith_client"] +exclude = ["**/test_*.py"] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/__init__.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/__init__.py new file mode 100644 index 0000000..a81f67f --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/__init__.py @@ -0,0 +1,80 @@ +"""LangSmith Control Plane API client and CLI utilities. + +This package provides a shared client for interacting with the +LangSmith Control Plane API. +""" + +from ess_langsmith_client.cli import ( + common_options, + create_client, + deployment_option, + echo_deployment_created, + echo_deployment_resolved, + echo_success, + env_option, + handle_wait, + print_deployments, + resolve_deployment_base_url, + resolve_live_deployment_record, +) +from ess_langsmith_client.client import ( + CONTROL_PLANE_HOSTS, + MAX_WAIT_TIME, + POLL_INTERVAL, + ControlPlaneClient, +) +from ess_langsmith_client.naming import ( + DEFAULT_ENV, + ENV_VAR, + choose_deploy_name, + compute_base_name, + get_git_sha, + resolve_deploy_base, + resolve_env, + sanitize_name_component, +) +from ess_langsmith_client.project import ( + ProjectInfo, + get_project_info, +) +from ess_langsmith_client.secrets import ( + get_env_secrets, + merge_secrets, + parse_secrets, +) + +__all__ = [ + # Client + "ControlPlaneClient", + "CONTROL_PLANE_HOSTS", + "MAX_WAIT_TIME", + "POLL_INTERVAL", + # CLI utilities + "common_options", + "create_client", + "deployment_option", + "echo_deployment_created", + "echo_deployment_resolved", + "echo_success", + "env_option", + "handle_wait", + "print_deployments", + "resolve_deployment_base_url", + "resolve_live_deployment_record", + # Secrets + "get_env_secrets", + "merge_secrets", + "parse_secrets", + # Project info + "ProjectInfo", + "get_project_info", + # Naming convention + "DEFAULT_ENV", + "ENV_VAR", + "choose_deploy_name", + "compute_base_name", + "get_git_sha", + "resolve_deploy_base", + "resolve_env", + "sanitize_name_component", +] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/_version.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/_version.py new file mode 100644 index 0000000..6f79165 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/_version.py @@ -0,0 +1,11 @@ +"""Installed package version for CLI --version output.""" + +from importlib.metadata import PackageNotFoundError, version + + +def get_package_version() -> str: + """Return the installed ess-langsmith-client distribution version.""" + try: + return version("ess-langsmith-client") + except PackageNotFoundError: + return "0.0.0" diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/__init__.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/__init__.py new file mode 100644 index 0000000..911c722 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/__init__.py @@ -0,0 +1,16 @@ +"""Agent test harness for deployed and local LangGraph agents. + +Requires the ``agent-test`` optional extra:: + + ess-langsmith-client[agent-test] + +Use :func:`build_test_command` and :func:`build_local_test_command` from app +``tools/`` shims, or the ``langsmith-client test-deployed`` console script. +""" + +from ess_langsmith_client.agent_test.cli import ( + build_local_test_command, + build_test_command, +) + +__all__ = ["build_local_test_command", "build_test_command"] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/cli.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/cli.py new file mode 100644 index 0000000..da4b0dc --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/cli.py @@ -0,0 +1,252 @@ +"""Click CLI builders for deployed and local agent smoke tests.""" + +import socket +import urllib.parse + +import click + +from ess_langsmith_client.agent_test.resolution import resolve_base_url +from ess_langsmith_client.agent_test.runner import run_conversation + +_DEFAULT_LOCAL_URL = "http://127.0.0.1:2024" + + +def _server_is_reachable(url: str) -> bool: + """Check if the server is accepting connections.""" + parsed = urllib.parse.urlparse(url) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or (443 if parsed.scheme == "https" else 80) + try: + with socket.create_connection((host, port), timeout=2): + return True + except OSError: + return False + + +def build_test_command( + service: str, + *, + default_message: str = "Hello! What can you do?", + default_assistant_id: str = "agent", +) -> click.Command: + """Build a Click command for testing a deployed LangGraph agent.""" + + @click.command() + @click.option( + "--deployment", + "-d", + default=None, + help=( + f"Deployment name to resolve via the control plane " + f"(default: <{service}>-). Ignored if --prefix/--kubectl is set." + ), + ) + @click.option( + "--env", + "-e", + default=None, + help="Environment for canonical deployment name (default: $APP_ENV or dev).", + ) + @click.option( + "--region", + type=click.Choice(["us", "eu"]), + default="us", + show_default=True, + help="LangSmith Control Plane region (us or eu).", + ) + @click.option( + "--url", + default=None, + help=( + "Friendly ingress host for control-plane resolution " + "(e.g. https://agents.example.com), or base URL for " + "--prefix/--kubectl (default: http://localhost:8000 when prefix/kubectl)." + ), + ) + @click.option( + "--prefix", + default=None, + help="Mount prefix (e.g. /lgp/) joined onto --url. Skips resolution.", + ) + @click.option( + "--kubectl", + "use_kubectl", + is_flag=True, + help="Auto-detect the mount prefix from a running pod via kubectl, onto --url.", + ) + @click.option( + "--assistant-id", + default=default_assistant_id, + help=f"Assistant/graph ID to invoke (default: {default_assistant_id})", + ) + @click.option( + "--message", + "-m", + default=default_message, + help="Message to send to the agent", + ) + @click.option( + "--stream", + "use_stream", + is_flag=True, + help="Stream the response instead of waiting for completion", + ) + def test( # noqa: PLR0913 # Click injects one param per option + deployment: str | None, + env: str | None, + region: str, + url: str | None, + prefix: str | None, + use_kubectl: bool, + assistant_id: str, + message: str, + use_stream: bool, + ): + """Test a deployed LangGraph agent. + + \b + Examples: + # Resolve the nice URL from the control plane (default) + uv run python tools/test_deployed.py --deployment {service}-prod-dev + # Same deployment, friendly dev ingress host + uv run python tools/test_deployed.py \\ + --url https://agents.example.com -d {service}-prod-dev + # Canonical name from project + env + uv run python tools/test_deployed.py --env prod -m "What is 2+2?" + # Manual prefix (port-forward) + uv run python tools/test_deployed.py --prefix /lgp/abc + # kubectl auto-detect (legacy port-forward flow) + uv run python tools/test_deployed.py --kubectl + # Stream the response + uv run python tools/test_deployed.py -d {service}-prod-dev --stream + """.format(service=service) + base_url = resolve_base_url( + service, + url=url, + prefix=prefix, + use_kubectl=use_kubectl, + deployment=deployment, + env=env, + region=region, + ) + click.echo(f"Connecting to {base_url}") + run_conversation( + base_url, + assistant_id, + message, + use_stream=use_stream, + ) + + return test + + +def build_local_test_command( + service: str, # pylint: disable=unused-argument # per-app shim identity + *, + default_message: str = "Hello!", + default_assistant_id: str = "agent", + default_url: str = _DEFAULT_LOCAL_URL, +) -> click.Command: + """Build a Click command for testing a local LangGraph dev server.""" + + @click.command() + @click.option( + "--url", + default=default_url, + help=f"Local LangGraph dev server URL (default: {default_url})", + ) + @click.option( + "--assistant-id", + default=default_assistant_id, + help=f"Assistant/graph ID to invoke (default: {default_assistant_id})", + ) + @click.option( + "--message", + "-m", + default=default_message, + help=f'Message to send to the agent (default: "{default_message}")', + ) + @click.option( + "--stream", + "use_stream", + is_flag=True, + help="Stream the response instead of waiting for completion", + ) + def test(url: str, assistant_id: str, message: str, use_stream: bool): + """Test the agent running locally via ``langgraph dev``. + + \b + Examples: + uv run python tools/test_local.py + uv run python tools/test_local.py -m "What is 2+2?" + uv run python tools/test_local.py --stream + uv run python tools/test_local.py --url http://127.0.0.1:8123 + """ + if not _server_is_reachable(url): + raise click.ClickException( + f"Cannot reach server at {url}. " + "Start the local server first: uv run langgraph dev" + ) + + click.echo(f"Connecting to {url}") + run_conversation( + url, + assistant_id, + message, + use_stream=use_stream, + api_key=None, + ) + + return test + + +@click.command() +@click.option("--service", required=True, help="Service name for control-plane lookup.") +@click.option("--deployment", "-d", default=None, help="Explicit deployment name.") +@click.option("--env", "-e", default=None, help="Environment for canonical name.") +@click.option( + "--region", + type=click.Choice(["us", "eu"]), + default="us", + show_default=True, + help="LangSmith Control Plane region (us or eu).", +) +@click.option( + "--url", + default=None, + help="Friendly ingress host or port-forward base URL.", +) +@click.option("--prefix", default=None) +@click.option("--kubectl", "use_kubectl", is_flag=True) +@click.option("--assistant-id", default="agent") +@click.option("--message", "-m", default="Hello! What can you do?") +@click.option("--stream", "use_stream", is_flag=True) +def deployed( # noqa: PLR0913 # Click injects one param per option + service: str, + deployment: str | None, + env: str | None, + region: str, + url: str | None, + prefix: str | None, + use_kubectl: bool, + assistant_id: str, + message: str, + use_stream: bool, +) -> None: + """Console entry point: test a deployed agent by service name.""" + base_url = resolve_base_url( + service, + url=url, + prefix=prefix, + use_kubectl=use_kubectl, + deployment=deployment, + env=env, + region=region, + ) + click.echo(f"Connecting to {base_url}") + run_conversation( + base_url, + assistant_id, + message, + use_stream=use_stream, + ) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/resolution.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/resolution.py new file mode 100644 index 0000000..c7ae068 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/resolution.py @@ -0,0 +1,112 @@ +"""Base URL resolution for deployed LangGraph agents.""" + +import subprocess # nosec B404 # developer tooling shells out to kubectl +import urllib.parse + +import click + +from ess_langsmith_client import resolve_deployment_base_url + +_DEFAULT_PORT_FORWARD_HOST = "http://localhost:8000" + + +def _detect_prefix() -> str | None: + """Auto-detect the mount prefix from a running agent pod.""" + try: + result = subprocess.run( # nosec B603 B607 # hardcoded kubectl with list args, no shell + [ + "kubectl", + "get", + "pods", + "-o", + "jsonpath={range .items[*]}{range .spec.containers[0].env[*]}" + '{.name}={.value}{"\\n"}{end}{end}', + ], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + for line in result.stdout.splitlines(): + if line.startswith("MOUNT_PREFIX="): + return line.split("=", 1)[1] + return None + + +def resolve_prefix_via_kubectl(host: str) -> str: + """Join a host URL with the mount prefix auto-detected from kubectl.""" + click.echo("Auto-detecting mount prefix from kubectl...") + detected = _detect_prefix() + if not detected: + raise click.ClickException( + "Could not auto-detect mount prefix from kubectl. Use --prefix to specify." + ) + click.echo(f" Found: {detected}") + return f"{host.rstrip('/')}{detected}" + + +def join_host_with_custom_url(host_url: str, custom_url: str) -> str: + """Combine a user-supplied friendly host with a control-plane ``custom_url`` path. + + Args: + host_url: Ingress host the user wants to hit (e.g. + ``https://agents.example.com``). Only scheme and netloc are used. + custom_url: Full URL from the control plane (e.g. + ``https://agents.internal.example.com/lgp/hello-agent-dev-abc``). + + Returns: + ``{host scheme+netloc}{custom_url path}`` with no trailing slash on the host. + + Raises: + click.ClickException: if ``custom_url`` has no path to join. + """ + host = urllib.parse.urlparse(host_url) + custom = urllib.parse.urlparse(custom_url) + if not custom.path or custom.path == "/": + raise click.ClickException( + f"Deployment custom_url has no mount path: {custom_url!r}. " + "Use --prefix or --kubectl instead." + ) + scheme = host.scheme or custom.scheme or "https" + netloc = host.netloc or custom.netloc + if not netloc: + raise click.ClickException( + f"Could not determine host from --url={host_url!r} or custom_url." + ) + return f"{scheme}://{netloc}{custom.path}" + + +def resolve_base_url( # noqa: PLR0913 # one param per resolution input + service: str, + *, + url: str | None, + prefix: str | None, + use_kubectl: bool, + deployment: str | None, + env: str | None, + region: str, +) -> str: + """Determine the full base URL for a deployed agent. + + Precedence: + 1. ``--prefix`` -> host + prefix (host defaults to port-forward localhost). + 2. ``--kubectl`` -> host + kubectl-detected prefix. + 3. Control plane -> resolve ``custom_url``; if ``--url`` host given, join + host with ``custom_url`` path; else return ``custom_url`` as-is. + """ + if prefix: + host = (url or _DEFAULT_PORT_FORWARD_HOST).rstrip("/") + normalized_prefix = prefix if prefix.startswith("/") else f"/{prefix}" + return f"{host}{normalized_prefix}" + if use_kubectl: + return resolve_prefix_via_kubectl(url or _DEFAULT_PORT_FORWARD_HOST) + custom_url = resolve_deployment_base_url( + service, deployment=deployment, env=env, region=region + ) + if url: + joined = join_host_with_custom_url(url, custom_url) + click.echo(f" Using host: {joined}") + return joined + return custom_url diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/runner.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/runner.py new file mode 100644 index 0000000..eadcee1 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/runner.py @@ -0,0 +1,110 @@ +"""Conversation runner for LangGraph agent smoke tests.""" + +import json +import os + +import click + +_AGENT_TEST_EXTRA = "ess-langsmith-client[agent-test]" + + +def _get_sync_client(): + try: + from langgraph_sdk import get_sync_client # noqa: PLC0415 # optional extra + except ImportError as exc: + raise click.ClickException( + f"langgraph-sdk is required for agent tests. " + f"Install with: uv add --dev {_AGENT_TEST_EXTRA}" + ) from exc + return get_sync_client + + +def list_assistants(client) -> None: + click.echo("\nAvailable assistants:") + try: + assistants = client.assistants.search() + for assistant in assistants: + graph_id = assistant.get("graph_id", "N/A") + name = assistant.get("name", graph_id) + click.echo(f" - {name} (graph_id={graph_id})") + except Exception as exc: + click.echo( + click.style(f" Warning: Could not list assistants: {exc}", fg="yellow") + ) + + +def _handle_streaming_response( + client, thread_id: str, assistant_id: str, message: str +) -> None: + click.echo("--- Streaming response ---") + input_data = {"messages": [{"role": "user", "content": message}]} + for event in client.runs.stream( + thread_id=thread_id, + assistant_id=assistant_id, + input=input_data, + ): + if hasattr(event, "data") and event.data: + messages = event.data.get("messages", []) + for msg in messages: + if msg.get("type") == "ai" or msg.get("role") == "assistant": + content = msg.get("content", "") + if content: + click.echo(content) + click.echo("--- End of stream ---") + + +def _handle_non_streaming_response( + client, thread_id: str, assistant_id: str, message: str +) -> None: + input_data = {"messages": [{"role": "user", "content": message}]} + result = client.runs.wait( + thread_id=thread_id, + assistant_id=assistant_id, + input=input_data, + ) + messages = result.get("messages", []) + if messages: + click.echo("--- Response ---") + for msg in messages: + role = msg.get("type", msg.get("role", "unknown")) + content = msg.get("content", "") + if content: + click.echo(f"[{role}] {content}") + click.echo("--- End ---") + else: + click.echo("Raw result:") + click.echo(json.dumps(result, indent=2, default=str)) + + +def run_conversation( + base_url: str, + assistant_id: str, + message: str, + *, + use_stream: bool, + api_key: str | None = None, +) -> str: + """Connect to an agent, send a message, and return the thread ID.""" + get_sync_client = _get_sync_client() + client = get_sync_client( + url=base_url, + api_key=api_key or os.environ.get("LANGSMITH_API_KEY"), + ) + list_assistants(client) + + click.echo("\nCreating thread and sending message...") + click.echo(f" Assistant: {assistant_id}") + click.echo(f" Message: {message}") + click.echo() + + thread = client.threads.create() + thread_id = thread["thread_id"] + + if use_stream: + _handle_streaming_response(client, thread_id, assistant_id, message) + else: + _handle_non_streaming_response(client, thread_id, assistant_id, message) + + click.echo(f"\nThread ID: {thread_id}") + click.echo(click.style("Test complete!", fg="green")) + return thread_id diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/test_resolution.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/test_resolution.py new file mode 100644 index 0000000..43868b1 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/agent_test/test_resolution.py @@ -0,0 +1,117 @@ +"""Tests for agent_test URL resolution.""" + +import click +import pytest + +from ess_langsmith_client.agent_test.resolution import ( + join_host_with_custom_url, + resolve_base_url, +) +from ess_langsmith_client.client import ControlPlaneClient + +_CREDS_PATCH = { + "api_key": "test-key", + "workspace_id": "test-ws", +} + + +def _stub_deployments(monkeypatch, resources): + def _fake_list_deployments(self, name_contains=None): + return {"resources": resources} + + monkeypatch.setattr(ControlPlaneClient, "list_deployments", _fake_list_deployments) + + +def _deployment(name, custom_url): + return { + "name": name, + "id": "d1", + "status": "DEPLOYED", + "source_config": {"custom_url": custom_url}, + } + + +class TestJoinHostWithCustomUrl: + def test_replaces_host_keeps_path(self): + joined = join_host_with_custom_url( + "https://agents.example.com", + "https://agents.internal.example.com/lgp/hello-agent-dev-abc", + ) + assert joined == "https://agents.example.com/lgp/hello-agent-dev-abc" + + def test_missing_path_raises(self): + with pytest.raises(click.ClickException, match="no mount path"): + join_host_with_custom_url( + "https://agents.example.com", + "https://agents.internal.example.com", + ) + + +class TestResolveBaseUrl: + def test_prefix_mode_uses_port_forward_default(self): + base = resolve_base_url( + "hello-agent", + url=None, + prefix="/lgp/abc", + use_kubectl=False, + deployment=None, + env="dev", + region="us", + ) + assert base == "http://localhost:8000/lgp/abc" + + def test_prefix_mode_honors_url(self): + base = resolve_base_url( + "hello-agent", + url="http://127.0.0.1:9000", + prefix="/lgp/abc", + use_kubectl=False, + deployment=None, + env="dev", + region="us", + ) + assert base == "http://127.0.0.1:9000/lgp/abc" + + def test_prefix_mode_adds_leading_slash(self): + base = resolve_base_url( + "hello-agent", + url=None, + prefix="lgp/abc", + use_kubectl=False, + deployment=None, + env="dev", + region="us", + ) + assert base == "http://localhost:8000/lgp/abc" + + def test_control_plane_returns_custom_url(self, monkeypatch): + custom = "https://agents.internal.example.com/lgp/hello-agent-dev-abc" + _stub_deployments(monkeypatch, [_deployment("hello-agent-dev", custom)]) + monkeypatch.setenv("LANGSMITH_API_KEY", _CREDS_PATCH["api_key"]) + monkeypatch.setenv("LANGSMITH_WORKSPACE_ID", _CREDS_PATCH["workspace_id"]) + base = resolve_base_url( + "hello-agent", + url=None, + prefix=None, + use_kubectl=False, + deployment=None, + env="dev", + region="us", + ) + assert base == custom + + def test_control_plane_joins_friendly_host(self, monkeypatch): + custom = "https://agents.internal.example.com/lgp/hello-agent-dev-abc" + _stub_deployments(monkeypatch, [_deployment("hello-agent-dev", custom)]) + monkeypatch.setenv("LANGSMITH_API_KEY", _CREDS_PATCH["api_key"]) + monkeypatch.setenv("LANGSMITH_WORKSPACE_ID", _CREDS_PATCH["workspace_id"]) + base = resolve_base_url( + "hello-agent", + url="https://agents.example.com", + prefix=None, + use_kubectl=False, + deployment=None, + env="dev", + region="us", + ) + assert base == "https://agents.example.com/lgp/hello-agent-dev-abc" diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/cli.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/cli.py new file mode 100644 index 0000000..ec25cef --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/cli.py @@ -0,0 +1,222 @@ +"""Click CLI utilities for LangSmith Control Plane scripts.""" + +from typing import Any, TypeVar + +import click + +from ess_langsmith_client.client import ControlPlaneClient +from ess_langsmith_client.naming import resolve_deploy_base + +T = TypeVar("T", bound=ControlPlaneClient) + + +def common_options(func): + """Decorator to add common CLI options (region, api-key, workspace-id).""" + func = click.option( + "--region", + type=click.Choice(["us", "eu"]), + default="us", + help="LangSmith region", + )(func) + func = click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key", + )(func) + func = click.option( + "--workspace-id", + envvar="LANGSMITH_WORKSPACE_ID", + help="LangSmith workspace ID", + )(func) + return func + + +def env_option(func): + """Add the --env option used to build the canonical - name. + + Defaults are resolved by ``ess_langsmith_client.naming.resolve_env``: the flag + value wins, then the ``APP_ENV`` environment variable, then ``dev``. + """ + return click.option( + "--env", + "env", + default=None, + help=( + "Deployment environment, e.g. dev or prod " + "(defaults to $APP_ENV, then 'dev')" + ), + )(func) + + +def deployment_option(func): + """Add ``--deployment`` for a full base name (skips ``-``).""" + return click.option( + "--deployment", + default=None, + help=( + "Full deployment base name; uses this value as-is instead of " + "building - from --name and --env" + ), + )(func) + + +def create_client( + client_class: type[T], + api_key: str | None, + workspace_id: str | None, + region: str, +) -> T: + """Create a client instance, converting ValueError to ClickException.""" + try: + return client_class( + api_key=api_key, + workspace_id=workspace_id, + region=region, + ) + except ValueError as e: + raise click.ClickException(str(e)) from e + + +def resolve_live_deployment_record( + client: ControlPlaneClient, + *, + deployment: str | None = None, + service: str | None = None, + env: str | None = None, +) -> dict[str, Any] | None: + """Return the live deployment record for a base name, or None.""" + base = resolve_deploy_base(deployment=deployment, service=service, env=env) + return client.resolve_live_deployment(base) + + +def resolve_deployment_base_url( # noqa: PLR0913 # one param per resolution/cred input + service: str, + *, + deployment: str | None = None, + env: str | None = None, + region: str = "us", + api_key: str | None = None, + workspace_id: str | None = None, +) -> str: + """Resolve a deployed agent's public base URL from the Control Plane. + + Looks up the live deployment for the explicit ``deployment`` name (or the + canonical ``-`` base) and returns its + ``source_config.custom_url`` -- the ingress "nice URL" including the + ``/lgp/`` mount prefix. This lets test tooling reach a deployed agent + over its public URL without kubectl or the raw mount prefix. + + Args: + service: Service name used to build the canonical ``-`` + base name when ``deployment`` is not given. + deployment: Explicit deployment (base) name to resolve. Overrides the + canonical name derived from ``service``/``env``. + env: Environment for the canonical name (resolved via ``resolve_env``: + the value, then ``$APP_ENV``, then ``dev``). + region: LangSmith Control Plane region ("us" or "eu"). + api_key: LangSmith API key (defaults to ``LANGSMITH_API_KEY``). + workspace_id: LangSmith workspace ID (defaults to + ``LANGSMITH_WORKSPACE_ID``). + + Returns: + The deployment's public base URL (hostname + ``/lgp/`` prefix). + + Raises: + click.ClickException: if credentials are missing, no live deployment + matches, or the deployment exposes no ingress ``custom_url``. + """ + try: + base = resolve_deploy_base(deployment=deployment, service=service, env=env) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"Resolving deployment '{base}' via LangSmith Control Plane...") + client = create_client(ControlPlaneClient, api_key, workspace_id, region) + deployment_record = resolve_live_deployment_record( + client, + deployment=deployment, + service=service, + env=env, + ) + if not deployment_record: + raise click.ClickException( + f"No live deployment found for '{base}'. " + "Pass --deployment or check --env/--region." + ) + custom_url = (deployment_record.get("source_config") or {}).get("custom_url") + if not custom_url: + raise click.ClickException( + f"Deployment '{deployment_record.get('name', base)}' has no custom_url " + "(no ingress hostname configured). Use --prefix or --kubectl instead." + ) + click.echo(f" Found: {custom_url}") + return custom_url + + +def echo_success(message: str) -> None: + """Print a success message in green.""" + click.echo(click.style(message, fg="green")) + + +def echo_deployment_created(deployment_id: str, revision_id: str | None) -> None: + """Print deployment created output.""" + echo_success("\nDeployment created!") + click.echo(f" Deployment ID: {deployment_id}") + click.echo(f" Revision ID: {revision_id}") + + +def echo_deployment_resolved( + action: str, + name: str, + deployment_id: str, + revision_id: str | None = None, + url: str | None = None, +) -> None: + """Print the resolved deployment name, ID, and URL after a deploy. + + ``action`` is a short verb such as "created" or "updated". This is the + canonical way to surface which name/ID a deploy converged on, so the active + name is always visible without having to know any rescue suffix. + """ + echo_success(f"\nDeployment {action}: {name}") + click.echo(f" Deployment ID: {deployment_id}") + if revision_id: + click.echo(f" Revision ID: {revision_id}") + if url: + click.echo(f" URL: {url}") + + +def print_deployments(deployments: list[dict[str, Any]]) -> None: + """Print a formatted list of deployments.""" + if not deployments: + click.echo("No deployments found.") + return + + click.echo(f"\nFound {len(deployments)} deployment(s):\n") + for dep in deployments: + click.echo(f" ID: {dep['id']}") + click.echo(f" Name: {dep['name']}") + click.echo(f" Source: {dep.get('source', 'N/A')}") + click.echo(f" Status: {dep.get('status', 'N/A')}") + if dep.get("url"): + click.echo(f" URL: {dep['url']}") + click.echo() + + +def handle_wait( + client: ControlPlaneClient, + deployment_id: str, + revision_id: str | None, + wait: bool, + url: str | None = None, +) -> None: + """Handle the --wait flag for deployment commands.""" + if wait and revision_id: + click.echo("\nWaiting for deployment to complete...") + try: + final = client.wait_for_deployment(deployment_id, revision_id) + echo_success("\nDeployment complete!") + click.echo(f" Status: {final.get('status')}") + if url: + click.echo(f" URL: {url}") + except RuntimeError as e: + raise click.ClickException(str(e)) from e diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py new file mode 100644 index 0000000..ae7b199 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py @@ -0,0 +1,351 @@ +""" +LangSmith Control Plane API client. + +Provides the base client for interacting with the LangSmith Control Plane API. + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import os +import time +from http import HTTPStatus +from typing import Any + +import requests +from dotenv import load_dotenv + +load_dotenv() + +_REQUEST_TIMEOUT = 30 + +# Smith app/data API host (tracing sessions, API keys, workspaces). +# Distinct from the control-plane host below (deployments and projects). +_SMITH_API_URL = "https://api.smith.langchain.com" + +CONTROL_PLANE_HOSTS = { + "us": "https://api.host.langchain.com", + "eu": "https://eu.api.host.langchain.com", +} + +# Maximum time to wait for deployment (30 minutes) +MAX_WAIT_TIME = 1800 + +# Poll interval for deployment status (60 seconds) +POLL_INTERVAL = 60 + +# Substrings in a deployment/revision status that mark it as unrecoverable. +# A deployment whose status contains any of these is treated as "stuck" and is +# skipped when resolving the live deployment for a canonical name. +_UNHEALTHY_STATUS_MARKERS = ("FAIL", "ERROR", "DELET") + + +class ControlPlaneClient: + """Client for the LangSmith Control Plane API.""" + + def __init__( + self, + api_key: str | None = None, + workspace_id: str | None = None, + region: str = "us", + ): + """ + Initialize the Control Plane client. + + Args: + api_key: LangSmith API key (defaults to LANGSMITH_API_KEY env var). + Required — the key is what authorizes access. + workspace_id: LangSmith workspace ID (defaults to + LANGSMITH_WORKSPACE_ID env var). Optional: the key authorizes + access, and the workspace merely scopes which tenant an + operation targets. Leave unset here and pass it per operation to + select the workspace at runtime. + region: LangSmith region ("us" or "eu") + """ + self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") + self.workspace_id = workspace_id or os.environ.get("LANGSMITH_WORKSPACE_ID") + + if not self.api_key: + raise ValueError( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or pass it to the constructor." + ) + + if region not in CONTROL_PLANE_HOSTS: + raise ValueError( + f"Invalid region: {region}. " + f"Must be one of: {list(CONTROL_PLANE_HOSTS.keys())}" + ) + + self.control_plane_host = CONTROL_PLANE_HOSTS[region] + self.base_url = f"{self.control_plane_host}/v2" + # The API key authorizes access; the workspace only scopes which tenant + # an operation targets. X-Tenant-Id is sent only when a workspace is + # known (from here or per operation). + self.headers = self._build_headers(self.workspace_id) + + def _build_headers(self, workspace_id: str | None) -> dict[str, str]: + """Build request headers, adding tenant scope only when a workspace is set.""" + headers = { + "X-Api-Key": self.api_key, + "Content-Type": "application/json", + } + if workspace_id: + headers["X-Tenant-Id"] = workspace_id + return headers + + def list_listeners(self) -> dict[str, Any]: + """List all listeners (hybrid deployment agents) for the workspace.""" + response = requests.get( + f"{self.base_url}/listeners", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + else: + raise RuntimeError( + f"Failed to list listeners: {response.status_code}\n{response.text}" + ) + + def list_deployments(self, name_contains: str | None = None) -> dict[str, Any]: + """List all deployments, optionally filtered by name.""" + params = {} + if name_contains: + params["name_contains"] = name_contains + + response = requests.get( + f"{self.base_url}/deployments", + headers=self.headers, + params=params, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + else: + raise RuntimeError( + f"Failed to list deployments: {response.status_code}\n{response.text}" + ) + + @staticmethod + def is_deployment_healthy(deployment: dict[str, Any]) -> bool: + """Return True if a deployment is usable as the live target for its name. + + A deployment is considered stuck (not healthy) when its status contains + a failure/error/deleting marker. A missing status is treated as healthy, + since freshly created deployments may not report one yet. + """ + status = str(deployment.get("status") or "").upper() + return not any(marker in status for marker in _UNHEALTHY_STATUS_MARKERS) + + def find_deployments_by_base(self, base: str) -> list[dict[str, Any]]: + """Find deployments belonging to a canonical ``-`` base. + + Resolution is by stable logical key, not exact name: a deployment + matches when its name equals ``base`` or starts with ``base + "-"`` + (so ``hello-agent-dev`` does not match ``hello-agent-prod``). This lets + a git-SHA rescue sibling (e.g. ``hello-agent-dev-3f9a2c``) be found + without knowing the suffix. + """ + result = self.list_deployments(name_contains=base) + resources = result.get("resources", []) + prefix = f"{base}-" + return [ + deployment + for deployment in resources + if deployment.get("name") == base + or str(deployment.get("name", "")).startswith(prefix) + ] + + def resolve_live_deployment(self, base: str) -> dict[str, Any] | None: + """Return the single healthy deployment for ``base``, or None. + + When multiple healthy deployments share the base, the most recently + created one wins (falling back to last-listed when no timestamp is + available), so deploys converge onto the newest live instance. + """ + healthy = [ + deployment + for deployment in self.find_deployments_by_base(base) + if self.is_deployment_healthy(deployment) + ] + if not healthy: + return None + + # Sort by created_at, breaking ties by list position so that when + # timestamps are missing/equal the last-listed deployment wins (the + # most recently created sorts last either way). + def sort_key(indexed: tuple[int, dict[str, Any]]) -> tuple[str, int]: + index, deployment = indexed + return (str(deployment.get("created_at") or ""), index) + + return max(enumerate(healthy), key=sort_key)[1] + + def get_deployment(self, deployment_id: str) -> dict[str, Any]: + """Get a specific deployment by ID.""" + response = requests.get( + f"{self.base_url}/deployments/{deployment_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + else: + raise RuntimeError( + f"Failed to get deployment {deployment_id}: " + f"{response.status_code}\n{response.text}" + ) + + def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]: + """Get a specific revision of a deployment.""" + response = requests.get( + f"{self.base_url}/deployments/{deployment_id}/revisions/{revision_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + else: + raise RuntimeError( + f"Failed to get revision {revision_id}: " + f"{response.status_code}\n{response.text}" + ) + + def list_revisions(self, deployment_id: str) -> dict[str, Any]: + """List all revisions for a deployment.""" + response = requests.get( + f"{self.base_url}/deployments/{deployment_id}/revisions", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + else: + raise RuntimeError( + f"Failed to list revisions: {response.status_code}\n{response.text}" + ) + + def delete_deployment(self, deployment_id: str) -> bool: + """Delete a deployment.""" + response = requests.delete( + f"{self.base_url}/deployments/{deployment_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.NO_CONTENT: + return True + else: + raise RuntimeError( + f"Failed to delete deployment: {response.status_code}\n{response.text}" + ) + + def delete_project( + self, + project_id: str, + *, + workspace_id: str | None = None, + force: bool = True, + delete_tracing_project: bool = False, + ) -> bool: + """Delete a control-plane project record. + + This targets the control-plane host's ``/v1/projects/{id}`` resource + (``api.host.langchain.com`` on SaaS), which is distinct from a tracing + project (session). To delete a tracing project and its traces, use + ``TracingProjectClient`` instead. + + On SaaS the control plane requires ``X-Tenant-Id`` (a workspace) to + route the request, so a workspace must be provided here or on the + client. + + Args: + project_id: Control-plane project ID. + workspace_id: Workspace to scope the delete to, selected at runtime. + Defaults to the client's workspace if one was set. Sent as the + ``X-Tenant-Id`` header; required by the SaaS control plane. + force: Force deletion of the control-plane record, clearing stale + references that would otherwise block it. + delete_tracing_project: When ``False`` (default), the paired tracing + project and its traces are preserved; only the control-plane + record is removed. + + Returns: + True on success (HTTP 200). + + Raises: + RuntimeError: If the API returns a non-200 status. + """ + # The control-plane "projects" resource lives at /v1/projects on the + # control-plane host (api.host.langchain.com), not under this client's + # /v2 base_url (which is the deployments API). + tenant_id = workspace_id or self.workspace_id + if not tenant_id: + raise ValueError( + "A workspace is required to delete a control-plane project " + "(sent as X-Tenant-Id). Pass workspace_id or set one on the client." + ) + + response = requests.delete( + f"{self.control_plane_host}/v1/projects/{project_id}", + headers=self._build_headers(tenant_id), + params={ + "force": str(force).lower(), + "delete_tracing_project": str(delete_tracing_project).lower(), + }, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return True + raise RuntimeError( + f"Failed to delete project {project_id}: " + f"{response.status_code}\n{response.text}" + ) + + def wait_for_deployment( + self, + deployment_id: str, + revision_id: str, + max_wait: int = MAX_WAIT_TIME, + poll_interval: int = POLL_INTERVAL, + ) -> dict[str, Any]: + """ + Wait for a deployment revision to reach DEPLOYED status. + + Args: + deployment_id: ID of the deployment + revision_id: ID of the revision to wait for + max_wait: Maximum time to wait in seconds + poll_interval: Time between status checks in seconds + + Returns: + Final revision status + + Raises: + RuntimeError: If deployment fails or times out + """ + start_time = time.time() + revision = None + status = None + + while time.time() - start_time < max_wait: + revision = self.get_revision(deployment_id, revision_id) + status = revision.get("status") + + if status == "DEPLOYED": + return revision + elif "FAILED" in str(status): + raise RuntimeError(f"Deployment failed: {revision}") + + print(f" Status: {status}... waiting {poll_interval}s") + time.sleep(poll_interval) + + raise RuntimeError( + f"Timeout waiting for deployment. Last status: {status}\n{revision}" + ) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/naming.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/naming.py new file mode 100644 index 0000000..a645c3a --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/naming.py @@ -0,0 +1,157 @@ +"""Naming convention for LangSmith deployments and tracing projects. + +Deployment naming is owned here and by ``langsmith-client deploy docker`` — deploy +shell scripts must not construct ``-`` strings or pass a pre-suffixed +name as ``--name``. + +Three independent axes: + +* **Service** — logical app identity (``[project].name`` in ``pyproject.toml``, + or ``--name`` on the CLI). Example: ``hello-agent``. +* **Env** — deployment environment suffix (``--env``, then ``APP_ENV``, then + ``dev``). Example: ``prod``. +* **Workspace** — LangSmith tenant (``LANGSMITH_WORKSPACE_ID`` + API key). Does + not affect the deployment name. Example: a separate prod workspace. + +The canonical deployment name is ``-`` — for example +``hello-agent-dev``. The same logical service deploys to that name over and over. + +When the canonical name becomes stuck or orphaned and cannot be reclaimed, a git +short SHA is appended as a rescue suffix (``hello-agent-dev-3f9a2c``), with a +compact UTC time tiebreak if even that is taken. The SHA is decoration for +uniqueness only — deploys resolve by the stable canonical base and update by +deployment ID, so the rescue suffix never has to be known or matched. +""" + +import os +import re +import subprocess # nosec B404 # developer tooling shells out to git +from collections.abc import Iterable +from datetime import datetime, timezone + +DEFAULT_ENV = "dev" + +# Environment variable consulted when --env is not passed (12-factor style). +ENV_VAR = "APP_ENV" + +_INVALID_CHARS = re.compile(r"[^a-zA-Z0-9_-]") + + +def sanitize_name_component(value: str) -> str: + """Replace characters unsafe for LangSmith names with hyphens. + + Mirrors the sanitization used for Docker-derived names: anything outside + ``[a-zA-Z0-9_-]`` becomes ``-``. Leading/trailing hyphens are trimmed. + """ + cleaned = _INVALID_CHARS.sub("-", value.strip()) + return cleaned.strip("-") + + +def resolve_env(env: str | None = None) -> str: + """Resolve the deployment environment. + + Precedence: explicit ``env`` argument, then the ``APP_ENV`` environment + variable, then ``"dev"``. + """ + resolved = env or os.environ.get(ENV_VAR) or DEFAULT_ENV + return sanitize_name_component(resolved) + + +def compute_base_name(service: str, env: str | None = None) -> str: + """Build the canonical ``-`` name. + + Args: + service: Logical service name (typically ``[project].name``). + env: Environment; resolved via :func:`resolve_env` when omitted. + """ + return f"{sanitize_name_component(service)}-{resolve_env(env)}" + + +def resolve_deploy_base( + *, + deployment: str | None = None, + service: str | None = None, + env: str | None = None, +) -> str: + """Return the deployment base name for create/upsert. + + When ``deployment`` is provided, it is sanitized and used as-is (no + ``-`` construction). Otherwise builds the canonical name via + :func:`compute_base_name`. + """ + if deployment: + cleaned = sanitize_name_component(deployment) + if not cleaned: + raise ValueError("deployment name is empty after sanitization") + return cleaned + if not service: + raise ValueError("service is required when deployment is omitted") + return compute_base_name(service, env) + + +def get_git_sha() -> str | None: + """Return the short Git commit SHA, or None if unavailable.""" + try: + result = subprocess.run( # nosec B603 B607 # hardcoded git command with list args, no shell + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode == 0: + return result.stdout.strip() + return None + + +def _rescue_candidates( + base: str, + git_sha: str | None, + *, + now: datetime | None = None, +) -> list[str]: + """Ordered rescue names to try when the canonical base is unavailable. + + Prefers a git-SHA suffix (traceable to the deploying commit); falls back to + a compact UTC ``YYYYMMDD-HHMM`` stamp when no SHA is available, and adds an + ``HHMM`` time tiebreak after the SHA for the rare same-commit collision. + """ + moment = now or datetime.now(timezone.utc) + if git_sha: + sha = sanitize_name_component(git_sha) + return [f"{base}-{sha}", f"{base}-{sha}-{moment:%H%M}"] + stamp = f"{moment:%Y%m%d-%H%M}" + return [f"{base}-{stamp}", f"{base}-{stamp}-{moment:%S}"] + + +def choose_deploy_name( + base: str, + taken: Iterable[str], + git_sha: str | None = None, + *, + now: datetime | None = None, +) -> str: + """Pick the name to create when no healthy deployment exists for ``base``. + + Returns the canonical ``base`` when it is free, otherwise the first + available git-SHA rescue name. Raises ``RuntimeError`` if every candidate + is already taken (extremely unlikely; signals manual cleanup is needed). + + Args: + base: Canonical ``-`` name. + taken: Names already in use for this base (stuck/orphaned siblings). + git_sha: Short Git SHA used for the rescue suffix. + now: Override for the current time (testing). + """ + taken_set = set(taken) + candidates = [base, *_rescue_candidates(base, git_sha, now=now)] + for candidate in candidates: + if candidate not in taken_set: + return candidate + raise RuntimeError( + f"Could not find an available deployment name for base '{base}'. " + f"Existing names: {sorted(taken_set)}. Clean up stuck deployments " + "with 'langsmith-client projects delete --force'." + ) diff --git a/packages/python/langsmith-client/src/langsmith_client/project.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/project.py similarity index 100% rename from packages/python/langsmith-client/src/langsmith_client/project.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/project.py diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py new file mode 100644 index 0000000..552a8d5 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py @@ -0,0 +1,57 @@ +"""Secret management utilities for LangSmith deployments.""" + +import os +from collections.abc import Iterable + + +def get_env_secrets(keys: Iterable[str]) -> list[dict[str, str]]: + """Read the named environment variables as deployment secrets. + + Args: + keys: Environment variable names to look up. + + Returns: + A ``{"name", "value"}`` entry for each key that is set and non-empty, + in the order the keys were given. + """ + secrets = [] + for key in keys: + value = os.environ.get(key) + if value: + secrets.append({"name": key, "value": value}) + return secrets + + +def parse_secrets(secret_args: list[str] | None) -> list[dict[str, str]]: + """Parse secrets from command line arguments (NAME=VALUE format).""" + secrets = [] + if secret_args: + for secret in secret_args: + name, value = secret.split("=", 1) + secrets.append({"name": name, "value": value}) + return secrets + + +def merge_secrets( + cli_secrets: tuple[str, ...] | list[str], + auto_detect_keys: Iterable[str] = (), +) -> list[dict[str, str]]: + """Merge explicitly passed secrets with ones read from the environment. + + Nothing is read from the environment unless ``auto_detect_keys`` names it, so + a deployment never receives a credential the caller did not ask for. Callers + that want the convenience of picking keys up from the environment pass their + own list. + + Args: + cli_secrets: ``NAME=VALUE`` strings, typically from a ``--secret`` flag. + auto_detect_keys: Environment variable names to fall back to. + + Returns: + The merged list. ``cli_secrets`` win on name collisions. + """ + all_secrets = parse_secrets(list(cli_secrets)) + for secret in get_env_secrets(auto_detect_keys): + if not any(existing["name"] == secret["name"] for existing in all_secrets): + all_secrets.append(secret) + return all_secrets diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py new file mode 100644 index 0000000..0e579e7 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py @@ -0,0 +1,74 @@ +"""Tests for ess_langsmith_client.cli helpers.""" + +import click +import pytest + +from ess_langsmith_client import ( + resolve_deployment_base_url, + resolve_live_deployment_record, +) +from ess_langsmith_client.client import ControlPlaneClient + +_CREDS = {"api_key": "test-key", "workspace_id": "test-ws"} + + +def _stub_resources(monkeypatch, resources): + """Make every ControlPlaneClient list deployments return ``resources``.""" + + def _fake_list_deployments(self, name_contains=None): + return {"resources": resources} + + monkeypatch.setattr(ControlPlaneClient, "list_deployments", _fake_list_deployments) + + +def _deployment(name, custom_url): + return { + "name": name, + "id": "d1", + "status": "DEPLOYED", + "source_config": {"custom_url": custom_url}, + } + + +class TestResolveLiveDeploymentRecord: + def test_returns_live_deployment(self, monkeypatch): + resource = _deployment("hello-agent-dev", "https://a.example/lgp/x") + _stub_resources(monkeypatch, [resource]) + client = ControlPlaneClient(**_CREDS) + live = resolve_live_deployment_record(client, service="hello-agent", env="dev") + assert live is not None + assert live["id"] == "d1" + assert live["name"] == "hello-agent-dev" + + def test_returns_none_when_missing(self, monkeypatch): + _stub_resources(monkeypatch, []) + client = ControlPlaneClient(**_CREDS) + assert ( + resolve_live_deployment_record(client, service="hello-agent", env="dev") + is None + ) + + +class TestResolveDeploymentBaseUrl: + def test_returns_custom_url_for_canonical_name(self, monkeypatch): + url = "https://a.example/lgp/hello-agent-dev-abc" + _stub_resources(monkeypatch, [_deployment("hello-agent-dev", url)]) + assert resolve_deployment_base_url("hello-agent", env="dev", **_CREDS) == url + + def test_explicit_deployment_overrides_service(self, monkeypatch): + url = "https://a.example/lgp/hello-agent-prod-dev-xyz" + _stub_resources(monkeypatch, [_deployment("hello-agent-prod-dev", url)]) + resolved = resolve_deployment_base_url( + "hello-agent", deployment="hello-agent-prod-dev", **_CREDS + ) + assert resolved == url + + def test_no_deployment_raises(self, monkeypatch): + _stub_resources(monkeypatch, []) + with pytest.raises(click.ClickException, match="No live deployment"): + resolve_deployment_base_url("hello-agent", env="dev", **_CREDS) + + def test_missing_custom_url_raises(self, monkeypatch): + _stub_resources(monkeypatch, [_deployment("hello-agent-dev", None)]) + with pytest.raises(click.ClickException, match="no custom_url"): + resolve_deployment_base_url("hello-agent", env="dev", **_CREDS) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py new file mode 100644 index 0000000..c9d30c5 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py @@ -0,0 +1,128 @@ +"""Tests for ControlPlaneClient deployment resolution.""" + +import pytest + +from ess_langsmith_client.client import ControlPlaneClient + + +@pytest.fixture +def client() -> ControlPlaneClient: + return ControlPlaneClient(api_key="test-key", workspace_id="test-ws", region="us") + + +class TestIsDeploymentHealthy: + @pytest.mark.parametrize( + "status", + ["DEPLOYED", "DEPLOYING", "deployed", "", None], + ) + def test_healthy_statuses(self, status): + assert ControlPlaneClient.is_deployment_healthy({"status": status}) is True + + @pytest.mark.parametrize( + "status", + ["FAILED", "DEPLOY_FAILED", "ERROR", "DELETING"], + ) + def test_unhealthy_statuses(self, status): + assert ControlPlaneClient.is_deployment_healthy({"status": status}) is False + + +class TestFindDeploymentsByBase: + def _stub_list(self, client, names): + def _fake_list_deployments(name_contains=None): + return {"resources": [{"name": n, "id": n} for n in names]} + + client.list_deployments = _fake_list_deployments # type: ignore[method-assign] + + def test_anchored_prefix_match(self, client): + self._stub_list( + client, + [ + "hello-agent-dev", + "hello-agent-dev-3f9a2c", + "hello-agent-prod", + "hello-agent-development", + ], + ) + matches = { + deployment["name"] + for deployment in client.find_deployments_by_base("hello-agent-dev") + } + # Exact and "-..." match; sibling envs and longer words do not. + assert matches == {"hello-agent-dev", "hello-agent-dev-3f9a2c"} + + def test_no_matches(self, client): + self._stub_list(client, ["other-service-dev"]) + assert client.find_deployments_by_base("hello-agent-dev") == [] + + +class TestResolveLiveDeployment: + def _stub(self, client, resources): + def _fake_list_deployments(name_contains=None): + return {"resources": resources} + + client.list_deployments = _fake_list_deployments # type: ignore[method-assign] + + def test_returns_healthy(self, client): + self._stub( + client, + [{"name": "hello-agent-dev", "id": "d1", "status": "DEPLOYED"}], + ) + live = client.resolve_live_deployment("hello-agent-dev") + assert live is not None and live["id"] == "d1" + + def test_skips_stuck_picks_rescue(self, client): + self._stub( + client, + [ + {"name": "hello-agent-dev", "id": "stuck", "status": "FAILED"}, + { + "name": "hello-agent-dev-3f9a2c", + "id": "live", + "status": "DEPLOYED", + "created_at": "2026-05-29T10:00:00Z", + }, + ], + ) + live = client.resolve_live_deployment("hello-agent-dev") + assert live is not None and live["id"] == "live" + + def test_prefers_newest_healthy(self, client): + self._stub( + client, + [ + { + "name": "hello-agent-dev", + "id": "old", + "status": "DEPLOYED", + "created_at": "2026-05-01T00:00:00Z", + }, + { + "name": "hello-agent-dev-3f9a2c", + "id": "new", + "status": "DEPLOYED", + "created_at": "2026-05-29T00:00:00Z", + }, + ], + ) + live = client.resolve_live_deployment("hello-agent-dev") + assert live is not None and live["id"] == "new" + + def test_tie_break_picks_last_listed_without_timestamps(self, client): + # No created_at on any match: the last-listed healthy one should win, + # matching the documented fallback. + self._stub( + client, + [ + {"name": "hello-agent-dev", "id": "first", "status": "DEPLOYED"}, + {"name": "hello-agent-dev-3f9a2c", "id": "last", "status": "DEPLOYED"}, + ], + ) + live = client.resolve_live_deployment("hello-agent-dev") + assert live is not None and live["id"] == "last" + + def test_returns_none_when_all_stuck(self, client): + self._stub( + client, + [{"name": "hello-agent-dev", "id": "stuck", "status": "FAILED"}], + ) + assert client.resolve_live_deployment("hello-agent-dev") is None diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_control_plane.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_control_plane.py new file mode 100644 index 0000000..bc6dc94 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_control_plane.py @@ -0,0 +1,142 @@ +"""Tests for ControlPlaneClient.delete_project (control-plane projects).""" + +from http import HTTPStatus +from typing import Any + +import pytest + +from ess_langsmith_client import client as client_module +from ess_langsmith_client.client import ControlPlaneClient + + +class _FakeResponse: + def __init__(self, status_code: int, payload: Any = None, text: str = ""): + self.status_code = status_code + self._payload = payload + self.text = text + + def json(self) -> Any: + return self._payload + + +@pytest.fixture(autouse=True) +def _clear_workspace_env(monkeypatch): + # Keep construction deterministic: a stray LANGSMITH_WORKSPACE_ID in the + # environment must not leak a default workspace into these tests. + monkeypatch.delenv("LANGSMITH_WORKSPACE_ID", raising=False) + + +@pytest.fixture +def client() -> ControlPlaneClient: + # No workspace at construction: the API key authorizes; workspace is + # selected per operation. + return ControlPlaneClient(api_key="test-key") + + +class TestConstruction: + def test_workspace_is_optional(self): + """The client instantiates with only an API key (no workspace).""" + instance = ControlPlaneClient(api_key="test-key") + assert instance.workspace_id is None + assert "X-Tenant-Id" not in instance.headers + + def test_api_key_still_required(self, monkeypatch): + monkeypatch.delenv("LANGSMITH_API_KEY", raising=False) + with pytest.raises(ValueError, match="LANGSMITH_API_KEY is required"): + ControlPlaneClient(api_key=None, workspace_id="ws") + + def test_constructor_workspace_sets_tenant_header(self): + instance = ControlPlaneClient(api_key="test-key", workspace_id="ws-1") + assert instance.headers["X-Tenant-Id"] == "ws-1" + + +class TestDeleteProject: + def _capture_delete(self, monkeypatch) -> dict[str, Any]: + captured: dict[str, Any] = {} + + def _fake_delete(url, *, headers, params, timeout): + captured["url"] = url + captured["headers"] = headers + captured["params"] = params + return _FakeResponse(HTTPStatus.OK) + + monkeypatch.setattr(client_module.requests, "delete", _fake_delete) + return captured + + def test_builds_control_plane_url(self, client, monkeypatch): + captured = self._capture_delete(monkeypatch) + client.delete_project("abc", workspace_id="ws-test") + assert captured["url"].endswith("/v1/projects/abc") + assert "api.host.langchain.com" in captured["url"] + assert "/api-host/" not in captured["url"] + assert "/v2/" not in captured["url"] + + def test_eu_region_uses_eu_control_plane_host(self, monkeypatch): + captured = self._capture_delete(monkeypatch) + eu_client = ControlPlaneClient(api_key="test-key", region="eu") + eu_client.delete_project("abc", workspace_id="ws-test") + assert "eu.api.host.langchain.com" in captured["url"] + assert captured["url"].endswith("/v1/projects/abc") + + def test_default_params_preserve_traces(self, client, monkeypatch): + captured = self._capture_delete(monkeypatch) + client.delete_project("abc", workspace_id="ws-test") + assert captured["params"] == { + "force": "true", + "delete_tracing_project": "false", + } + + def test_params_reflect_overrides(self, client, monkeypatch): + captured = self._capture_delete(monkeypatch) + client.delete_project( + "abc", workspace_id="ws-test", force=False, delete_tracing_project=True + ) + assert captured["params"] == { + "force": "false", + "delete_tracing_project": "true", + } + + def test_sends_api_key_header(self, client, monkeypatch): + captured = self._capture_delete(monkeypatch) + client.delete_project("abc", workspace_id="ws-test") + assert captured["headers"]["X-Api-Key"] == "test-key" + + def test_raises_without_workspace(self, client): + """SaaS control-plane deletes require X-Tenant-Id; fail fast when missing.""" + with pytest.raises(ValueError, match="workspace is required"): + client.delete_project("abc") + + def test_runtime_workspace_scopes_request(self, client, monkeypatch): + """A workspace passed at call time sets X-Tenant-Id for that request.""" + captured = self._capture_delete(monkeypatch) + client.delete_project("abc", workspace_id="ws-runtime") + assert captured["headers"]["X-Tenant-Id"] == "ws-runtime" + + def test_runtime_workspace_overrides_client_default(self, monkeypatch): + captured = self._capture_delete(monkeypatch) + instance = ControlPlaneClient(api_key="test-key", workspace_id="ws-default") + instance.delete_project("abc", workspace_id="ws-runtime") + assert captured["headers"]["X-Tenant-Id"] == "ws-runtime" + + def test_falls_back_to_client_workspace(self, monkeypatch): + captured = self._capture_delete(monkeypatch) + instance = ControlPlaneClient(api_key="test-key", workspace_id="ws-default") + instance.delete_project("abc") + assert captured["headers"]["X-Tenant-Id"] == "ws-default" + + def test_returns_true_on_ok(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "delete", + lambda *a, **k: _FakeResponse(HTTPStatus.OK), + ) + assert client.delete_project("abc", workspace_id="ws-test") is True + + def test_raises_on_error_with_body(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "delete", + lambda *a, **k: _FakeResponse(HTTPStatus.FORBIDDEN, text="Forbidden"), + ) + with pytest.raises(RuntimeError, match="Forbidden"): + client.delete_project("abc", workspace_id="ws-test") diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_naming.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_naming.py new file mode 100644 index 0000000..eaf7f3a --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_naming.py @@ -0,0 +1,107 @@ +"""Tests for the deployment/tracing-project naming convention.""" + +from datetime import datetime, timezone + +import pytest + +from ess_langsmith_client.naming import ( + choose_deploy_name, + compute_base_name, + resolve_deploy_base, + resolve_env, + sanitize_name_component, +) + +_NOW = datetime(2026, 5, 29, 10, 47, 8, tzinfo=timezone.utc) + + +class TestSanitizeNameComponent: + def test_passthrough_clean(self): + assert sanitize_name_component("hello-agent") == "hello-agent" + + def test_replaces_invalid_chars(self): + assert sanitize_name_component("hello/agent@v1") == "hello-agent-v1" + + def test_trims_leading_trailing_hyphens(self): + assert sanitize_name_component(" hello.agent ") == "hello-agent" + + +class TestResolveEnv: + def test_explicit_wins(self, monkeypatch): + monkeypatch.setenv("APP_ENV", "prod") + assert resolve_env("dev") == "dev" + + def test_falls_back_to_app_env(self, monkeypatch): + monkeypatch.setenv("APP_ENV", "prod") + assert resolve_env(None) == "prod" + + def test_defaults_to_dev(self, monkeypatch): + monkeypatch.delenv("APP_ENV", raising=False) + assert resolve_env(None) == "dev" + + def test_sanitizes(self, monkeypatch): + monkeypatch.delenv("APP_ENV", raising=False) + assert resolve_env("Staging/East") == "Staging-East" + + +class TestComputeBaseName: + def test_basic(self, monkeypatch): + monkeypatch.delenv("APP_ENV", raising=False) + assert compute_base_name("hello-agent", "dev") == "hello-agent-dev" + + def test_uses_env_fallback(self, monkeypatch): + monkeypatch.setenv("APP_ENV", "prod") + assert compute_base_name("hello-agent") == "hello-agent-prod" + + +class TestResolveDeployBase: + def test_uses_explicit_deployment(self): + assert ( + resolve_deploy_base(deployment="my-agent-prod-dev") == "my-agent-prod-dev" + ) + + def test_builds_from_service_and_env(self, monkeypatch): + monkeypatch.delenv("APP_ENV", raising=False) + base = resolve_deploy_base(service="hello-agent", env="prod") + assert base == "hello-agent-prod" + + def test_requires_service_when_deployment_omitted(self): + with pytest.raises(ValueError, match="service is required"): + resolve_deploy_base() + + +class TestChooseDeployName: + def test_canonical_when_free(self): + name = choose_deploy_name("hello-agent-dev", set(), "3f9a2c") + assert name == "hello-agent-dev" + + def test_sha_rescue_when_canonical_taken(self): + name = choose_deploy_name("hello-agent-dev", {"hello-agent-dev"}, "3f9a2c") + assert name == "hello-agent-dev-3f9a2c" + + def test_time_tiebreak_when_sha_also_taken(self): + name = choose_deploy_name( + "hello-agent-dev", + {"hello-agent-dev", "hello-agent-dev-3f9a2c"}, + "3f9a2c", + now=_NOW, + ) + assert name == "hello-agent-dev-3f9a2c-1047" + + def test_timestamp_rescue_without_sha(self): + name = choose_deploy_name( + "hello-agent-dev", + {"hello-agent-dev"}, + git_sha=None, + now=_NOW, + ) + assert name == "hello-agent-dev-20260529-1047" + + def test_raises_when_all_candidates_taken(self): + taken = { + "hello-agent-dev", + "hello-agent-dev-3f9a2c", + "hello-agent-dev-3f9a2c-1047", + } + with pytest.raises(RuntimeError): + choose_deploy_name("hello-agent-dev", taken, "3f9a2c", now=_NOW) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_projects.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_projects.py new file mode 100644 index 0000000..c9555e3 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_projects.py @@ -0,0 +1,104 @@ +"""Tests for langsmith-client projects tracing-project management.""" + +from http import HTTPStatus +from typing import Any + +import pytest + +from ess_langsmith_client.tools import projects +from ess_langsmith_client.tools.projects import ( + TracingProjectClient, + _delete_one, +) + + +class _FakeResponse: + def __init__(self, status_code: int, payload: Any = None, text: str = ""): + self.status_code = status_code + self._payload = payload + self.text = text + + def json(self) -> Any: + return self._payload + + +@pytest.fixture +def client() -> TracingProjectClient: + return TracingProjectClient(api_key="test-key") + + +class TestGetProjectById: + def test_returns_json_on_ok(self, client, monkeypatch): + payload = {"id": "abc", "name": "hello-agent-dev", "run_count": 5} + monkeypatch.setattr( + projects.requests, + "get", + lambda *a, **k: _FakeResponse(HTTPStatus.OK, payload), + ) + assert client.get_project_by_id("abc") == payload + + def test_returns_none_on_not_found(self, client, monkeypatch): + monkeypatch.setattr( + projects.requests, + "get", + lambda *a, **k: _FakeResponse(HTTPStatus.NOT_FOUND, text="missing"), + ) + assert client.get_project_by_id("abc") is None + + def test_raises_on_server_error(self, client, monkeypatch): + monkeypatch.setattr( + projects.requests, + "get", + lambda *a, **k: _FakeResponse( + HTTPStatus.INTERNAL_SERVER_ERROR, text="boom" + ), + ) + with pytest.raises(RuntimeError): + client.get_project_by_id("abc") + + def test_requests_include_stats(self, client, monkeypatch): + captured: dict[str, Any] = {} + + def _fake_get(url, *, headers, params, timeout): + captured["url"] = url + captured["params"] = params + return _FakeResponse(HTTPStatus.OK, {"id": "abc"}) + + monkeypatch.setattr(projects.requests, "get", _fake_get) + client.get_project_by_id("abc") + assert captured["params"] == {"include_stats": "true"} + assert captured["url"].endswith("/api/v1/sessions/abc") + + +class TestDeleteOneTraceGuard: + def _client_with_recorder(self, monkeypatch): + client = TracingProjectClient(api_key="test-key") + calls: list[tuple[str, bool]] = [] + + def _record_delete(project_id: str, *, force: bool = False) -> bool: + calls.append((project_id, force)) + return True + + monkeypatch.setattr(client, "delete_project", _record_delete) + return client, calls + + def test_skips_project_with_traces_without_force(self, monkeypatch): + client, calls = self._client_with_recorder(monkeypatch) + project = {"id": "abc", "name": "hello-agent-dev", "run_count": 7} + result = _delete_one(client, project, force=False) + assert result == "skipped" + assert calls == [] + + def test_deletes_project_with_traces_when_forced(self, monkeypatch): + client, calls = self._client_with_recorder(monkeypatch) + project = {"id": "abc", "name": "hello-agent-dev", "run_count": 7} + result = _delete_one(client, project, force=True) + assert result == "deleted" + assert calls == [("abc", True)] + + def test_deletes_empty_project_without_force(self, monkeypatch): + client, calls = self._client_with_recorder(monkeypatch) + project = {"id": "abc", "name": "hello-agent-dev", "run_count": 0} + result = _delete_one(client, project, force=False) + assert result == "deleted" + assert calls == [("abc", False)] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py new file mode 100644 index 0000000..6adb0f6 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py @@ -0,0 +1,51 @@ +"""Tests for secret parsing and merging.""" + +import pytest + +from ess_langsmith_client.secrets import get_env_secrets, merge_secrets, parse_secrets + + +def test_parse_secrets_splits_on_first_equals(): + assert parse_secrets(["TOKEN=abc=def"]) == [{"name": "TOKEN", "value": "abc=def"}] + + +def test_parse_secrets_handles_none(): + assert parse_secrets(None) == [] + + +def test_get_env_secrets_reads_named_keys(monkeypatch): + monkeypatch.setenv("WANTED", "yes") + assert get_env_secrets(["WANTED"]) == [{"name": "WANTED", "value": "yes"}] + + +def test_get_env_secrets_skips_unset_and_empty(monkeypatch): + monkeypatch.setenv("EMPTY", "") + monkeypatch.delenv("ABSENT", raising=False) + assert get_env_secrets(["EMPTY", "ABSENT"]) == [] + + +def test_merge_secrets_ignores_environment_by_default(monkeypatch): + """A deploy must not ship credentials the caller never named.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "leaked") + monkeypatch.setenv("TAVILY_API_KEY", "leaked") + assert merge_secrets([]) == [] + + +def test_merge_secrets_adds_only_requested_env_keys(monkeypatch): + monkeypatch.setenv("OPTED_IN", "value") + monkeypatch.setenv("NOT_REQUESTED", "value") + assert merge_secrets([], auto_detect_keys=["OPTED_IN"]) == [ + {"name": "OPTED_IN", "value": "value"} + ] + + +def test_merge_secrets_cli_wins_over_environment(monkeypatch): + monkeypatch.setenv("TOKEN", "from-env") + assert merge_secrets(["TOKEN=from-cli"], auto_detect_keys=["TOKEN"]) == [ + {"name": "TOKEN", "value": "from-cli"} + ] + + +@pytest.mark.parametrize("cli_secrets", [(), []]) +def test_merge_secrets_accepts_tuple_or_list(cli_secrets): + assert merge_secrets(cli_secrets) == [] diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/__init__.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/__init__.py similarity index 100% rename from packages/python/langsmith-client/src/langsmith_client/tools/__init__.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/__init__.py diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/build.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/build.py similarity index 82% rename from packages/python/langsmith-client/src/langsmith_client/tools/build.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/build.py index e14a988..2bea03f 100644 --- a/packages/python/langsmith-client/src/langsmith_client/tools/build.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/build.py @@ -6,6 +6,10 @@ Can target any project directory via --project-dir / -C. """ +# The build command callback exposes one function parameter per CLI option, so +# the argument count necessarily exceeds PLR0913's limit. Suppress it here. +# ruff: noqa: PLR0913 + import os import subprocess # nosec B404 # developer tooling shells out to docker/langgraph import sys @@ -13,24 +17,7 @@ import click -from langsmith_client import get_project_info - - -def _get_git_sha() -> str | None: - """Return the short Git commit SHA, or None if unavailable.""" - try: - result = subprocess.run( # nosec B603 B607 # hardcoded git command with list args, no shell - ["git", "rev-parse", "--short", "HEAD"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - if result.returncode == 0: - return result.stdout.strip() - return None +from ess_langsmith_client import get_git_sha, get_project_info @click.command( @@ -57,7 +44,7 @@ def _get_git_sha() -> str | None: ), ) @click.argument("langgraph_args", nargs=-1, type=click.UNPROCESSED) -def build( # noqa: PLR0913 +def build( tag: str | None, push: bool, registry: str | None, @@ -72,19 +59,19 @@ def build( # noqa: PLR0913 \b Examples: # Build with defaults from pyproject.toml - langsmith-build + langsmith-client build # Build a specific project - langsmith-build -C labs/python/hello-world-graph + langsmith-client build -C path/to/my-agent # Build with custom tag - langsmith-build -t my-image:v2 + langsmith-client build -t my-image:v2 # Build and push to registry - langsmith-build --push --registry gcr.io/my-project + langsmith-client build --push --registry gcr.io/my-project # Build for a different platform (e.g., local testing on Apple Silicon) - langsmith-build --platform linux/arm64 + langsmith-client build --platform linux/arm64 """ project_path = Path(project_dir) @@ -96,7 +83,7 @@ def build( # noqa: PLR0913 "Could not read pyproject.toml. Provide --tag or ensure " "pyproject.toml exists." ) - git_sha = _get_git_sha() + git_sha = get_git_sha() tag = ( f"{project.name}:{project.version}-{git_sha}" if git_sha diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/control_plane.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/control_plane.py new file mode 100644 index 0000000..f2e9839 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/control_plane.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Manage LangSmith Control Plane resources. + +The control plane (``api.host.langchain.com`` on SaaS) owns deployments and +their backing "project" records at ``/v1/projects/{id}``. This CLI groups those +resources under one command so you do not have to remember individual endpoints. + +Control-plane projects are distinct from tracing projects (sessions). Deleting a +control-plane project here does NOT delete traces unless you explicitly pass +``--delete-tracing-project``. To manage tracing projects, use +``langsmith-client projects``. + +PREREQUISITES: +- LANGSMITH_API_KEY: A LangSmith API key with workspace-admin role +- A workspace ID: passed via --workspace-id or LANGSMITH_WORKSPACE_ID. The SaaS + control plane requires it (sent as the X-Tenant-Id header) to route requests. + +USAGE: + langsmith-client control-plane projects delete --id --workspace-id + langsmith-client control-plane projects delete --id --id --yes + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import click + +from ess_langsmith_client import ( + ControlPlaneClient, + common_options, + create_client, + echo_success, +) +from ess_langsmith_client._version import get_package_version + + +@click.group() +@click.version_option(version=get_package_version()) +def cli(): + """Manage LangSmith Control Plane resources. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: A LangSmith API key with workspace-admin role + - A workspace ID via --workspace-id or LANGSMITH_WORKSPACE_ID (sent as + X-Tenant-Id; required by the SaaS control plane) + """ + pass + + +@cli.group() +def projects(): + """Manage control-plane project records. + + \b + These are distinct from tracing projects (sessions). Use + 'langsmith-client projects' to manage tracing projects and their traces. + """ + pass + + +@projects.command() +@common_options +@click.option( + "--id", + "project_ids", + multiple=True, + required=True, + help="Control-plane project ID to delete (repeatable)", +) +@click.option( + "--force/--no-force", + default=True, + help="Force deletion, clearing stale references that block it (default: on)", +) +@click.option( + "--delete-tracing-project", + is_flag=True, + default=False, + help=( + "Also delete the paired tracing project and its traces. " + "Off by default so trace data is preserved." + ), +) +@click.option("--yes", is_flag=True, help="Skip the confirmation prompt") +def delete( # noqa: PLR0913 — Click option surface; one param per flag is idiomatic + region: str, + api_key: str | None, + workspace_id: str | None, + project_ids: tuple[str, ...], + force: bool, + delete_tracing_project: bool, + yes: bool, +): + """Delete one or more control-plane project records by ID.""" + # The SaaS control plane routes by tenant, so a workspace (X-Tenant-Id) is + # required. Fail early with a clear message instead of a confusing 404. + if not workspace_id: + raise click.ClickException( + "A workspace is required: pass --workspace-id or set " + "LANGSMITH_WORKSPACE_ID (sent as X-Tenant-Id)." + ) + + # The API key authorizes; the workspace is selected at runtime (below), + # so it is not needed to instantiate the client. + client = create_client(ControlPlaneClient, api_key, None, region) + + click.echo(f"About to delete {len(project_ids)} control-plane project(s):") + for project_id in project_ids: + click.echo(f" - {project_id}") + if delete_tracing_project: + click.echo( + click.style( + " --delete-tracing-project is set: traces WILL be deleted.", + fg="yellow", + ) + ) + + if not yes: + click.confirm("Proceed?", abort=True) + + failed = 0 + for project_id in project_ids: + click.echo(f"Deleting '{project_id}' ...") + try: + client.delete_project( + project_id, + workspace_id=workspace_id, + force=force, + delete_tracing_project=delete_tracing_project, + ) + echo_success(" Deleted") + except RuntimeError as e: + click.echo(click.style(f" {e}", fg="red")) + failed += 1 + + click.echo() + deleted = len(project_ids) - failed + if deleted: + echo_success(f"{deleted} project(s) deleted.") + if failed: + raise click.ClickException(f"{failed} project(s) failed to delete.") + + +if __name__ == "__main__": + cli() diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/deploy_docker.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py similarity index 56% rename from packages/python/langsmith-client/src/langsmith_client/tools/deploy_docker.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py index 952c30e..5bfab7d 100644 --- a/packages/python/langsmith-client/src/langsmith_client/tools/deploy_docker.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py @@ -11,35 +11,53 @@ - A Docker image pushed to a container registry accessible by LangSmith BUILD AND PUSH WORKFLOW: - langsmith-build --push --registry your-registry + langsmith-client build --push --registry your-registry + +Deployments use a stable canonical name of the form -, where +service defaults to [project].name from pyproject.toml (override with --name) +and env comes from --env (defaults to $APP_ENV, then "dev"). Pass --deployment +to use a full base name as-is (for example my-agent-prod-dev) without +splitting service and env. + +Re-running ``create`` is an idempotent upsert: it updates the live deployment +in place, or creates a git-SHA rescue name only when the canonical name is +stuck/orphaned. CONTROL PLANE API REFERENCE: https://docs.langchain.com/langsmith/api-ref-control-plane """ +# Click command callbacks expose one function parameter per CLI option, and the +# deployment-creation helper takes one per tunable deployment setting, so the +# argument count necessarily exceeds PLR0913's limit. Suppress it module-wide. +# ruff: noqa: PLR0913 + import re +from http import HTTPStatus from pathlib import Path from typing import Any import click import requests -from langsmith_client import ( +from ess_langsmith_client import ( ControlPlaneClient, + choose_deploy_name, common_options, create_client, - echo_deployment_created, + deployment_option, + echo_deployment_resolved, echo_success, + env_option, + get_git_sha, get_project_info, handle_wait, merge_secrets, print_deployments, + resolve_live_deployment_record, ) - -HTTP_OK = 200 -HTTP_CREATED = 201 - -_REQUEST_TIMEOUT = 30 +from ess_langsmith_client.client import _REQUEST_TIMEOUT +from ess_langsmith_client.naming import resolve_deploy_base def extract_name_from_image_uri(image_uri: str) -> str: @@ -58,7 +76,7 @@ def extract_name_from_image_uri(image_uri: str) -> str: class DockerDeploymentClient(ControlPlaneClient): """Client for Docker-based deployments to LangSmith.""" - def create_deployment( # noqa: PLR0913 + def create_deployment( self, name: str, image_uri: str, @@ -66,10 +84,10 @@ def create_deployment( # noqa: PLR0913 k8s_namespace: str | None = None, secrets: list[dict[str, str]] | None = None, env_vars: list[dict[str, str]] | None = None, - min_scale: int = 1, - max_scale: int = 3, - cpu: int = 1, - memory_mb: int = 1024, + min_scale: int = 2, + max_scale: int = 10, + cpu: int = 4, + memory_mb: int = 8192, ) -> dict[str, Any]: """Create a new deployment from a Docker image.""" source_config: dict[str, Any] = { @@ -111,7 +129,7 @@ def create_deployment( # noqa: PLR0913 timeout=_REQUEST_TIMEOUT, ) - if response.status_code in (HTTP_OK, HTTP_CREATED): + if response.status_code in (HTTPStatus.OK, HTTPStatus.CREATED): return response.json() raise RuntimeError( f"Failed to create deployment: {response.status_code}\n{response.text}" @@ -122,8 +140,18 @@ def update_deployment( deployment_id: str, image_uri: str, secrets: list[dict[str, str]] | None = None, + min_scale: int | None = None, + max_scale: int | None = None, + cpu: int | None = None, + memory_mb: int | None = None, ) -> dict[str, Any]: - """Update an existing Docker deployment with a new image.""" + """Update an existing Docker deployment with a new image and spec. + + Passing any of ``min_scale``/``max_scale``/``cpu``/``memory_mb`` applies + the resource spec so re-deploying reconciles the desired scale/resources, + not just the image. Listener and Kubernetes namespace are placement + settings fixed at creation time and are not updated here. + """ request_body: dict[str, Any] = { "source_revision_config": { "repo_ref": None, @@ -134,6 +162,19 @@ def update_deployment( if secrets: request_body["secrets"] = secrets + resource_spec = { + key: value + for key, value in { + "min_scale": min_scale, + "max_scale": max_scale, + "cpu": cpu, + "memory_mb": memory_mb, + }.items() + if value is not None + } + if resource_spec: + request_body["source_config"] = {"resource_spec": resource_spec} + response = requests.patch( f"{self.base_url}/deployments/{deployment_id}", headers=self.headers, @@ -141,7 +182,7 @@ def update_deployment( timeout=_REQUEST_TIMEOUT, ) - if response.status_code == HTTP_OK: + if response.status_code == HTTPStatus.OK: return response.json() raise RuntimeError( f"Failed to update deployment: {response.status_code}\n{response.text}" @@ -175,9 +216,13 @@ def cli(): @cli.command() @common_options +@env_option +@deployment_option @_project_dir_option @click.option( - "--name", help="Deployment name (defaults to project name from pyproject.toml)" + "--name", + help="Service name for the canonical - name " + "(defaults to project name from pyproject.toml)", ) @click.option("--image-uri", help="Docker image URI (defaults to project-name:version)") @click.option( @@ -199,10 +244,12 @@ def cli(): @click.option("--cpu", type=int, default=1, help="CPU cores per instance") @click.option("--memory", type=int, default=1024, help="Memory in MB") @click.option("--wait", is_flag=True, help="Wait for deployment to complete") -def create( # noqa: PLR0913 +def create( region: str, api_key: str | None, workspace_id: str | None, + env: str | None, + deployment: str | None, project_dir: str, name: str | None, image_uri: str | None, @@ -215,24 +262,39 @@ def create( # noqa: PLR0913 memory: int, wait: bool, ): - """Create a new deployment from a Docker image.""" + """Deploy a Docker image to LangSmith (idempotent upsert). + + \b + Resolves the canonical - name and: + - updates the live deployment in place if one exists, + - creates the canonical name if none exists, + - creates a git-SHA rescue name (--) only when the + canonical name is stuck/orphaned. + """ client = create_client(DockerDeploymentClient, api_key, workspace_id, region) # Get project info from pyproject.toml project = get_project_info(start_path=Path(project_dir)) - # Derive name from project or image URI - if not name: + # Derive the service name from --name, the project, or the image URI. + service = name + if not service and not deployment: if project: - name = project.name + service = project.name elif image_uri: - name = extract_name_from_image_uri(image_uri) + service = extract_name_from_image_uri(image_uri) else: raise click.ClickException( "Could not determine deployment name. " - "Provide --name or ensure pyproject.toml exists with [project].name" + "Provide --deployment, --name, or ensure pyproject.toml exists " + "with [project].name" ) + try: + base = resolve_deploy_base(deployment=deployment, service=service, env=env) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + # Derive image URI from project if not provided if not image_uri: if project: @@ -244,25 +306,64 @@ def create( # noqa: PLR0913 "Provide --image-uri or ensure pyproject.toml exists" ) - click.echo(f"Creating Docker deployment: {name}") - click.echo(f" Image: {image_uri}") - click.echo(f" Scale: {min_scale}-{max_scale} instances") + secret_list = merge_secrets(secrets) try: + live = client.resolve_live_deployment(base) + + if live: + click.echo(f"Updating live deployment '{live['name']}' [{live['id']}]") + click.echo(f" Image: {image_uri}") + click.echo(f" Scale: {min_scale}-{max_scale} instances") + result = client.update_deployment( + deployment_id=live["id"], + image_uri=image_uri, + secrets=secret_list, + min_scale=min_scale, + max_scale=max_scale, + cpu=cpu, + memory_mb=memory, + ) + revision_id = result.get("latest_revision_id") + echo_deployment_resolved( + "updated", live["name"], live["id"], revision_id, live.get("url") + ) + handle_wait(client, live["id"], revision_id, wait, live.get("url")) + return + + taken = { + deployment["name"] for deployment in client.find_deployments_by_base(base) + } + deploy_name = choose_deploy_name(base, taken, get_git_sha()) + if deploy_name != base: + click.echo( + click.style( + f"Canonical name '{base}' is stuck/orphaned; " + f"using rescue name '{deploy_name}'.", + fg="yellow", + ) + ) + click.echo(f"Creating Docker deployment: {deploy_name}") + click.echo(f" Image: {image_uri}") + click.echo(f" Scale: {min_scale}-{max_scale} instances") + result = client.create_deployment( - name=name, + name=deploy_name, image_uri=image_uri, listener_id=listener_id, k8s_namespace=k8s_namespace, - secrets=merge_secrets(secrets), + secrets=secret_list, min_scale=min_scale, max_scale=max_scale, cpu=cpu, memory_mb=memory, ) - echo_deployment_created(result["id"], result.get("latest_revision_id")) - handle_wait(client, result["id"], result.get("latest_revision_id"), wait) + revision_id = result.get("latest_revision_id") + echo_deployment_resolved( + "created", deploy_name, result["id"], revision_id, result.get("url") + ) + handle_wait(client, result["id"], revision_id, wait, result.get("url")) except RuntimeError as e: raise click.ClickException(str(e)) from e @@ -283,7 +384,7 @@ def create( # noqa: PLR0913 help="Secret in NAME=VALUE or NAME=$ENV_VAR format", ) @click.option("--wait", is_flag=True, help="Wait for deployment to complete") -def update( # noqa: PLR0913 +def update( region: str, api_key: str | None, workspace_id: str | None, @@ -312,7 +413,7 @@ def update( # noqa: PLR0913 click.echo(f"Updating deployment: {deployment_id}") click.echo(f" New image: {image_uri}") if secret_list: - click.echo(f" Secrets: {', '.join(s['name'] for s in secret_list)}") + click.echo(f" Secrets: {', '.join(secret['name'] for secret in secret_list)}") try: result = client.update_deployment( @@ -351,7 +452,9 @@ def list_deployments( if docker_only: deployments = [ - d for d in deployments if d.get("source") == "external_docker" + deployment + for deployment in deployments + if deployment.get("source") == "external_docker" ] print_deployments(deployments) @@ -362,21 +465,87 @@ def list_deployments( @cli.command() @common_options -@click.option("--deployment-id", required=True, help="Deployment ID to delete") -@click.confirmation_option(prompt="Are you sure you want to delete this deployment?") +@env_option +@deployment_option +@_project_dir_option +@click.option( + "--name", + help="Service name (defaults to [project].name in pyproject.toml)", +) +@click.option( + "--deployment-id", + help="Deployment ID to delete (alternative to -C / --deployment resolution)", +) +@click.option( + "--if-exists", + is_flag=True, + help="Exit 0 when no live deployment matches the resolved base name", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompt", +) def delete( region: str, api_key: str | None, workspace_id: str | None, - deployment_id: str, + env: str | None, + deployment: str | None, + project_dir: str, + name: str | None, + deployment_id: str | None, + if_exists: bool, + yes: bool, ): - """Delete a deployment.""" + """Delete a deployment by ID or by resolved ``-`` base name.""" client = create_client(DockerDeploymentClient, api_key, workspace_id, region) - click.echo(f"Deleting deployment: {deployment_id}") + target_id = deployment_id + target_name: str | None = None + + if not target_id: + project = get_project_info(start_path=Path(project_dir)) + service = name + if not service and not deployment: + if project: + service = project.name + else: + raise click.ClickException( + "Provide --deployment-id, --deployment, or -C with pyproject.toml" + ) + try: + base = resolve_deploy_base(deployment=deployment, service=service, env=env) + live = resolve_live_deployment_record( + client, + deployment=deployment, + service=service, + env=env, + ) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + if not live: + if if_exists: + click.echo(f"No live deployment found for '{base}'. Nothing to delete.") + return + raise click.ClickException( + f"No live deployment found for '{base}'. " + "Pass --deployment-id or check --env/$APP_ENV." + ) + target_id = live["id"] + target_name = live.get("name") + + display = f"{target_name} [{target_id}]" if target_name else target_id + if not yes: + click.confirm( + f"Are you sure you want to delete deployment {display}?", + abort=True, + ) + click.echo(f"Deleting deployment: {display}") try: - client.delete_deployment(deployment_id) + client.delete_deployment(target_id) echo_success("Deployment deleted successfully!") except RuntimeError as e: diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/deploy_github.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py similarity index 67% rename from packages/python/langsmith-client/src/langsmith_client/tools/deploy_github.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py index ec6114c..7004755 100644 --- a/packages/python/langsmith-client/src/langsmith_client/tools/deploy_github.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py @@ -15,41 +15,50 @@ 2. Select "Import from GitHub" and complete the OAuth flow 3. Note your GitHub integration ID from LangSmith -Deployment name defaults to [project].name from pyproject.toml when run from -a project directory; otherwise use --name. +Deployments use a stable canonical name of the form -, where +service defaults to [project].name from pyproject.toml (override with --name) +and env comes from --env (defaults to $APP_ENV, then "dev"). Re-running create +is an idempotent upsert: it updates the live deployment in place, or creates a +git-SHA rescue name only when the canonical name is stuck/orphaned. CONTROL PLANE API REFERENCE: https://docs.langchain.com/langsmith/api-ref-control-plane """ +# Click command callbacks expose one function parameter per CLI option, and the +# deployment-creation helper takes one per tunable deployment setting, so the +# argument count necessarily exceeds PLR0913's limit. Suppress it module-wide. +# ruff: noqa: PLR0913 + +from http import HTTPStatus from pathlib import Path from typing import Any import click import requests -from langsmith_client import ( +from ess_langsmith_client import ( ControlPlaneClient, + choose_deploy_name, common_options, + compute_base_name, create_client, - echo_deployment_created, + echo_deployment_resolved, echo_success, + env_option, + get_git_sha, get_project_info, handle_wait, merge_secrets, print_deployments, ) - -HTTP_OK = 200 -HTTP_CREATED = 201 - -_REQUEST_TIMEOUT = 30 +from ess_langsmith_client.client import _REQUEST_TIMEOUT class GitHubDeploymentClient(ControlPlaneClient): """Client for GitHub-based deployments to LangSmith Cloud.""" - def create_deployment( # noqa: PLR0913 + def create_deployment( self, name: str, integration_id: str, @@ -100,7 +109,7 @@ def create_deployment( # noqa: PLR0913 timeout=_REQUEST_TIMEOUT, ) - if response.status_code in (HTTP_OK, HTTP_CREATED): + if response.status_code in (HTTPStatus.OK, HTTPStatus.CREATED): return response.json() raise RuntimeError( f"Failed to create deployment: {response.status_code}\n{response.text}" @@ -112,12 +121,42 @@ def update_deployment( branch: str | None = None, config_path: str | None = None, build_on_push: bool | None = None, + secrets: list[dict[str, str]] | None = None, + min_scale: int | None = None, + max_scale: int | None = None, + cpu: int | None = None, + memory_mb: int | None = None, ) -> dict[str, Any]: - """Update an existing GitHub deployment (creates a new revision).""" + """Update an existing GitHub deployment (creates a new revision). + + Applies secrets and the resource spec (when any of + ``min_scale``/``max_scale``/``cpu``/``memory_mb`` is given) so + re-deploying reconciles the full desired spec, not just branch/config. + """ request_body: dict[str, Any] = {} + source_config: dict[str, Any] = {} if build_on_push is not None: - request_body["source_config"] = {"build_on_push": build_on_push} + source_config["build_on_push"] = build_on_push + + resource_spec = { + key: value + for key, value in { + "min_scale": min_scale, + "max_scale": max_scale, + "cpu": cpu, + "memory_mb": memory_mb, + }.items() + if value is not None + } + if resource_spec: + source_config["resource_spec"] = resource_spec + + if source_config: + request_body["source_config"] = source_config + + if secrets: + request_body["secrets"] = secrets source_revision_config: dict[str, Any] = {} if branch: @@ -135,7 +174,7 @@ def update_deployment( timeout=_REQUEST_TIMEOUT, ) - if response.status_code == HTTP_OK: + if response.status_code == HTTPStatus.OK: return response.json() raise RuntimeError( f"Failed to update deployment: {response.status_code}\n{response.text}" @@ -169,9 +208,12 @@ def cli(): @cli.command() @common_options +@env_option @_project_dir_option @click.option( - "--name", help="Deployment name (defaults to project name from pyproject.toml)" + "--name", + help="Service name for the canonical - name " + "(defaults to project name from pyproject.toml)", ) @click.option("--repo-url", required=True, help="GitHub repository URL") @click.option("--branch", default="main", help="Git branch to deploy from") @@ -194,10 +236,11 @@ def cli(): @click.option("--cpu", type=int, default=1, help="CPU cores per instance") @click.option("--memory", type=int, default=1024, help="Memory in MB") @click.option("--wait", is_flag=True, help="Wait for deployment to complete") -def create( # noqa: PLR0913 +def create( region: str, api_key: str | None, workspace_id: str | None, + env: str | None, project_dir: str, name: str | None, repo_url: str, @@ -214,34 +257,82 @@ def create( # noqa: PLR0913 memory: int, wait: bool, ): - """Create a new deployment from a GitHub repository.""" + """Deploy a GitHub repository to LangSmith (idempotent upsert). + + \b + Resolves the canonical - name and: + - updates the live deployment in place if one exists, + - creates the canonical name if none exists, + - creates a git-SHA rescue name (--) only when the + canonical name is stuck/orphaned. + """ if not integration_id: raise click.ClickException( "GitHub integration ID is required.\n" "Set GITHUB_INTEGRATION_ID env var or use --integration-id" ) - # Derive name from pyproject.toml - if not name: + # Derive the service name from --name or pyproject.toml + service = name + if not service: project = get_project_info(start_path=Path(project_dir)) if project: - name = project.name + service = project.name else: raise click.ClickException( "Could not determine deployment name. " "Provide --name or ensure pyproject.toml exists with [project].name" ) - client = create_client(GitHubDeploymentClient, api_key, workspace_id, region) + base = compute_base_name(service, env) - click.echo(f"Creating GitHub deployment: {name}") - click.echo(f" Repository: {repo_url}") - click.echo(f" Branch: {branch}") - click.echo(f" Scale: {min_scale}-{max_scale} instances") + client = create_client(GitHubDeploymentClient, api_key, workspace_id, region) try: + live = client.resolve_live_deployment(base) + + if live: + click.echo(f"Updating live deployment '{live['name']}' [{live['id']}]") + click.echo(f" Repository: {repo_url}") + click.echo(f" Branch: {branch}") + click.echo(f" Scale: {min_scale}-{max_scale} instances") + result = client.update_deployment( + deployment_id=live["id"], + branch=branch, + config_path=config_path, + build_on_push=auto_build, + secrets=merge_secrets(secrets), + min_scale=min_scale, + max_scale=max_scale, + cpu=cpu, + memory_mb=memory, + ) + revision_id = result.get("latest_revision_id") + echo_deployment_resolved( + "updated", live["name"], live["id"], revision_id, live.get("url") + ) + handle_wait(client, live["id"], revision_id, wait, live.get("url")) + return + + taken = { + deployment["name"] for deployment in client.find_deployments_by_base(base) + } + deploy_name = choose_deploy_name(base, taken, get_git_sha()) + if deploy_name != base: + click.echo( + click.style( + f"Canonical name '{base}' is stuck/orphaned; " + f"using rescue name '{deploy_name}'.", + fg="yellow", + ) + ) + click.echo(f"Creating GitHub deployment: {deploy_name}") + click.echo(f" Repository: {repo_url}") + click.echo(f" Branch: {branch}") + click.echo(f" Scale: {min_scale}-{max_scale} instances") + result = client.create_deployment( - name=name, + name=deploy_name, integration_id=integration_id, repo_url=repo_url, branch=branch, @@ -256,14 +347,11 @@ def create( # noqa: PLR0913 memory_mb=memory, ) - echo_deployment_created(result["id"], result.get("latest_revision_id")) - handle_wait( - client, - result["id"], - result.get("latest_revision_id"), - wait, - result.get("url"), + revision_id = result.get("latest_revision_id") + echo_deployment_resolved( + "created", deploy_name, result["id"], revision_id, result.get("url") ) + handle_wait(client, result["id"], revision_id, wait, result.get("url")) except RuntimeError as e: raise click.ClickException(str(e)) from e @@ -278,7 +366,7 @@ def create( # noqa: PLR0913 "--auto-build/--no-auto-build", default=None, help="Enable/disable auto-build" ) @click.option("--wait", is_flag=True, help="Wait for deployment to complete") -def update( # noqa: PLR0913 +def update( region: str, api_key: str | None, workspace_id: str | None, @@ -330,7 +418,11 @@ def list_deployments( deployments = result.get("resources", []) if github_only: - deployments = [d for d in deployments if d.get("source") == "github"] + deployments = [ + deployment + for deployment in deployments + if deployment.get("source") == "github" + ] print_deployments(deployments) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py new file mode 100644 index 0000000..186df0d --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +Manage LangSmith API keys -- list, create, and delete. + +PREREQUISITES: +- LANGSMITH_API_KEY: A valid LangSmith API key with admin access + +USAGE: + langsmith-client keys list + langsmith-client keys list --expired + langsmith-client keys list --older-than 90 + langsmith-client keys create "LangSmith Deployment: my-app" + langsmith-client keys delete "my-old-key" "another-old-key" + langsmith-client keys delete "duplicated-name" --all +""" + +import json +import os +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Any + +import click +import requests +from dotenv import load_dotenv + +from ess_langsmith_client.client import _REQUEST_TIMEOUT, _SMITH_API_URL + +load_dotenv() + + +# ============================================================================= +# Client +# ============================================================================= + + +class APIKeyClient: + """Client for the LangSmith API key management endpoints.""" + + def __init__( + self, + api_key: str | None = None, + workspace_id: str | None = None, + ): + self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") + if not self.api_key: + raise ValueError( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or pass --api-key." + ) + self.headers: dict[str, str] = { + "x-api-key": self.api_key, + "Content-Type": "application/json", + } + workspace_id = workspace_id or os.environ.get("LANGSMITH_WORKSPACE_ID") + if workspace_id: + self.headers["X-Tenant-Id"] = workspace_id + + def list_keys(self) -> list[dict[str, Any]]: + """List all API keys for the current tenant.""" + resp = requests.get( + f"{_SMITH_API_URL}/api/v1/api-key", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to list API keys: {resp.status_code}\n{resp.text}" + ) + return resp.json() + + def create_key(self, description: str) -> dict[str, Any]: + """Create a new service API key. + + Returns the full key metadata including the raw key value, which is + only available at creation time. + """ + resp = requests.post( + f"{_SMITH_API_URL}/api/v1/api-key", + headers=self.headers, + json={"description": description}, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to create API key: {resp.status_code}\n{resp.text}" + ) + return resp.json() + + def delete_key(self, key_id: str) -> dict[str, Any]: + """Delete an API key by its UUID.""" + resp = requests.delete( + f"{_SMITH_API_URL}/api/v1/api-key/{key_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to delete key {key_id}: {resp.status_code}\n{resp.text}" + ) + return resp.json() + + +# ============================================================================= +# Output formatters +# ============================================================================= + + +def _parse_dt(value: str | datetime) -> datetime: + """Parse API timestamps; normalize RFC3339 Z and naive datetimes to UTC.""" + if isinstance(value, datetime): + dt = value + else: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _age_days(key: dict[str, Any]) -> int | None: + """Return the number of days since the key was created, or None.""" + created_at = key.get("created_at") + if not created_at: + return None + created_at = _parse_dt(created_at) + return (datetime.now(timezone.utc) - created_at).days + + +def _is_expired(key: dict[str, Any]) -> bool: + expires_at = key.get("expires_at") + if not expires_at: + return False + expires_at = _parse_dt(expires_at) + return expires_at < datetime.now(timezone.utc) + + +def _format_expires(key: dict[str, Any]) -> str: + expires_at = key.get("expires_at") + if not expires_at: + return "never" + expires_at = _parse_dt(expires_at) + label = expires_at.strftime("%Y-%m-%d") + if _is_expired(key): + return f"{label} (EXPIRED)" + return label + + +def _print_table(keys: list[dict[str, Any]]) -> None: + if not keys: + click.echo("No API keys found.") + return + + desc_w = max((len(k.get("description", "")) for k in keys), default=20) + desc_w = max(desc_w, 11) + + header = ( + f"{'Description':<{desc_w}} {'Short Key':<12} " + f"{'Age (days)':<10} {'Expires':<22} ID" + ) + click.echo(f"\nFound {len(keys)} key(s):\n") + click.echo(header) + click.echo("-" * len(header)) + + for k in keys: + age = _age_days(k) + age_str = str(age) if age is not None else "?" + expires = _format_expires(k) + click.echo( + f"{k.get('description', ''):<{desc_w}} " + f"{k.get('short_key', ''):<12} " + f"{age_str:<10} " + f"{expires:<22} " + f"{k.get('id', '')}" + ) + click.echo() + + +# ============================================================================= +# CLI +# ============================================================================= + + +def _select_targets( + all_keys: list[dict[str, Any]], + descriptions: tuple[str, ...], +) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: + """Return (targets, duplicates_by_description). + + duplicates_by_description holds only descriptions matching >1 key. + """ + keys_by_description: dict[str, list[dict[str, Any]]] = {} + for key in all_keys: + description = key.get("description") + if description in descriptions: + keys_by_description.setdefault(description, []).append(key) + targets = [ + key for matching_keys in keys_by_description.values() for key in matching_keys + ] + duplicates = { + description: matching_keys + for description, matching_keys in keys_by_description.items() + if len(matching_keys) > 1 + } + return targets, duplicates + + +def _format_duplicate_error(duplicates: dict[str, list[dict[str, Any]]]) -> str: + """Build the error message listing descriptions that match multiple keys.""" + error_lines = ["Refusing to delete: these descriptions match multiple keys."] + for description, matching_keys in sorted(duplicates.items()): + error_lines.append(f" '{description}' matches {len(matching_keys)} keys:") + error_lines.extend( + f" {key.get('short_key', '???')} id={key.get('id', '???')} " + f"expires: {_format_expires(key)}" + for key in matching_keys + ) + error_lines.append("Re-run with --all to delete every matching key.") + return "\n".join(error_lines) + + +def _common_options(func): + func = click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key (defaults to LANGSMITH_API_KEY env var)", + )(func) + func = click.option( + "--workspace-id", + envvar="LANGSMITH_WORKSPACE_ID", + help="Target workspace ID (required for PATs with multiple workspaces)", + )(func) + return func + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Manage LangSmith API keys. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: A valid API key with admin access + """ + + +@cli.command("list") +@_common_options +@click.option("--expired", is_flag=True, help="Show only expired keys") +@click.option( + "--older-than", + type=int, + default=None, + help="Show only keys older than N days (based on created_at)", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def list_keys( + api_key: str | None, + workspace_id: str | None, + expired: bool, + older_than: int | None, + output_format: str, +): + """List API keys.""" + try: + client = APIKeyClient(api_key, workspace_id) + keys = client.list_keys() + except (ValueError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + + if expired: + keys = [k for k in keys if _is_expired(k)] + + if older_than is not None: + keys = [k for k in keys if (_age_days(k) or 0) > older_than] + + if output_format == "json": + click.echo(json.dumps(keys, indent=2, default=str)) + else: + _print_table(keys) + + +@cli.command() +@_common_options +@click.argument("description") +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def create( + api_key: str | None, + workspace_id: str | None, + description: str, + output_format: str, +): + """Create a new service API key. + + The full key value is shown only once -- copy it immediately. + + \b + Examples: + langsmith-client keys create "LangSmith Deployment: hello-world-graph" + langsmith-client keys create "my-service-key" --format json + """ + try: + client = APIKeyClient(api_key, workspace_id) + result = client.create_key(description) + except (ValueError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + + if output_format == "json": + click.echo(json.dumps(result, indent=2, default=str)) + else: + click.echo() + click.echo(click.style("Key created successfully.", fg="green")) + click.echo() + click.echo(f" Description: {result.get('description', '')}") + click.echo(f" ID: {result.get('id', '')}") + click.echo(f" Short Key: {result.get('short_key', '')}") + click.echo() + click.echo( + click.style( + " COPY THIS NOW -- the full key is only shown once:", + fg="yellow", + bold=True, + ) + ) + click.echo(f" {result.get('key', '???')}") + click.echo() + + +@cli.command() +@_common_options +@click.argument("descriptions", nargs=-1, required=True) +@click.option( + "--all", + "delete_all", + is_flag=True, + help=( + "Delete every key matching a description, even when a description " + "matches more than one key (default: error on duplicates)." + ), +) +@click.option("--yes", is_flag=True, help="Skip confirmation prompt") +def delete( + api_key: str | None, + workspace_id: str | None, + descriptions: tuple[str, ...], + delete_all: bool, + yes: bool, +): + """Delete API keys by description (name). + + Pass one or more key descriptions as arguments. The tool matches them + exactly against the description field shown in the LangSmith UI. + + By default, if any description matches more than one key the command + refuses to delete and exits with an error. Pass --all to explicitly + delete every matching key. + + \b + Examples: + langsmith-client keys delete "my-old-key" + langsmith-client keys delete "key-1" "key-2" "key-3" + langsmith-client keys delete "key-1" --yes + langsmith-client keys delete "duplicated-name" --all + """ + try: + client = APIKeyClient(api_key, workspace_id) + all_keys = client.list_keys() + except (ValueError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + + targets, duplicates = _select_targets(all_keys, descriptions) + + if duplicates and not delete_all: + raise click.ClickException(_format_duplicate_error(duplicates)) + + if not targets: + matched_descs = {k.get("description") for k in all_keys} + labels = sorted(d or "(no description)" for d in matched_descs) + click.echo("No keys found matching the given descriptions.") + click.echo(f"Available descriptions: {', '.join(labels)}") + return + + click.echo(f"\nKeys to delete ({len(targets)}):\n") + for k in targets: + expires = _format_expires(k) + click.echo( + f" {k['description']} ({k.get('short_key', '???')}) expires: {expires}" + ) + click.echo() + + if not yes: + click.confirm("Delete these keys?", abort=True) + + deleted = 0 + failed = 0 + for k in targets: + desc = k.get("description", k["id"]) + try: + client.delete_key(k["id"]) + click.echo(click.style(f" Deleted: {desc}", fg="green")) + deleted += 1 + except RuntimeError as e: + click.echo(click.style(f" Failed: {desc} -- {e}", fg="red")) + failed += 1 + + click.echo() + if deleted: + click.echo(click.style(f"{deleted} key(s) deleted.", fg="green")) + if failed: + click.echo(click.style(f"{failed} key(s) failed.", fg="red")) + + +if __name__ == "__main__": + cli() diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/list_listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py similarity index 96% rename from packages/python/langsmith-client/src/langsmith_client/tools/list_listeners.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py index d00d820..6226943 100644 --- a/packages/python/langsmith-client/src/langsmith_client/tools/list_listeners.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py @@ -11,8 +11,8 @@ - LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID USAGE: - langsmith-listeners list - langsmith-listeners list --format json + langsmith-client listeners list + langsmith-client listeners list --format json CONTROL PLANE API REFERENCE: https://docs.langchain.com/langsmith/api-ref-control-plane @@ -23,7 +23,7 @@ import click -from langsmith_client import ( +from ess_langsmith_client import ( ControlPlaneClient, common_options, create_client, diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/list_workspaces.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_workspaces.py similarity index 95% rename from packages/python/langsmith-client/src/langsmith_client/tools/list_workspaces.py rename to packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_workspaces.py index e3f51e2..35b43bf 100644 --- a/packages/python/langsmith-client/src/langsmith_client/tools/list_workspaces.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_workspaces.py @@ -9,9 +9,9 @@ - LANGSMITH_API_KEY: Your LangSmith API key USAGE: - langsmith-workspaces list - langsmith-workspaces list --format json - langsmith-workspaces list --format table + langsmith-client workspaces list + langsmith-client workspaces list --format json + langsmith-client workspaces list --format table API REFERENCE: https://api.smith.langchain.com/api/v1/workspaces @@ -19,21 +19,16 @@ import json from datetime import datetime +from http import HTTPStatus import click import requests from dotenv import load_dotenv from pydantic import BaseModel, Field -# Load environment variables from .env file -load_dotenv() - -# LangSmith API base URL -LANGSMITH_API_URL = "https://api.smith.langchain.com" +from ess_langsmith_client.client import _REQUEST_TIMEOUT, _SMITH_API_URL -HTTP_OK = 200 - -_REQUEST_TIMEOUT = 30 +load_dotenv() # ============================================================================= @@ -114,7 +109,7 @@ def get_workspaces(api_key: str) -> WorkspacesResult: RuntimeError: If the API request fails """ response = requests.get( - f"{LANGSMITH_API_URL}/api/v1/workspaces", + f"{_SMITH_API_URL}/api/v1/workspaces", headers={ "x-api-key": api_key, "Content-Type": "application/json", @@ -122,7 +117,7 @@ def get_workspaces(api_key: str) -> WorkspacesResult: timeout=_REQUEST_TIMEOUT, ) - if response.status_code == HTTP_OK: + if response.status_code == HTTPStatus.OK: return WorkspacesResult.from_api_response(response.json()) else: raise RuntimeError( diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py new file mode 100644 index 0000000..0bf1468 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Unified LangSmith Control Plane CLI. + +Groups every LangSmith tool under a single ``langsmith-client`` command so you +do not have to remember individual entry points. Each subcommand is the same +Click group/command exposed by its module, composed here by reference. + +USAGE: + langsmith-client keys list + langsmith-client workspaces list + langsmith-client deploy docker create ... + langsmith-client projects delete --name + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import click + +from ess_langsmith_client._version import get_package_version +from ess_langsmith_client.agent_test.cli import deployed as test_deployed_cmd +from ess_langsmith_client.tools import ( + build, + control_plane, + deploy_docker, + deploy_github, + keys, + list_listeners, + list_workspaces, + projects, +) + + +@click.group() +@click.version_option(version=get_package_version()) +def cli() -> None: + """LangSmith Control Plane CLI. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key (admin for key/project ops) + - LANGSMITH_WORKSPACE_ID: Your workspace ID (scopes tenant-specific calls) + """ + + +@cli.group() +def deploy() -> None: + """Deploy agents from Docker images or GitHub repositories.""" + + +cli.add_command(keys.cli, name="keys") +cli.add_command(list_workspaces.cli, name="workspaces") +cli.add_command(list_listeners.cli, name="listeners") +cli.add_command(projects.cli, name="projects") +cli.add_command(control_plane.cli, name="control-plane") +cli.add_command(build.build, name="build") +cli.add_command(test_deployed_cmd, name="test-deployed") + +deploy.add_command(deploy_docker.cli, name="docker") +deploy.add_command(deploy_github.cli, name="github") + + +if __name__ == "__main__": + cli() diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/projects.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/projects.py new file mode 100644 index 0000000..e0a8fa9 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/projects.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +""" +Manage LangSmith tracing projects. + +Tracing projects (also called sessions) are created automatically when LangGraph +deployments are created. Use this tool to list and delete them. + +The delete command transparently handles orphaned projects — where the linked +deployment no longer exists — by clearing the stale reference and retrying. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key + +USAGE: + langsmith-client projects list + langsmith-client projects list --name hello-agent-auth + langsmith-client projects list --prefix hello-agent-dev + langsmith-client projects info --name hello-agent-dev + langsmith-client projects delete --id + langsmith-client projects delete --name hello-agent-auth + langsmith-client projects delete --name hello-world --force +""" + +import json +import os +from http import HTTPStatus +from typing import Any + +import click +import requests +from dotenv import load_dotenv + +from ess_langsmith_client.client import _REQUEST_TIMEOUT, _SMITH_API_URL + +load_dotenv() + + +# ============================================================================= +# Client +# ============================================================================= + + +class TracingProjectClient: + """Client for the LangSmith tracing projects (sessions) API.""" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") + if not self.api_key: + raise ValueError( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or pass --api-key." + ) + self.headers = { + "x-api-key": self.api_key, + "Content-Type": "application/json", + } + + def list_projects( + self, + name: str | None = None, + *, + include_stats: bool = False, + ) -> list[dict[str, Any]]: + """List tracing projects, optionally filtered by exact name. + + Args: + name: Filter by exact project name. + include_stats: Include aggregate stats (run_count, latency, etc.). + """ + params: dict[str, str] = {} + if name: + params["name"] = name + if include_stats: + params["include_stats"] = "true" + resp = requests.get( + f"{_SMITH_API_URL}/api/v1/sessions", + headers=self.headers, + params=params, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to list projects: {resp.status_code}\n{resp.text}" + ) + return resp.json() + + def list_projects_by_prefix( + self, + prefix: str, + *, + include_stats: bool = False, + ) -> list[dict[str, Any]]: + """List tracing projects belonging to a canonical name base. + + Anchored match: a project's name equals ``prefix`` or starts with + ``prefix + "-"``. This finds the live tracing project for a service + regardless of any git-SHA rescue suffix (e.g. ``hello-agent-dev`` and + ``hello-agent-dev-3f9a2c`` both match, but ``hello-agent-prod`` does + not). + """ + projects = self.list_projects(include_stats=include_stats) + anchor = f"{prefix}-" + return [ + project + for project in projects + if project.get("name") == prefix + or str(project.get("name", "")).startswith(anchor) + ] + + def get_project(self, name: str) -> dict[str, Any] | None: + """Get a single project by exact name, with stats. + + Returns: + Project dict with stats fields (run_count, last_run_start_time, etc.), + or None if no project matches. + """ + projects = self.list_projects(name=name, include_stats=True) + return projects[0] if projects else None + + def get_project_by_id(self, project_id: str) -> dict[str, Any] | None: + """Get a single project by ID, with stats. + + Returns: + Project dict with stats fields (run_count, last_run_start_time, etc.), + or None if no project matches the ID. + """ + resp = requests.get( + f"{_SMITH_API_URL}/api/v1/sessions/{project_id}", + headers=self.headers, + params={"include_stats": "true"}, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code == HTTPStatus.NOT_FOUND: + return None + if resp.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to get project {project_id}: {resp.status_code}\n{resp.text}" + ) + return resp.json() + + def _clear_deployment_ref(self, project_id: str) -> None: + """Remove stale deployment_id from a project's extra metadata.""" + resp = requests.patch( + f"{_SMITH_API_URL}/api/v1/sessions/{project_id}", + headers=self.headers, + json={"extra": {}}, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to clear deployment reference on {project_id}: " + f"{resp.status_code}\n{resp.text}" + ) + + def delete_project(self, project_id: str, *, force: bool = False) -> bool: + """ + Delete a tracing project by ID. + + If the project has a stale deployment reference (409) and force=True, + the reference is cleared automatically before retrying the delete. + Returns True on success, raises RuntimeError otherwise. + """ + resp = requests.delete( + f"{_SMITH_API_URL}/api/v1/sessions/{project_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if resp.status_code == HTTPStatus.ACCEPTED: + return True + + if resp.status_code == HTTPStatus.CONFLICT: + detail = resp.json().get("detail", resp.text) + + if "associated with a LangGraph deployment" not in detail: + raise RuntimeError(f"409 Conflict: {detail}") + + if not force: + raise RuntimeError( + f"409 Conflict: {detail}\n" + "Re-run with --force to automatically clear stale " + "deployment references." + ) + + # Deployment is orphaned — clear the reference and retry. + self._clear_deployment_ref(project_id) + retry = requests.delete( + f"{_SMITH_API_URL}/api/v1/sessions/{project_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + if retry.status_code == HTTPStatus.ACCEPTED: + return True + raise RuntimeError( + f"Delete failed after clearing deployment reference: " + f"{retry.status_code}\n{retry.text}" + ) + + raise RuntimeError( + f"Failed to delete project {project_id}: {resp.status_code}\n{resp.text}" + ) + + +# ============================================================================= +# Output formatters +# ============================================================================= + + +def _print_table(projects: list[dict[str, Any]]) -> None: + if not projects: + click.echo("No projects found.") + return + + id_w = max((len(project.get("id", "")) for project in projects), default=36) + id_w = max(id_w, 4) + name_w = max((len(project.get("name", "")) for project in projects), default=20) + name_w = max(name_w, 4) + runs_w = 6 + + header = f"{'ID':<{id_w}} {'Name':<{name_w}} {'Runs':>{runs_w}} Deployment ID" + click.echo(f"\nFound {len(projects)} project(s):\n") + click.echo(header) + click.echo("-" * (len(header) + 10)) + + for project in projects: + dep_id = (project.get("extra") or {}).get("deployment_id", "") + run_count = project.get("run_count", "") + run_str = str(run_count) if run_count is not None else "" + click.echo( + f"{project.get('id', ''):<{id_w}} {project.get('name', ''):<{name_w}} " + f"{run_str:>{runs_w}} {dep_id}" + ) + click.echo() + + +# ============================================================================= +# CLI +# ============================================================================= + + +def _api_key_option(func): + return click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key (defaults to LANGSMITH_API_KEY env var)", + )(func) + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Manage LangSmith tracing projects. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + """ + pass + + +@cli.command("list") +@_api_key_option +@click.option("--name", help="Filter by exact project name") +@click.option( + "--prefix", + help="Filter by canonical name base (matches '' and '-*'); " + "useful for finding a service's live project regardless of any rescue suffix", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def list_projects( + api_key: str | None, + name: str | None, + prefix: str | None, + output_format: str, +): + """List tracing projects.""" + if name and prefix: + raise click.UsageError("Provide either --name or --prefix, not both.") + + client = TracingProjectClient(api_key) + + try: + if prefix: + projects = client.list_projects_by_prefix(prefix, include_stats=True) + else: + projects = client.list_projects(name=name, include_stats=True) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + if output_format == "json": + click.echo(json.dumps(projects, indent=2, default=str)) + else: + _print_table(projects) + + +@cli.command() +@_api_key_option +@click.option("--name", required=True, help="Exact project name to inspect") +def info(api_key: str | None, name: str): + """Show project metadata and trace statistics.""" + client = TracingProjectClient(api_key) + + try: + projects = client.list_projects(name=name, include_stats=True) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + if not projects: + raise click.ClickException(f"No project found with name: {name}") + + if len(projects) > 1: + click.echo( + click.style( + f"Warning: {len(projects)} projects share the name '{name}'. " + "Showing the first; use 'list --name' to see all or " + "'delete --id' to target a specific one.", + fg="yellow", + ) + ) + for match in projects: + click.echo(f" - {match['id']}") + + project = projects[0] + run_count = project.get("run_count", 0) or 0 + last_run = project.get("last_run_start_time") or "\u2014" + dep_id = (project.get("extra") or {}).get("deployment_id") or "(none)" + + click.echo(f"\nProject: {project['name']}") + click.echo(f" ID: {project['id']}") + click.echo(f" Run count: {run_count}") + click.echo(f" Last run: {last_run}") + click.echo(f" Deployment ID: {dep_id}") + click.echo() + + +def _resolve_targets( + client: TracingProjectClient, + project_id: str | None, + name: str | None, +) -> list[dict[str, Any]]: + """Resolve deletion targets from --id or --name. + + For --id, the project is fetched with stats so the trace-loss guard in + ``_delete_one`` applies identically to both modes (an ID with no stats would + otherwise be treated as having zero traces and bypass the guard). + """ + if project_id: + project = client.get_project_by_id(project_id) + if not project: + click.echo(f"No project found with id: {project_id}") + return [] + return [project] + + targets = client.list_projects(name=name, include_stats=True) + if not targets: + click.echo(f"No projects found with name: {name}") + else: + click.echo(f"Found {len(targets)} project(s) named '{name}'") + return targets + + +def _delete_one( + client: TracingProjectClient, + project: dict[str, Any], + *, + force: bool, +) -> str: + """Attempt to delete a single project. Returns 'deleted', 'skipped', or 'failed'.""" + pid = project["id"] + pname = project.get("name", pid) + run_count = project.get("run_count", 0) or 0 + dep_id = (project.get("extra") or {}).get("deployment_id", "") + + if run_count > 0 and not force: + click.echo( + click.style( + f" Skipping '{pname}' [{pid}] — " + f"has {run_count} trace(s). Use --force to delete.", + fg="yellow", + ) + ) + return "skipped" + + suffix = "" + if dep_id and force: + suffix = f" (orphaned deployment: {dep_id})" + if run_count > 0: + suffix += f" ({run_count} traces will be lost)" + click.echo(f" Deleting '{pname}' [{pid}]{suffix} ...") + + try: + client.delete_project(pid, force=force) + click.echo(click.style(" ✓ Deleted", fg="green")) + except RuntimeError as e: + click.echo(click.style(f" ✗ {e}", fg="red")) + return "failed" + return "deleted" + + +@cli.command() +@_api_key_option +@click.option("--id", "project_id", help="Project ID to delete") +@click.option("--name", help="Delete all projects matching this exact name") +@click.option( + "--force", + is_flag=True, + help=( + "Delete even if the project contains traces, and clear stale " + "deployment references to unblock deletion" + ), +) +@click.confirmation_option(prompt="Are you sure you want to delete this project?") +def delete( + api_key: str | None, + project_id: str | None, + name: str | None, + force: bool, +): + """Delete one or more tracing projects. + + \b + Provide either --id for a single project or --name to delete all + projects with that exact name. + + \b + Use --force to: + - Delete projects that still contain traces (data loss) + - Clear stale deployment references (orphaned deployments) + """ + if not project_id and not name: + raise click.UsageError("Provide either --id or --name.") + if project_id and name: + raise click.UsageError("Provide either --id or --name, not both.") + + client = TracingProjectClient(api_key) + + try: + targets = _resolve_targets(client, project_id, name) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + if not targets: + return + + results = [_delete_one(client, target, force=force) for target in targets] + + click.echo() + deleted = results.count("deleted") + skipped = results.count("skipped") + failed = results.count("failed") + if deleted: + click.echo(click.style(f"{deleted} project(s) deleted.", fg="green")) + if skipped: + click.echo( + click.style(f"{skipped} project(s) skipped (have traces).", fg="yellow") + ) + if failed: + click.echo(click.style(f"{failed} project(s) failed.", fg="red")) + + +if __name__ == "__main__": + cli() diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py new file mode 100644 index 0000000..5845878 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py @@ -0,0 +1,203 @@ +"""Tests for langsmith-client keys timestamp parsing and list filtering.""" + +import json +from datetime import datetime, timezone + +import pytest +from click.testing import CliRunner + +from ess_langsmith_client.tools import keys +from ess_langsmith_client.tools.keys import ( + _age_days, + _format_expires, + _is_expired, + _parse_dt, + _select_targets, + cli, +) + +# Pin "now" so age/expiry calculations are deterministic. +_FIXED_NOW = datetime(2026, 6, 1, tzinfo=timezone.utc) +# Days between 2026-01-01 and _FIXED_NOW (2026 is not a leap year). +_EXPECTED_AGE_DAYS = 151 + + +class _FrozenDatetime(datetime): + """datetime subclass with a fixed ``now`` for deterministic tests.""" + + @classmethod + def now(cls, tz=None): + return _FIXED_NOW if tz is None else _FIXED_NOW.astimezone(tz) + + +@pytest.fixture +def frozen_now(monkeypatch): + """Pin ``keys.datetime.now`` to a fixed instant.""" + monkeypatch.setattr(keys, "datetime", _FrozenDatetime) + return _FIXED_NOW + + +def _sample_keys() -> list[dict]: + """Three keys: an old one, an expired one, and a fresh non-expiring one.""" + return [ + { + "id": "id-a", + "description": "old-key", + "short_key": "aaaa", + # 151 days before _FIXED_NOW; expires far in the future. + "created_at": "2026-01-01T00:00:00Z", + "expires_at": "2027-01-01T00:00:00Z", + }, + { + "id": "id-b", + "description": "expired-key", + "short_key": "bbbb", + "created_at": "2026-05-25T00:00:00Z", + "expires_at": "2026-05-30T00:00:00Z", + }, + { + "id": "id-c", + "description": "fresh-key", + "short_key": "cccc", + "created_at": "2026-05-31T00:00:00Z", + "expires_at": None, + }, + ] + + +class TestParseDt: + def test_normalizes_trailing_z(self): + parsed = _parse_dt("2026-01-01T00:00:00Z") + assert parsed == datetime(2026, 1, 1, tzinfo=timezone.utc) + assert parsed.tzinfo is not None + + def test_naive_string_becomes_utc(self): + parsed = _parse_dt("2026-01-01T00:00:00") + assert parsed.tzinfo == timezone.utc + + def test_naive_datetime_object_becomes_utc(self): + parsed = _parse_dt(datetime(2026, 1, 1, 12, 0, 0)) + assert parsed.tzinfo == timezone.utc + + def test_aware_datetime_object_preserved(self): + aware = datetime(2026, 1, 1, tzinfo=timezone.utc) + assert _parse_dt(aware) == aware + + +class TestAgeDays: + def test_parses_rfc3339_z(self, frozen_now): + assert _age_days({"created_at": "2026-01-01T00:00:00Z"}) == _EXPECTED_AGE_DAYS + + def test_missing_created_at_returns_none(self, frozen_now): + assert _age_days({}) is None + + +class TestIsExpired: + def test_expired_z_timestamp(self, frozen_now): + assert _is_expired({"expires_at": "2026-05-01T00:00:00Z"}) is True + + def test_future_z_timestamp_not_expired(self, frozen_now): + assert _is_expired({"expires_at": "2026-07-01T00:00:00Z"}) is False + + def test_missing_expires_at_not_expired(self, frozen_now): + assert _is_expired({}) is False + + +class TestFormatExpires: + def test_expired_z_timestamp_labelled(self, frozen_now): + assert _format_expires({"expires_at": "2026-05-01T00:00:00Z"}) == ( + "2026-05-01 (EXPIRED)" + ) + + def test_future_z_timestamp_plain(self, frozen_now): + assert _format_expires({"expires_at": "2026-07-01T00:00:00Z"}) == "2026-07-01" + + def test_no_expiry(self, frozen_now): + assert _format_expires({"expires_at": None}) == "never" + + +class TestListFilters: + def _invoke(self, monkeypatch, frozen_now, args): + monkeypatch.setattr(keys.APIKeyClient, "list_keys", lambda self: _sample_keys()) + runner = CliRunner() + return runner.invoke(cli, ["list", "--api-key", "test-key", *args]) + + def test_expired_filter(self, monkeypatch, frozen_now): + result = self._invoke( + monkeypatch, frozen_now, ["--expired", "--format", "json"] + ) + assert result.exit_code == 0 + ids = {k["id"] for k in json.loads(result.output)} + assert ids == {"id-b"} + + def test_older_than_filter(self, monkeypatch, frozen_now): + result = self._invoke( + monkeypatch, frozen_now, ["--older-than", "90", "--format", "json"] + ) + assert result.exit_code == 0 + ids = {k["id"] for k in json.loads(result.output)} + assert ids == {"id-a"} + + +def _keys_with_duplicate() -> list[dict]: + """Keys where "dup-key" appears twice and "unique-key" once.""" + return [ + {"id": "id-1", "description": "dup-key", "short_key": "1111"}, + {"id": "id-2", "description": "dup-key", "short_key": "2222"}, + {"id": "id-3", "description": "unique-key", "short_key": "3333"}, + ] + + +class TestSelectTargets: + def test_unique_match(self): + targets, duplicates = _select_targets(_keys_with_duplicate(), ("unique-key",)) + assert {k["id"] for k in targets} == {"id-3"} + assert duplicates == {} + + def test_duplicate_detection(self): + targets, duplicates = _select_targets(_keys_with_duplicate(), ("dup-key",)) + assert {k["id"] for k in targets} == {"id-1", "id-2"} + assert set(duplicates) == {"dup-key"} + assert {k["id"] for k in duplicates["dup-key"]} == {"id-1", "id-2"} + + def test_non_matching_description_ignored(self): + targets, duplicates = _select_targets( + _keys_with_duplicate(), ("does-not-exist",) + ) + assert targets == [] + assert duplicates == {} + + +class TestDelete: + def _run(self, monkeypatch, args, deleted_ids): + monkeypatch.setattr( + keys.APIKeyClient, "list_keys", lambda self: _keys_with_duplicate() + ) + + def _record_delete(self, key_id): + deleted_ids.append(key_id) + return {"id": key_id} + + monkeypatch.setattr(keys.APIKeyClient, "delete_key", _record_delete) + runner = CliRunner() + return runner.invoke(cli, ["delete", "--api-key", "test-key", *args]) + + def test_duplicate_without_all_errors(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["dup-key", "--yes"], deleted_ids) + assert result.exit_code != 0 + assert "dup-key" in result.output + assert "matches 2 keys" in result.output + assert deleted_ids == [] + + def test_duplicate_with_all_deletes_both(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["dup-key", "--all", "--yes"], deleted_ids) + assert result.exit_code == 0 + assert set(deleted_ids) == {"id-1", "id-2"} + + def test_unique_deletes_single(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key", "--yes"], deleted_ids) + assert result.exit_code == 0 + assert deleted_ids == ["id-3"] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_main.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_main.py new file mode 100644 index 0000000..abbf678 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_main.py @@ -0,0 +1,43 @@ +"""Tests for the unified langsmith-client root CLI group.""" + +from click.testing import CliRunner + +from ess_langsmith_client.tools.main import cli + +_EXPECTED_SUBCOMMANDS = { + "keys", + "workspaces", + "listeners", + "projects", + "control-plane", + "build", + "deploy", + "test-deployed", +} + +_EXPECTED_DEPLOY_SUBCOMMANDS = {"docker", "github"} + + +def test_root_group_registers_expected_subcommands(): + """The root group exposes every consolidated subcommand.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + for subcommand in _EXPECTED_SUBCOMMANDS: + assert subcommand in result.output + + +def test_deploy_group_registers_docker_and_github(): + """The deploy subgroup nests docker and github.""" + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "--help"]) + assert result.exit_code == 0 + for subcommand in _EXPECTED_DEPLOY_SUBCOMMANDS: + assert subcommand in result.output + + +def test_registered_command_names_match_expected(): + """Guard against accidental additions/removals in the command mapping.""" + assert set(cli.commands) == _EXPECTED_SUBCOMMANDS + deploy_group = cli.commands["deploy"] + assert set(deploy_group.commands) == _EXPECTED_DEPLOY_SUBCOMMANDS diff --git a/packages/python/langsmith-client/.env.example b/packages/python/langsmith-client/.env.example deleted file mode 100644 index feb95ba..0000000 --- a/packages/python/langsmith-client/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -# LangSmith Control Plane (required for most commands) -LANGSMITH_API_KEY= -LANGSMITH_WORKSPACE_ID= - -# Docker/hybrid deployments (langsmith-deploy-docker) -LANGSMITH_LISTENER_ID= - -# GitHub deployments (langsmith-deploy-github) -GITHUB_INTEGRATION_ID= diff --git a/packages/python/langsmith-client/pyproject.toml b/packages/python/langsmith-client/pyproject.toml deleted file mode 100644 index 0189fba..0000000 --- a/packages/python/langsmith-client/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[project] -name = "langsmith-client" -version = "0.1.0" -description = "LangSmith Control Plane API client and CLI utilities" -requires-python = ">=3.12,<3.13" -dependencies = [ - "requests>=2.31.0", - "click>=8.1.0", - "python-dotenv>=1.1.0", - "python-decouple>=3.8", - "pydantic>=2.0.0", -] - -[project.scripts] -langsmith-build = "langsmith_client.tools.build:build" -langsmith-deploy-docker = "langsmith_client.tools.deploy_docker:cli" -langsmith-deploy-github = "langsmith_client.tools.deploy_github:cli" -langsmith-workspaces = "langsmith_client.tools.list_workspaces:cli" -langsmith-listeners = "langsmith_client.tools.list_listeners:cli" -langsmith-projects = "langsmith_client.tools.projects:cli" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/langsmith_client"] diff --git a/packages/python/langsmith-client/src/langsmith_client/__init__.py b/packages/python/langsmith-client/src/langsmith_client/__init__.py deleted file mode 100644 index 118771b..0000000 --- a/packages/python/langsmith-client/src/langsmith_client/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -"""LangSmith Control Plane API client and CLI utilities. - -This package provides the shared client for interacting with the -LangSmith Control Plane API. -""" - -from langsmith_client.cli import ( - common_options, - create_client, - echo_deployment_created, - echo_success, - handle_wait, - print_deployments, -) -from langsmith_client.client import ( - CONTROL_PLANE_HOSTS, - MAX_WAIT_TIME, - POLL_INTERVAL, - ControlPlaneClient, -) -from langsmith_client.project import ( - ProjectInfo, - get_project_info, -) -from langsmith_client.secrets import ( - get_env_secrets, - merge_secrets, - parse_secrets, -) - -__all__ = [ - # Client - "ControlPlaneClient", - "CONTROL_PLANE_HOSTS", - "MAX_WAIT_TIME", - "POLL_INTERVAL", - # CLI utilities - "common_options", - "create_client", - "echo_deployment_created", - "echo_success", - "handle_wait", - "print_deployments", - # Secrets - "get_env_secrets", - "merge_secrets", - "parse_secrets", - # Project info - "ProjectInfo", - "get_project_info", -] diff --git a/packages/python/langsmith-client/src/langsmith_client/cli.py b/packages/python/langsmith-client/src/langsmith_client/cli.py deleted file mode 100644 index 45ffc2a..0000000 --- a/packages/python/langsmith-client/src/langsmith_client/cli.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Click CLI utilities for LangSmith Control Plane scripts.""" - -from typing import Any, TypeVar - -import click - -from langsmith_client.client import ControlPlaneClient - -T = TypeVar("T", bound=ControlPlaneClient) - - -def common_options(func): - """Decorator to add common CLI options (region, api-key, workspace-id).""" - func = click.option( - "--region", - type=click.Choice(["us", "eu"]), - default="us", - help="LangSmith region", - )(func) - func = click.option( - "--api-key", - envvar="LANGSMITH_API_KEY", - help="LangSmith API key", - )(func) - func = click.option( - "--workspace-id", - envvar="LANGSMITH_WORKSPACE_ID", - help="LangSmith workspace ID", - )(func) - return func - - -def create_client( - client_class: type[T], - api_key: str | None, - workspace_id: str | None, - region: str, -) -> T: - """Create a client instance, converting ValueError to ClickException.""" - try: - return client_class( - api_key=api_key, - workspace_id=workspace_id, - region=region, - ) - except ValueError as e: - raise click.ClickException(str(e)) from e - - -def echo_success(message: str) -> None: - """Print a success message in green.""" - click.echo(click.style(message, fg="green")) - - -def echo_deployment_created(deployment_id: str, revision_id: str | None) -> None: - """Print deployment created output.""" - echo_success("\nDeployment created!") - click.echo(f" Deployment ID: {deployment_id}") - click.echo(f" Revision ID: {revision_id}") - - -def print_deployments(deployments: list[dict[str, Any]]) -> None: - """Print a formatted list of deployments.""" - if not deployments: - click.echo("No deployments found.") - return - - click.echo(f"\nFound {len(deployments)} deployment(s):\n") - for dep in deployments: - click.echo(f" ID: {dep['id']}") - click.echo(f" Name: {dep['name']}") - click.echo(f" Source: {dep.get('source', 'N/A')}") - click.echo(f" Status: {dep.get('status', 'N/A')}") - if dep.get("url"): - click.echo(f" URL: {dep['url']}") - click.echo() - - -def handle_wait( - client: ControlPlaneClient, - deployment_id: str, - revision_id: str | None, - wait: bool, - url: str | None = None, -) -> None: - """Handle the --wait flag for deployment commands.""" - if wait and revision_id: - click.echo("\nWaiting for deployment to complete...") - try: - final = client.wait_for_deployment(deployment_id, revision_id) - echo_success("\nDeployment complete!") - click.echo(f" Status: {final.get('status')}") - if url: - click.echo(f" URL: {url}") - except RuntimeError as e: - raise click.ClickException(str(e)) from e diff --git a/packages/python/langsmith-client/src/langsmith_client/client.py b/packages/python/langsmith-client/src/langsmith_client/client.py deleted file mode 100644 index ac60e13..0000000 --- a/packages/python/langsmith-client/src/langsmith_client/client.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -LangSmith Control Plane API client. - -Provides the base client for interacting with the LangSmith Control Plane API. - -CONTROL PLANE API REFERENCE: - https://docs.langchain.com/langsmith/api-ref-control-plane -""" - -import os -import time -from typing import Any - -import requests -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -HTTP_OK = 200 -HTTP_NO_CONTENT = 204 - -_REQUEST_TIMEOUT = 30 - -# Control Plane API hosts by region -CONTROL_PLANE_HOSTS = { - "us": "https://api.host.langchain.com", - "eu": "https://eu.api.host.langchain.com", -} - -# Maximum time to wait for deployment (30 minutes) -MAX_WAIT_TIME = 1800 - -# Poll interval for deployment status (60 seconds) -POLL_INTERVAL = 60 - - -class ControlPlaneClient: - """Client for the LangSmith Control Plane API.""" - - def __init__( - self, - api_key: str | None = None, - workspace_id: str | None = None, - region: str = "us", - ): - """ - Initialize the Control Plane client. - - Args: - api_key: LangSmith API key (defaults to LANGSMITH_API_KEY env var) - workspace_id: LangSmith workspace ID (defaults to LANGSMITH_WORKSPACE_ID - env var) - region: LangSmith region ("us" or "eu") - """ - self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") - self.workspace_id = workspace_id or os.environ.get("LANGSMITH_WORKSPACE_ID") - - if not self.api_key: - raise ValueError( - "LANGSMITH_API_KEY is required. " - "Set it as an environment variable or pass it to the constructor." - ) - - if not self.workspace_id: - raise ValueError( - "LANGSMITH_WORKSPACE_ID is required. " - "Find it in your LangSmith workspace settings." - ) - - if region not in CONTROL_PLANE_HOSTS: - raise ValueError( - f"Invalid region: {region}. " - f"Must be one of: {list(CONTROL_PLANE_HOSTS.keys())}" - ) - - self.base_url = f"{CONTROL_PLANE_HOSTS[region]}/v2" - self.headers = { - "X-Api-Key": self.api_key, - "X-Tenant-Id": self.workspace_id, - "Content-Type": "application/json", - } - - def list_listeners(self) -> dict[str, Any]: - """List all listeners (hybrid deployment agents) for the workspace.""" - response = requests.get( - f"{self.base_url}/listeners", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - - if response.status_code == HTTP_OK: - return response.json() - else: - raise RuntimeError( - f"Failed to list listeners: {response.status_code}\n{response.text}" - ) - - def list_deployments(self, name_contains: str | None = None) -> dict[str, Any]: - """List all deployments, optionally filtered by name.""" - params = {} - if name_contains: - params["name_contains"] = name_contains - - response = requests.get( - f"{self.base_url}/deployments", - headers=self.headers, - params=params, - timeout=_REQUEST_TIMEOUT, - ) - - if response.status_code == HTTP_OK: - return response.json() - else: - raise RuntimeError( - f"Failed to list deployments: {response.status_code}\n{response.text}" - ) - - def get_deployment(self, deployment_id: str) -> dict[str, Any]: - """Get a specific deployment by ID.""" - response = requests.get( - f"{self.base_url}/deployments/{deployment_id}", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - - if response.status_code == HTTP_OK: - return response.json() - else: - raise RuntimeError( - f"Failed to get deployment {deployment_id}: " - f"{response.status_code}\n{response.text}" - ) - - def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]: - """Get a specific revision of a deployment.""" - response = requests.get( - f"{self.base_url}/deployments/{deployment_id}/revisions/{revision_id}", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - - if response.status_code == HTTP_OK: - return response.json() - else: - raise RuntimeError( - f"Failed to get revision {revision_id}: " - f"{response.status_code}\n{response.text}" - ) - - def list_revisions(self, deployment_id: str) -> dict[str, Any]: - """List all revisions for a deployment.""" - response = requests.get( - f"{self.base_url}/deployments/{deployment_id}/revisions", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - - if response.status_code == HTTP_OK: - return response.json() - else: - raise RuntimeError( - f"Failed to list revisions: {response.status_code}\n{response.text}" - ) - - def delete_deployment(self, deployment_id: str) -> bool: - """Delete a deployment.""" - response = requests.delete( - f"{self.base_url}/deployments/{deployment_id}", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - - if response.status_code == HTTP_NO_CONTENT: - return True - else: - raise RuntimeError( - f"Failed to delete deployment: {response.status_code}\n{response.text}" - ) - - def wait_for_deployment( - self, - deployment_id: str, - revision_id: str, - max_wait: int = MAX_WAIT_TIME, - poll_interval: int = POLL_INTERVAL, - ) -> dict[str, Any]: - """ - Wait for a deployment revision to reach DEPLOYED status. - - Args: - deployment_id: ID of the deployment - revision_id: ID of the revision to wait for - max_wait: Maximum time to wait in seconds - poll_interval: Time between status checks in seconds - - Returns: - Final revision status - - Raises: - RuntimeError: If deployment fails or times out - """ - start_time = time.time() - revision = None - status = None - - while time.time() - start_time < max_wait: - revision = self.get_revision(deployment_id, revision_id) - status = revision.get("status") - - if status == "DEPLOYED": - return revision - elif "FAILED" in str(status): - raise RuntimeError(f"Deployment failed: {revision}") - - print(f" Status: {status}... waiting {poll_interval}s") - time.sleep(poll_interval) - - raise RuntimeError( - f"Timeout waiting for deployment. Last status: {status}\n{revision}" - ) diff --git a/packages/python/langsmith-client/src/langsmith_client/secrets.py b/packages/python/langsmith-client/src/langsmith_client/secrets.py deleted file mode 100644 index 93f04fb..0000000 --- a/packages/python/langsmith-client/src/langsmith_client/secrets.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Secret management utilities for LangSmith deployments.""" - -import os - -# Environment variables to auto-detect as deployment secrets. -# These are common API keys needed by LangGraph agents at runtime. -AUTO_DETECT_KEYS = [ - "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_ENDPOINT", - "AZURE_OPENAI_DEPLOYMENT", - "AZURE_OPENAI_API_VERSION", - "TAVILY_API_KEY", -] - - -def get_env_secrets() -> list[dict[str, str]]: - """Get deployment secrets from well-known environment variables.""" - secrets = [] - for key in AUTO_DETECT_KEYS: - value = os.environ.get(key) - if value: - secrets.append({"name": key, "value": value}) - return secrets - - -def parse_secrets(secret_args: list[str] | None) -> list[dict[str, str]]: - """Parse secrets from command line arguments (NAME=VALUE format).""" - secrets = [] - if secret_args: - for secret in secret_args: - name, value = secret.split("=", 1) - secrets.append({"name": name, "value": value}) - return secrets - - -def merge_secrets(cli_secrets: tuple[str, ...] | list[str]) -> list[dict[str, str]]: - """Merge CLI secrets with auto-detected secrets from environment. - - CLI secrets take precedence over auto-detected ones. - """ - all_secrets = parse_secrets(list(cli_secrets)) - env_secrets = get_env_secrets() - for secret in env_secrets: - if not any(s["name"] == secret["name"] for s in all_secrets): - all_secrets.append(secret) - return all_secrets diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/projects.py b/packages/python/langsmith-client/src/langsmith_client/tools/projects.py deleted file mode 100644 index b5940bc..0000000 --- a/packages/python/langsmith-client/src/langsmith_client/tools/projects.py +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env python3 -""" -Manage LangSmith tracing projects. - -Tracing projects (also called sessions) are created automatically when LangGraph -deployments are created. Use this tool to list and delete them. - -The delete command transparently handles orphaned projects — where the linked -deployment no longer exists — by clearing the stale reference and retrying. - -PREREQUISITES: -- LANGSMITH_API_KEY: Your LangSmith API key - -USAGE: - langsmith-projects list - langsmith-projects list --name hello-world-graph - langsmith-projects delete --id - langsmith-projects delete --name hello-world-graph - langsmith-projects delete --name hello-world --force -""" - -import json -import os -from typing import Any - -import click -import requests -from dotenv import load_dotenv - -load_dotenv() - -SMITH_API = "https://api.smith.langchain.com" - -HTTP_OK = 200 -HTTP_ACCEPTED = 202 -HTTP_CONFLICT = 409 - -_REQUEST_TIMEOUT = 30 - - -# ============================================================================= -# Client -# ============================================================================= - - -class TracingProjectClient: - """Client for the LangSmith tracing projects (sessions) API.""" - - def __init__(self, api_key: str | None = None): - self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") - if not self.api_key: - raise ValueError( - "LANGSMITH_API_KEY is required. " - "Set it as an environment variable or pass --api-key." - ) - self.headers = { - "x-api-key": self.api_key, - "Content-Type": "application/json", - } - - def list_projects(self, name: str | None = None) -> list[dict[str, Any]]: - """List tracing projects, optionally filtered by exact name.""" - params: dict[str, str] = {} - if name: - params["name"] = name - resp = requests.get( - f"{SMITH_API}/api/v1/sessions", - headers=self.headers, - params=params, - timeout=_REQUEST_TIMEOUT, - ) - if resp.status_code != HTTP_OK: - raise RuntimeError( - f"Failed to list projects: {resp.status_code}\n{resp.text}" - ) - return resp.json() - - def _clear_deployment_ref(self, project_id: str) -> None: - """Remove stale deployment_id from a project's extra metadata.""" - resp = requests.patch( - f"{SMITH_API}/api/v1/sessions/{project_id}", - headers=self.headers, - json={"extra": {}}, - timeout=_REQUEST_TIMEOUT, - ) - if resp.status_code != HTTP_OK: - raise RuntimeError( - f"Failed to clear deployment reference on {project_id}: " - f"{resp.status_code}\n{resp.text}" - ) - - def delete_project(self, project_id: str, *, force: bool = False) -> bool: - """ - Delete a tracing project by ID. - - If the project has a stale deployment reference (409) and force=True, - the reference is cleared automatically before retrying the delete. - Returns True on success, raises RuntimeError otherwise. - """ - resp = requests.delete( - f"{SMITH_API}/api/v1/sessions/{project_id}", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - - if resp.status_code == HTTP_ACCEPTED: - return True - - if resp.status_code == HTTP_CONFLICT: - detail = resp.json().get("detail", resp.text) - - if "associated with a LangGraph deployment" not in detail: - raise RuntimeError(f"409 Conflict: {detail}") - - if not force: - raise RuntimeError( - f"409 Conflict: {detail}\n" - "Re-run with --force to automatically clear stale " - "deployment references." - ) - - # Deployment is orphaned — clear the reference and retry. - self._clear_deployment_ref(project_id) - retry = requests.delete( - f"{SMITH_API}/api/v1/sessions/{project_id}", - headers=self.headers, - timeout=_REQUEST_TIMEOUT, - ) - if retry.status_code == HTTP_ACCEPTED: - return True - raise RuntimeError( - f"Delete failed after clearing deployment reference: " - f"{retry.status_code}\n{retry.text}" - ) - - raise RuntimeError( - f"Failed to delete project {project_id}: {resp.status_code}\n{resp.text}" - ) - - -# ============================================================================= -# Output formatters -# ============================================================================= - - -def _print_table(projects: list[dict[str, Any]]) -> None: - if not projects: - click.echo("No projects found.") - return - - id_w = max((len(p.get("id", "")) for p in projects), default=36) - id_w = max(id_w, 4) - name_w = max((len(p.get("name", "")) for p in projects), default=20) - name_w = max(name_w, 4) - - header = f"{'ID':<{id_w}} {'Name':<{name_w}} Deployment ID" - click.echo(f"\nFound {len(projects)} project(s):\n") - click.echo(header) - click.echo("-" * (len(header) + 10)) - - for p in projects: - dep_id = p.get("extra", {}).get("deployment_id", "") - click.echo( - f"{p.get('id', ''):<{id_w}} {p.get('name', ''):<{name_w}} {dep_id}" - ) - click.echo() - - -# ============================================================================= -# CLI -# ============================================================================= - - -def _api_key_option(func): - return click.option( - "--api-key", - envvar="LANGSMITH_API_KEY", - help="LangSmith API key (defaults to LANGSMITH_API_KEY env var)", - )(func) - - -@click.group() -@click.version_option(version="1.0.0") -def cli(): - """Manage LangSmith tracing projects. - - \b - PREREQUISITES: - - LANGSMITH_API_KEY: Your LangSmith API key - """ - pass - - -@cli.command("list") -@_api_key_option -@click.option("--name", help="Filter by exact project name") -@click.option( - "--format", - "output_format", - type=click.Choice(["table", "json"]), - default="table", - help="Output format (default: table)", -) -def list_projects(api_key: str | None, name: str | None, output_format: str): - """List tracing projects.""" - client = TracingProjectClient(api_key) - - try: - projects = client.list_projects(name=name) - except RuntimeError as e: - raise click.ClickException(str(e)) from e - - if output_format == "json": - click.echo(json.dumps(projects, indent=2, default=str)) - else: - _print_table(projects) - - -@cli.command() -@_api_key_option -@click.option("--id", "project_id", help="Project ID to delete") -@click.option("--name", help="Delete all projects matching this exact name") -@click.option( - "--force", - is_flag=True, - help="Clear stale deployment references to unblock deletion", -) -@click.confirmation_option(prompt="Are you sure you want to delete this project?") -def delete( - api_key: str | None, - project_id: str | None, - name: str | None, - force: bool, -): - """Delete one or more tracing projects. - - \b - Provide either --id for a single project or --name to delete all - projects with that exact name. - - \b - Use --force when a project is blocked by a stale deployment reference - (the linked deployment no longer exists but was not properly cleaned up). - """ - if not project_id and not name: - raise click.UsageError("Provide either --id or --name.") - if project_id and name: - raise click.UsageError("Provide either --id or --name, not both.") - - client = TracingProjectClient(api_key) - - targets: list[dict[str, Any]] = [] - - try: - if project_id: - targets = [{"id": project_id, "name": project_id}] - else: - targets = client.list_projects(name=name) - if not targets: - click.echo(f"No projects found with name: {name}") - return - click.echo(f"Found {len(targets)} project(s) named '{name}'") - except RuntimeError as e: - raise click.ClickException(str(e)) from e - - deleted = 0 - failed = 0 - for project in targets: - pid = project["id"] - pname = project.get("name", pid) - dep_id = project.get("extra", {}).get("deployment_id", "") - suffix = f" (orphaned deployment: {dep_id})" if dep_id and force else "" - click.echo(f" Deleting '{pname}' [{pid}]{suffix} ...") - - try: - client.delete_project(pid, force=force) - click.echo(click.style(" ✓ Deleted", fg="green")) - deleted += 1 - except RuntimeError as e: - click.echo(click.style(f" ✗ {e}", fg="red")) - failed += 1 - - click.echo() - if deleted: - click.echo(click.style(f"{deleted} project(s) deleted.", fg="green")) - if failed: - click.echo(click.style(f"{failed} project(s) failed.", fg="red")) - - -if __name__ == "__main__": - cli() diff --git a/pyproject.toml b/pyproject.toml index fe5e7e5..74dfc41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,10 @@ members = [ "packages/python/ess-auth", "packages/python/ess-browser", "packages/python/ess-dirs", + "packages/python/ess-langsmith-client", "packages/python/ess-outlook", "packages/python/ess-service-now-incident", "packages/python/ess-webex", - "packages/python/langsmith-client", "packages/python/langsmith-network", "packages/python/azure-ai", "examples/python/ess-messages", diff --git a/uv.lock b/uv.lock index 108daae..ab111e8 100644 --- a/uv.lock +++ b/uv.lock @@ -10,13 +10,13 @@ members = [ "ess-browser", "ess-dirs", "ess-hello-jwt-auth-code", + "ess-langsmith-client", "ess-messages", "ess-outlook", "ess-service-now-incident", "ess-webex", "essentials", "gcp-gemini", - "langsmith-client", "langsmith-hosting", "langsmith-network", "pulumi-utils", @@ -536,6 +536,34 @@ dev = [ { name = "ruff", specifier = ">=0.14.2" }, ] +[[package]] +name = "ess-langsmith-client" +version = "0.1.0" +source = { editable = "packages/python/ess-langsmith-client" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "python-decouple" }, + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.optional-dependencies] +agent-test = [ + { name = "langgraph-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, + { name = "langgraph-sdk", marker = "extra == 'agent-test'", specifier = ">=0.1.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "python-decouple", specifier = ">=3.8" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] +provides-extras = ["agent-test"] + [[package]] name = "ess-messages" version = "0.1.0" @@ -1063,24 +1091,98 @@ wheels = [ ] [[package]] -name = "langsmith-client" -version = "0.1.0" -source = { editable = "packages/python/langsmith-client" } +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, { name = "pydantic" }, - { name = "python-decouple" }, - { name = "python-dotenv" }, - { name = "requests" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/88/ebc98df187c525d729725ab39759337c56d5d2803423632376aa35bde899/langchain_core-1.6.0.tar.gz", hash = "sha256:dc72e36678ed26683ec0ad8829b44011fba461d10f9b31dbd9110215e5ec2333", size = 992493, upload-time = "2026-08-19T15:55:40.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4c/508a90b9d2e3bd7738fd93cb2ac2178ce734662c75e69b3b81c445f1b360/langchain_core-1.6.0-py3-none-any.whl", hash = "sha256:d8bb924cd413955d9d3192ccced140407427c3ff51e50ffb941222648da92cb2", size = 570006, upload-time = "2026-08-19T15:55:38.935Z" }, ] -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.1.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "python-decouple", specifier = ">=3.8" }, - { name = "python-dotenv", specifier = ">=1.1.0" }, - { name = "requests", specifier = ">=2.31.0" }, +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/0e/3c0347fd517390e807d9772d2037f9cc65c0227af27f07052ba9c8ebdfa2/langgraph_sdk-0.4.3.tar.gz", hash = "sha256:f101cc043ddd7400ceaef66d934c42fa2f0e7d4ea5029754d286260d39038251", size = 344145, upload-time = "2026-08-19T18:05:21.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/a0/f3bc44b46ef730588f66fdf9e9ddc54189db78482a9e914ae6a554a2b2e0/langgraph_sdk-0.4.3-py3-none-any.whl", hash = "sha256:1b7920b39b6dc439843d122a06f04a0cb8b65c02fd086ddf68e18596a230a0e7", size = 161794, upload-time = "2026-08-19T18:05:20.495Z" }, +] + +[[package]] +name = "langsmith" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/57/7b6c11080c9e082ebf1456a2e2372fae8f23a85a5ae2869bdd5ab9a6507d/langsmith-0.11.1.tar.gz", hash = "sha256:47998977366acb3ba3093881fd465cbf11a5f8c2f4e87e40a17dda203f6dedf4", size = 4814724, upload-time = "2026-08-19T15:47:44.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/85/0ad6df25588122760b2b40ec182eafc55532c9e66015c83e16675bd34a29/langsmith-0.11.1-py3-none-any.whl", hash = "sha256:cfc3437a9cf27440cd0095c24df945edbceb6df10b579bc8b3980b4ad367835f", size = 744589, upload-time = "2026-08-19T15:47:42.043Z" }, ] [[package]] @@ -1229,6 +1331,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, ] +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -2018,6 +2139,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/80/a7e685968e3cec99d6fe2fb25d0f5726310e1bba356da68c13dfd8b7d140/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287", size = 556403, upload-time = "2026-07-09T13:48:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/3102d93bcb7b0bfe6bede63ff8f221a7f91348e10a37f682773be27c56d9/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968", size = 285608, upload-time = "2026-07-09T13:48:28.769Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/d59695f0f8db065b93c63316eaafa05a22d75a0486978a33736c52c646d5/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6", size = 319926, upload-time = "2026-07-09T13:48:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/03/62fabcd1e990e07a0e220e8d552af45bc16f107fa8e55c2014a706bb1a1e/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988", size = 327172, upload-time = "2026-07-09T13:48:31.187Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/a5081391338b459e2f8d8b12581f00f8caa6317fab510e0e85c18c59e938/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94", size = 439075, upload-time = "2026-07-09T13:48:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/59/30/91795bd01e17a13661280d4899fbf38fb05e3f38e873f9aaec106ec30aa0/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc", size = 320247, upload-time = "2026-07-09T13:48:33.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/09102b78303e4eb62069d6d88ef9fd661dc523e8f429e1fd67eaa78a6f44/uuid_utils-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a", size = 344738, upload-time = "2026-07-09T13:48:34.786Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/be95bad6954b60328878c3800258f01a6accd24fd75112d13f023462d53f/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c", size = 496845, upload-time = "2026-07-09T13:48:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/02/8a19a34e0530d987488a068a71576a236f5c8c746630b870b57f71eb24ef/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a", size = 603233, upload-time = "2026-07-09T13:48:37.512Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a8/b1abab36ff73b0248d82179816467f6d39a2e80fd64329a895ca94f3508e/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206", size = 561401, upload-time = "2026-07-09T13:48:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/70e7b528b351cc03a9ca43e6116371cdde31bb12bcead7ca2ca1367366cc/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc", size = 525314, upload-time = "2026-07-09T13:48:40.599Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/9167e90cf9937d6558f92d022ff3024a69d938a514d9c8faa4080f73b001/uuid_utils-0.17.0-cp312-cp312-win32.whl", hash = "sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d", size = 166831, upload-time = "2026-07-09T13:48:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/0b889654d9ee3413f810cf4685e241285f650d98a4103ac9f3c6bcc95f29/uuid_utils-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637", size = 172944, upload-time = "2026-07-09T13:48:42.992Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/8c6e1bf65e4d400352885dadc656ad6d0af96e89231e3f04686bc2197128/uuid_utils-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652", size = 172459, upload-time = "2026-07-09T13:48:44.271Z" }, +] + [[package]] name = "virtualenv" version = "21.1.0" @@ -2070,6 +2213,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/04/86ab8349a02b43340eae36f275c94712f178a55a9b9c4842864dc74becf6/wxc_sdk-1.34.0-py3-none-any.whl", hash = "sha256:afb827965665412546663ddc16e5f5935607565b3d7c46820ada5a1b5ada2697", size = 796634, upload-time = "2026-04-22T17:49:09.161Z" }, ] +[[package]] +name = "xxhash" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" }, + { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" }, + { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" }, + { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" }, + { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" }, +] + [[package]] name = "yarl" version = "1.23.0" @@ -2101,3 +2277,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, +]