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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ jobs:

- name: Validate Python SDK
run: |
uvx ruff@0.16.2 format --check --line-length 120 sdk/python tests cli.py auth.py
uvx ruff@0.16.2 check sdk/python tests cli.py auth.py
uvx ruff@0.16.2 format --check --line-length 120 sdk/python tests cli.py auth.py hooks.py
uvx ruff@0.16.2 check sdk/python tests cli.py auth.py hooks.py
python -m compileall -q sdk/python/src
uv build --project sdk/python
unzip -l sdk/python/dist/*.whl | grep --quiet '\.dist-info/licenses/LICENSE$'
Expand Down Expand Up @@ -134,13 +134,13 @@ jobs:
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "hash=$(printf '%s\n%s\n%s\n' "$(sha256sum openapi.json)" "$(git -C .generator rev-parse HEAD)" "$(git ls-files cli.py auth.py openapi.config.json README.python.md | xargs sha256sum)" | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT"
echo "hash=$(printf '%s\n%s\n%s\n' "$(sha256sum openapi.json)" "$(git -C .generator rev-parse HEAD)" "$(git ls-files cli.py auth.py hooks.py openapi.config.json README.python.md | xargs sha256sum)" | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT"

- name: Validate regenerated Python SDK
if: steps.sync.outputs.changed == 'true'
run: |
uvx ruff@0.16.2 format --check --line-length 120 sdk/python tests cli.py auth.py
uvx ruff@0.16.2 check sdk/python tests cli.py auth.py
uvx ruff@0.16.2 format --check --line-length 120 sdk/python tests cli.py auth.py hooks.py
uvx ruff@0.16.2 check sdk/python tests cli.py auth.py hooks.py
python -m compileall -q sdk/python/src
uv build --project sdk/python
uv pip install pytest jsonschema referencing "./sdk/python"
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ export OPENAPI_CONFIG="$PWD/openapi.config.json"
diff --recursive --unified sdk/python .generator/generated/python

pytest tests -v
uvx ruff@0.16.2 format --check --line-length 120 sdk/python tests cli.py auth.py
uvx ruff@0.16.2 check sdk/python tests cli.py auth.py
uvx ruff@0.16.2 format --check --line-length 120 sdk/python tests cli.py auth.py hooks.py
uvx ruff@0.16.2 check sdk/python tests cli.py auth.py hooks.py
```

Use the generator's `main` branch, never a pinned SHA or tag; update an existing `.generator` checkout before regenerating. Check drift before running tests/builds, which leave ignored artifacts in the generated tree. After intentional source changes, inspect the diff, then copy with `rsync --archive --delete .generator/generated/python/ sdk/python/`. Never repair generated files by hand. Install the package in the same interpreter that runs pytest. Use `python -m ultralytics_platform.cli` if the system `ul` command shadows the console script.

## Where to look

- CLI and credential customization → `cli.py`, `auth.py`.
- CLI, credential, and response hook customization → `cli.py`, `auth.py`, `hooks.py`.
- Generator configuration and README template → `openapi.config.json`, `README.python.md`.
- Pinned contract → `openapi.json`, `openapi.sha256`.
- Generated package → `sdk/python/`.
Expand All @@ -76,7 +76,7 @@ Use the generator's `main` branch, never a pinned SHA or tag; update an existing

- Ultralytics-owned PyPI packages use `MAJOR.MINOR.PATCH` versions only; no suffixes.
- License headers (`# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license`) are added automatically by Ultralytics Actions — don't add or revert them manually. Generated files receive the same header from the generator (`header` in `openapi.config.json`).
- Google-style docstrings, `from __future__ import annotations` for modern type hints, line length 120. `format.yml` sets `python: false` and `prettier: false`, so nothing reformats Python or Markdown on PR branches: run `uvx ruff@0.16.2 format --line-length 120 cli.py auth.py tests` yourself before pushing; the generator formats `sdk/python`.
- Google-style docstrings, `from __future__ import annotations` for modern type hints, line length 120. `format.yml` sets `python: false` and `prettier: false`, so nothing reformats Python or Markdown on PR branches: run `uvx ruff@0.16.2 format --line-length 120 cli.py auth.py hooks.py tests` yourself before pushing; the generator formats `sdk/python`.

## Pitfalls

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Typed SDKs for the [Ultralytics Platform API](https://platform.ultralytics.com),

## 🐍 Python

The package includes the unified `ul` CLI for local YOLO and Platform SDK commands; see the [CLI guide](README.python.md#unified-ul-cli). Maintain `cli.py` and `auth.py` at the repository root, then regenerate their installed copies through the configured `python.cli.source` and `python.authProvider` hooks.
The package includes the unified `ul` CLI for local YOLO and Platform SDK commands; see the [CLI guide](README.python.md#unified-ul-cli). Maintain `cli.py`, `auth.py`, and `hooks.py` at the repository root, then regenerate their installed copies through the configured `python.cli.source`, `python.authProvider`, and `python.hooksProvider` hooks.

[![PyPI - Version](https://img.shields.io/pypi/v/ultralytics-platform?logo=pypi&logoColor=white)](https://pypi.org/project/ultralytics-platform/) [![Ultralytics Downloads](https://static.pepy.tech/badge/ultralytics-platform)](https://clickpy.clickhouse.com/dashboard/ultralytics-platform) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ultralytics-platform?logo=python&logoColor=gold)](https://pypi.org/project/ultralytics-platform/)

Expand Down Expand Up @@ -87,7 +87,7 @@ Platform OpenAPI contract
└── java/ # coming soon
```

`openapi.config.json` contains product and package configuration, including the source path for `README.python.md`. `auth.py` owns the Python SDK's Ultralytics credential discovery and is included by the generic `python.authProvider` hook on every regeneration. `openapi.json` and `openapi.sha256` pin the exact consumed contract. Generated files are never edited manually; update the contract snapshot, configuration, package README source, or generator and regenerate.
`openapi.config.json` contains product and package configuration, including the source path for `README.python.md`. `auth.py` owns the Python SDK's Ultralytics credential discovery and is included by the generic `python.authProvider` hook on every regeneration; `hooks.py`, included by `python.hooksProvider`, wakes a local On Premise worker after the SDK creates an ingest or training job. `openapi.json` and `openapi.sha256` pin the exact consumed contract. Generated files are never edited manually; update the contract snapshot, configuration, package README source, or generator and regenerate.

## 🛠️ Validation

Expand Down
2 changes: 2 additions & 0 deletions README.python.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ asyncio.run(main())

The package includes typed responses, multipart uploads, retries for temporary failures, structured API errors, custom HTTP clients, and context-manager cleanup.

After a successful ingest or training start, both clients send a best-effort `GET http://127.0.0.1:8765/wake` so an On Premise worker on the same machine claims the job at once; a custom `http_client` skips it.

## Unified `ul` CLI

This package installs `ul`. API resource commands work without the ML package. Local commands and `ul cloud train/predict/export` require `ultralytics` in the same environment. Existing `yolo` behavior, including local training with `ul://` inputs, is unchanged.
Expand Down
24 changes: 2 additions & 22 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,13 @@
import typing
import zipfile
from collections.abc import Sequence
from contextlib import ExitStack, suppress
from contextlib import ExitStack
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from tarfile import is_tarfile
from tempfile import TemporaryDirectory
from typing import Any, BinaryIO, Literal, get_args, get_origin, get_type_hints

import httpx

from . import NOT_GIVEN, APIConnectionError, APIError, NotGiven, Platform
from ._cli_metadata import MULTIPART_FILES

Expand All @@ -35,23 +33,6 @@ def platform_url() -> str:
return os.getenv("ULTRALYTICS_PLATFORM_URL", "https://platform.ultralytics.com").rstrip("/")


def wake_worker(response: httpx.Response) -> None:
"""Have an On Premise worker on this machine claim a just-created ingest or training job instead of polling.

Responses do not say where a job runs, so every one wakes; without a worker, or an old one, nothing changes.
"""
request = response.request
if response.is_success and request.method == "POST" and request.url.path.endswith(("/ingest", "/training/start")):
# stream returns after the headers, so a listener that drips a body can't hold the CLI open
with (
suppress(httpx.HTTPError),
httpx.stream(
"GET", "http://127.0.0.1:8765/wake", headers={"Origin": platform_url()}, timeout=0.5, trust_env=False
),
):
pass


def kinds(annotation: Any) -> tuple[set[str], list | None]:
"""Map a generated SDK annotation to JSON kinds and literal choices; empty kinds accept any JSON value."""
if annotation is Any:
Expand Down Expand Up @@ -734,8 +715,7 @@ def dispatch(tokens: list[str]) -> int:
"""Execute one SDK operation, inferring only a missing resource-path owner."""
tokens = list(tokens)
with ExitStack() as stack:
http_client = httpx.Client(timeout=60.0, event_hooks={"response": [wake_worker]})
client = stack.enter_context(Platform(base_url=platform_url(), http_client=http_client))
client = stack.enter_context(Platform(base_url=platform_url()))
if tokens and tokens[0] in CLOUD_COMMANDS:
if len(tokens) == 1 or any(token in {"help", "--help", "-h"} for token in tokens):
print(inspect.getdoc(CLOUD_COMMANDS[tokens[0]]))
Expand Down
33 changes: 33 additions & 0 deletions hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
"""Response hooks for Platform and AsyncPlatform: wake a local On Premise worker after creating its job."""

from __future__ import annotations

from contextlib import suppress

import httpx


def wake_request(response: httpx.Response) -> dict | None:
"""Return the local wake request for a created ingest or training job; responses don't say where a job runs."""
url = response.request.url
if response.is_success and response.request.method == "POST" and url.path.endswith(("/ingest", "/training/start")):
# Workers accept only their Platform's origin; reading headers only keeps a dripping listener from blocking
origin = f"{url.scheme}://{url.netloc.decode()}"
return {"method": "GET", "url": "http://127.0.0.1:8765/wake", "headers": {"Origin": origin}, "timeout": 0.5}
return None


def response_hook(response: httpx.Response) -> None:
"""Wake the local worker; never raises."""
if request := wake_request(response):
with suppress(httpx.HTTPError), httpx.stream(**request, trust_env=False):
pass


async def async_response_hook(response: httpx.Response) -> None:
"""Wake the local worker without blocking the event loop; never raises."""
if request := wake_request(response):
with suppress(httpx.HTTPError):
async with httpx.AsyncClient(trust_env=False) as client, client.stream(**request):
pass
3 changes: 2 additions & 1 deletion openapi.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"project": "ultralytics-platform",
"readme": "README.python.md",
"requiresPython": ">=3.11",
"authProvider": "auth.py"
"authProvider": "auth.py",
"hooksProvider": "hooks.py"
}
}
2 changes: 2 additions & 0 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ asyncio.run(main())

The package includes typed responses, multipart uploads, retries for temporary failures, structured API errors, custom HTTP clients, and context-manager cleanup.

After a successful ingest or training start, both clients send a best-effort `GET http://127.0.0.1:8765/wake` so an On Premise worker on the same machine claims the job at once; a custom `http_client` skips it.

## Unified `ul` CLI

This package installs `ul`. API resource commands work without the ML package. Local commands and `ul cloud train/predict/export` require `ultralytics` in the same environment. Existing `yolo` behavior, including local training with `ul://` inputs, is unchanged.
Expand Down
7 changes: 5 additions & 2 deletions sdk/python/src/ultralytics_platform/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from ._auth import get_api_key
from ._exceptions import APIConnectionError, APIError
from ._hooks import async_response_hook, response_hook


def _resolve_api_key(api_key: str | None) -> str | None:
Expand Down Expand Up @@ -126,7 +127,7 @@ def __init__(
retry_methods: Sequence[str],
http_client: httpx.Client | None,
) -> None:
self._client = http_client or httpx.Client(timeout=timeout)
self._client = http_client or httpx.Client(timeout=timeout, event_hooks={"response": [response_hook]})
Comment thread
glenn-jocher marked this conversation as resolved.
Resolved
self._base_url = httpx.URL(f"{base_url.rstrip('/')}/")
self._api_key = api_key
self._max_retries = max_retries
Expand Down Expand Up @@ -192,7 +193,9 @@ def __init__(
retry_methods: Sequence[str],
http_client: httpx.AsyncClient | None,
) -> None:
self._client = http_client or httpx.AsyncClient(timeout=timeout)
self._client = http_client or httpx.AsyncClient(
timeout=timeout, event_hooks={"response": [async_response_hook]}
)
self._base_url = httpx.URL(f"{base_url.rstrip('/')}/")
self._api_key = api_key
self._max_retries = max_retries
Expand Down
33 changes: 33 additions & 0 deletions sdk/python/src/ultralytics_platform/_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
"""Response hooks for Platform and AsyncPlatform: wake a local On Premise worker after creating its job."""

from __future__ import annotations

from contextlib import suppress

import httpx


def wake_request(response: httpx.Response) -> dict | None:
"""Return the local wake request for a created ingest or training job; responses don't say where a job runs."""
url = response.request.url
if response.is_success and response.request.method == "POST" and url.path.endswith(("/ingest", "/training/start")):
# Workers accept only their Platform's origin; reading headers only keeps a dripping listener from blocking
origin = f"{url.scheme}://{url.netloc.decode()}"
return {"method": "GET", "url": "http://127.0.0.1:8765/wake", "headers": {"Origin": origin}, "timeout": 0.5}
return None


def response_hook(response: httpx.Response) -> None:
"""Wake the local worker; never raises."""
if request := wake_request(response):
with suppress(httpx.HTTPError), httpx.stream(**request, trust_env=False):
pass


async def async_response_hook(response: httpx.Response) -> None:
"""Wake the local worker without blocking the event loop; never raises."""
if request := wake_request(response):
with suppress(httpx.HTTPError):
async with httpx.AsyncClient(trust_env=False) as client, client.stream(**request):
pass
2 changes: 1 addition & 1 deletion sdk/python/src/ultralytics_platform/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(
timeout (float | httpx.Timeout): Request timeout.
max_retries (int): Retries for connection errors and retryable responses.
retry_methods (Sequence[str]): HTTP methods safe to repeat after network errors or HTTP 408, 409, 429, and 5xx responses. Defaults to GET, HEAD, OPTIONS. Opt in only when the operation is safe to repeat and its request body can be resent.
http_client (httpx.AsyncClient, optional): Custom HTTP client.
http_client (httpx.AsyncClient, optional): Custom HTTP client, used as is without the configured response hooks.
"""
resolved_api_key = _resolve_api_key(api_key)
self._client = AsyncAPIClient(
Expand Down
24 changes: 2 additions & 22 deletions sdk/python/src/ultralytics_platform/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,13 @@
import typing
import zipfile
from collections.abc import Sequence
from contextlib import ExitStack, suppress
from contextlib import ExitStack
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from tarfile import is_tarfile
from tempfile import TemporaryDirectory
from typing import Any, BinaryIO, Literal, get_args, get_origin, get_type_hints

import httpx

from . import NOT_GIVEN, APIConnectionError, APIError, NotGiven, Platform
from ._cli_metadata import MULTIPART_FILES

Expand All @@ -35,23 +33,6 @@ def platform_url() -> str:
return os.getenv("ULTRALYTICS_PLATFORM_URL", "https://platform.ultralytics.com").rstrip("/")


def wake_worker(response: httpx.Response) -> None:
"""Have an On Premise worker on this machine claim a just-created ingest or training job instead of polling.

Responses do not say where a job runs, so every one wakes; without a worker, or an old one, nothing changes.
"""
request = response.request
if response.is_success and request.method == "POST" and request.url.path.endswith(("/ingest", "/training/start")):
# stream returns after the headers, so a listener that drips a body can't hold the CLI open
with (
suppress(httpx.HTTPError),
httpx.stream(
"GET", "http://127.0.0.1:8765/wake", headers={"Origin": platform_url()}, timeout=0.5, trust_env=False
),
):
pass


def kinds(annotation: Any) -> tuple[set[str], list | None]:
"""Map a generated SDK annotation to JSON kinds and literal choices; empty kinds accept any JSON value."""
if annotation is Any:
Expand Down Expand Up @@ -734,8 +715,7 @@ def dispatch(tokens: list[str]) -> int:
"""Execute one SDK operation, inferring only a missing resource-path owner."""
tokens = list(tokens)
with ExitStack() as stack:
http_client = httpx.Client(timeout=60.0, event_hooks={"response": [wake_worker]})
client = stack.enter_context(Platform(base_url=platform_url(), http_client=http_client))
client = stack.enter_context(Platform(base_url=platform_url()))
if tokens and tokens[0] in CLOUD_COMMANDS:
if len(tokens) == 1 or any(token in {"help", "--help", "-h"} for token in tokens):
print(inspect.getdoc(CLOUD_COMMANDS[tokens[0]]))
Expand Down
2 changes: 1 addition & 1 deletion sdk/python/src/ultralytics_platform/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(
timeout (float | httpx.Timeout): Request timeout.
max_retries (int): Retries for connection errors and retryable responses.
retry_methods (Sequence[str]): HTTP methods safe to repeat after network errors or HTTP 408, 409, 429, and 5xx responses. Defaults to GET, HEAD, OPTIONS. Opt in only when the operation is safe to repeat and its request body can be resent.
http_client (httpx.Client, optional): Custom HTTP client.
http_client (httpx.Client, optional): Custom HTTP client, used as is without the configured response hooks.
"""
resolved_api_key = _resolve_api_key(api_key)
self._client = SyncAPIClient(
Expand Down
Loading