diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e9507e55 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,233 @@ +# AGENTS.md — Dapr Python SDK + +This file provides context for AI agents working on the Dapr Python SDK. +The project is the official Python SDK for [Dapr](https://dapr.io/) (Distributed Application Runtime), +enabling Python developers to build distributed applications using Dapr building blocks. + +Repository: https://github.com/dapr/python-sdk +License: Apache 2.0 + +> **Deeper documentation lives alongside the code.** This root file gives you the big picture and +> tells you where to look. Each extension and the examples directory has its own `AGENTS.md` with +> detailed architecture, APIs, and patterns. + +## Project structure + +``` +dapr/ # Core SDK package +├── actor/ # Actor framework (virtual actor model) +├── aio/ # Async I/O modules +├── clients/ # Dapr clients (gRPC and HTTP) +├── common/ # Shared utilities +├── conf/ # Configuration (settings, environment) +├── proto/ # Auto-generated gRPC protobuf stubs (DO NOT EDIT) +├── serializers/ # JSON and pluggable serializers +└── version/ # Version metadata + +ext/ # Extension packages (each is a separate PyPI package) +├── dapr-ext-workflow/ # Workflow authoring ← see ext/dapr-ext-workflow/AGENTS.md +├── dapr-ext-grpc/ # gRPC App extension ← see ext/dapr-ext-grpc/AGENTS.md +├── dapr-ext-fastapi/ # FastAPI integration ← see ext/dapr-ext-fastapi/AGENTS.md +├── dapr-ext-langgraph/ # LangGraph checkpointer ← see ext/dapr-ext-langgraph/AGENTS.md +├── dapr-ext-strands/ # Strands agent sessions ← see ext/dapr-ext-strands/AGENTS.md +└── flask_dapr/ # Flask integration ← see ext/flask_dapr/AGENTS.md + +tests/ # Unit tests (mirrors dapr/ package structure) +examples/ # Integration test suite ← see examples/AGENTS.md +docs/ # Sphinx documentation source +tools/ # Build and release scripts +``` + +## Key architectural patterns + +- **Namespace packages**: The `dapr` namespace is shared across the core SDK and extensions via `find_namespace_packages`. Extensions live in `ext/` but install into the `dapr.ext.*` namespace. Do not add `__init__.py` to namespace package roots in extensions. +- **Client architecture**: `DaprGrpcClient` (primary, high-performance) and HTTP-based clients. Both implement shared interfaces. +- **Actor model**: `Actor` base class, `ActorInterface` with `@actormethod` decorator, `ActorProxy`/`ActorProxyFactory` for client-side references, `ActorRuntime` for server-side hosting. +- **Serialization**: Pluggable via `Serializer` base class. `DefaultJSONSerializer` is the default. +- **Proto files**: Auto-generated from Dapr proto definitions. Never edit files under `dapr/proto/` directly. + +## Extension overview + +Each extension is a **separate PyPI package** with its own `setup.cfg`, `setup.py`, `tests/`, and `AGENTS.md`. + +| Extension | Package | Purpose | Active development | +|-----------|---------|---------|-------------------| +| `dapr-ext-workflow` | `dapr.ext.workflow` | Durable workflow orchestration via durabletask-dapr | **High** — major focus area | +| `dapr-ext-grpc` | `dapr.ext.grpc` | gRPC server for Dapr callbacks (methods, pub/sub, bindings, jobs) | Moderate | +| `dapr-ext-fastapi` | `dapr.ext.fastapi` | FastAPI integration for pub/sub and actors | Moderate | +| `flask_dapr` | `flask_dapr` | Flask integration for pub/sub and actors | Low | +| `dapr-ext-langgraph` | `dapr.ext.langgraph` | LangGraph checkpoint persistence to Dapr state store | Moderate | +| `dapr-ext-strands` | `dapr.ext.strands` | Strands agent session management via Dapr state store | New | + +## Examples (integration test suite) + +The `examples/` directory serves as both user-facing documentation and the project's integration test suite. Examples are validated in CI using [mechanical-markdown](https://pypi.org/project/mechanical-markdown/), which executes bash code blocks from README files and asserts expected output. + +**See `examples/AGENTS.md`** for the full guide on example structure, validation, mechanical-markdown STEP blocks, and how to add new examples. + +Quick reference: +```bash +tox -e examples # Run all examples (needs Dapr runtime) +tox -e example-component -- state_store # Run a single example +cd examples && ./validate.sh state_store # Run directly +``` + +## Python version support + +- **Minimum**: Python 3.10 +- **Tested**: 3.10, 3.11, 3.12, 3.13, 3.14 +- **Target version for tooling**: `py310` (ruff, mypy) + +## Development setup + +Install all packages in editable mode with dev dependencies: + +```bash +pip install -r dev-requirements.txt \ + -e . \ + -e ext/dapr-ext-workflow/ \ + -e ext/dapr-ext-grpc/ \ + -e ext/dapr-ext-fastapi/ \ + -e ext/dapr-ext-langgraph/ \ + -e ext/dapr-ext-strands/ \ + -e ext/flask_dapr/ +``` + +## Running tests + +Tests use Python's built-in `unittest` framework with `coverage`. Run via tox: + +```bash +# Run all unit tests (replace 311 with your Python version) +tox -e py311 + +# Run linting and formatting +tox -e ruff + +# Run type checking +tox -e type + +# Validate examples (requires Dapr runtime) +tox -e examples +``` + +To run tests directly without tox: + +```bash +# Core SDK tests +python -m unittest discover -v ./tests + +# Extension tests (run each separately) +python -m unittest discover -v ./ext/dapr-ext-workflow/tests +python -m unittest discover -v ./ext/dapr-ext-grpc/tests +python -m unittest discover -v ./ext/dapr-ext-fastapi/tests +python -m unittest discover -v ./ext/dapr-ext-langgraph/tests +python -m unittest discover -v ./ext/dapr-ext-strands/tests +python -m unittest discover -v ./ext/flask_dapr/tests +``` + +## Code style and linting + +**Formatter/Linter**: Ruff (v0.14.1) + +Key rules: +- **Line length**: 100 characters (E501 is currently ignored, but respect the 100-char target) +- **Quote style**: Single quotes +- **Import sorting**: isort-compatible (ruff `I` rules) +- **Target**: Python 3.10 +- **Excluded from linting**: `.github/`, `dapr/proto/` + +Run formatting and lint fixes: + +```bash +ruff check --fix +ruff format +``` + +**Type checking**: MyPy + +```bash +mypy --config-file mypy.ini +``` + +MyPy is configured to check: `dapr/actor/`, `dapr/clients/`, `dapr/conf/`, `dapr/serializers/`, `ext/dapr-ext-grpc/`, `ext/dapr-ext-fastapi/`, `ext/flask_dapr/`, and `examples/demo_actor/`. Proto stubs (`dapr.proto.*`) have errors ignored. + +## Commit and PR conventions + +- **DCO required**: Every commit must include a `Signed-off-by` line. Use `git commit -s` to add it automatically. +- **CI checks**: Linting (ruff), unit tests (Python 3.10-3.14), type checking (mypy), and DCO verification run on all PRs. +- **Branch targets**: PRs go to `main` or `release-*` branches. +- **Tag-based releases**: Tags like `v*`, `workflow-v*`, `grpc-v*`, `fastapi-v*`, `flask-v*`, `langgraph-v*`, `strands-v*` trigger PyPI publishing for the corresponding package. + +## Agent task checklist + +When completing any task on this project, work through this checklist. Not every item applies to every change — use judgment — but always consider each one. + +### Before writing code + +- [ ] Read the relevant existing source files before making changes +- [ ] Understand the existing patterns in the area you're modifying (naming, error handling, async vs sync) +- [ ] Check if there's both a sync and async variant that needs updating (see `dapr/aio/` and extension `aio/` subdirectories) +- [ ] Read the relevant extension's `AGENTS.md` for architecture and gotchas specific to that area + +### Implementation + +- [ ] Follow existing code style: single quotes, 100-char lines, Python 3.10+ syntax +- [ ] Do not edit files under `dapr/proto/` — these are auto-generated +- [ ] Do not add `__init__.py` files to namespace package roots in extensions + +### Unit tests + +- [ ] Add or update unit tests under `tests/` (core SDK) or `ext/*/tests/` (extensions) +- [ ] Tests use `unittest` — follow the existing test patterns in the relevant directory +- [ ] Verify tests pass: `python -m unittest discover -v ./tests` (or the relevant test directory) + +### Linting and type checking + +- [ ] Run `ruff check --fix && ruff format` and fix any remaining issues +- [ ] Run `mypy --config-file mypy.ini` if you changed files covered by mypy (actor, clients, conf, serializers, ext-grpc, ext-fastapi, flask_dapr) + +### Examples (integration tests) + +- [ ] If you added a new user-facing feature or building block, add or update an example in `examples/` +- [ ] Ensure the example README has `` blocks with `expected_stdout_lines` so it is validated in CI +- [ ] If you added a new example, register it in `tox.ini` under `[testenv:examples]` +- [ ] If you changed output format of existing functionality, update `expected_stdout_lines` in affected example READMEs +- [ ] See `examples/AGENTS.md` for full details on writing examples + +### Documentation + +- [ ] Update docstrings if you changed a public API's signature or behavior +- [ ] Update the relevant example README if the usage pattern changed + +### Final verification + +- [ ] Run `tox -e ruff` — linting must be clean +- [ ] Run `tox -e py311` (or your Python version) — all unit tests must pass +- [ ] If you touched examples: `tox -e example-component -- ` to validate locally +- [ ] Commits must be signed off for DCO: `git commit -s` + +## Important files + +| File | Purpose | +|------|---------| +| `setup.cfg` | Core package metadata and dependencies | +| `setup.py` | Package build script (handles dev version suffixing) | +| `pyproject.toml` | Ruff configuration | +| `tox.ini` | Test environments and CI commands | +| `mypy.ini` | Type checking configuration | +| `dev-requirements.txt` | Development/test dependencies | +| `dapr/version/__init__.py` | SDK version string | +| `ext/*/setup.cfg` | Extension package metadata and dependencies | +| `examples/validate.sh` | Entry point for mechanical-markdown example validation | + +## Gotchas + +- **Namespace packages**: Do not add `__init__.py` to the top-level `dapr/` directory in extensions — it will break namespace package resolution. +- **Proto files**: Never manually edit anything under `dapr/proto/`. These are generated. +- **Extension independence**: Each extension is a separate PyPI package. Core SDK changes should not break extensions; extension changes should not require core SDK changes unless intentional. +- **DCO signoff**: PRs will be blocked by the DCO bot if commits lack `Signed-off-by`. Always use `git commit -s`. +- **Ruff version pinned**: Dev requirements pin `ruff === 0.14.1`. Use this exact version to match CI. +- **Examples are integration tests**: Changing output format (log messages, print statements) can break example validation. Always check `expected_stdout_lines` in example READMEs when modifying user-visible output. +- **Background processes in examples**: Examples that start background services (servers, subscribers) must include a cleanup step to stop them, or CI will hang. +- **Workflow is the most active area**: See `ext/dapr-ext-workflow/AGENTS.md` for workflow-specific architecture and constraints. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/examples/AGENTS.md b/examples/AGENTS.md new file mode 100644 index 00000000..9c1fccd5 --- /dev/null +++ b/examples/AGENTS.md @@ -0,0 +1,250 @@ +# AGENTS.md — Dapr Python SDK Examples + +The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated automatically in CI using [mechanical-markdown](https://pypi.org/project/mechanical-markdown/), which executes bash code blocks embedded in README files and asserts expected output. + +## How validation works + +1. `examples/validate.sh` is the entry point — it `cd`s into an example directory and runs `mm.py -l README.md` +2. `mm.py` (mechanical-markdown) parses `` HTML comment blocks in the README +3. Each STEP block wraps a fenced bash code block that gets executed +4. stdout/stderr is captured and checked against `expected_stdout_lines` / `expected_stderr_lines` +5. Validation fails if any expected output line is missing + +Run examples locally (requires a running Dapr runtime via `dapr init`): + +```bash +# All examples +tox -e examples + +# Single example +tox -e example-component -- state_store + +# Or directly +cd examples && ./validate.sh state_store +``` + +In CI (`validate_examples.yaml`), examples run on all supported Python versions (3.10-3.14) on Ubuntu with a full Dapr runtime including Docker, Redis, and (for LLM examples) Ollama. + +## Example directory structure + +Each example follows this pattern: + +``` +examples// +├── README.md # Documentation + mechanical-markdown STEP blocks (REQUIRED) +├── *.py # Python application files +├── requirements.txt # Dependencies (e.g., dapr>=1.17.0rc6) +├── components/ # Dapr component YAML configs (if needed) +│ ├── statestore.yaml +│ └── pubsub.yaml +├── config.yaml # Dapr configuration (optional, e.g., for tracing/features) +└── proto/ # Protobuf definitions (for gRPC examples) +``` + +Common Python file naming conventions: +- Server/receiver side: `*-receiver.py`, `subscriber.py`, `*_service.py` +- Client/caller side: `*-caller.py`, `publisher.py`, `*_client.py` +- Standalone: `state_store.py`, `crypto.py`, etc. + +## Mechanical-markdown STEP block format + +STEP blocks are HTML comments wrapping fenced bash code in the README: + +````markdown + + +```bash +dapr run --app-id myapp --resources-path ./components/ python3 example.py +``` + + +```` + +### STEP block attributes + +| Attribute | Description | +|-----------|-------------| +| `name` | Descriptive name for the step | +| `expected_stdout_lines` | List of strings that must appear in stdout | +| `expected_stderr_lines` | List of strings that must appear in stderr | +| `background` | `true` to run in background (for long-running services) | +| `sleep` | Seconds to wait after starting before moving to the next step | +| `timeout_seconds` | Max seconds before the step is killed | +| `output_match_mode` | `substring` for partial matching (default is exact) | +| `match_order` | `none` if output lines can appear in any order | + +### Tips for writing STEP blocks + +- Use `background: true` with `sleep:` for services that need to stay running (servers, subscribers) +- Use `timeout_seconds:` to prevent CI hangs on broken examples +- Use `output_match_mode: substring` when output contains timestamps or dynamic content +- Use `match_order: none` when multiple concurrent operations produce unpredictable ordering +- Always include a cleanup step (e.g., `dapr stop --app-id ...`) when using background processes +- Make `expected_stdout_lines` specific enough to validate correctness, but not so brittle they break on cosmetic changes +- Dapr prefixes app output with `== APP ==` — use this in expected lines + +## Dapr component YAML format + +Components in `components/` directories follow the standard Dapr resource format: + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: statestore +spec: + type: state.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" +``` + +Common component types used in examples: `state.redis`, `pubsub.redis`, `lock.redis`, `configuration.redis`, `crypto.dapr.localstorage`, `bindings.*`. + +## All examples by building block + +### State management +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `state_store` | Standalone client | `dapr`, `dapr-ext-grpc` | Yes | +| `state_store_query` | Standalone client | `dapr`, `dapr-ext-grpc` | Yes | + +### Service invocation +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `invoke-simple` | Client-server (receiver/caller) | `dapr`, `dapr-ext-grpc` | No | +| `invoke-custom-data` | Client-server (protobuf) | `dapr`, `dapr-ext-grpc` | No | +| `invoke-http` | Client-server (Flask) | `dapr`, Flask | No | +| `invoke-binding` | Client with bindings | `dapr`, `dapr-ext-grpc` | Yes | +| `grpc_proxying` | Client-server (gRPC proxy) | `dapr`, `dapr-ext-grpc` | No (has config.yaml) | + +### Pub/sub +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `pubsub-simple` | Client-server (publisher/subscriber) | `dapr`, `dapr-ext-grpc` | No | +| `pubsub-streaming` | Streaming pub/sub | `dapr` (base only) | No | +| `pubsub-streaming-async` | Async streaming pub/sub | `dapr` (base only) | No | + +### Virtual actors +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `demo_actor` | Client-server (FastAPI/Flask + client) | `dapr`, `dapr-ext-fastapi` | No | + +### Workflow +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `workflow` | Multiple standalone scripts | `dapr-ext-workflow`, `dapr` | No | +| `demo_workflow` | Legacy (deprecated DaprClient methods) | `dapr-ext-workflow` | Yes | + +The `workflow` example includes: `simple.py`, `task_chaining.py`, `fan_out_fan_in.py`, `human_approval.py`, `monitor.py`, `child_workflow.py`, `cross-app1/2/3.py`, `versioning.py`, `simple_aio_client.py`. + +### Secrets, configuration, locks +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `secret_store` | Standalone client | `dapr`, `dapr-ext-grpc` | Yes | +| `configuration` | Standalone client with subscription | `dapr`, `dapr-ext-grpc` | Yes | +| `distributed_lock` | Standalone client | `dapr`, `dapr-ext-grpc` | Yes | + +### Cryptography +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `crypto` | Standalone (sync + async) | `dapr`, `dapr-ext-grpc` | Yes | + +### Jobs, tracing, metadata, errors +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `jobs` | Standalone + gRPC event handler | `dapr`, `dapr-ext-grpc` | No | +| `w3c-tracing` | Client-server with OpenTelemetry | `dapr`, `dapr-ext-grpc`, OpenTelemetry | No | +| `metadata` | Standalone client | `dapr`, `dapr-ext-grpc` | Yes | +| `error_handling` | Standalone client | `dapr`, `dapr-ext-grpc` | Yes | + +### AI/LLM integrations +| Example | Pattern | SDK packages | Has components | +|---------|---------|-------------|----------------| +| `conversation` | Standalone client | `dapr` (base, uses sidecar) | No (uses config/) | +| `langgraph-checkpointer` | Standalone gRPC server | `dapr-ext-langgraph`, LangGraph, LangChain | Yes | + +## Adding a new example + +1. Create a directory under `examples/` with a descriptive kebab-case name +2. Add Python source files and a `requirements.txt` referencing the needed SDK packages +3. Add Dapr component YAMLs in a `components/` subdirectory if the example uses state, pubsub, etc. +4. Write a `README.md` with: + - Introduction explaining what the example demonstrates + - Pre-requisites section (Dapr CLI, Python 3.10+, any special tools) + - Install instructions (`pip3 install dapr dapr-ext-grpc` etc.) + - Running instructions with `` blocks wrapping `dapr run` commands + - Expected output section + - Cleanup step to stop background processes +5. Register the example in `tox.ini` under `[testenv:examples]` commands: + ``` + ./validate.sh your-example-name + ``` +6. Test locally: `cd examples && ./validate.sh your-example-name` + +## Common README template + +```markdown +# Dapr [Building Block] Example + +This example demonstrates how to use the Dapr [building block] API with the Python SDK. + +## Pre-requisites + +- [Dapr CLI and initialized environment](https://docs.dapr.io/getting-started) +- Python 3.10+ + +## Install Dapr python-SDK + +\`\`\`bash +pip3 install dapr dapr-ext-grpc +\`\`\` + +## Run the example + + + +\`\`\`bash +dapr run --app-id myapp --resources-path ./components/ python3 example.py +\`\`\` + + + +## Cleanup + + + +\`\`\`bash +dapr stop --app-id myapp +\`\`\` + + +``` + +## Gotchas + +- **Output format changes break CI**: If you modify print statements or log output in SDK code, check whether any example's `expected_stdout_lines` depend on that output. +- **Background processes must be cleaned up**: Missing cleanup steps cause CI to hang. +- **Dapr prefixes output**: Application stdout appears as `== APP == ` when run via `dapr run`. +- **Redis is available in CI**: The CI environment has Redis running on `localhost:6379` — most component YAMLs use this. +- **Some examples need special setup**: `crypto` generates keys, `configuration` seeds Redis, `conversation` needs LLM config — check individual READMEs. diff --git a/ext/dapr-ext-fastapi/AGENTS.md b/ext/dapr-ext-fastapi/AGENTS.md new file mode 100644 index 00000000..40d3e369 --- /dev/null +++ b/ext/dapr-ext-fastapi/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md — dapr-ext-fastapi + +The FastAPI extension provides two integration classes for building Dapr applications with [FastAPI](https://fastapi.tiangolo.com/): `DaprApp` for pub/sub subscriptions and `DaprActor` for actor hosting. + +## Source layout + +``` +ext/dapr-ext-fastapi/ +├── setup.cfg # Deps: dapr, uvicorn, fastapi +├── setup.py +├── tests/ +│ ├── test_app.py # DaprApp pub/sub tests +│ └── test_dapractor.py # DaprActor response wrapping + route tests +└── dapr/ext/fastapi/ + ├── __init__.py # Exports: DaprApp, DaprActor + ├── app.py # DaprApp — pub/sub subscription handler + ├── actor.py # DaprActor — actor runtime HTTP adapter + └── version.py +``` + +## Public API + +```python +from dapr.ext.fastapi import DaprApp, DaprActor +``` + +### DaprApp (`app.py`) + +Wraps a FastAPI instance to add Dapr pub/sub event handling. + +```python +app = FastAPI() +dapr_app = DaprApp(app, router_tags=['PubSub']) # router_tags optional, default ['PubSub'] + +@dapr_app.subscribe(pubsub='pubsub', topic='orders', route='/handle-order', + metadata={}, dead_letter_topic=None) +def handle_order(event_data): + return {'status': 'ok'} +``` + +- Auto-registers `GET /dapr/subscribe` endpoint returning subscription metadata +- Each `@subscribe` registers a POST route on the FastAPI app +- If `route` is omitted, defaults to `/events/{pubsub}/{topic}` +- Subscription metadata format: `{"pubsubname", "topic", "route", "metadata", "deadLetterTopic"}` + +### DaprActor (`actor.py`) + +Integrates Dapr's actor runtime with FastAPI by registering HTTP endpoints. + +```python +app = FastAPI() +dapr_actor = DaprActor(app, router_tags=['Actor']) # router_tags optional, default ['Actor'] + +await dapr_actor.register_actor(MyActorClass) +``` + +Auto-registers six endpoints: +- `GET /healthz` — health check +- `GET /dapr/config` — actor configuration discovery +- `DELETE /actors/{type}/{id}` — deactivation +- `PUT /actors/{type}/{id}/method/{method}` — method invocation +- `PUT /actors/{type}/{id}/method/timer/{timer}` — timer callback +- `PUT /actors/{type}/{id}/method/remind/{reminder}` — reminder callback + +Method invocation extracts `Dapr-Reentrancy-Id` header for reentrant actor calls. All actor operations delegate to `ActorRuntime` from the core SDK. + +**Response wrapping** (`_wrap_response`): Converts handler results to HTTP responses: +- String → JSON `{"message": "..."}` with optional `errorCode` for errors +- Bytes → raw `Response` with specified media type +- Dict/object → JSON serialized + +**Error handling**: Catches `DaprInternalError` and generic `Exception`, returns 500 with error details. + +## Dependencies + +- `dapr >= 1.17.0.dev` +- `fastapi >= 0.60.1` +- `uvicorn >= 0.11.6` + +## Testing + +```bash +python -m unittest discover -v ./ext/dapr-ext-fastapi/tests +``` + +- `test_app.py` — uses FastAPI `TestClient` for HTTP-level testing: subscription registration, custom routes, metadata, dead letter topics, router tags +- `test_dapractor.py` — tests `_wrap_response` utility (string, bytes, error, object), router tag propagation across all 6 actor routes + +## Key details + +- **Async actors**: `register_actor` is an async method (must be awaited). Actor method/timer/reminder handlers are dispatched through `ActorRuntime` which uses `asyncio.run()`. +- **Router tags**: Both classes support `router_tags` parameter to customize OpenAPI/Swagger documentation grouping. +- **No gRPC**: This extension is HTTP-only. It works with Dapr's HTTP callback protocol, not gRPC. diff --git a/ext/dapr-ext-grpc/AGENTS.md b/ext/dapr-ext-grpc/AGENTS.md new file mode 100644 index 00000000..a152ea9e --- /dev/null +++ b/ext/dapr-ext-grpc/AGENTS.md @@ -0,0 +1,133 @@ +# AGENTS.md — dapr-ext-grpc + +The gRPC extension provides a **server-side callback framework** for Dapr applications. It enables Python apps to act as Dapr callback services using a decorator-based API, handling service invocation, pub/sub subscriptions, input bindings, job events, and health checks. + +## Source layout + +``` +ext/dapr-ext-grpc/ +├── setup.cfg # Deps: dapr, cloudevents +├── setup.py +├── tests/ +│ ├── test_app.py # Decorator registration tests +│ ├── test_servicier.py # Routing, handlers, bulk events +│ ├── test_health_servicer.py # Health check tests +│ └── test_topic_event_response.py # Response status tests +└── dapr/ext/grpc/ + ├── __init__.py # Public API exports + ├── app.py # App class — main entry point + ├── _servicer.py # _CallbackServicer — internal routing + ├── _health_servicer.py # _HealthCheckServicer + └── version.py +``` + +## Public API + +```python +from dapr.ext.grpc import ( + App, # Main entry point — decorator-based gRPC server + Rule, # CEL-based topic rule with priority + InvokeMethodRequest, # Request object for service invocation handlers + InvokeMethodResponse, # Response object for service invocation handlers + BindingRequest, # Request object for input binding handlers + TopicEventResponse, # Response object for pub/sub handlers + Job, # Job definition for scheduler + JobEvent, # Job event received by handler + FailurePolicy, # ABC for job failure policies + DropFailurePolicy, # Drop on failure (no retry) + ConstantFailurePolicy, # Retry with constant interval +) +``` + +Note: `InvokeMethodRequest`, `InvokeMethodResponse`, `BindingRequest`, `TopicEventResponse`, `Job`, `JobEvent`, and failure policies are actually defined in the core SDK (`dapr/clients/grpc/`) and re-exported here. + +## App class (`app.py`) + +The central entry point. Creates a gRPC server and provides decorators for handler registration. + +### Decorators + +```python +app = App() + +@app.method('method_name') +def handle_method(request: InvokeMethodRequest) -> InvokeMethodResponse: + ... + +@app.subscribe(pubsub_name='pubsub', topic='orders', metadata={}, dead_letter_topic=None, + rule=Rule('event.type == "order"', priority=1), disable_topic_validation=False) +def handle_event(event: v1.Event) -> Optional[TopicEventResponse]: + ... + +@app.binding('binding_name') +def handle_binding(request: BindingRequest) -> None: + ... + +@app.job_event('job_name') +def handle_job(event: JobEvent) -> None: + ... + +app.register_health_check(lambda: None) # Not a decorator — direct registration +``` + +### Lifecycle + +- `app.run(app_port=3010, listen_address='[::]')` — starts gRPC server and blocks +- `app.stop()` — gracefully shuts down +- `app.add_external_service(servicer_cb, external_servicer)` — add external gRPC services + +### Handler return types + +**Method handlers** can return: +- `str` or `bytes` → wrapped in `InvokeMethodResponse` with `application/json` +- `InvokeMethodResponse` → used directly +- Protobuf message → packed into `google.protobuf.Any` + +**Topic handlers** can return: +- `TopicEventResponse('success'|'retry'|'drop')` → explicit status +- `None` → defaults to SUCCESS + +## Internal routing (`_servicer.py`) + +`_CallbackServicer` implements `AppCallbackServicer` + `AppCallbackAlphaServicer` gRPC service interfaces. It maintains internal registries: + +- `_invoke_method_map` — method name → handler +- `_topic_map` — topic key → handler +- `_binding_map` — binding name → handler +- `_job_event_map` — job name → handler + +**Topic routing with rules**: Topics support multiple handlers with CEL-based rules and priorities. Rules are sorted by priority (lower = higher priority). Topic key format: `{pubsub_name}:{topic}:{path}`. + +**Bulk event processing**: `OnBulkTopicEvent` processes multiple entries per request. Each entry can be raw bytes or a CloudEvent. Per-entry status tracking in the response. Handler exceptions return RETRY status for that entry. + +## Request/response types (from core SDK) + +**InvokeMethodRequest**: `data` (bytes), `content_type`, `metadata` (from gRPC context), `text()`, `is_proto()`, `unpack(message)` + +**InvokeMethodResponse**: `data` (bytes), `content_type`, `headers`, `status_code`, `text()`, `json()`, `is_proto()`, `pack(val)` + +**BindingRequest**: `data` (bytes), `binding_metadata` (dict), `metadata`, `text()` + +**TopicEventResponse**: `status` property → `TopicEventResponseStatus` enum (success=0, retry=1, drop=2) + +**JobEvent**: `name` (str), `data` (bytes), `get_data_as_string(encoding='utf-8')` + +## Testing + +```bash +python -m unittest discover -v ./ext/dapr-ext-grpc/tests +``` + +Test patterns: +- `test_app.py` — decorator registration, health check registration +- `test_servicier.py` — handler invocation with mock gRPC context, return type handling (str, bytes, proto, response object), topic subscriptions, bulk events, bindings, duplicate registration errors +- `test_health_servicer.py` — health check callback invocation, missing callback (UNIMPLEMENTED) +- `test_topic_event_response.py` — response creation from enum and string values + +## Key details + +- **Synchronous only**: Uses `grpc.server()` with `ThreadPoolExecutor(10)`. No async handler support. +- **Default port**: 3010 (from `dapr.conf.global_settings.GRPC_APP_PORT`) +- **CloudEvents**: Requires `cloudevents >= 1.0.0` for pub/sub event handling +- **Duplicate registration**: Registering the same method/topic/binding name twice raises `ValueError` +- **Missing handlers**: Calling an unregistered method/topic/binding raises `NotImplementedError` (gRPC UNIMPLEMENTED) diff --git a/ext/dapr-ext-langgraph/AGENTS.md b/ext/dapr-ext-langgraph/AGENTS.md new file mode 100644 index 00000000..3103fb44 --- /dev/null +++ b/ext/dapr-ext-langgraph/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md — dapr-ext-langgraph + +The LangGraph extension provides a Dapr-backed checkpoint saver for [LangGraph](https://langchain-ai.github.io/langgraph/) workflows, persisting workflow state to any Dapr state store. + +## Source layout + +``` +ext/dapr-ext-langgraph/ +├── setup.cfg # Deps: dapr, langgraph, langchain, python-ulid, msgpack +├── setup.py +├── tests/ +│ └── test_checkpointer.py # Unit tests with mocked DaprClient +└── dapr/ext/langgraph/ + ├── __init__.py # Exports: DaprCheckpointer + ├── dapr_checkpointer.py # Main implementation (~420 lines) + └── version.py +``` + +## Public API + +```python +from dapr.ext.langgraph import DaprCheckpointer +``` + +### DaprCheckpointer (`dapr_checkpointer.py`) + +Extends `langgraph.checkpoint.base.BaseCheckpointSaver[Checkpoint]`. + +```python +cp = DaprCheckpointer(store_name='statestore', key_prefix='lg') +config = {'configurable': {'thread_id': 't1'}} + +# Save checkpoint +next_config = cp.put(config, checkpoint, metadata, new_versions) + +# Retrieve latest +tuple = cp.get_tuple(config) # → Optional[CheckpointTuple] + +# List all +all_checkpoints = cp.list(config) # → list[CheckpointTuple] + +# Store intermediate writes +cp.put_writes(config, writes=[(channel, value)], task_id='task1') + +# Delete thread +cp.delete_thread(config) +``` + +### Key methods + +**`put(config, checkpoint, metadata, new_versions)`** — Serializes and saves a checkpoint to the state store. Creates two keys: the checkpoint data key (`checkpoint:{thread_id}:{ns}:{id}`) and a "latest" pointer key (`checkpoint_latest:{thread_id}:{ns}`). + +**`get_tuple(config)`** — Retrieves the most recent checkpoint. Follows the latest pointer, then fetches the actual data. Handles both binary (msgpack) and JSON formats. Performs recursive byte decoding and LangChain message type conversion (`HumanMessage`, `AIMessage`, `ToolMessage`). + +**`put_writes(config, writes, task_id, task_path)`** — Stores intermediate channel writes linked to a checkpoint. Each write is serialized with `serde.dumps_typed()` and base64-encoded. + +**`list(config)`** — Lists all checkpoints for a thread using a registry key (`dapr_checkpoint_registry`). + +**`delete_thread(config)`** — Deletes checkpoint data and removes it from the registry. + +## Data storage schema + +| Key pattern | Contents | +|-------------|----------| +| `checkpoint:{thread_id}:{ns}:{id}` | Full checkpoint data (channel values, versions, metadata) | +| `checkpoint_latest:{thread_id}:{ns}` | Points to the latest checkpoint key | +| `dapr_checkpoint_registry` | List of all checkpoint keys (for `list()`) | + +## Dependencies + +- `dapr >= 1.17.0.dev` +- `langgraph >= 0.3.6` +- `langchain >= 0.1.17` +- `python-ulid >= 3.0.0` (for checkpoint ID ordering) +- `msgpack-python >= 0.4.5` (for binary serialization) + +## Testing + +```bash +python -m unittest discover -v ./ext/dapr-ext-langgraph/tests +``` + +6 test cases using `@mock.patch('dapr.ext.langgraph.dapr_checkpointer.DaprClient')`: +- `test_get_tuple_returns_checkpoint` / `test_get_tuple_none_when_missing` +- `test_put_saves_checkpoint_and_registry` +- `test_put_writes_updates_channel_values` +- `test_list_returns_all_checkpoints` +- `test_delete_thread_removes_key_and_updates_registry` + +## Key details + +- **Serialization**: Uses `JsonPlusSerializer` from LangGraph for complex types, with msgpack for binary optimization and base64 for blob encoding. +- **Message conversion**: Handles LangChain message types (`HumanMessage`, `AIMessage`, `ToolMessage`) during deserialization from msgpack `ExtType` objects. +- **State store agnostic**: Works with any Dapr state store backend (Redis, Cosmos DB, PostgreSQL, etc.) — all state operations go through `DaprClient.save_state()` / `get_state()` / `delete_state()`. +- **Thread isolation**: Each workflow thread is namespaced by `thread_id` in all keys. +- **Numeric string conversion**: `_decode_bytes` converts numeric strings to `int` for LangGraph `channel_version` comparisons. diff --git a/ext/dapr-ext-strands/AGENTS.md b/ext/dapr-ext-strands/AGENTS.md new file mode 100644 index 00000000..9848aef6 --- /dev/null +++ b/ext/dapr-ext-strands/AGENTS.md @@ -0,0 +1,108 @@ +# AGENTS.md — dapr-ext-strands + +The Strands extension provides distributed session management for [Strands Agents](https://github.com/strands-agents/strands-agents), persisting sessions, agents, and messages to any Dapr state store with optional TTL and consistency controls. + +## Source layout + +``` +ext/dapr-ext-strands/ +├── setup.cfg # Deps: dapr, strands-agents, strands-agents-tools, python-ulid, msgpack +├── setup.py +├── tests/ +│ └── test_session_manager.py # Unit tests with mocked DaprClient +└── dapr/ext/strands/ + ├── __init__.py # Exports: DaprSessionManager + ├── dapr_session_manager.py # Main implementation (~550 lines) + └── version.py +``` + +## Public API + +```python +from dapr.ext.strands import DaprSessionManager +``` + +### DaprSessionManager (`dapr_session_manager.py`) + +Extends both `RepositorySessionManager` and `SessionRepository` from the Strands agents framework. + +**Constructor:** +```python +manager = DaprSessionManager( + session_id='my-session', + state_store_name='statestore', + dapr_client=client, # DaprClient instance + ttl=3600, # Optional: TTL in seconds + consistency='eventual', # 'eventual' (default) or 'strong' +) +``` + +**Factory method:** +```python +manager = DaprSessionManager.from_address( + session_id='my-session', + state_store_name='statestore', + dapr_address='localhost:50001', # Auto-creates DaprClient +) +``` + +### Methods + +**Session operations:** +- `create_session(session)` → `Session` — creates new session (raises if exists) +- `read_session(session_id)` → `Optional[Session]` +- `delete_session(session_id)` — cascade deletes session + all agents + messages + +**Agent operations:** +- `create_agent(session_id, session_agent)` — creates agent, initializes empty messages, updates manifest +- `read_agent(session_id, agent_id)` → `Optional[SessionAgent]` +- `update_agent(session_id, session_agent)` — preserves original `created_at` + +**Message operations:** +- `create_message(session_id, agent_id, message)` — appends to message list +- `read_message(session_id, agent_id, message_id)` → `Optional[SessionMessage]` +- `update_message(session_id, agent_id, message)` — preserves original `created_at` +- `list_messages(session_id, agent_id, limit=None, offset=0)` → `List[SessionMessage]` + +**Lifecycle:** +- `close()` — closes DaprClient if owned by this manager + +## State store key schema + +| Key pattern | Contents | +|-------------|----------| +| `{session_id}:session` | Session metadata (JSON) | +| `{session_id}:agents:{agent_id}` | Agent metadata (JSON) | +| `{session_id}:messages:{agent_id}` | Message list: `{"messages": [...]}` (JSON) | +| `{session_id}:manifest` | Agent ID registry: `{"agents": [...]}` (used for cascade deletion) | + +## Dependencies + +- `dapr >= 1.17.0.dev` +- `strands-agents` — Strands agents framework +- `strands-agents-tools` — Strands agent tools +- `python-ulid >= 3.0.0` +- `msgpack-python >= 0.4.5` + +## Testing + +```bash +python -m unittest discover -v ./ext/dapr-ext-strands/tests +``` + +8 test cases using `@mock.patch('dapr.ext.strands.dapr_session_manager.DaprClient')`: +- `test_create_and_read_session`, `test_create_session_raises_if_exists` +- `test_create_and_read_agent`, `test_update_agent_preserves_created_at` +- `test_create_and_read_message`, `test_update_message_preserves_created_at` +- `test_delete_session_deletes_agents_and_messages` (verifies cascade: 6 delete calls for 2 agents) +- `test_close_only_closes_owned_client` + +## Key details + +- **ID validation**: Session IDs and agent IDs are validated via `strands._identifier.validate()` — path separators (`/`, `\`) are rejected. +- **Manifest pattern**: A manifest key tracks all agent IDs per session, enabling cascade deletion without scanning. +- **TTL support**: Optional time-to-live via Dapr state metadata (`ttlInSeconds`). +- **Consistency levels**: Maps to Dapr's `Consistency.eventual` / `Consistency.strong` via `StateOptions`. +- **Client ownership**: The `_owns_client` flag tracks whether `DaprSessionManager` created its own client (via `from_address`) or received one externally. Only owned clients are closed by `close()`. +- **Timestamp preservation**: `update_agent` and `update_message` read the existing record first to preserve the original `created_at` timestamp. +- **All errors are `SessionException`**: All Dapr state operation failures are wrapped in Strands' `SessionException`. diff --git a/ext/dapr-ext-workflow/AGENTS.md b/ext/dapr-ext-workflow/AGENTS.md new file mode 100644 index 00000000..0b3a717f --- /dev/null +++ b/ext/dapr-ext-workflow/AGENTS.md @@ -0,0 +1,232 @@ +# AGENTS.md — dapr-ext-workflow + +The workflow extension is a **major area of active development**. It provides durable workflow orchestration for Python, built on the [durabletask-dapr](https://pypi.org/project/durabletask-dapr/) engine (>= 0.2.0a19). + +## Source layout + +``` +ext/dapr-ext-workflow/ +├── setup.cfg # Deps: dapr, durabletask-dapr +├── setup.py +├── tests/ +│ ├── test_dapr_workflow_context.py # Context method proxying +│ ├── test_workflow_activity_context.py # Activity context properties +│ ├── test_workflow_client.py # Sync client (mock gRPC) +│ ├── test_workflow_client_aio.py # Async client (IsolatedAsyncioTestCase) +│ ├── test_workflow_runtime.py # Registration, decorators, worker readiness +│ └── test_workflow_util.py # Address resolution +└── dapr/ext/workflow/ + ├── __init__.py # Public API exports + ├── workflow_runtime.py # WorkflowRuntime — registration & lifecycle + ├── dapr_workflow_client.py # DaprWorkflowClient (sync) + ├── aio/dapr_workflow_client.py # DaprWorkflowClient (async) + ├── dapr_workflow_context.py # DaprWorkflowContext + when_all/when_any + ├── workflow_context.py # WorkflowContext ABC + ├── workflow_activity_context.py # WorkflowActivityContext wrapper + ├── workflow_state.py # WorkflowState, WorkflowStatus enum + ├── retry_policy.py # RetryPolicy wrapper + ├── util.py # gRPC address resolution + ├── logger/options.py # LoggerOptions + └── logger/logger.py # Logger wrapper +``` + +## Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ User code: @wfr.workflow / @wfr.activity │ +└──────────────────┬───────────────────────────────┘ + │ +┌──────────────────▼───────────────────────────────┐ +│ WorkflowRuntime │ +│ - Decorator-based registration │ +│ - Wraps user functions with context wrappers │ +│ - Manages TaskHubGrpcWorker lifecycle │ +└──────────────────┬───────────────────────────────┘ + │ +┌──────────────────▼───────────────────────────────┐ +│ DaprWorkflowContext / WorkflowActivityContext │ +│ - Proxy wrappers around durabletask contexts │ +│ - Adds Dapr-specific features (app_id, logging) │ +└──────────────────┬───────────────────────────────┘ + │ +┌──────────────────▼───────────────────────────────┐ +│ DaprWorkflowClient (sync) / (async) │ +│ - Schedule, query, pause, resume, terminate │ +│ - Wraps TaskHubGrpcClient │ +└──────────────────┬───────────────────────────────┘ + │ +┌──────────────────▼───────────────────────────────┐ +│ durabletask-dapr (external package) │ +│ - TaskHubGrpcWorker: receives work items │ +│ - TaskHubGrpcClient: manages orchestrations │ +│ - OrchestrationContext / ActivityContext │ +│ - History replay engine (deterministic execution)│ +└──────────────────┬───────────────────────────────┘ + │ + ▼ + Dapr sidecar (gRPC) +``` + +## Public API + +All public symbols are exported from `dapr.ext.workflow`: + +```python +from dapr.ext.workflow import ( + WorkflowRuntime, # Registration & lifecycle (start/shutdown) + DaprWorkflowClient, # Sync client for scheduling/managing workflows + DaprWorkflowContext, # Passed to workflow functions as first arg + WorkflowActivityContext, # Passed to activity functions as first arg + WorkflowState, # Snapshot of a workflow instance's state + WorkflowStatus, # Enum: RUNNING, COMPLETED, FAILED, TERMINATED, PENDING, SUSPENDED + when_all, # Parallel combinator — wait for all tasks + when_any, # Race combinator — wait for first task + alternate_name, # Decorator to set a custom registration name + RetryPolicy, # Retry config for activities/child workflows +) + +# Async client: +from dapr.ext.workflow.aio import DaprWorkflowClient # async variant +``` + +## Key classes + +### WorkflowRuntime (`workflow_runtime.py`) + +The entry point for registration and lifecycle: + +- `register_workflow(fn, *, name=None)` / `@workflow(name=None)` decorator +- `register_activity(fn, *, name=None)` / `@activity(name=None)` decorator +- `register_versioned_workflow(fn, *, name, version_name, is_latest)` / `@versioned_workflow(...)` decorator +- `start()` — starts the gRPC worker, waits for stream readiness +- `shutdown()` — stops the worker +- `wait_for_worker_ready(timeout=30.0)` — polls worker readiness + +Internally wraps user functions: workflow functions get a `DaprWorkflowContext`, activity functions get a `WorkflowActivityContext`. Tracks registration state via `_workflow_registered` / `_activity_registered` attributes on functions to prevent double registration. + +### DaprWorkflowClient (`dapr_workflow_client.py`) + +Client for workflow lifecycle management: + +- `schedule_new_workflow(workflow, *, input, instance_id, start_at, reuse_id_policy)` → returns `instance_id` +- `get_workflow_state(instance_id, *, fetch_payloads=True)` → `Optional[WorkflowState]` +- `wait_for_workflow_start(instance_id, *, fetch_payloads, timeout_in_seconds)` +- `wait_for_workflow_completion(instance_id, *, fetch_payloads, timeout_in_seconds)` +- `raise_workflow_event(instance_id, event_name, *, data)` +- `terminate_workflow(instance_id, *, output, recursive)` +- `pause_workflow(instance_id)` / `resume_workflow(instance_id)` +- `purge_workflow(instance_id, *, recursive)` +- `close()` — close gRPC connection + +Converts gRPC "no such instance exists" errors to `None` returns. The async variant in `aio/` has the same API with `async` methods. + +### DaprWorkflowContext (`dapr_workflow_context.py`) + +Passed to workflow functions as the first argument: + +- `instance_id`, `current_utc_datetime`, `is_replaying` — properties +- `call_activity(activity, *, input, retry_policy, app_id)` → `Task` +- `call_child_workflow(workflow, *, input, instance_id, retry_policy, app_id)` → `Task` +- `create_timer(fire_at)` → `Task` (accepts `datetime` or `timedelta`) +- `wait_for_external_event(name)` → `Task` +- `set_custom_status(status)` / `continue_as_new(new_input, *, save_events)` + +Module-level functions: +- `when_all(tasks)` → `WhenAllTask` — wait for all tasks to complete +- `when_any(tasks)` → `WhenAnyTask` — wait for first task to complete + +### WorkflowActivityContext (`workflow_activity_context.py`) + +Passed to activity functions as the first argument: + +- `workflow_id` — the parent workflow's instance ID +- `task_id` — unique ID for this activity invocation + +### RetryPolicy (`retry_policy.py`) + +Retry configuration for activities and child workflows: + +- `first_retry_interval: timedelta` — initial retry delay +- `max_number_of_attempts: int` — maximum retries (>= 1) +- `backoff_coefficient: Optional[float]` — exponential backoff multiplier (>= 1, default 1.0) +- `max_retry_interval: Optional[timedelta]` — maximum delay between retries +- `retry_timeout: Optional[timedelta]` — total time budget for retries + +### WorkflowState / WorkflowStatus (`workflow_state.py`) + +- `WorkflowStatus` enum: `UNKNOWN`, `RUNNING`, `COMPLETED`, `FAILED`, `TERMINATED`, `PENDING`, `SUSPENDED`, `STALLED` +- `WorkflowState`: wraps `OrchestrationState` with properties `instance_id`, `name`, `runtime_status`, `created_at`, `last_updated_at`, `serialized_input`, `serialized_output`, `serialized_custom_status`, `failure_details` + +## How workflows execute + +1. **Registration**: User decorates functions with `@wfr.workflow` / `@wfr.activity`. The runtime wraps them and stores them in the durabletask worker's registry. +2. **Startup**: `wfr.start()` opens a gRPC stream to the Dapr sidecar. The worker polls for work items. +3. **Scheduling**: Client calls `schedule_new_workflow(fn, input=...)`. The function's name (or `_dapr_alternate_name`) is sent to the backend. +4. **Execution**: The durabletask engine dispatches work items. Workflow functions are Python **generators** that `yield` tasks (activity calls, timers, child workflows). The engine records history; on replay, yielded tasks return cached results without re-executing. +5. **Determinism**: Workflows must be deterministic — no random, no wall-clock time, no I/O. Use `ctx.current_utc_datetime` instead of `datetime.now()`. Use `ctx.is_replaying` to guard side effects like logging. +6. **Completion**: Client polls via `wait_for_workflow_completion()` or `get_workflow_state()`. + +## Naming and cross-app calls + +- Default name: function's `__name__` +- Custom name: `@wfr.workflow(name='my_name')` or `@alternate_name('my_name')` +- Stored as `_dapr_alternate_name` attribute on the function +- Cross-app: pass activity/workflow name as a string + `app_id` parameter: + ```python + result = yield ctx.call_activity('remote_activity', input=data, app_id='other-app') + ``` + +## Relationship to core SDK's DaprClient + +The core `DaprClient` in `dapr/clients/` has workflow methods (`start_workflow`, `get_workflow`, `pause_workflow`, etc.) but **these are deprecated**. The `examples/demo_workflow/` example demonstrates this old pattern with a deprecation notice. All new workflow code should use `DaprWorkflowClient` from this extension instead. + +## Examples + +Two example directories exercise workflows: + +- **`examples/workflow/`** — primary, comprehensive examples: + - `simple.py` — activities, retries, child workflows, external events, pause/resume + - `task_chaining.py` — sequential activity chaining with error handling + - `fan_out_fan_in.py` — parallel execution with `when_all()` + - `human_approval.py` — external event waiting with timeouts + - `monitor.py` — eternal polling workflow with `continue_as_new()` + - `child_workflow.py` — child workflow orchestration + - `cross-app1.py`, `cross-app2.py`, `cross-app3.py` — cross-app calls + - `versioning.py` — workflow versioning with `is_patched()` + - `simple_aio_client.py` — async client variant + +- **`examples/demo_workflow/`** — legacy example using deprecated `DaprClient` workflow methods + +## Testing + +Unit tests use mocks to simulate the durabletask layer (no Dapr runtime needed): + +```bash +python -m unittest discover -v ./ext/dapr-ext-workflow/tests +``` + +Test patterns: +- **Mock classes**: `FakeTaskHubGrpcClient`, `FakeAsyncTaskHubGrpcClient`, `FakeOrchestrationContext`, `FakeActivityContext` — simulate durabletask responses without a real gRPC connection +- **Registration tests**: verify decorator behavior, custom naming, duplicate prevention +- **Client tests**: verify schedule/query/pause/resume/terminate round-trips +- **Async tests**: use `unittest.IsolatedAsyncioTestCase` +- **Worker readiness tests**: verify `start()` waits for gRPC stream, timeout behavior + +## Environment variables + +The extension resolves the Dapr sidecar address from (in order of precedence): +- Constructor `host`/`port` parameters +- `DAPR_GRPC_ENDPOINT` — full gRPC endpoint (overrides host:port) +- `DAPR_RUNTIME_HOST` (default `127.0.0.1`) + `DAPR_GRPC_PORT` (default `50001`) +- `DAPR_API_TOKEN` — optional authentication token (from `dapr.conf.settings`) + +## Gotchas + +- **Sync + async parity**: The sync client (`dapr_workflow_client.py`) and async client (`aio/dapr_workflow_client.py`) must stay in sync. Any new client method needs both variants. +- **Determinism**: Workflow functions are replayed from history. Non-deterministic code (random, datetime.now, I/O) inside a workflow function will break replay. Only activities can have side effects. +- **Generator pattern**: Workflow functions are generators that `yield` tasks. The return value is the workflow output. Do not use `await` — use `yield`. +- **Naming matters**: The name used to register a workflow/activity must match the name used to schedule it. Custom names via `@alternate_name` or `name=` parameter are stored as function attributes. +- **durabletask-dapr is external**: The underlying engine is not in this repo. The minimum version is pinned in `setup.cfg`. +- **Deprecated core methods**: Do not add new workflow functionality to `DaprClient` in the core SDK. Use the extension's `DaprWorkflowClient` instead. +- **Double registration guard**: Functions decorated with `@wfr.workflow` or `@wfr.activity` get `_workflow_registered` / `_activity_registered` attributes set to `True`. Attempting to re-register raises an error. diff --git a/ext/flask_dapr/AGENTS.md b/ext/flask_dapr/AGENTS.md new file mode 100644 index 00000000..91ca76e3 --- /dev/null +++ b/ext/flask_dapr/AGENTS.md @@ -0,0 +1,88 @@ +# AGENTS.md — flask_dapr + +The Flask extension provides two integration classes for building Dapr applications with [Flask](https://flask.palletsprojects.com/): `DaprApp` for pub/sub subscriptions and `DaprActor` for actor hosting. It mirrors the FastAPI extension's functionality but uses Flask's routing and request model. + +## Source layout + +``` +ext/flask_dapr/ +├── setup.cfg # Deps: dapr, Flask +├── setup.py +├── tests/ +│ └── test_app.py # DaprApp pub/sub tests +└── flask_dapr/ + ├── __init__.py # Exports: DaprApp, DaprActor + ├── app.py # DaprApp — pub/sub subscription handler + ├── actor.py # DaprActor — actor runtime HTTP adapter + └── version.py +``` + +Note: Unlike other extensions, this package uses `flask_dapr` as its top-level namespace (not `dapr.ext.*`). + +## Public API + +```python +from flask_dapr import DaprApp, DaprActor +``` + +### DaprApp (`app.py`) + +Wraps a Flask instance to add Dapr pub/sub event handling. + +```python +app = Flask('myapp') +dapr_app = DaprApp(app) + +@dapr_app.subscribe(pubsub='pubsub', topic='orders', route='/handle-order', + metadata={}, dead_letter_topic=None) +def handle_order(): + event_data = request.json + return 'ok' +``` + +- Auto-registers `GET /dapr/subscribe` endpoint +- Each `@subscribe` registers a POST route via `add_url_rule()` +- Default route: `/events/{pubsub}/{topic}` +- Handlers use Flask's `request` context (not function arguments) + +### DaprActor (`actor.py`) + +Integrates Dapr's actor runtime with Flask. + +```python +app = Flask('actor_service') +dapr_actor = DaprActor(app) +dapr_actor.register_actor(MyActorClass) +``` + +Auto-registers six endpoints (same as FastAPI extension): +- `GET /healthz`, `GET /dapr/config` +- `DELETE /actors/{type}/{id}` — deactivation +- `PUT /actors/{type}/{id}/method/{method}` — method invocation +- `PUT /actors/{type}/{id}/method/timer/{timer}`, `PUT /actors/{type}/{id}/method/remind/{reminder}` + +**Async bridging**: Uses `asyncio.run()` to bridge Flask's synchronous request handling with the async `ActorRuntime`. Each handler call spawns a new event loop. + +**Response wrapping** (`wrap_response`): Same pattern as FastAPI extension — string → JSON, bytes → raw, dict → JSON. Error responses include `errorCode` field. + +## Dependencies + +- `dapr >= 1.17.0.dev` +- `Flask >= 1.1` + +## Testing + +```bash +python -m unittest discover -v ./ext/flask_dapr/tests +``` + +- `test_app.py` — uses Flask `test_client()` for HTTP-level testing: subscription registration, custom routes, metadata, dead letter topics + +Note: No tests for `DaprActor` in this extension (unlike FastAPI which tests `_wrap_response`). + +## Key details + +- **Synchronous + asyncio bridge**: Flask is sync, but `ActorRuntime` is async. The extension uses `asyncio.run()` for each actor operation. +- **Different namespace**: This is `flask_dapr`, not `dapr.ext.flask`. Import as `from flask_dapr import DaprApp, DaprActor`. +- **Similar to FastAPI extension**: The two extensions have nearly identical functionality. When modifying one, check if the same change is needed in the other. +- **Reentrancy ID**: Actor method invocation extracts `Dapr-Reentrancy-Id` header, same as FastAPI extension.