Skip to content
Draft
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
55 changes: 26 additions & 29 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
name: Publish to PyPI

on:
push:
branches: [main]
paths:
- pyproject.toml
workflow_run:
workflows: ['Python SDK Quality']
types: [completed]

permissions:
actions: read
contents: read

jobs:
check-version:
name: Check for version change
if: >-
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version_changed: ${{ steps.check.outputs.changed }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Checkout validated commit
uses: actions/checkout@v7
with:
fetch-depth: 2
ref: ${{ github.event.workflow_run.head_sha }}

- name: Check if version changed
id: check
run: |
set -euo pipefail
current=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
previous=$(git show HEAD~1:pyproject.toml 2>/dev/null | grep '^version' | head -1 | sed 's/.*"\(.*\)"/\1/' || echo "")
echo "version=$current" >> "$GITHUB_OUTPUT"
Expand All @@ -30,39 +41,25 @@ jobs:
echo "changed=false" >> "$GITHUB_OUTPUT"
fi

build:
name: Build distribution
needs: check-version
if: needs.check-version.outputs.version_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Build package
run: uv build

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
name: Publish ${{ needs.check-version.outputs.version }} to PyPI
needs: [check-version, build]
needs: check-version
if: needs.check-version.outputs.version_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: production
permissions:
actions: read
contents: read
id-token: write
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Download validated distributions
uses: actions/download-artifact@v8
with:
name: dist
path: dist/
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
116 changes: 116 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: Python SDK Quality

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
tests:
name: Tests (Python 3.14)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.14'

- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: '0.9.28'
enable-cache: true
cache-dependency-glob: uv.lock

- name: Install locked dependencies
run: uv sync --all-extras --dev --frozen --python 3.14

- name: Run unit tests
run: uv run --frozen pytest

quality:
name: Static checks and package
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.10'

- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: '0.9.28'
enable-cache: true
cache-dependency-glob: uv.lock

- name: Install locked dependencies
run: uv sync --all-extras --dev --frozen --python 3.10

- name: Check formatting
run: uv run --frozen ruff format --check .

- name: Run Ruff
run: uv run --frozen ruff check .

- name: Run mypy
run: uv run --frozen mypy

- name: Run Pyright
run: uv run --frozen pyright

- name: Run unit tests
run: uv run --frozen pytest

- name: Build distributions
run: uv build

- name: Check distribution metadata
run: uv run --frozen --group package twine check dist/*

- name: Validate and install wheel
run: uv run --frozen python scripts/validate_distribution.py

- name: Upload distributions
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
if-no-files-found: error

python-sdk-quality:
name: python-sdk-quality
needs: [tests, quality]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check quality status
env:
QUALITY_RESULT: ${{ needs.quality.result }}
TEST_RESULT: ${{ needs.tests.result }}
run: |
set -euo pipefail
if [[ "$TEST_RESULT" != "success" ]] ||
[[ "$QUALITY_RESULT" != "success" ]]; then
echo "SDK quality failed: tests=$TEST_RESULT quality=$QUALITY_RESULT"
exit 1
fi
echo "All Python SDK quality checks passed."
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,26 @@ credentials, while model examples use bounded prompts and finite timeouts.
See the complete command matrix and API contract in the
[Python SDK documentation](https://docs.factory.ai/sdk/python).

## Development checks

Install the locked development environment, then run the same checks as CI:

```bash
uv sync --all-extras --dev --frozen
uv run --frozen pytest
uv run --frozen ruff format --check .
uv run --frozen ruff check .
uv run --frozen mypy
uv run --frozen pyright
uv build
uv run --frozen --group package twine check dist/*
uv run --frozen python scripts/validate_distribution.py
```

The normal test suite skips `tests/test_live_droid_exec.py`. Set
`DROID_LIVE_TESTS=1` only when intentionally running tests that create real
Droid sessions and consume model usage.

## Limitations

- Local `droid` subprocesses only
Expand Down
10 changes: 8 additions & 2 deletions examples/factory_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import struct
import zlib
from collections.abc import Mapping, Sequence
from typing import cast

from droid_sdk import Document, Image, Session

Expand Down Expand Up @@ -113,9 +114,14 @@ async def routed_model(

def report_routing(notification: Mapping[str, object]) -> None:
message = notification.get("message")
if isinstance(message, Mapping) and message.get("role") == "assistant":
if isinstance(message, Mapping):
message_fields = cast("Mapping[str, object]", message)
else:
return
if message_fields.get("role") == "assistant":
decision.append(
f"{message.get('modelId')} (effort {message.get('reasoningEffort')})"
f"{message_fields.get('modelId')} "
f"(effort {message_fields.get('reasoningEffort')})"
)

async with Session(model="auto") as session:
Expand Down
6 changes: 3 additions & 3 deletions examples/interactive_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ async def main() -> None:
include_partial_messages=True,
timeout=60,
) as second:
async for event in second:
if isinstance(event, TextDelta):
print(event.text, end="", flush=True)
async for partial_event in second:
if isinstance(partial_event, TextDelta):
print(partial_event.text, end="", flush=True)
print()


Expand Down
7 changes: 5 additions & 2 deletions examples/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

import asyncio
import json
from typing import Any
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import AsyncIterator

from droid_sdk import Runtime, Session
from droid_sdk.observability import (
Expand Down Expand Up @@ -66,7 +69,7 @@ async def send(self, message: str) -> None:
{"jsonrpc": "2.0", "id": request["id"], "result": result}
)

async def read_messages(self) -> Any:
async def read_messages(self) -> AsyncIterator[dict[str, Any]]:
while (message := await self._messages.get()) is not None:
yield message

Expand Down
6 changes: 4 additions & 2 deletions examples/structured_output_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ async def main() -> None:
assert isinstance(result, RunSuccess), (
result.error.message if result.error else result.subtype
)
assert result.output == Review(
output = result.output
assert output is not None
assert output == Review(
summary="Structured output works.",
findings=[
Finding(
Expand All @@ -45,7 +47,7 @@ async def main() -> None:
)
],
)
print(result.output.summary)
print(output.summary)


if __name__ == "__main__":
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dev = [
"starlette>=0.40",
"uvicorn>=0.31,<1",
]
package = ["twine"]

[build-system]
requires = ["hatchling"]
Expand All @@ -40,7 +41,7 @@ build-backend = "hatchling.build"
packages = ["src/droid_sdk"]

[tool.hatch.build.targets.sdist]
exclude = ["/tests"]
exclude = ["/.github", "/scripts", "/tests"]

[tool.pytest.ini_options]
asyncio_mode = "strict"
Expand All @@ -49,7 +50,7 @@ testpaths = ["tests"]
[tool.mypy]
strict = true
plugins = ["pydantic.mypy"]
files = ["src", "tests/typing/positive.py"]
files = ["src", "examples", "scripts", "tests/typing/positive.py"]

[tool.pydantic-mypy]
init_forbid_extra = true
Expand All @@ -59,12 +60,12 @@ warn_untyped_fields = true

[tool.pyright]
typeCheckingMode = "strict"
include = ["src", "tests/typing/positive.py"]
include = ["src", "examples", "scripts", "tests/typing/positive.py"]
pythonVersion = "3.10"

[tool.ruff]
target-version = "py310"
src = ["src", "tests"]
src = ["src", "tests", "examples", "scripts"]

[tool.ruff.lint]
select = [
Expand Down
Loading