diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 928b275..f5c0133 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -28,8 +28,7 @@ RUN DEBIAN_FRONTEND=noninteractive \ # Python and uv installation USER $USER ARG HOME="/home/$USER" -ARG PYTHON_VERSION=3.9 -# ARG PYTHON_VERSION=3.10 +ARG PYTHON_VERSION=3.10 ENV PYENV_ROOT="${HOME}/.pyenv" ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${HOME}/.local/bin:$PATH" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 12deb9e..430e710 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,12 +7,14 @@ "rabbitmq:15672" ], "containerEnv": { - "AMQP_URI": "amqp://guest:guest@rabbitmq/" + "AMQP_URI": "amqp://guest:guest@rabbitmq/", + "PYTEST_AMQP_URI": "amqp://guest:guest@rabbitmq/" }, "customizations": { "vscode": { "extensions": [ "ms-python.python", + "ms-pyright.pyright", "njpwerner.autodocstring", "tamasfe.even-better-toml", "ms-python.mypy-type-checker", @@ -26,6 +28,21 @@ ], "settings": { "python.defaultInterpreterPath": "/workspaces/${localWorkspaceFolderBasename}/.venv/bin/python", + "python.analysis.typeCheckingMode": "strict", + "python.analysis.autoImportCompletions": true, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.autoSearchPaths": true, + "python.analysis.extraPaths": [ + "./src" + ], + "python.analysis.include": [ + "src/**", + "*.py" + ], + "python.analysis.stubPath": "./src", + "python.linting.enabled": true, + "python.linting.pylintEnabled": false, + "python.linting.mypyEnabled": false, "yaml.schemas": { "https://asyncapi.com/schema-store/3.0.0-without-$id.json": [ "file:///workspaces/asyncapi-python/examples/*.yaml" @@ -40,9 +57,17 @@ "amqp", "venv", "jsonschema", - "fanout" + "fanout", + "anypointmq", + "googlepubsub", + "ibmmq", + "mqtt" ] } } + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": {} } } \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 925970d..b60003e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ permissions: on: push: - tags: "v*.*.*" + tags: + - "v*.*.*" jobs: test: @@ -25,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" - name: Install UV uses: astral-sh/setup-uv@v3 @@ -56,7 +57,7 @@ jobs: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: true - prerelease: false + prerelease: ${{ contains(github.ref, 'rc') }} - name: Build sdist, wheel, and pex run: > diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 952b934..38d1695 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,17 +9,67 @@ on: workflow_call: jobs: + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install UV + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check code formatting with Black + run: uv run black --check --diff --color src/ tests/ + + pyright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install UV + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run PyRight type checking + run: | + uv run pyright src/asyncapi_python + uv run pyright src/asyncapi_python_codegen + test: runs-on: ubuntu-latest strategy: matrix: - python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python_version: ["3.10", "3.11", "3.12", "3.13"] services: rabbitmq: image: rabbitmq:3.13.6 - ports: ["5672"] + ports: + - 5672:5672 + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -37,7 +87,12 @@ jobs: - name: Install dependencies run: uv sync --all-extras + - name: Wait for RabbitMQ to be ready + run: | + timeout 60s bash -c 'until nc -z localhost 5672; do sleep 1; done' + sleep 5 # Additional wait to ensure RabbitMQ is fully initialized + - name: Run tests env: - AMQP_URI: amqp://localhost:${{ job.services.rabbitmq.ports[5672] }} + PYTEST_AMQP_URI: amqp://guest:guest@localhost:5672/ run: uv run pytest diff --git a/README.md b/README.md index 3f8077b..6382006 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Easily generate type-safe and async Python applications from AsyncAPI 3 specific ## Requirements -- `python>=3.9` +- `python>=3.10` - `pydantic>=2` - `pytz` - For `codegen` extra diff --git a/examples/amqp-pub-sub/.gitignore b/examples/amqp-pub-sub/.gitignore index 2132cb0..ffd9116 100644 --- a/examples/amqp-pub-sub/.gitignore +++ b/examples/amqp-pub-sub/.gitignore @@ -1,2 +1,6 @@ +# Generated code directories publisher/ subscriber/ + +# Virtual environment +.venv/ \ No newline at end of file diff --git a/examples/amqp-pub-sub/Makefile b/examples/amqp-pub-sub/Makefile index 56ebe69..2c0f47f 100644 --- a/examples/amqp-pub-sub/Makefile +++ b/examples/amqp-pub-sub/Makefile @@ -9,19 +9,19 @@ venv: python3 -m venv $(VENV_NAME) install: - $(PIP) install asyncapi-python[amqp,codegen]==$(PACKAGE_VERSION) + $(PIP) install -e ../../[amqp,codegen] generate: $(CODEGEN) spec/subscriber.asyncapi.yaml subscriber --force $(CODEGEN) spec/publisher.asyncapi.yaml publisher --force client: - $(PYTHON) main-subscriber.py + $(PYTHON) main-publisher.py server: - $(PYTHON) main-publisher.py + $(PYTHON) main-subscriber.py clean: rm -rf $(VENV_NAME) -.PHONY: client server \ No newline at end of file +.PHONY: client server diff --git a/examples/amqp-pub-sub/main-publisher.py b/examples/amqp-pub-sub/main-publisher.py index e3b93ba..a2adcf0 100644 --- a/examples/amqp-pub-sub/main-publisher.py +++ b/examples/amqp-pub-sub/main-publisher.py @@ -1,21 +1,23 @@ import asyncio from os import environ from publisher import Application -from publisher.messages import Ping +from publisher.messages.json import Ping +from asyncapi_python.contrib.wire.amqp import AmqpWire AMQP_URI = environ.get("AMQP_URI", "amqp://guest:guest@localhost") NUM_REQUESTS = 3 -app = Application(AMQP_URI) +app = Application(AmqpWire(AMQP_URI)) async def main() -> None: - await app.start(blocking=False) + await app.start() for _ in range(NUM_REQUESTS): req = Ping() print(f"Sending request: {req}") - await app.producer.application.ping(req) + await app.producer.application_ping(req) + await app.stop() if __name__ == "__main__": diff --git a/examples/amqp-pub-sub/main-subscriber.py b/examples/amqp-pub-sub/main-subscriber.py index c976916..383f79b 100644 --- a/examples/amqp-pub-sub/main-subscriber.py +++ b/examples/amqp-pub-sub/main-subscriber.py @@ -2,17 +2,18 @@ from os import environ from sys import exit from subscriber import Application -from subscriber.messages import Ping +from subscriber.messages.json import Ping +from asyncapi_python.contrib.wire.amqp import AmqpWire AMQP_URI = environ.get("AMQP_URI", "amqp://guest:guest@localhost") MAX_REQUESTS = 3 request_count = 0 -app = Application(AMQP_URI) +app = Application(AmqpWire(AMQP_URI)) -@app.consumer.application.ping +@app.consumer.application_ping async def handle_ping_request(msg: Ping) -> None: global request_count print(f"Handling request: {msg}") @@ -24,13 +25,14 @@ async def termination_handler(): while True: await asyncio.sleep(1) if request_count >= MAX_REQUESTS: + await app.stop() exit(0) async def main() -> None: - app_handler = app.start(blocking=True) - term_handler = termination_handler() - await asyncio.gather(app_handler, term_handler) + await app.start() + # Keep running until termination_handler exits + await termination_handler() if __name__ == "__main__": diff --git a/examples/amqp-rpc/.gitignore b/examples/amqp-rpc/.gitignore index 1703256..e731135 100644 --- a/examples/amqp-rpc/.gitignore +++ b/examples/amqp-rpc/.gitignore @@ -1,2 +1,6 @@ +# Generated code directories client/ server/ + +# Virtual environment +.venv/ \ No newline at end of file diff --git a/examples/amqp-rpc/Makefile b/examples/amqp-rpc/Makefile index f9dc3c3..80f497b 100644 --- a/examples/amqp-rpc/Makefile +++ b/examples/amqp-rpc/Makefile @@ -9,7 +9,7 @@ venv: python3 -m venv $(VENV_NAME) install: - $(PIP) install asyncapi-python[amqp,codegen]==$(PACKAGE_VERSION) + $(PIP) install -e ../../[amqp,codegen] generate: $(CODEGEN) spec/client.asyncapi.yaml client --force @@ -24,4 +24,4 @@ server: clean: rm -rf $(VENV_NAME) -.PHONY: client server \ No newline at end of file +.PHONY: client server diff --git a/examples/amqp-rpc/main-client.py b/examples/amqp-rpc/main-client.py index 9a39473..83b9fb7 100644 --- a/examples/amqp-rpc/main-client.py +++ b/examples/amqp-rpc/main-client.py @@ -1,22 +1,24 @@ import asyncio from os import environ from client import Application -from client.messages import Ping, Pong +from client.messages.json import Ping, Pong +from asyncapi_python.contrib.wire.amqp import AmqpWire AMQP_URI = environ.get("AMQP_URI", "amqp://guest:guest@localhost") NUM_REQUESTS = 3 -app = Application(AMQP_URI) +app = Application(AmqpWire(AMQP_URI)) async def main() -> None: - await app.start(blocking=False) + await app.start() for _ in range(NUM_REQUESTS): req = Ping() print(f"Sending request: {req}") - res: Pong = await app.producer.ping_request(req) + res: Pong = await app.producer.pingrequest(req) print(f"Got response: {res}") + await app.stop() if __name__ == "__main__": diff --git a/examples/amqp-rpc/main-server.py b/examples/amqp-rpc/main-server.py index 6d6557f..da9f8f8 100644 --- a/examples/amqp-rpc/main-server.py +++ b/examples/amqp-rpc/main-server.py @@ -2,17 +2,18 @@ from os import environ from sys import exit from server import Application -from server.messages import Ping, Pong +from server.messages.json import Ping, Pong +from asyncapi_python.contrib.wire.amqp import AmqpWire AMQP_URI = environ.get("AMQP_URI", "amqp://guest:guest@localhost") MAX_REQUESTS = 3 request_count = 0 -app = Application(AMQP_URI) +app = Application(AmqpWire(AMQP_URI)) -@app.consumer.on_ping_request +@app.consumer.onpingrequest async def handle_ping_request(msg: Ping) -> Pong: global request_count print(f"Handling request: {msg}") @@ -27,13 +28,14 @@ async def termination_handler(): while True: await asyncio.sleep(1) if request_count >= MAX_REQUESTS: + await app.stop() exit(0) async def main() -> None: - app_handler = app.start(blocking=True) - term_handler = termination_handler() - await asyncio.gather(app_handler, term_handler) + await app.start() + # Keep running until termination_handler exits + await termination_handler() if __name__ == "__main__": diff --git a/examples/amqp-work-queue/.gitignore b/examples/amqp-work-queue/.gitignore new file mode 100644 index 0000000..3db97d1 --- /dev/null +++ b/examples/amqp-work-queue/.gitignore @@ -0,0 +1,6 @@ +# Generated code directories +producer/ +worker/ + +# Virtual environment +.venv/ \ No newline at end of file diff --git a/examples/amqp-work-queue/Makefile b/examples/amqp-work-queue/Makefile new file mode 100644 index 0000000..114a550 --- /dev/null +++ b/examples/amqp-work-queue/Makefile @@ -0,0 +1,36 @@ +VENV_NAME := .venv +PYTHON := $(VENV_NAME)/bin/python +CODEGEN := $(VENV_NAME)/bin/asyncapi-python-codegen +PIP := $(VENV_NAME)/bin/pip +PACKAGE_VERSION := 0.1.0 + + +venv: + python3 -m venv $(VENV_NAME) + +install: + $(PIP) install -e ../../[amqp,codegen] + +generate: + $(CODEGEN) spec/producer.asyncapi.yaml producer --force + $(CODEGEN) spec/worker.asyncapi.yaml worker --force + +producer: + $(PYTHON) main-producer.py + +worker1: + $(PYTHON) main-worker.py worker1 + +worker2: + $(PYTHON) main-worker.py worker2 + +worker3: + $(PYTHON) main-worker.py worker3 + +test-workqueue: + $(PYTHON) test_workqueue.py + +clean: + rm -rf $(VENV_NAME) + +.PHONY: producer worker1 worker2 worker3 test-workqueue \ No newline at end of file diff --git a/examples/amqp-work-queue/README.md b/examples/amqp-work-queue/README.md index ea7d700..5ebd887 100644 --- a/examples/amqp-work-queue/README.md +++ b/examples/amqp-work-queue/README.md @@ -1,3 +1,76 @@ -# Asyncapi-Python Work Queue Example +# AMQP Work Queue Example -This example is a work in progress +This example demonstrates the **Work Queue** (Task Queue) messaging pattern using AsyncAPI Python. In this pattern, tasks are distributed among multiple workers, with each task being processed by exactly one worker. + +## Pattern Characteristics + +- **1:N Distribution**: One producer sends tasks to multiple workers +- **Load Balancing**: Tasks are automatically distributed among available workers +- **Reliability**: Each task is delivered to exactly one worker (no duplication) +- **Scalability**: Add more workers to handle increased load + +## Architecture + +``` +Producer → [Task Queue] → Worker 1 + ├→ Worker 2 + └→ Worker 3 +``` + +- **Producer**: Sends tasks to a durable queue +- **Queue**: AMQP queue that holds tasks until processed +- **Workers**: Multiple instances that compete for tasks + +## Files + +- `spec/common.asyncapi.yaml` - Shared channel and message definitions +- `spec/producer.asyncapi.yaml` - Task producer specification +- `spec/worker.asyncapi.yaml` - Task worker specification +- `main-producer.py` - Task producer implementation +- `main-worker.py` - Worker implementation (accepts worker ID argument) +- `test_workqueue.py` - Automated test demonstrating work queue behavior + +## Quick Start + +1. **Setup environment**: + ```bash + make venv install generate + ``` + +2. **Run the automated test**: + ```bash + make test-workqueue + ``` + +3. **Manual testing**: Start multiple workers in separate terminals, then run producer: + ```bash + # Terminal 1 + make worker1 + + # Terminal 2 + make worker2 + + # Terminal 3 + make worker3 + + # Terminal 4 - Send tasks + make producer + ``` + +## Expected Behavior + +- ✅ Each task is processed by exactly one worker +- ✅ Tasks are distributed among available workers +- ✅ Workers can be added/removed dynamically +- ✅ Queue persists tasks if no workers are available +- ✅ Failed tasks can be retried (depending on configuration) + +## AMQP Configuration + +The work queue uses: +- **Queue Type**: Durable, non-exclusive queue +- **Routing**: Direct routing to named queue +- **Delivery**: Round-robin distribution among consumers +- **Acknowledgment**: Manual ACK for reliability + +This pattern is ideal for background job processing, image processing pipelines, email sending, and other scalable task processing scenarios. \ No newline at end of file diff --git a/examples/amqp-work-queue/main-producer.py b/examples/amqp-work-queue/main-producer.py new file mode 100644 index 0000000..0f3237b --- /dev/null +++ b/examples/amqp-work-queue/main-producer.py @@ -0,0 +1,46 @@ +import asyncio +import uuid +from datetime import datetime +from os import environ +from producer import Application +from producer.messages.json import Task +from asyncapi_python.contrib.wire.amqp import AmqpWire + + +AMQP_URI = environ.get("AMQP_URI", "amqp://guest:guest@localhost") +NUM_TASKS = 10 + +app = Application(AmqpWire(AMQP_URI)) + + +async def main() -> None: + print(f"Starting task producer - will create {NUM_TASKS} tasks") + + await app.start() + + # Produce tasks + for i in range(NUM_TASKS): + task_id = str(uuid.uuid4())[:8] + task = Task( + id=task_id, + payload={ + "task_number": i + 1, + "description": f"Process task {i + 1}", + "data": f"Important work item #{i + 1}", + "processing_time": 2 + (i % 3), # Vary processing time + }, + created_at=datetime.utcnow().isoformat(), + ) + + print(f"📤 Sending task {i + 1}/{NUM_TASKS} (ID: {task_id})") + await app.producer.task_send(task) + + # Small delay to see distribution + await asyncio.sleep(0.5) + + print(f"✅ All {NUM_TASKS} tasks sent to queue") + await app.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/amqp-work-queue/main-worker.py b/examples/amqp-work-queue/main-worker.py new file mode 100644 index 0000000..87155de --- /dev/null +++ b/examples/amqp-work-queue/main-worker.py @@ -0,0 +1,49 @@ +import asyncio +import sys +from os import environ +from worker import Application +from worker.messages.json import Task +from asyncapi_python.contrib.wire.amqp import AmqpWire + + +AMQP_URI = environ.get("AMQP_URI", "amqp://guest:guest@localhost") + +# Get worker ID from command line argument or default to "worker" +worker_id = sys.argv[1] if len(sys.argv) > 1 else "worker" + +app = Application(AmqpWire(AMQP_URI)) + + +@app.consumer.task_process +async def handle_task(task: Task) -> None: + print( + f"🔨 [{worker_id}] Processing task {task.id}: {task.payload.get('description', 'N/A')}" + ) + + # Simulate processing time based on task data + processing_time = task.payload.get("processing_time", 2) + await asyncio.sleep(processing_time) + + task_number = task.payload.get("task_number", "?") + print( + f"✅ [{worker_id}] Completed task {task.id} (#{task_number}) - took {processing_time}s" + ) + + +async def main() -> None: + print(f"🚀 Starting worker '{worker_id}' - waiting for tasks...") + + await app.start() + + # Keep worker running + try: + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + print(f"\n🛑 [{worker_id}] Stopping worker...") + finally: + await app.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/amqp-work-queue/spec/common.asyncapi.yaml b/examples/amqp-work-queue/spec/common.asyncapi.yaml new file mode 100644 index 0000000..2d8983b --- /dev/null +++ b/examples/amqp-work-queue/spec/common.asyncapi.yaml @@ -0,0 +1,37 @@ +asyncapi: "3.0.0" +info: + title: Work Queue Common Definitions + version: 1.0.0 + +channels: + /task/queue: + address: task.queue + title: Task Work Queue + description: Queue for distributing tasks among workers + bindings: + amqp: + queue: + name: task-queue + durable: true + exclusive: false + autoDelete: false + messages: + Task: + name: Task + title: Work Task + payload: + type: object + properties: + id: + type: string + description: Unique task identifier + payload: + type: object + description: Task data + created_at: + type: string + format: date-time + description: Task creation timestamp + required: + - id + - payload \ No newline at end of file diff --git a/examples/amqp-work-queue/spec/producer.asyncapi.yaml b/examples/amqp-work-queue/spec/producer.asyncapi.yaml new file mode 100644 index 0000000..3410aa4 --- /dev/null +++ b/examples/amqp-work-queue/spec/producer.asyncapi.yaml @@ -0,0 +1,12 @@ +asyncapi: "3.0.0" +info: + title: Task Producer for Work Queue + version: 1.0.0 + description: Produces tasks for the work queue + +operations: + /task/send: + action: send + channel: { $ref: "./common.asyncapi.yaml#/channels/~1task~1queue" } + messages: + - { $ref: "./common.asyncapi.yaml#/channels/~1task~1queue/messages/Task" } \ No newline at end of file diff --git a/examples/amqp-work-queue/spec/worker.asyncapi.yaml b/examples/amqp-work-queue/spec/worker.asyncapi.yaml new file mode 100644 index 0000000..4caf802 --- /dev/null +++ b/examples/amqp-work-queue/spec/worker.asyncapi.yaml @@ -0,0 +1,12 @@ +asyncapi: "3.0.0" +info: + title: Task Worker for Work Queue + version: 1.0.0 + description: Processes tasks from the work queue + +operations: + /task/process: + action: receive + channel: { $ref: "./common.asyncapi.yaml#/channels/~1task~1queue" } + messages: + - { $ref: "./common.asyncapi.yaml#/channels/~1task~1queue/messages/Task" } \ No newline at end of file diff --git a/examples/specs/financial-trading-system.yaml b/examples/specs/financial-trading-system.yaml new file mode 100644 index 0000000..2191479 --- /dev/null +++ b/examples/specs/financial-trading-system.yaml @@ -0,0 +1,1367 @@ +asyncapi: 3.0.0 +info: + title: Extreme Financial Trading System + version: 2.1.0 + description: | + Ultra-complex financial trading system with real-time market data, + algorithmic trading, risk management, and multi-asset portfolio management. + Tests every edge case of AsyncAPI code generation. + license: + name: MIT + url: https://opensource.org/licenses/MIT + contact: + name: Trading Platform Team + email: trading@example.com + url: https://example.com/trading + +defaultContentType: application/json + +servers: + production: + host: trading.example.com + protocol: amqp + description: Production AMQP cluster + variables: + environment: + enum: [prod, staging, dev] + default: prod + bindings: + amqp: + heartbeat: 60 + frameMax: 131072 + + kafka-stream: + host: kafka.trading.com:9092 + protocol: kafka + description: High-throughput market data stream + + websocket-feed: + host: ws.trading.com + protocol: ws + description: Real-time WebSocket data feed + +channels: + # Complex parameterized channels with multiple variables + market.data.{exchange}.{symbol}.{timeframe}: + address: market.data.{exchange}.{symbol}.{timeframe} + description: Real-time market data feed for specific instruments + parameters: + exchange: + description: Exchange identifier + enum: [NYSE, NASDAQ, LSE, TSE, HKEX] + examples: + - NYSE + - NASDAQ + symbol: + description: Trading symbol + pattern: '^[A-Z]{1,5}$' + examples: + - AAPL + - GOOGL + timeframe: + description: Data timeframe + enum: [1m, 5m, 15m, 1h, 4h, 1d] + default: 1m + messages: + marketTick: + $ref: '#/components/messages/MarketTick' + marketDepth: + $ref: '#/components/messages/MarketDepth' + tradeExecution: + $ref: '#/components/messages/TradeExecution' + bindings: + amqp: + type: routingKey + exchange: + name: market_data + type: topic + durable: true + autoDelete: false + vhost: /trading + + # Queue-based order processing + orders.processing.{region}: + address: orders.processing.{region} + description: Regional order processing queues + parameters: + region: + description: Trading region + enum: [US, EU, ASIA, LATAM] + examples: + - US + - EU + messages: + orderSubmitted: + $ref: '#/components/messages/OrderSubmitted' + orderFilled: + $ref: '#/components/messages/OrderFilled' + orderRejected: + $ref: '#/components/messages/OrderRejected' + bindings: + amqp: + type: queue + queue: + name: order_processing_{region} + durable: true + exclusive: false + autoDelete: false + arguments: + x-max-priority: 10 + x-message-ttl: 300000 + + # Risk management alerts + risk.alerts.{severity}.{portfolio}: + address: risk.alerts.{severity}.{portfolio} + description: Risk management alerts by severity and portfolio + parameters: + severity: + description: Alert severity level + enum: [LOW, MEDIUM, HIGH, CRITICAL] + default: MEDIUM + portfolio: + description: Portfolio identifier + pattern: '^[A-Z0-9]{8}$' + examples: + - TECH0001 + - BOND0042 + messages: + riskAlert: + $ref: '#/components/messages/RiskAlert' + marginCall: + $ref: '#/components/messages/MarginCall' + positionLimit: + $ref: '#/components/messages/PositionLimit' + bindings: + amqp: + type: routingKey + exchange: + name: risk_management + type: direct + durable: true + vhost: /risk + + # High-frequency trading data + hft.signals.{strategy}.{asset_class}: + address: hft.signals.{strategy}.{asset_class} + description: High-frequency trading signals + parameters: + strategy: + description: Trading strategy identifier + enum: [momentum, arbitrage, mean_reversion, pairs_trading, market_making] + asset_class: + description: Asset class + enum: [equity, fixed_income, fx, commodity, crypto] + messages: + buySignal: + $ref: '#/components/messages/BuySignal' + sellSignal: + $ref: '#/components/messages/SellSignal' + hedgeSignal: + $ref: '#/components/messages/HedgeSignal' + bindings: + amqp: + type: routingKey + exchange: + name: hft_signals + type: topic + durable: false + autoDelete: true + arguments: + x-max-length: 100000 + + # Portfolio analytics + portfolio.analytics.{client_id}: + address: portfolio.analytics.{client_id} + description: Client-specific portfolio analytics + parameters: + client_id: + description: Client identifier + pattern: '^CLIENT_[0-9]{8}$' + examples: + - CLIENT_12345678 + messages: + portfolioValuation: + $ref: '#/components/messages/PortfolioValuation' + performanceReport: + $ref: '#/components/messages/PerformanceReport' + riskMetrics: + $ref: '#/components/messages/RiskMetrics' + bindings: + amqp: + type: queue + queue: + name: analytics_{client_id} + durable: true + exclusive: true + autoDelete: true + + # Regulatory reporting + regulatory.reports.{jurisdiction}.{report_type}: + address: regulatory.reports.{jurisdiction}.{report_type} + description: Regulatory reporting by jurisdiction and type + parameters: + jurisdiction: + description: Regulatory jurisdiction + enum: [SEC, FINRA, FCA, ESMA, JFSA, ASIC] + report_type: + description: Type of regulatory report + enum: [trade_report, position_report, risk_report, client_report] + messages: + regulatoryReport: + $ref: '#/components/messages/RegulatoryReport' + bindings: + amqp: + type: queue + queue: + name: regulatory_{jurisdiction}_{report_type} + durable: true + exclusive: false + autoDelete: false + arguments: + x-dead-letter-exchange: dlx_regulatory + x-max-retries: 3 + +operations: + # Market data operations + market.data.subscribe: + action: receive + channel: + $ref: '#/channels/market.data.{exchange}.{symbol}.{timeframe}' + summary: Subscribe to real-time market data + description: Receive real-time market data including ticks, depth, and trade executions + tags: + - name: market-data + - name: real-time + bindings: + amqp: + ack: true + deliveryMode: 1 # Non-persistent for real-time data + priority: 5 + + market.data.publish: + action: send + channel: + $ref: '#/channels/market.data.{exchange}.{symbol}.{timeframe}' + summary: Publish market data updates + description: Publish real-time market data to subscribers + tags: + - name: market-data + - name: publishing + + # Order management operations + orders.submit: + action: send + channel: + $ref: '#/channels/orders.processing.{region}' + summary: Submit trading order + description: Submit a new trading order to the processing queue + reply: + address: orders.responses.{correlation_id} + channel: + description: Order processing response + messages: + orderAck: + $ref: '#/components/messages/OrderAcknowledgment' + orderReject: + $ref: '#/components/messages/OrderRejection' + tags: + - name: orders + - name: trading + + orders.process: + action: receive + channel: + $ref: '#/channels/orders.processing.{region}' + summary: Process incoming orders + description: Process orders from the regional queues + bindings: + amqp: + ack: true + deliveryMode: 2 # Persistent for orders + priority: 8 + + # Risk management operations + risk.monitor: + action: receive + channel: + $ref: '#/channels/risk.alerts.{severity}.{portfolio}' + summary: Monitor risk alerts + description: Monitor and respond to risk management alerts + tags: + - name: risk-management + - name: monitoring + + risk.alert.send: + action: send + channel: + $ref: '#/channels/risk.alerts.{severity}.{portfolio}' + summary: Send risk alert + description: Send risk management alerts to monitoring systems + + # High-frequency trading operations + hft.signals.receive: + action: receive + channel: + $ref: '#/channels/hft.signals.{strategy}.{asset_class}' + summary: Receive HFT signals + description: Receive high-frequency trading signals for automated execution + bindings: + amqp: + ack: false # No ack for ultra-low latency + deliveryMode: 1 + + hft.signals.generate: + action: send + channel: + $ref: '#/channels/hft.signals.{strategy}.{asset_class}' + summary: Generate HFT signals + description: Generate and broadcast high-frequency trading signals + + # Portfolio analytics operations + portfolio.analyze: + action: receive + channel: + $ref: '#/channels/portfolio.analytics.{client_id}' + summary: Analyze client portfolios + description: Perform comprehensive portfolio analysis for clients + + portfolio.report.generate: + action: send + channel: + $ref: '#/channels/portfolio.analytics.{client_id}' + summary: Generate portfolio reports + description: Generate and send portfolio analysis reports + + # Regulatory reporting operations + regulatory.report.submit: + action: send + channel: + $ref: '#/channels/regulatory.reports.{jurisdiction}.{report_type}' + summary: Submit regulatory report + description: Submit regulatory reports to appropriate authorities + bindings: + amqp: + ack: true + deliveryMode: 2 # Must be persistent for regulatory data + mandatory: true + priority: 10 + + regulatory.report.process: + action: receive + channel: + $ref: '#/channels/regulatory.reports.{jurisdiction}.{report_type}' + summary: Process regulatory reports + description: Process incoming regulatory reports and forward to authorities + + # Analytics RPC operations + analytics.request: + action: send + channel: + address: analytics.requests + description: Analytics request processing queue + messages: + analyticsRequest: + $ref: '#/components/messages/AnalyticsRequest' + summary: Request analytics processing + description: Send analytics processing requests + reply: + address: analytics.responses.{correlation_id} + channel: + description: Analytics response channel + messages: + analyticsResponse: + $ref: '#/components/messages/AnalyticsResponse' + + analytics.process: + action: receive + channel: + address: analytics.requests + description: Analytics request processing queue + messages: + analyticsRequest: + $ref: '#/components/messages/AnalyticsRequest' + summary: Process analytics requests + description: Process incoming analytics requests and return results + reply: + address: analytics.responses.{correlation_id} + channel: + description: Analytics response channel + messages: + analyticsResponse: + $ref: '#/components/messages/AnalyticsResponse' + +components: + messages: + MarketTick: + title: MarketTick + summary: Real-time market tick data + contentType: application/json + payload: + $ref: '#/components/schemas/MarketTick' + + MarketDepth: + title: MarketDepth + summary: Market depth/order book data + payload: + $ref: '#/components/schemas/MarketDepth' + + TradeExecution: + title: TradeExecution + summary: Trade execution notification + payload: + $ref: '#/components/schemas/TradeExecution' + + OrderSubmitted: + title: OrderSubmitted + summary: Order submission confirmation + payload: + $ref: '#/components/schemas/OrderSubmitted' + + OrderFilled: + title: OrderFilled + summary: Order fill notification + payload: + $ref: '#/components/schemas/OrderFilled' + + OrderRejected: + title: OrderRejected + summary: Order rejection notification + payload: + $ref: '#/components/schemas/OrderRejected' + + OrderAcknowledgment: + title: OrderAcknowledgment + summary: Order acknowledgment response + payload: + $ref: '#/components/schemas/OrderAcknowledgment' + + OrderRejection: + title: OrderRejection + summary: Order rejection response + payload: + $ref: '#/components/schemas/OrderRejection' + + RiskAlert: + title: RiskAlert + summary: Risk management alert + payload: + $ref: '#/components/schemas/RiskAlert' + + MarginCall: + title: MarginCall + summary: Margin call notification + payload: + $ref: '#/components/schemas/MarginCall' + + PositionLimit: + title: PositionLimit + summary: Position limit breach alert + payload: + $ref: '#/components/schemas/PositionLimit' + + BuySignal: + title: BuySignal + summary: Buy signal from HFT algorithm + payload: + $ref: '#/components/schemas/TradingSignal' + + SellSignal: + title: SellSignal + summary: Sell signal from HFT algorithm + payload: + $ref: '#/components/schemas/TradingSignal' + + HedgeSignal: + title: HedgeSignal + summary: Hedge signal from risk management + payload: + $ref: '#/components/schemas/TradingSignal' + + PortfolioValuation: + title: PortfolioValuation + summary: Portfolio valuation report + payload: + $ref: '#/components/schemas/PortfolioValuation' + + PerformanceReport: + title: PerformanceReport + summary: Portfolio performance analysis + payload: + $ref: '#/components/schemas/PerformanceReport' + + RiskMetrics: + title: RiskMetrics + summary: Portfolio risk metrics + payload: + $ref: '#/components/schemas/RiskMetrics' + + RegulatoryReport: + title: RegulatoryReport + summary: Regulatory compliance report + payload: + $ref: '#/components/schemas/RegulatoryReport' + + AnalyticsRequest: + title: AnalyticsRequest + summary: Analytics processing request + payload: + $ref: '#/components/schemas/AnalyticsRequest' + + AnalyticsResponse: + title: AnalyticsResponse + summary: Analytics processing response + payload: + $ref: '#/components/schemas/AnalyticsResponse' + + schemas: + MarketTick: + type: object + required: [symbol, exchange, timestamp, price, volume, bid, ask] + properties: + symbol: + type: string + pattern: '^[A-Z]{1,5}$' + description: Trading symbol + exchange: + type: string + enum: [NYSE, NASDAQ, LSE, TSE, HKEX] + description: Exchange identifier + timestamp: + type: string + format: date-time + description: Tick timestamp in ISO 8601 + price: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + description: Last trade price + volume: + type: integer + minimum: 0 + description: Trade volume + bid: + $ref: '#/components/schemas/PriceLevel' + ask: + $ref: '#/components/schemas/PriceLevel' + metadata: + $ref: '#/components/schemas/MessageMetadata' + + MarketDepth: + type: object + required: [symbol, exchange, timestamp, bids, asks] + properties: + symbol: + type: string + pattern: '^[A-Z]{1,5}$' + exchange: + type: string + enum: [NYSE, NASDAQ, LSE, TSE, HKEX] + timestamp: + type: string + format: date-time + bids: + type: array + items: + $ref: '#/components/schemas/PriceLevel' + maxItems: 20 + description: Top 20 bid levels + asks: + type: array + items: + $ref: '#/components/schemas/PriceLevel' + maxItems: 20 + description: Top 20 ask levels + metadata: + $ref: '#/components/schemas/MessageMetadata' + + TradeExecution: + type: object + required: [tradeId, orderId, symbol, side, quantity, price, timestamp] + properties: + tradeId: + type: string + format: uuid + description: Unique trade identifier + orderId: + type: string + format: uuid + description: Related order identifier + symbol: + type: string + pattern: '^[A-Z]{1,5}$' + side: + type: string + enum: [BUY, SELL] + quantity: + type: integer + minimum: 1 + maximum: 1000000 + price: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + timestamp: + type: string + format: date-time + fees: + $ref: '#/components/schemas/TradingFees' + metadata: + $ref: '#/components/schemas/MessageMetadata' + + OrderSubmitted: + type: object + required: [orderId, clientOrderId, symbol, side, orderType, quantity, timeInForce] + properties: + orderId: + type: string + format: uuid + clientOrderId: + type: string + maxLength: 50 + description: Client-provided order identifier + symbol: + type: string + pattern: '^[A-Z]{1,5}$' + side: + type: string + enum: [BUY, SELL] + orderType: + type: string + enum: [MARKET, LIMIT, STOP, STOP_LIMIT, TRAILING_STOP] + quantity: + type: integer + minimum: 1 + maximum: 1000000 + price: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + description: Limit price (required for limit orders) + stopPrice: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + description: Stop price (required for stop orders) + timeInForce: + type: string + enum: [DAY, GTC, IOC, FOK] + description: Order time in force + account: + type: string + pattern: '^ACC_[0-9]{8}$' + description: Trading account identifier + portfolio: + type: string + pattern: '^[A-Z0-9]{8}$' + description: Portfolio identifier + metadata: + $ref: '#/components/schemas/MessageMetadata' + + OrderFilled: + type: object + required: [orderId, fillId, quantity, price, timestamp, remainingQuantity] + properties: + orderId: + type: string + format: uuid + fillId: + type: string + format: uuid + description: Unique fill identifier + quantity: + type: integer + minimum: 1 + price: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + timestamp: + type: string + format: date-time + remainingQuantity: + type: integer + minimum: 0 + description: Remaining unfilled quantity + fees: + $ref: '#/components/schemas/TradingFees' + liquidity: + type: string + enum: [MAKER, TAKER] + description: Liquidity provision type + metadata: + $ref: '#/components/schemas/MessageMetadata' + + OrderRejected: + type: object + required: [orderId, rejectionCode, rejectionReason, timestamp] + properties: + orderId: + type: string + format: uuid + rejectionCode: + type: string + enum: [INSUFFICIENT_FUNDS, INVALID_SYMBOL, MARKET_CLOSED, POSITION_LIMIT, RISK_CHECK_FAILED] + rejectionReason: + type: string + maxLength: 255 + description: Human-readable rejection reason + timestamp: + type: string + format: date-time + metadata: + $ref: '#/components/schemas/MessageMetadata' + + OrderAcknowledgment: + type: object + required: [orderId, status, timestamp] + properties: + orderId: + type: string + format: uuid + status: + type: string + enum: [ACKNOWLEDGED, PENDING, WORKING] + timestamp: + type: string + format: date-time + estimatedFillTime: + type: string + format: date-time + description: Estimated time to fill + metadata: + $ref: '#/components/schemas/MessageMetadata' + + OrderRejection: + type: object + required: [orderId, rejectionCode, rejectionReason] + properties: + orderId: + type: string + format: uuid + rejectionCode: + type: string + enum: [DUPLICATE_ORDER, INVALID_PARAMETERS, SYSTEM_ERROR, COMPLIANCE_VIOLATION] + rejectionReason: + type: string + maxLength: 255 + metadata: + $ref: '#/components/schemas/MessageMetadata' + + RiskAlert: + type: object + required: [alertId, alertType, severity, portfolio, threshold, currentValue] + properties: + alertId: + type: string + format: uuid + alertType: + type: string + enum: [VAR_BREACH, CONCENTRATION_RISK, LIQUIDITY_RISK, CREDIT_RISK, OPERATIONAL_RISK] + severity: + type: string + enum: [LOW, MEDIUM, HIGH, CRITICAL] + portfolio: + type: string + pattern: '^[A-Z0-9]{8}$' + threshold: + type: number + format: double + description: Risk threshold value + currentValue: + type: number + format: double + description: Current risk value + timestamp: + type: string + format: date-time + description: + type: string + maxLength: 500 + recommendedActions: + type: array + items: + type: string + maxLength: 200 + maxItems: 10 + metadata: + $ref: '#/components/schemas/MessageMetadata' + + MarginCall: + type: object + required: [callId, account, requiredMargin, currentMargin, deficiency, dueDate] + properties: + callId: + type: string + format: uuid + account: + type: string + pattern: '^ACC_[0-9]{8}$' + requiredMargin: + type: number + format: double + minimum: 0 + description: Required margin amount + currentMargin: + type: number + format: double + minimum: 0 + description: Current margin amount + deficiency: + type: number + format: double + minimum: 0 + description: Margin deficiency amount + dueDate: + type: string + format: date-time + description: Margin call due date + timestamp: + type: string + format: date-time + metadata: + $ref: '#/components/schemas/MessageMetadata' + + PositionLimit: + type: object + required: [limitId, portfolio, instrument, limitType, limit, currentPosition] + properties: + limitId: + type: string + format: uuid + portfolio: + type: string + pattern: '^[A-Z0-9]{8}$' + instrument: + type: string + description: Financial instrument identifier + limitType: + type: string + enum: [GROSS_EXPOSURE, NET_EXPOSURE, CONCENTRATION, SECTOR_LIMIT] + limit: + type: number + format: double + description: Position limit value + currentPosition: + type: number + format: double + description: Current position value + utilizationPercent: + type: number + format: double + minimum: 0 + maximum: 200 + description: Limit utilization percentage + timestamp: + type: string + format: date-time + metadata: + $ref: '#/components/schemas/MessageMetadata' + + TradingSignal: + type: object + required: [signalId, strategy, symbol, action, confidence, timestamp] + properties: + signalId: + type: string + format: uuid + strategy: + type: string + enum: [momentum, arbitrage, mean_reversion, pairs_trading, market_making] + symbol: + type: string + pattern: '^[A-Z]{1,5}$' + action: + type: string + enum: [BUY, SELL, HOLD, HEDGE] + confidence: + type: number + format: double + minimum: 0 + maximum: 1 + description: Signal confidence score (0-1) + targetPrice: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + stopLoss: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + takeProfit: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + quantity: + type: integer + minimum: 1 + maximum: 1000000 + urgency: + type: string + enum: [LOW, MEDIUM, HIGH, IMMEDIATE] + timeToLive: + type: integer + minimum: 1 + maximum: 3600 + description: Signal TTL in seconds + timestamp: + type: string + format: date-time + metadata: + $ref: '#/components/schemas/MessageMetadata' + + PortfolioValuation: + type: object + required: [portfolioId, valuationDate, totalValue, currency] + properties: + portfolioId: + type: string + pattern: '^[A-Z0-9]{8}$' + clientId: + type: string + pattern: '^CLIENT_[0-9]{8}$' + valuationDate: + type: string + format: date + totalValue: + type: number + format: double + minimum: 0 + description: Total portfolio value + currency: + type: string + pattern: '^[A-Z]{3}$' + description: Base currency + positions: + type: array + items: + $ref: '#/components/schemas/Position' + maxItems: 10000 + pnl: + $ref: '#/components/schemas/PnLBreakdown' + riskMetrics: + $ref: '#/components/schemas/PortfolioRiskMetrics' + benchmark: + $ref: '#/components/schemas/BenchmarkComparison' + metadata: + $ref: '#/components/schemas/MessageMetadata' + + PerformanceReport: + type: object + required: [portfolioId, reportPeriod, returns, metrics] + properties: + portfolioId: + type: string + pattern: '^[A-Z0-9]{8}$' + clientId: + type: string + pattern: '^CLIENT_[0-9]{8}$' + reportPeriod: + $ref: '#/components/schemas/DateRange' + returns: + $ref: '#/components/schemas/ReturnsAnalysis' + metrics: + $ref: '#/components/schemas/PerformanceMetrics' + attribution: + $ref: '#/components/schemas/AttributionAnalysis' + metadata: + $ref: '#/components/schemas/MessageMetadata' + + RiskMetrics: + type: object + required: [portfolioId, calculationDate, var, expectedShortfall] + properties: + portfolioId: + type: string + pattern: '^[A-Z0-9]{8}$' + calculationDate: + type: string + format: date + var: + $ref: '#/components/schemas/VaRCalculation' + expectedShortfall: + type: number + format: double + description: Expected Shortfall (CVaR) + beta: + type: number + format: double + description: Portfolio beta + sharpeRatio: + type: number + format: double + description: Sharpe ratio + maxDrawdown: + type: number + format: double + minimum: 0 + maximum: 1 + description: Maximum drawdown percentage + trackingError: + type: number + format: double + minimum: 0 + description: Tracking error vs benchmark + informationRatio: + type: number + format: double + description: Information ratio + metadata: + $ref: '#/components/schemas/MessageMetadata' + + RegulatoryReport: + type: object + required: [reportId, jurisdiction, reportType, reportingPeriod, data] + properties: + reportId: + type: string + format: uuid + jurisdiction: + type: string + enum: [SEC, FINRA, FCA, ESMA, JFSA, ASIC] + reportType: + type: string + enum: [trade_report, position_report, risk_report, client_report] + reportingPeriod: + $ref: '#/components/schemas/DateRange' + data: + type: object + description: Report-specific data structure + additionalProperties: true + submissionDeadline: + type: string + format: date-time + status: + type: string + enum: [DRAFT, PENDING, SUBMITTED, ACCEPTED, REJECTED] + validationErrors: + type: array + items: + type: string + maxLength: 200 + metadata: + $ref: '#/components/schemas/MessageMetadata' + + # Supporting schemas + PriceLevel: + type: object + required: [price, size] + properties: + price: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + size: + type: integer + minimum: 1 + + TradingFees: + type: object + properties: + commission: + type: number + format: double + minimum: 0 + regulatoryFees: + type: number + format: double + minimum: 0 + exchangeFees: + type: number + format: double + minimum: 0 + total: + type: number + format: double + minimum: 0 + + Position: + type: object + required: [symbol, quantity, marketValue, unrealizedPnL] + properties: + symbol: + type: string + pattern: '^[A-Z]{1,5}$' + quantity: + type: number + format: double + description: Position size (negative for short) + marketValue: + type: number + format: double + unrealizedPnL: + type: number + format: double + costBasis: + type: number + format: double + weight: + type: number + format: double + minimum: 0 + maximum: 1 + description: Position weight in portfolio + + PnLBreakdown: + type: object + properties: + totalPnL: + type: number + format: double + realizedPnL: + type: number + format: double + unrealizedPnL: + type: number + format: double + dividends: + type: number + format: double + interest: + type: number + format: double + fees: + type: number + format: double + + PortfolioRiskMetrics: + type: object + properties: + volatility: + type: number + format: double + minimum: 0 + beta: + type: number + format: double + var95: + type: number + format: double + var99: + type: number + format: double + maxDrawdown: + type: number + format: double + + BenchmarkComparison: + type: object + required: [benchmarkName, correlation, trackingError] + properties: + benchmarkName: + type: string + maxLength: 50 + correlation: + type: number + format: double + minimum: -1 + maximum: 1 + trackingError: + type: number + format: double + minimum: 0 + alpha: + type: number + format: double + beta: + type: number + format: double + + DateRange: + type: object + required: [startDate, endDate] + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + + ReturnsAnalysis: + type: object + properties: + totalReturn: + type: number + format: double + annualizedReturn: + type: number + format: double + monthlyReturns: + type: array + items: + type: number + format: double + maxItems: 12 + + PerformanceMetrics: + type: object + properties: + sharpeRatio: + type: number + format: double + sortinoRatio: + type: number + format: double + calmarRatio: + type: number + format: double + maxDrawdown: + type: number + format: double + + AttributionAnalysis: + type: object + properties: + sectorAttribution: + type: array + items: + $ref: '#/components/schemas/SectorAttribution' + securitySelection: + type: number + format: double + assetAllocation: + type: number + format: double + + SectorAttribution: + type: object + required: [sector, contribution] + properties: + sector: + type: string + maxLength: 50 + contribution: + type: number + format: double + + VaRCalculation: + type: object + required: [confidence, timeHorizon, value] + properties: + confidence: + type: number + format: double + minimum: 0.9 + maximum: 0.99 + description: Confidence level (e.g., 0.95, 0.99) + timeHorizon: + type: integer + minimum: 1 + maximum: 252 + description: Time horizon in days + value: + type: number + format: double + description: VaR value + method: + type: string + enum: [PARAMETRIC, HISTORICAL, MONTE_CARLO] + + MessageMetadata: + type: object + required: [messageId, timestamp, version, source] + properties: + messageId: + type: string + format: uuid + timestamp: + type: string + format: date-time + version: + type: string + pattern: '^[0-9]+\.[0-9]+\.[0-9]+$' + source: + type: string + maxLength: 100 + description: Message source system + correlationId: + type: string + format: uuid + causationId: + type: string + format: uuid + userId: + type: string + pattern: '^USER_[0-9]{8}$' + sessionId: + type: string + format: uuid + traceId: + type: string + description: Distributed tracing identifier + environment: + type: string + enum: [dev, staging, prod] + region: + type: string + enum: [us-east-1, us-west-2, eu-west-1, ap-southeast-1] + + AnalyticsRequest: + type: object + required: [requestId, analysisType, parameters] + properties: + requestId: + type: string + format: uuid + description: Unique request identifier + analysisType: + type: string + enum: [PERFORMANCE, RISK, ATTRIBUTION, VAR_CALCULATION] + description: Type of analysis to perform + portfolioId: + type: string + pattern: '^[A-Z0-9]{8}$' + description: Portfolio identifier + dateRange: + $ref: '#/components/schemas/DateRange' + parameters: + type: object + additionalProperties: true + description: Analysis-specific parameters + metadata: + $ref: '#/components/schemas/MessageMetadata' + + AnalyticsResponse: + type: object + required: [requestId, status] + properties: + requestId: + type: string + format: uuid + description: Original request identifier + status: + type: string + enum: [SUCCESS, FAILED, PARTIAL] + description: Processing status + result: + type: object + additionalProperties: true + description: Analysis results + error: + type: string + description: Error message if processing failed + processingTime: + type: number + format: double + minimum: 0 + description: Processing time in seconds + metadata: + $ref: '#/components/schemas/MessageMetadata' \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 14a2b0d..29baf53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,12 @@ [project] name = "asyncapi-python" -version = "0.2.5" -license = {text = "Apache-2.0"} +version = "0.3.0rc1" +license = { text = "Apache-2.0" } description = "Easily generate type-safe and async Python applications from AsyncAPI 3 specifications." -authors = [{name = "Yaroslav Petrov", email = "yaroslav.v.petrov@gmail.com"}] +authors = [{ name = "Yaroslav Petrov", email = "yaroslav.v.petrov@gmail.com" }] readme = "README.md" -requires-python = ">=3.9,<3.14" -dependencies = [ - "pydantic>=2", - "pytz", -] +requires-python = ">=3.10,<3.14" +dependencies = ["pydantic>=2", "pytz"] [project.optional-dependencies] codegen = [ @@ -33,7 +30,9 @@ dev-dependencies = [ "pytest", "types-pytz", "pytest-asyncio", + "pytest-timeout", "pex", + "pyright>=1.1.405", ] [build-system] @@ -51,3 +50,13 @@ packages = [ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" +timeout = 30 + +[tool.pyright] +include = ["src", "*.py"] +exclude = ["**/__pycache__", "src/asyncapi_python_pants"] +pythonVersion = "3.10" +pythonPlatform = "Linux" +typeCheckingMode = "strict" +venvPath = "." +venv = ".venv" diff --git a/src/asyncapi_python/amqp/__init__.py b/src/asyncapi_python/amqp/__init__.py deleted file mode 100644 index d570ec6..0000000 --- a/src/asyncapi_python/amqp/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from .connection import channel_pool, AmqpPool -from .base_application import BaseApplication, Router -from .endpoint import Receiver, RpcReceiver, Sender, RpcSender, EndpointParams -from .operation import Operation -from .utils import union_model -from .error import Rejection, RejectedError -from .params import AmqpParams - -__all__ = [ - "channel_pool", - "AmqpParams", - "AmqpPool", - "BaseApplication", - "Router", - "Receiver", - "RpcReceiver", - "Sender", - "RpcSender", - "Operation", - "EndpointParams", - "union_model", - "Rejection", - "RejectedError", -] diff --git a/src/asyncapi_python/amqp/base_application.py b/src/asyncapi_python/amqp/base_application.py deleted file mode 100644 index 00583b9..0000000 --- a/src/asyncapi_python/amqp/base_application.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from asyncio import Future -from collections import defaultdict -import json -from aio_pika.abc import AbstractIncomingMessage, ConsumerTag -from aio_pika import Message -from uuid import uuid4 - -from .error import RejectedError -from .endpoint import EndpointParams -from .connection import channel_pool -from .utils import encode_message, decode_message -from .params import AmqpParams -from typing import Generic, Optional, TypeVar - - -class Router: - def __init__(self, params: EndpointParams): - self._params = params - - async def start(self) -> None: - for f in self.__dict__.values(): - if not isinstance(f, Router): - continue - await f.start() - - async def stop(self) -> None: - for f in self.__dict__.values(): - if not isinstance(f, Router): - continue - await f.stop() - - -P = TypeVar("P", bound=Router) -C = TypeVar("C", bound=Router) - - -class BaseApplication(Generic[P, C]): - # TODO: Create AbstractEndpoint instance to handle reply queue - # TODO: Create AbstractEndpoint instance to handle error queue - # TODO: Do not mess with aio_pika api here -- use endpoints - # TODO: If rpc server rejects, and the error - # is invalid, we have to raise on both client and server - # to prevent permanent locking - # TODO: Add configurable timeouts to calls - - def __init__( - self, - amqp_uri: str, - producer_factory: type[P], - consumer_factory: type[C], - amqp_params: AmqpParams, - ): - self.__params = EndpointParams( - pool=channel_pool(amqp_uri), - encode=encode_message, - decode=decode_message, - register_correlation_id=self.__register_correlation_id, - stop_application=self.stop, - app_id=str(uuid4()), - amqp_params=amqp_params, - ) - self.__reply_futures: dict[ - str, - Future[AbstractIncomingMessage], - ] = defaultdict(lambda: Future()) - self.__stop_future: Optional[Future[None]] = None - - self.__reply_tag: Optional[ConsumerTag] = None - self.__error_tag: Optional[ConsumerTag] = None - - self.producer: P = producer_factory(self.__params) - self.consumer: C = consumer_factory(self.__params) - - async def start(self, blocking: bool = True): - await self.consumer.start() - await self.producer.start() - async with self.__params.pool.acquire() as ch: - reply_queue = await ch.declare_queue( - self.__params.reply_queue_name, exclusive=True - ) - self.__reply_tag = await reply_queue.consume(self.__handle_reply) - error_queue = await ch.declare_queue( - self.__params.error_queue_name, exclusive=True - ) - self.__error_tag = await error_queue.consume(self.__handle_error) - - if not blocking: - return - - if self.__stop_future: - raise AssertionError( - "Calling start multiple times with blocking=True is not supported" - ) - self.__stop_future = Future() - await self.__stop_future - - async def stop(self) -> None: - await self.producer.stop() - await self.consumer.stop() - if self.__stop_future: - stop_future, self.__stop_future = self.__stop_future, None - stop_future.set_result(None) - async with self.__params.pool.acquire() as ch: - if self.__reply_tag: - q = await ch.get_queue(self.__params.reply_queue_name) - await q.cancel(self.__reply_tag) - if self.__error_tag: - q = await ch.get_queue(self.__params.error_queue_name) - await q.cancel(self.__error_tag) - - async def __handle_reply(self, message: AbstractIncomingMessage): - if future := self.__reply_futures.pop(message.correlation_id or "", None): - future.set_result(message) - await message.ack() - - async def __handle_error(self, message: AbstractIncomingMessage): - try: - # All valid errors must be json with keys 'error' and 'original_message' - # All messages that do not satisfy the format are just dropped - payload = json.loads(message.body) - error, msg = payload["error"], payload["original_message"] - exception = RejectedError(error, msg) - await message.ack() - except: - # If the error is invalid, then send error to the author of the message - await message.reject() - if not message.app_id: - return - err_payload = json.dumps( - { - "error": {"message": "Invalid error channel payload"}, - "original_message": { - "headers": message.headers, - "body": json.loads(message.body), - }, - } - ).encode() - async with self.__params.pool.acquire() as ch: - await ch.default_exchange.publish( - Message( - err_payload, - app_id=self.__params.app_id, - ), - self.__params.get_error_queue(message.app_id), - ) - return - - # If error has no correlation id, raise here - if not message.correlation_id: - raise exception - # If the correlation id is expected -- raise it where it is expected - elif future := self.__reply_futures.pop(message.correlation_id or "", None): - future.set_exception(exception) - # Else drop message - - def __register_correlation_id(self) -> tuple[str, Future[AbstractIncomingMessage]]: - corr_id = str(uuid4()) - return corr_id, self.__reply_futures[corr_id] diff --git a/src/asyncapi_python/amqp/connection.py b/src/asyncapi_python/amqp/connection.py deleted file mode 100644 index e24a86a..0000000 --- a/src/asyncapi_python/amqp/connection.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from functools import cache -from aio_pika.robust_connection import ( - AbstractRobustConnection, - AbstractRobustChannel, - connect_robust, -) -from aio_pika.pool import Pool - - -@cache -def connection_pool(amqp_uri: str) -> Pool[AbstractRobustConnection]: - async def get_connection(): - return await connect_robust(amqp_uri) - - return Pool(get_connection, max_size=2) - - -AmqpPool = Pool[AbstractRobustChannel] - - -@cache -def channel_pool(amqp_uri: str) -> AmqpPool: - async def get_channel(): - async with connection_pool(amqp_uri).acquire() as connection: - return await connection.channel() - - return Pool(get_channel, max_size=10) diff --git a/src/asyncapi_python/amqp/endpoint/__init__.py b/src/asyncapi_python/amqp/endpoint/__init__.py deleted file mode 100644 index 5de78ca..0000000 --- a/src/asyncapi_python/amqp/endpoint/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from .base import Rejection, RejectedError, EndpointParams, AbstractEndpoint -from .receiver import Receiver, RpcReceiver -from .sender import Sender, RpcSender - - -__all__ = [ - "Rejection", - "RejectedError", - "Receiver", - "RpcReceiver", - "Sender", - "RpcSender", - "EndpointParams", - "AbstractEndpoint", -] diff --git a/src/asyncapi_python/amqp/endpoint/base.py b/src/asyncapi_python/amqp/endpoint/base.py deleted file mode 100644 index fd3069a..0000000 --- a/src/asyncapi_python/amqp/endpoint/base.py +++ /dev/null @@ -1,138 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import ( - Awaitable, - Callable, - Generic, - Optional, - Protocol, - Type, - TypeVar, - Union, -) - -from aio_pika import Message -from pydantic import BaseModel -from ..error import Rejection, RejectedError -from ..connection import AmqpPool -from ..operation import Operation -from ..params import AmqpParams -from aio_pika.abc import ( - AbstractRobustChannel, - AbstractRobustQueue, - AbstractIncomingMessage, -) - -I = TypeVar("I", bound=BaseModel) -U = TypeVar("U", bound=BaseModel) -O = TypeVar("O", bound=Union[BaseModel, None]) - - -class Encoder(Protocol): - """A function that turns base model into bytes""" - - def __call__(self, message: BaseModel) -> bytes: ... - - -class Decoder(Protocol[I]): - """A function that turns bytes into subclass of base model using schema""" - - def __call__(self, body: bytes, schema: Type[I]) -> I: ... - - -@dataclass -class EndpointParams: - pool: AmqpPool - encode: Callable[[I], bytes] - decode: Callable[[bytes, Type[I]], I] - register_correlation_id: Callable[ - [], tuple[str, Awaitable[AbstractIncomingMessage]] - ] - app_id: str - stop_application: Callable[[], Awaitable[None]] - amqp_params: AmqpParams - - @property - def reply_queue_name(self) -> str: - return f"reply-queue-{self.app_id}" - - @property - def error_queue_name(self) -> str: - return self.get_error_queue(self.app_id) - - @classmethod - def get_error_queue(cls, app_id: str) -> str: - return f"error-queue-{app_id}" - - -class AbstractEndpoint(ABC, Generic[I, O]): - def __init__(self, op: Operation, params: EndpointParams): - self._op = op - self._params = params - - @abstractmethod - async def start(self): - raise NotImplementedError - - @abstractmethod - async def stop(self): - raise NotImplementedError - - async def _declare(self, ch: AbstractRobustChannel) -> AbstractRobustQueue: - ex_name = self._op.exchange_name - ex_type = self._op.exchange_type - q_name = self._op.routing_key - - # Debug/Test mode - # TODO: Inject this code instead of having if-else - if self._op.debug_auto_delete: - q = await ch.declare_queue( - name=q_name, - durable=False, - exclusive=True, - ) - if ex_name: - ex = await ch.declare_exchange( - name=ex_name, - type=ex_type, - auto_delete=True, - ) - await q.bind(ex) - # Production mode - else: - q = await ch.declare_queue( - name=q_name, - durable=bool(q_name), - exclusive=not bool(q_name), - ) - if ex_name: - ex = await ch.declare_exchange(name=ex_name, type=ex_type) - await q.bind(ex) - return q - - def _create_message( - self, - body: bytes, - correlation_id: Optional[str] = None, - ) -> Message: - return Message( - body, - app_id=self._params.app_id, - correlation_id=correlation_id, - reply_to=self._params.reply_queue_name if correlation_id else None, - ) diff --git a/src/asyncapi_python/amqp/endpoint/receiver.py b/src/asyncapi_python/amqp/endpoint/receiver.py deleted file mode 100644 index e75fbcf..0000000 --- a/src/asyncapi_python/amqp/endpoint/receiver.py +++ /dev/null @@ -1,146 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from abc import abstractmethod -import json -from typing import Awaitable, Callable, Optional, TypeVar, Union, cast, get_args - -from pydantic import BaseModel, ValidationError - -from .base import AbstractEndpoint, EndpointParams -from ..error import Rejection, BadRequestRejection -from ..operation import Operation -from aio_pika.abc import AbstractIncomingMessage, AbstractRobustQueue - - -I = TypeVar("I", bound=BaseModel) -U = TypeVar("U", bound=BaseModel) -O = TypeVar("O", bound=Union[BaseModel, None]) - - -Callback = Callable[[I], Awaitable[O]] -"""A callback that turns input type into output type""" - - -class AbstractReceiver(AbstractEndpoint[I, O]): - def __init__(self, op: Operation, params: EndpointParams): - super().__init__(op, params) - self._fn: Optional[Callback[I, O]] = None - self._consumer_tag: Optional[str] = None - self._queue: Optional[AbstractRobustQueue] = None - - async def start(self) -> None: - print("start", self._op) - if self._fn: - async with self._params.pool.acquire() as ch: - if prefetch_count := self._params.amqp_params.get("prefetch_count"): - await ch.set_qos(prefetch_count=prefetch_count) - q = self._queue = await self._declare(ch) - self._consumer_tag = await q.consume(self._consumer) - return - path = ".".join(self._op.path) - args = get_args(getattr(self.__class__, "__orig_bases__")[0]) - i = args[0].__name__ - o = args[1].__name__ if len(args) > 1 else None - raise NotImplementedError( - "The following operation must be implemented " - f"before the system can start: {self._op.name}. " - "This can be done by:\n\n\n" - "```python\n" - f"@app.consumer.{path}\n" - f"async def callback(msg: {i}) -> {o}:\n" - " # TODO: Implement callback for this handler\n" - " raise NotImplementedError\n" - "```\n" - ) - - async def stop(self): - if not (self._consumer_tag and self._queue): - return - await self._queue.cancel(self._consumer_tag) - - async def _consumer(self, message: AbstractIncomingMessage): - try: - payload = self._decode_payload(message) - await self._handle_message(message, payload) - await message.ack() - except Rejection as e: - await self._reject(e, message) - - def _decode_payload(self, message: AbstractIncomingMessage) -> I: - try: - payload: I = self._params.decode(message.body, self._op.message_type) - except ValidationError as e: - raise BadRequestRejection(e) - return payload - - async def _reject(self, err: Rejection, message: AbstractIncomingMessage): - await message.reject() - if not (app_id := message.app_id): - return - - err_payload = json.dumps( - { - "error": err.asdict(), - "original_message": { - "headers": message.headers, - "body": json.loads(message.body), - }, - } - ).encode() - err_msg = self._create_message(err_payload, message.correlation_id) - routing_key = self._params.get_error_queue(app_id) - async with self._params.pool.acquire() as ch: - await ch.default_exchange.publish(err_msg, routing_key) - - @abstractmethod - async def _handle_message(self, message: AbstractIncomingMessage, payload: I): - raise NotImplementedError - - def __call__(self, callback: Callback[I, O]) -> None: - if not self._fn: - self._fn = callback - return - raise ValueError( - f"Operation handler {self._op.name} has already been implemented" - ) - - -class Receiver(AbstractReceiver[I, None]): - async def _handle_message(self, message: AbstractIncomingMessage, payload: I): - if message.correlation_id or message.reply_to: - raise Rejection("Expected publish, but message has reply_to/correlation_id") - fn = cast(Callback[I, None], self._fn) - await fn(payload) - - -class RpcReceiver(AbstractReceiver[I, U]): - async def _handle_message(self, message: AbstractIncomingMessage, payload: I): - if not (message.correlation_id and message.reply_to): - raise Rejection( - "Expected RPC call, but message has no reply_to/correlation_id" - ) - - fn = cast(Callback[I, U], self._fn) - res = await fn(payload) - encoded_res = self._params.encode(res) - - async with self._params.pool.acquire() as ch: - await ch.default_exchange.publish( - self._create_message( - encoded_res, correlation_id=message.correlation_id - ), - message.reply_to, - ) diff --git a/src/asyncapi_python/amqp/endpoint/sender.py b/src/asyncapi_python/amqp/endpoint/sender.py deleted file mode 100644 index 4abf845..0000000 --- a/src/asyncapi_python/amqp/endpoint/sender.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from abc import abstractmethod -from typing import Any, TypeVar, Union - -from pydantic import BaseModel -from .base import AbstractEndpoint -from aio_pika import Message - - -I = TypeVar("I", bound=BaseModel) -U = TypeVar("U", bound=BaseModel) -O = TypeVar("O", bound=Union[BaseModel, None]) - - -class AbstractSender(AbstractEndpoint[I, O]): - async def start(self): - async with self._params.pool.acquire() as ch: - q = await self._declare(ch) - if q.exclusive: - await q.delete() - - async def stop(self): ... - - @abstractmethod - async def __call__(self, message: I) -> O: - raise NotImplementedError - - async def validate_and_call(self, message: Any) -> O: - return await self(self._op.message_type.model_validate(message)) - - async def validate_json_and_call(self, message: Union[str, bytes, bytearray]) -> O: - return await self(self._op.message_type.model_validate_json(message)) - - -class Sender(AbstractSender[I, None]): - async def __call__(self, message: I) -> None: - ex_n = self._op.exchange_name or "" - q_n = self._op.routing_key or "" - body = self._params.encode(message) - async with self._params.pool.acquire() as ch: - ex = await ch.get_exchange(ex_n) if ex_n else ch.default_exchange - await ex.publish(self._create_message(body), q_n) - - -class RpcSender(AbstractSender[I, U]): - async def __call__(self, message: I) -> U: - ex_n = self._op.exchange_name - q_n = self._op.routing_key or "" - body = self._params.encode(message) - corr_id, future = self._params.register_correlation_id() - async with self._params.pool.acquire() as ch: - ex = await ch.get_exchange(ex_n) if ex_n else ch.default_exchange - await ex.publish(self._create_message(body, corr_id), q_n) - res = await future - return self._params.decode(res.body, self._op.reply_type) diff --git a/src/asyncapi_python/amqp/error.py b/src/asyncapi_python/amqp/error.py deleted file mode 100644 index a719f5b..0000000 --- a/src/asyncapi_python/amqp/error.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import traceback -from typing import Any - -from pydantic import ValidationError - - -class Rejection(BaseException): - def asdict(self) -> dict[str, Any]: - return { - "__exception__": True, - "type": self.__class__.__name__, - "message": str(self), - "traceback": traceback.format_exc(), - } - - -class BadRequestRejection(Rejection): - def __init__(self, err: ValidationError): - super().__init__(err) - - def asdict(self) -> dict[str, Any]: - return json.loads(self.args[0]) - - -class RejectedError(BaseException): - def __init__(self, rejection: Any, original_message: Any): - super().__init__(rejection, original_message) diff --git a/src/asyncapi_python/amqp/operation.py b/src/asyncapi_python/amqp/operation.py deleted file mode 100644 index 25f80ba..0000000 --- a/src/asyncapi_python/amqp/operation.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from typing import ( - Generic, - Literal, - Type, - TypeVar, - Union, -) -from pydantic import BaseModel -from dataclasses import dataclass, field -from asyncapi_python.utils import snake_case - -ExchangeType = Literal["topic", "direct", "fanout", "default", "headers"] - -I = TypeVar("I", bound=BaseModel) -U = TypeVar("U", bound=BaseModel) -O = TypeVar("O", bound=Union[BaseModel, None]) - - -@dataclass -class Operation(Generic[I, O]): - name: str - """A name of the operation from asyncapi spec""" - - message_type: Type[I] - """A message payload""" - - reply_type: Type[O] - """A message payload sent to the reply queue. If None, assumes no reply.""" - - routing_key: Union[str, None] - """A queue name or a routing key (depending on the operation side). - If no name, the queue is exclusive, otherwise it is durable.""" - - exchange_name: Union[str, None] - """A name of the exchange that the queue will be bound, and to which the message will be sent""" - - exchange_type: ExchangeType - """An exchange type.""" - - debug_auto_delete: bool = field(default=False) - """A debug param that will force automatic deletion of the resources for this operation. Used for tests.""" - - @property - def path(self) -> tuple[str, ...]: - """A hierarchical path of the operation, like a/b/c or a.b.c - with empty parts of the path dropped""" - return tuple( - snake_case(y) for x in self.name.split("/") for y in x.split(".") if y - ) diff --git a/src/asyncapi_python/amqp/params.py b/src/asyncapi_python/amqp/params.py deleted file mode 100644 index b66b4ce..0000000 --- a/src/asyncapi_python/amqp/params.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import TypedDict - - -class AmqpParams(TypedDict, total=False): - prefetch_count: int diff --git a/src/asyncapi_python/amqp/utils.py b/src/asyncapi_python/amqp/utils.py deleted file mode 100644 index f869300..0000000 --- a/src/asyncapi_python/amqp/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from functools import cache -from pydantic import BaseModel, RootModel -from typing import TypeVar, Union, cast - -T = TypeVar("T", bound=BaseModel) -U = TypeVar("U") - - -class UnionModel(RootModel[U]): - """A trick to allow unions as constructor types""" - - -def encode_message(message: T) -> bytes: - return message.model_dump_json().encode() - - -def decode_message(message: bytes, schema: type[T]) -> T: - payload = schema.model_validate_json(message) - if isinstance(payload, UnionModel): - payload = cast(T, payload.root) - return payload - - -@cache -def union_model(types: tuple[type[U], ...]) -> type[UnionModel[U]]: - UnionType = Union.__getitem__(types) - return UnionModel[UnionType] # type: ignore diff --git a/src/asyncapi_python/contrib/__init__.py b/src/asyncapi_python/contrib/__init__.py new file mode 100644 index 0000000..2734318 --- /dev/null +++ b/src/asyncapi_python/contrib/__init__.py @@ -0,0 +1,3 @@ +"""AsyncAPI Python contrib modules - optional implementations""" + +__all__: list[str] = [] diff --git a/src/asyncapi_python/contrib/codec/__init__.py b/src/asyncapi_python/contrib/codec/__init__.py new file mode 100644 index 0000000..77c48ce --- /dev/null +++ b/src/asyncapi_python/contrib/codec/__init__.py @@ -0,0 +1,6 @@ +"""Codec implementations for various formats""" + +from .registry import CodecRegistry + + +__all__ = ["CodecRegistry"] diff --git a/src/asyncapi_python/contrib/codec/json.py b/src/asyncapi_python/contrib/codec/json.py new file mode 100644 index 0000000..e73bd20 --- /dev/null +++ b/src/asyncapi_python/contrib/codec/json.py @@ -0,0 +1,112 @@ +import json +from typing import Type, ClassVar +from types import ModuleType + +from pydantic import BaseModel, ValidationError + +from asyncapi_python.kernel.codec import Codec, CodecFactory +from asyncapi_python.kernel.document.message import Message + + +class JsonCodec(Codec[BaseModel, bytes]): + """JSON codec that converts between Pydantic BaseModel and bytes""" + + def __init__(self, model_class: Type[BaseModel]): + self._model_class = model_class + + def encode(self, payload: BaseModel) -> bytes: + """Encode a Pydantic model to JSON bytes""" + json_str = payload.model_dump_json() + return json_str.encode("utf-8") + + def decode(self, payload: bytes) -> BaseModel: + """Decode JSON bytes to a Pydantic model""" + try: + json_data = json.loads(payload.decode("utf-8")) + return self._model_class.model_validate(json_data) + except (json.JSONDecodeError, ValidationError, UnicodeDecodeError) as e: + raise ValueError(f"Failed to decode JSON payload: {e}") + + +class JsonCodecFactory(CodecFactory[BaseModel, bytes]): + """Factory for creating JSON codecs for Pydantic models + + This factory dynamically resolves Pydantic model classes from the generated code's + messages.json module. It expects the following structure in the root module: + + root_module/ + ├── messages/ + │ └── json.py # Contains all Pydantic model classes + + Model Resolution: + - Converts message names to PascalCase class names (e.g., "user.created" -> "UserCreated") + - Looks up the model class in root_module.messages.json + - Creates a JsonCodec instance for the resolved model class + + Registry: + - Caches codec instances to avoid creating them multiple times for the same message + - Uses message specs as cache keys (message specs are hashable) + - Shared across all JsonCodecFactory instances via class variable + """ + + _codec_registry: ClassVar[dict[str, JsonCodec]] = {} + + def __init__(self, module: ModuleType) -> None: + super().__init__(module) + + def create(self, message: Message) -> JsonCodec: + """Creates a JSON codec instance from the message spec""" + if not message.name: + raise ValueError("Message name is required to resolve model class") + + # Check if codec already exists in registry + if message.name in self._codec_registry: + return self._codec_registry[message.name] + + if not message.payload: + raise ValueError("Message payload is required for JSON codec") + + # Try to resolve the model class from the module + model_class = self._resolve_model_class(message) + codec = JsonCodec(model_class) + + # Cache the codec in registry + self._codec_registry[message.name] = codec + return codec + + def _resolve_model_class(self, message: Message) -> Type[BaseModel]: + """Resolve the Pydantic model class from the message""" + + # Convert message name to expected class name (e.g., "user.created" -> "UserCreated") + if message.name is None: + raise ValueError("Message name is required for model class resolution") + class_name = self._to_class_name(message.name) + + try: + # Look for models in messages.json submodule + messages_json_module = getattr(self._module, "messages").json + model_class = getattr(messages_json_module, class_name) + if not issubclass(model_class, BaseModel): + raise ValueError(f"Class {class_name} is not a Pydantic BaseModel") + return model_class + except AttributeError as e: + raise ValueError( + f"Model class {class_name} not found in {self._module}.messages.json: {e}" + ) + + def _to_class_name(self, message_name: str) -> str: + """Convert message name to PascalCase class name""" + # Always convert to PascalCase - the message compiler generates Pythonic class names + # Handle various naming conventions: + # "ping" -> "Ping" + # "user_created" -> "UserCreated" + # "user.created" -> "UserCreated" + # "user-created" -> "UserCreated" + + # If it's already in PascalCase (starts with uppercase and has no separators) + if message_name[0].isupper() and not any(c in message_name for c in "._-"): + return message_name + + # Convert to PascalCase + parts = message_name.replace("-", "_").replace(".", "_").split("_") + return "".join(part.capitalize() for part in parts if part) diff --git a/src/asyncapi_python/contrib/codec/registry.py b/src/asyncapi_python/contrib/codec/registry.py new file mode 100644 index 0000000..d118023 --- /dev/null +++ b/src/asyncapi_python/contrib/codec/registry.py @@ -0,0 +1,95 @@ +from typing import ClassVar, Any +from types import ModuleType +from asyncapi_python.kernel.codec import CodecFactory, Codec +from asyncapi_python.kernel.document.message import Message +from .json import JsonCodecFactory + + +class CodecRegistry(CodecFactory[Any, Any]): + """A registry-based codec factory that routes messages to appropriate codecs by content type. + + This factory maintains a class-level registry of codec factories mapped to content types, + and creates codec instances on demand. It supports fallback to a default codec when + no specific codec is registered for a content type. + + Example: + >>> # Register codec factories for different content types + >>> CodecRegistry.register("application/json", JsonCodecFactory) + >>> CodecRegistry.register("application/xml", XmlCodecFactory) + >>> + >>> # Create registry instance and use it + >>> registry = CodecRegistry(my_module) + >>> codec = registry.create(json_message) # Returns JSON codec + >>> codec = registry.create(xml_message) # Returns XML codec + """ + + _registry: ClassVar[dict[str | None, type[CodecFactory[Any, Any]]]] = {} + """Class-level registry mapping content types to codec factory classes.""" + + def __init__(self, module: ModuleType) -> None: + """Initialize the codec registry. + + Args: + module: The root module containing generated message classes. + """ + super().__init__(module) + self._codecs: dict[str | None, CodecFactory[Any, Any]] = {} + + @classmethod + def register( + cls, content_type: str | None, codec_factory: type[CodecFactory[Any, Any]], / + ) -> None: + """Register a codec factory for a specific content type. + + Args: + content_type: The MIME content type (e.g., "application/json") or None for default. + codec_factory: The codec factory class to use for this content type. + + Example: + >>> CodecRegistry.register("application/json", JsonCodecFactory) + >>> CodecRegistry.register(None, JsonCodecFactory) # Default fallback + """ + cls._registry[content_type] = codec_factory + + def create(self, message: Message) -> Codec[Any, Any]: + """Creates codec instance from the message specification. + + Looks up the appropriate codec factory based on the message's content type, + creates and caches codec factory instances, then delegates codec creation + to the specific factory. + + Args: + message: The AsyncAPI message specification containing content type info. + + Returns: + A codec instance capable of encoding/decoding the message. + + Raises: + ValueError: If no codec is registered for the message's content type + and no default codec is available. + + Example: + >>> message = Message(content_type="application/json", ...) + >>> codec = registry.create(message) + >>> encoded = codec.encode(my_data) + """ + content_type = message.content_type + + # Get or create codec instance for this content type + if content_type not in self._codecs: + codec_factory_class = self._registry.get(content_type) + if codec_factory_class is None: + # Fallback to default (None) content type + codec_factory_class = self._registry.get(None) + if codec_factory_class is None: + raise ValueError( + f"No codec registered for content type: {content_type}" + ) + + self._codecs[content_type] = codec_factory_class(self._module) + + return self._codecs[content_type].create(message) + + +CodecRegistry.register(None, JsonCodecFactory) +CodecRegistry.register("application/json", JsonCodecFactory) diff --git a/src/asyncapi_python/contrib/wire/__init__.py b/src/asyncapi_python/contrib/wire/__init__.py new file mode 100644 index 0000000..93887c1 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/__init__.py @@ -0,0 +1,5 @@ +"""Wire implementations for various transport protocols""" + +from .in_memory import InMemoryWire + +__all__ = ["InMemoryWire"] diff --git a/src/asyncapi_python/contrib/wire/amqp/__init__.py b/src/asyncapi_python/contrib/wire/amqp/__init__.py new file mode 100644 index 0000000..9f296c2 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/__init__.py @@ -0,0 +1,5 @@ +"""AMQP wire implementation with comprehensive binding support""" + +from .factory import AmqpWire + +__all__ = ["AmqpWire"] diff --git a/src/asyncapi_python/contrib/wire/amqp/config.py b/src/asyncapi_python/contrib/wire/amqp/config.py new file mode 100644 index 0000000..e1ab25b --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/config.py @@ -0,0 +1,49 @@ +"""AMQP configuration classes and enums""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class AmqpBindingType(Enum): + """Types of AMQP bindings supported""" + + QUEUE = "queue" + ROUTING_KEY = "routingKey" + EXCHANGE = "exchange" + REPLY = "reply" + + +@dataclass +class AmqpConfig: + """Resolved AMQP configuration from AsyncAPI bindings and precedence rules""" + + queue_name: str + exchange_name: str = "" + exchange_type: str = "direct" + routing_key: str = "" + binding_type: AmqpBindingType = AmqpBindingType.QUEUE + queue_properties: dict[str, Any] = field(default_factory=lambda: {}) + binding_arguments: dict[str, Any] = field(default_factory=lambda: {}) + + def to_producer_args(self) -> dict[str, Any]: + """Convert to AmqpProducer constructor arguments""" + return { + "queue_name": self.queue_name, + "exchange_name": self.exchange_name, + "exchange_type": self.exchange_type, + "routing_key": self.routing_key, + "queue_properties": self.queue_properties, + } + + def to_consumer_args(self) -> dict[str, Any]: + """Convert to AmqpConsumer constructor arguments""" + return { + "queue_name": self.queue_name, + "exchange_name": self.exchange_name, + "exchange_type": self.exchange_type, + "routing_key": self.routing_key, + "binding_type": self.binding_type, + "queue_properties": self.queue_properties, + "binding_arguments": self.binding_arguments, + } diff --git a/src/asyncapi_python/contrib/wire/amqp/consumer.py b/src/asyncapi_python/contrib/wire/amqp/consumer.py new file mode 100644 index 0000000..ed4ed32 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/consumer.py @@ -0,0 +1,210 @@ +"""AMQP consumer implementation""" + +import asyncio +from typing import Any, AsyncGenerator + +try: + from aio_pika import ExchangeType # type: ignore[import-not-found] + from aio_pika.abc import ( # type: ignore[import-not-found] + AbstractConnection, + AbstractChannel, + AbstractQueue, + AbstractExchange, + ) +except ImportError as e: + raise ImportError( + "aio-pika is required for AMQP support. " + "Install with: pip install asyncapi-python[amqp]" + ) from e + +from asyncapi_python.kernel.wire.typing import Consumer + +from .config import AmqpBindingType +from .message import AmqpIncomingMessage + + +class AmqpConsumer(Consumer[AmqpIncomingMessage]): + """AMQP consumer implementation with comprehensive binding support""" + + def __init__( + self, + connection: AbstractConnection, + queue_name: str, + exchange_name: str = "", + exchange_type: str = "direct", + routing_key: str = "", + binding_type: AmqpBindingType = AmqpBindingType.QUEUE, + queue_properties: dict[str, Any] | None = None, + binding_arguments: dict[str, Any] | None = None, + ): + self._connection = connection + self._queue_name = queue_name + self._exchange_name = exchange_name + self._exchange_type = exchange_type + self._routing_key = routing_key + self._binding_type = binding_type + self._queue_properties = queue_properties or {} + self._binding_arguments = binding_arguments or {} + self._channel: AbstractChannel | None = None + self._queue: AbstractQueue | None = None + self._exchange: AbstractExchange | None = None + self._started = False + self._stop_event = asyncio.Event() + + async def start(self) -> None: + """Start the consumer with pattern matching for binding types""" + if self._started: + return + + self._channel = await self._connection.channel() + + # Pattern matching for queue setup based on binding type + match self._binding_type: + # Reply channel pattern + case AmqpBindingType.REPLY: + self._queue = await self._channel.declare_queue( + name=self._queue_name, + durable=self._queue_properties.get("durable", True), + exclusive=self._queue_properties.get("exclusive", False), + auto_delete=self._queue_properties.get("auto_delete", False), + ) + + # Simple queue binding pattern (default exchange) + case AmqpBindingType.QUEUE: + self._queue = await self._channel.declare_queue( + name=self._queue_name, + durable=self._queue_properties.get("durable", True), + exclusive=self._queue_properties.get("exclusive", False), + auto_delete=self._queue_properties.get("auto_delete", False), + ) + + # Routing key binding pattern (pub/sub with named exchange) + case AmqpBindingType.ROUTING_KEY: + # Declare the exchange + match self._exchange_type: + case "direct": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.DIRECT, + durable=True, + ) + case "topic": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.TOPIC, + durable=True, + ) + case "fanout": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.FANOUT, + durable=True, + ) + case "headers": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.HEADERS, + durable=True, + ) + case unknown_type: + raise ValueError(f"Unsupported exchange type: {unknown_type}") + + # Create exclusive queue for this consumer + self._queue = await self._channel.declare_queue( + name="", # Auto-generated name + durable=self._queue_properties.get("durable", False), + exclusive=self._queue_properties.get("exclusive", True), + auto_delete=self._queue_properties.get("auto_delete", True), + ) + + # Bind queue to exchange with routing key + await self._queue.bind(self._exchange, routing_key=self._routing_key) + + # Exchange binding pattern (advanced pub/sub with binding arguments) + case AmqpBindingType.EXCHANGE: + # Declare the exchange + match self._exchange_type: + case "fanout": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.FANOUT, + durable=True, + ) + case "headers": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.HEADERS, + durable=True, + ) + case "topic": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.TOPIC, + durable=True, + ) + case "direct": + self._exchange = await self._channel.declare_exchange( + name=self._exchange_name, + type=ExchangeType.DIRECT, + durable=True, + ) + case unknown_type: + raise ValueError(f"Unsupported exchange type: {unknown_type}") + + # Create exclusive queue for this consumer + self._queue = await self._channel.declare_queue( + name="", # Auto-generated name + durable=self._queue_properties.get("durable", False), + exclusive=self._queue_properties.get("exclusive", True), + auto_delete=self._queue_properties.get("auto_delete", True), + ) + + # Bind queue to exchange with binding arguments (for headers exchange) + if self._binding_arguments: + await self._queue.bind( + self._exchange, arguments=self._binding_arguments + ) + else: + await self._queue.bind(self._exchange) + + self._started = True + + async def stop(self) -> None: + """Stop the consumer""" + if not self._started: + return + + self._stop_event.set() + + if self._channel: + await self._channel.close() + self._channel = None + self._queue = None + self._exchange = None + + self._started = False + + def recv(self) -> AsyncGenerator[AmqpIncomingMessage, None]: + """Async generator that yields incoming messages""" + return self._message_generator() + + async def _message_generator(self) -> AsyncGenerator[AmqpIncomingMessage, None]: + """Internal async generator for messages""" + if not self._started or not self._queue: + raise RuntimeError("Consumer not started") + + async with self._queue.iterator() as queue_iter: + async for amqp_message in queue_iter: + if self._stop_event.is_set(): + break + + # Convert to our message format + incoming_msg = AmqpIncomingMessage( + _payload=amqp_message.body, + _headers=dict(amqp_message.headers) if amqp_message.headers else {}, + _correlation_id=amqp_message.correlation_id, + _reply_to=amqp_message.reply_to, + _amqp_message=amqp_message, + ) + + yield incoming_msg diff --git a/src/asyncapi_python/contrib/wire/amqp/factory.py b/src/asyncapi_python/contrib/wire/amqp/factory.py new file mode 100644 index 0000000..e513e02 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/factory.py @@ -0,0 +1,187 @@ +"""AMQP wire factory implementation""" + +import secrets +from typing import Optional, Callable, Any, cast +from typing_extensions import Unpack + +try: + from aio_pika import connect, connect_robust # type: ignore[import-not-found] + from aio_pika.abc import AbstractConnection # type: ignore[import-not-found] +except ImportError as e: + raise ImportError( + "aio-pika is required for AMQP support. Install with: pip install asyncapi-python[amqp]" + ) from e + +from asyncapi_python.kernel.wire import AbstractWireFactory, EndpointParams +from asyncapi_python.kernel.wire.typing import Producer, Consumer + +from .message import AmqpWireMessage, AmqpIncomingMessage +from .producer import AmqpProducer +from .consumer import AmqpConsumer +from .resolver import resolve_amqp_config + + +class AmqpWire(AbstractWireFactory[AmqpWireMessage, AmqpIncomingMessage]): + """AMQP wire factory implementation with configurable connection robustness. + + By default, connections fail fast (for Kubernetes environments). + Set robust=True to enable automatic reconnection with exponential backoff. + """ + + def __init__( + self, + connection_url: str, + service_name: str = "app", + robust: bool = False, + reconnect_interval: float = 1.0, + max_reconnect_interval: float = 60.0, + connection_attempts: int = 3, + heartbeat: Optional[int] = 60, + connection_timeout: Optional[float] = 10.0, + on_connection_lost: Optional[Callable[[Exception], Any]] = None, + ): + """ + Initialize AMQP wire factory. + + Args: + connection_url: AMQP connection URL + service_name: Service name prefix for app_id + robust: Enable robust connection with auto-reconnect (default: False) + reconnect_interval: Initial reconnect interval in seconds (for robust mode) + max_reconnect_interval: Maximum reconnect interval in seconds (for robust mode) + connection_attempts: Number of connection attempts before giving up + heartbeat: Heartbeat interval in seconds (None to disable) + connection_timeout: Connection timeout in seconds + on_connection_lost: Callback when connection is lost (for non-robust mode) + """ + self._connection_url = connection_url + # Generate app_id with service name plus 8 random hex characters + random_hex = secrets.token_hex(4) # 4 bytes = 8 hex chars + self._app_id = f"{service_name}-{random_hex}" + self._connection: AbstractConnection | None = None + self._robust = robust + self._reconnect_interval = reconnect_interval + self._max_reconnect_interval = max_reconnect_interval + self._connection_attempts = connection_attempts + self._heartbeat = heartbeat + self._connection_timeout = connection_timeout + self._on_connection_lost = on_connection_lost + + @property + def app_id(self) -> str: + """Get the generated app_id for this wire instance""" + return self._app_id + + async def _get_connection(self) -> AbstractConnection: + """Get or create connection with configurable robustness""" + if self._connection is None or self._connection.is_closed: + if self._robust: + # Use robust connection with automatic reconnection + self._connection = await connect_robust( + self._connection_url, + reconnect_interval=self._reconnect_interval, + connection_attempts=self._connection_attempts, + heartbeat=self._heartbeat, + timeout=self._connection_timeout, + ) + else: + # Use standard connection that fails fast + try: + self._connection = await connect( + self._connection_url, + heartbeat=self._heartbeat, + timeout=self._connection_timeout, + ) + + # Set up connection lost handler for non-robust mode + if self._on_connection_lost: + self._connection.close_callbacks.add( + cast(Any, self._handle_connection_lost) + ) + + except Exception as e: + # In non-robust mode, let connection failures propagate + # This allows Kubernetes to restart the pod + raise ConnectionError( + f"Failed to connect to AMQP broker: {e}" + ) from e + + return self._connection + + def _handle_connection_lost( + self, connection: AbstractConnection, exception: Optional[BaseException] = None + ) -> None: + """Handle connection lost event in non-robust mode""" + if self._on_connection_lost and exception and isinstance(exception, Exception): + self._on_connection_lost(exception) + else: + # Default behavior: let the process die for Kubernetes restart + if exception: + raise ConnectionError( + f"AMQP connection lost: {exception}" + ) from exception + else: + raise ConnectionError("AMQP connection lost unexpectedly") + + async def create_consumer( + self, **kwargs: Unpack[EndpointParams] + ) -> Consumer[AmqpIncomingMessage]: + """ + Create an AMQP consumer using comprehensive binding resolution. + + Args: + **kwargs: EndpointParams with channel, parameters, bindings, etc. + """ + # Generate operation name from available information + operation_name = self._generate_operation_name(kwargs) + + # Resolve AMQP configuration using pattern matching + config = resolve_amqp_config(kwargs, operation_name, self._app_id) + + connection = await self._get_connection() + + return AmqpConsumer(connection=connection, **config.to_consumer_args()) + + async def create_producer( + self, **kwargs: Unpack[EndpointParams] + ) -> Producer[AmqpWireMessage]: + """ + Create an AMQP producer using comprehensive binding resolution. + + Args: + **kwargs: EndpointParams with channel, parameters, bindings, etc. + """ + # Generate operation name from available information + operation_name = self._generate_operation_name(kwargs) + + # Resolve AMQP configuration using pattern matching + config = resolve_amqp_config(kwargs, operation_name, self._app_id) + + connection = await self._get_connection() + + return AmqpProducer(connection=connection, **config.to_producer_args()) + + def _generate_operation_name(self, params: EndpointParams) -> str: + """Generate operation name from available endpoint parameters""" + channel = params["channel"] + + # Use channel address if available + if channel.address: + return channel.address + + # Use channel title if available + if channel.title: + return channel.title + + # Use first message name if available + if channel.messages: + first_msg_name = next(iter(channel.messages.keys())) + return f"op-{first_msg_name}" + + # Last resort - generate from app_id + return f"op-{self._app_id}" if self._app_id else "op-default" + + async def close(self) -> None: + """Close the connection""" + if self._connection and not self._connection.is_closed: + await self._connection.close() diff --git a/src/asyncapi_python/contrib/wire/amqp/message.py b/src/asyncapi_python/contrib/wire/amqp/message.py new file mode 100644 index 0000000..7cd4271 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/message.py @@ -0,0 +1,59 @@ +"""AMQP message classes""" + +from dataclasses import dataclass, field +from typing import Any + +try: + from aio_pika.abc import AbstractIncomingMessage # type: ignore[import-not-found] +except ImportError as e: + raise ImportError( + "aio-pika is required for AMQP support. Install with: pip install asyncapi-python[amqp]" + ) from e + + +@dataclass +class AmqpWireMessage: + """AMQP wire message implementation""" + + _payload: bytes + _headers: dict[str, Any] = field(default_factory=lambda: {}) + _correlation_id: str | None = None + _reply_to: str | None = None + + @property + def payload(self) -> bytes: + return self._payload + + @property + def headers(self) -> dict[str, Any]: + return self._headers + + @property + def correlation_id(self) -> str | None: + return self._correlation_id + + @property + def reply_to(self) -> str | None: + return self._reply_to + + +@dataclass +class AmqpIncomingMessage(AmqpWireMessage): + """AMQP incoming message with ack/nack/reject support""" + + _amqp_message: AbstractIncomingMessage | None = field(repr=False, default=None) + + async def ack(self) -> None: + """Acknowledge message processing""" + if self._amqp_message: + await self._amqp_message.ack() + + async def nack(self, requeue: bool = True) -> None: + """Negative acknowledge message""" + if self._amqp_message: + await self._amqp_message.nack(requeue=requeue) + + async def reject(self, requeue: bool = False) -> None: + """Reject message""" + if self._amqp_message: + await self._amqp_message.reject(requeue=requeue) diff --git a/src/asyncapi_python/contrib/wire/amqp/producer.py b/src/asyncapi_python/contrib/wire/amqp/producer.py new file mode 100644 index 0000000..69bd29c --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/producer.py @@ -0,0 +1,120 @@ +"""AMQP producer implementation""" + +from typing import Any + +try: + from aio_pika import Message as AmqpMessage, ExchangeType # type: ignore[import-not-found] + from aio_pika.abc import ( # type: ignore[import-not-found] + AbstractConnection, + AbstractChannel, + AbstractExchange, + ) +except ImportError as e: + raise ImportError( + "aio-pika is required for AMQP support. Install with: pip install asyncapi-python[amqp]" + ) from e + +from asyncapi_python.kernel.wire.typing import Producer + +from .message import AmqpWireMessage + + +class AmqpProducer(Producer[AmqpWireMessage]): + """AMQP producer implementation with comprehensive exchange type support""" + + def __init__( + self, + connection: AbstractConnection, + queue_name: str, + exchange_name: str = "", + exchange_type: str = "direct", + routing_key: str = "", + queue_properties: dict[str, Any] | None = None, + ): + self._connection = connection + self._queue_name = queue_name + self._exchange_name = exchange_name + self._exchange_type = exchange_type + self._routing_key = routing_key + self._queue_properties = queue_properties or {} + self._channel: AbstractChannel | None = None + self._target_exchange: AbstractExchange | None = None + self._started = False + + async def start(self) -> None: + """Start the producer with exchange type pattern matching""" + if self._started: + return + + self._channel = await self._connection.channel() + + # Pattern matching for exchange setup based on type + match (self._exchange_name, self._exchange_type): + # Default exchange pattern (queue-based routing) + case ("", _): + self._target_exchange = self._channel.default_exchange + # Declare queue for default exchange routing + if self._queue_name: + await self._channel.declare_queue( + name=self._queue_name, + durable=self._queue_properties.get("durable", True), + exclusive=self._queue_properties.get("exclusive", False), + auto_delete=self._queue_properties.get("auto_delete", False), + ) + + # Named exchange patterns + case (exchange_name, "direct"): + self._target_exchange = await self._channel.declare_exchange( + name=exchange_name, type=ExchangeType.DIRECT, durable=True + ) + + case (exchange_name, "topic"): + self._target_exchange = await self._channel.declare_exchange( + name=exchange_name, type=ExchangeType.TOPIC, durable=True + ) + + case (exchange_name, "fanout"): + self._target_exchange = await self._channel.declare_exchange( + name=exchange_name, type=ExchangeType.FANOUT, durable=True + ) + + case (exchange_name, "headers"): + self._target_exchange = await self._channel.declare_exchange( + name=exchange_name, type=ExchangeType.HEADERS, durable=True + ) + + case (exchange_name, unknown_type): + raise ValueError(f"Unsupported exchange type: {unknown_type}") + + self._started = True + + async def stop(self) -> None: + """Stop the producer""" + if not self._started: + return + + if self._channel: + await self._channel.close() + self._channel = None + self._target_exchange = None + + self._started = False + + async def send_batch(self, messages: list[AmqpWireMessage]) -> None: + """Send a batch of messages using the configured exchange""" + if not self._started or not self._channel or not self._target_exchange: + raise RuntimeError("Producer not started") + + for message in messages: + amqp_message = AmqpMessage( + body=message.payload, + headers=message.headers, + correlation_id=message.correlation_id, + reply_to=message.reply_to, + ) + + # Publish to the configured target exchange (not always default) + await self._target_exchange.publish( + amqp_message, + routing_key=self._routing_key, + ) diff --git a/src/asyncapi_python/contrib/wire/amqp/resolver.py b/src/asyncapi_python/contrib/wire/amqp/resolver.py new file mode 100644 index 0000000..2ee82a5 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/resolver.py @@ -0,0 +1,277 @@ +"""Binding resolution with comprehensive pattern matching""" + +from typing import Any +from asyncapi_python.kernel.wire import EndpointParams +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.bindings import AmqpChannelBinding + +from .config import AmqpConfig, AmqpBindingType +from .utils import validate_parameters_strict, substitute_parameters + + +def resolve_amqp_config( + params: EndpointParams, operation_name: str, app_id: str +) -> AmqpConfig: + """ + Resolve AMQP configuration using comprehensive pattern matching for precedence rules. + + Precedence (highest to lowest): + 1. Reply channel special case + 2. Channel AMQP binding (queue/routingKey/exchange) + 3. Channel address (with parameter substitution) + 4. Operation name + 5. REJECT if none available + """ + channel = params["channel"] + param_values = params["parameters"] or {} + is_reply = params["is_reply"] + + # Strict parameter validation first + validate_parameters_strict(channel, param_values) + + # Extract AMQP binding if present + amqp_binding = None + if channel.bindings and hasattr(channel.bindings, "amqp") and channel.bindings.amqp: + amqp_binding = channel.bindings.amqp + + # Comprehensive pattern matching for precedence + match ( + is_reply, + amqp_binding, + channel.address, + operation_name, + ): + + # Reply channel pattern - anonymous queue (no address, no binding) + case (True, None, None, _): + # Anonymous reply queue: exclusive and temporary (deleted on connection loss) + return AmqpConfig( + queue_name=f"reply-{app_id}", # App-specific reply queue + exchange_name="", # Default exchange for reply + routing_key=f"reply-{app_id}", # Direct routing to the reply queue + binding_type=AmqpBindingType.REPLY, + queue_properties={ + "durable": False, + "exclusive": True, + "auto_delete": True, + }, + ) + + # Reply channel with explicit address - shared channel with filtering + case (True, _, address, _) if address: + resolved_address = substitute_parameters(address, param_values) + return AmqpConfig( + queue_name=f"reply-{app_id}", # App-specific reply queue + exchange_name=resolved_address, # Shared exchange for replies + exchange_type="topic", # Enable pattern matching for filtering + routing_key=app_id, # Filter messages by app_id + binding_type=AmqpBindingType.REPLY, + queue_properties={"durable": True, "exclusive": False}, + ) + + # Reply channel with binding - defer to binding resolution + case (True, binding, _, _) if binding and binding.type == "queue": + config = resolve_queue_binding( + binding, param_values, channel, operation_name + ) + # Override queue name with reply- prefix for reply queues + config.queue_name = f"reply-{app_id}-{config.queue_name}" + config.routing_key = config.queue_name + config.binding_type = AmqpBindingType.REPLY + return config + + case (True, binding, _, _) if binding and binding.type == "routingKey": + config = resolve_routing_key_binding( + binding, param_values, channel, operation_name + ) + # For reply with routing key binding, create a prefixed queue + config.queue_name = f"reply-{app_id}" + config.binding_type = AmqpBindingType.REPLY + return config + + # AMQP queue binding pattern (dataclass only) + case (False, binding, _, _) if binding and binding.type == "queue": + return resolve_queue_binding(binding, param_values, channel, operation_name) + + # AMQP routing key binding pattern (dataclass only) + case (False, binding, _, _) if binding and binding.type == "routingKey": + return resolve_routing_key_binding( + binding, param_values, channel, operation_name + ) + + # AMQP exchange binding pattern (dataclass only) + case (False, binding, _, _) if binding and binding.exchange: + return resolve_exchange_binding( + binding, param_values, channel, operation_name, channel.key + ) + + # Channel address pattern (with parameter substitution) + case (False, None, address, _) if address: + resolved_address = substitute_parameters(address, param_values) + return AmqpConfig( + queue_name=resolved_address, + exchange_name="", # Default exchange + routing_key=resolved_address, + binding_type=AmqpBindingType.QUEUE, + queue_properties={"durable": True, "exclusive": False}, + ) + + # Operation name pattern (fallback) + case (False, None, None, op_name) if op_name: + return AmqpConfig( + queue_name=op_name, + exchange_name="", # Default exchange + routing_key=op_name, + binding_type=AmqpBindingType.QUEUE, + queue_properties={"durable": True, "exclusive": False}, + ) + + # No match - reject creation + case _: + raise ValueError( + f"Cannot resolve AMQP binding: no valid configuration found. " + f"Channel: {channel.address}, Binding: {amqp_binding}, Operation: {operation_name}" + ) + + +def resolve_queue_binding( + binding: AmqpChannelBinding, + param_values: dict[str, str], + channel: Channel, + operation_name: str, +) -> AmqpConfig: + """Resolve AMQP queue binding configuration""" + + # Determine queue name with precedence + match (getattr(binding, "queue", None), channel.address, operation_name): + case (queue_config, _, _) if queue_config and getattr( + queue_config, "name", None + ): + queue_name = substitute_parameters(queue_config.name, param_values) + case (_, address, _) if address: + queue_name = substitute_parameters(address, param_values) + case (_, _, op_name) if op_name: + queue_name = op_name + case _: + raise ValueError("Cannot determine queue name for queue binding") + + # Extract queue properties + queue_config = getattr(binding, "queue", None) + queue_properties = {"durable": True, "exclusive": False} # Defaults + if queue_config: + if hasattr(queue_config, "durable"): + queue_properties["durable"] = queue_config.durable + if hasattr(queue_config, "exclusive"): + queue_properties["exclusive"] = queue_config.exclusive + if hasattr(queue_config, "auto_delete"): + queue_properties["auto_delete"] = queue_config.auto_delete + + return AmqpConfig( + queue_name=queue_name, + exchange_name="", # Queue bindings use default exchange + routing_key=queue_name, # For default exchange, routing_key = queue_name + binding_type=AmqpBindingType.QUEUE, + queue_properties=queue_properties, + ) + + +def resolve_routing_key_binding( + binding: AmqpChannelBinding, + param_values: dict[str, str], + channel: Channel, + operation_name: str, +) -> AmqpConfig: + """Resolve AMQP routing key binding configuration for pub/sub patterns""" + + # Determine exchange name and type + exchange_config = getattr(binding, "exchange", None) + match ( + exchange_config and getattr(exchange_config, "name", None), + channel.address, + operation_name, + ): + case (exchange_name, _, _) if exchange_name: + resolved_exchange = substitute_parameters(exchange_name, param_values) + case (None, address, _) if address: + resolved_exchange = substitute_parameters(address, param_values) + case (None, None, op_name) if op_name: + resolved_exchange = op_name + case _: + raise ValueError("Cannot determine exchange name for routing key binding") + + # Determine exchange type + exchange_type = "topic" # Default for routing key bindings + if exchange_config and hasattr(exchange_config, "type"): + exchange_type = exchange_config.type + + # Determine routing key + match (getattr(binding, "routingKey", None), channel.address, operation_name): + case (routing_key, _, _) if routing_key: + resolved_routing_key = substitute_parameters(routing_key, param_values) + case (None, address, _) if address: + resolved_routing_key = substitute_parameters(address, param_values) + case (None, None, op_name) if op_name: + resolved_routing_key = op_name + case _: + raise ValueError("Cannot determine routing key for routing key binding") + + return AmqpConfig( + queue_name="", # Auto-generated exclusive queue for pub/sub + exchange_name=resolved_exchange, + exchange_type=exchange_type, + routing_key=resolved_routing_key, + binding_type=AmqpBindingType.ROUTING_KEY, + queue_properties={"durable": False, "exclusive": True, "auto_delete": True}, + ) + + +def resolve_exchange_binding( + binding: AmqpChannelBinding, + param_values: dict[str, str], + channel: Channel, + operation_name: str, + channel_key: str = "", +) -> AmqpConfig: + """Resolve AMQP exchange binding configuration for advanced pub/sub""" + + # Get exchange config from dataclass binding + exchange_config = getattr(binding, "exchange", None) + exchange_name = getattr(exchange_config, "name", None) if exchange_config else None + + match ( + exchange_name, + channel.address, + channel_key, + operation_name, + ): + case (exchange_name, _, _, _) if exchange_name: + resolved_exchange = substitute_parameters(exchange_name, param_values) + case (None, address, _, _) if address: + resolved_exchange = substitute_parameters(address, param_values) + case (None, None, ch_key, _) if ch_key: + # Use channel key as fallback when address is null + resolved_exchange = ch_key.lstrip("/") # Remove leading slash + case (None, None, "", op_name) if op_name: + resolved_exchange = op_name + case _: + raise ValueError("Cannot determine exchange name for exchange binding") + + # Determine exchange type from dataclass + exchange_type = "fanout" # Default for exchange bindings + if exchange_config and hasattr(exchange_config, "type"): + exchange_type = exchange_config.type + + # Extract binding arguments for headers exchange from dataclass + binding_args: dict[str, Any] = {} + # Note: bindingKeys is not part of AmqpChannelBinding spec + # This would be handled by operation-level bindings if needed + + return AmqpConfig( + queue_name="", # Auto-generated exclusive queue + exchange_name=resolved_exchange, + exchange_type=exchange_type, + routing_key="", # No routing key for fanout/headers exchanges + binding_type=AmqpBindingType.EXCHANGE, + queue_properties={"durable": False, "exclusive": True, "auto_delete": True}, + binding_arguments=binding_args, + ) diff --git a/src/asyncapi_python/contrib/wire/amqp/utils.py b/src/asyncapi_python/contrib/wire/amqp/utils.py new file mode 100644 index 0000000..cf43415 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/amqp/utils.py @@ -0,0 +1,85 @@ +"""Parameter validation and substitution utilities""" + +# TODO: This thing should be general wire utils, not tied to specific wire + +import re +from asyncapi_python.kernel.document.channel import Channel + + +def validate_parameters_strict(channel: Channel, provided: dict[str, str]) -> None: + """ + Strict parameter validation - all defined parameters must be provided. + Raises ValueError with detailed message if any parameters are missing. + """ + if not channel.parameters: + return # No parameters defined, nothing to validate + + required = set(channel.parameters.keys()) + provided_keys = set(provided.keys()) + + missing = required - provided_keys + if missing: + raise ValueError( + f"Missing required parameters for channel '{channel.address}': {missing}. " + f"Required: {sorted(required)}, Provided: {sorted(provided_keys)}" + ) + + extra = provided_keys - required + if extra: + raise ValueError( + f"Unexpected parameters for channel '{channel.address}': {extra}. " + f"Expected: {sorted(required)}, Provided: {sorted(provided_keys)}" + ) + + +def substitute_parameters(template: str, parameters: dict[str, str]) -> str: + """ + Substitute {param} placeholders with actual values. + All placeholders must have corresponding parameter values. + """ + # Find all {param} placeholders + placeholders = re.findall(r"\{(\w+)\}", template) + + # Check for undefined placeholders + undefined = [p for p in placeholders if p not in parameters] + if undefined: + raise ValueError( + f"Template '{template}' references undefined parameters: {undefined}. " + f"Available parameters: {sorted(parameters.keys())}" + ) + + # Perform substitution + result = template + for key, value in parameters.items(): + result = result.replace(f"{{{key}}}", value) + + return result + + +def validate_channel_template( + channel: Channel, template_name: str, template: str +) -> None: + """ + Validate that a template only references defined channel parameters. + Should be called during application startup to catch configuration errors early. + """ + if not template: + return + + placeholders = re.findall(r"\{(\w+)\}", template) + if not placeholders: + return # No parameters used in template + + if not channel.parameters: + raise ValueError( + f"Channel {template_name} template '{template}' uses parameters {placeholders} " + f"but no parameters are defined for the channel" + ) + + undefined = [p for p in placeholders if p not in channel.parameters] + if undefined: + raise ValueError( + f"Channel {template_name} template '{template}' references " + f"undefined parameters: {undefined}. " + f"Defined parameters: {sorted(channel.parameters.keys())}" + ) diff --git a/src/asyncapi_python/contrib/wire/in_memory.py b/src/asyncapi_python/contrib/wire/in_memory.py new file mode 100644 index 0000000..90ce040 --- /dev/null +++ b/src/asyncapi_python/contrib/wire/in_memory.py @@ -0,0 +1,231 @@ +"""In-memory wire implementation for testing purposes""" + +import asyncio +from collections import defaultdict, deque +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator +from typing_extensions import Unpack + +from asyncapi_python.kernel.wire import AbstractWireFactory, EndpointParams +from asyncapi_python.kernel.wire.typing import Producer, Consumer + + +@dataclass +class InMemoryMessage: + """In-memory implementation of Message protocol""" + + _payload: bytes + _headers: dict[str, Any] = field(default_factory=lambda: {}) + _correlation_id: str | None = None + _reply_to: str | None = None + + @property + def payload(self) -> bytes: + return self._payload + + @property + def headers(self) -> dict[str, Any]: + return self._headers + + @property + def correlation_id(self) -> str | None: + return self._correlation_id + + @property + def reply_to(self) -> str | None: + return self._reply_to + + +@dataclass +class InMemoryIncomingMessage(InMemoryMessage): + """In-memory implementation of IncomingMessage protocol with ack/nack/reject""" + + _acked: bool = field(default=False, init=False) + _nacked: bool = field(default=False, init=False) + _rejected: bool = field(default=False, init=False) + + async def ack(self) -> None: + """Mark message as successfully processed""" + self._acked = True + + async def nack(self) -> None: + """Mark message as failed due to app internal reason""" + self._nacked = True + + async def reject(self) -> None: + """Mark message as failed due to external reasons""" + self._rejected = True + + @property + def is_acknowledged(self) -> bool: + """Check if message was acknowledged""" + return self._acked + + @property + def is_nacked(self) -> bool: + """Check if message was nacked""" + return self._nacked + + @property + def is_rejected(self) -> bool: + """Check if message was rejected""" + return self._rejected + + +class InMemoryBus: + """Central message bus for in-memory wire communication""" + + def __init__(self) -> None: + # Channel name -> queue of messages + self._channels: dict[str, deque[InMemoryIncomingMessage]] = defaultdict(deque) + # Active consumers per channel + self._consumers: dict[str, list["InMemoryConsumer"]] = defaultdict(list) + self._lock = asyncio.Lock() + + async def publish(self, channel_name: str, message: InMemoryMessage) -> None: + """Publish a message to a channel""" + async with self._lock: + # Convert to incoming message for consumers + incoming_msg = InMemoryIncomingMessage( + _payload=message.payload, + _headers=message.headers.copy(), + _correlation_id=message.correlation_id, + _reply_to=message.reply_to, + ) + + # Add to channel queue + self._channels[channel_name].append(incoming_msg) + + # Notify all consumers on this channel + for consumer in self._consumers[channel_name]: + consumer._notify_new_message() # type: ignore[reportPrivateUsage] + + async def subscribe(self, channel_name: str, consumer: "InMemoryConsumer") -> None: + """Subscribe a consumer to a channel""" + async with self._lock: + if consumer not in self._consumers[channel_name]: + self._consumers[channel_name].append(consumer) + + async def unsubscribe( + self, channel_name: str, consumer: "InMemoryConsumer" + ) -> None: + """Unsubscribe a consumer from a channel""" + async with self._lock: + if consumer in self._consumers[channel_name]: + self._consumers[channel_name].remove(consumer) + + async def get_message(self, channel_name: str) -> InMemoryIncomingMessage | None: + """Get next message from channel (FIFO)""" + async with self._lock: + channel_queue = self._channels[channel_name] + if channel_queue: + return channel_queue.popleft() + return None + + +# Global message bus instance for testing +_bus = InMemoryBus() + + +class InMemoryProducer(Producer[InMemoryMessage]): + """In-memory producer implementation""" + + def __init__(self, channel_name: str): + self._channel_name = channel_name + self._started = False + + async def start(self) -> None: + """Start the producer""" + self._started = True + + async def stop(self) -> None: + """Stop the producer""" + self._started = False + + async def send_batch(self, messages: list[InMemoryMessage]) -> None: + """Send a batch of messages to the channel""" + if not self._started: + raise RuntimeError("Producer not started") + + for message in messages: + await _bus.publish(self._channel_name, message) + + +class InMemoryConsumer(Consumer[InMemoryIncomingMessage]): + """In-memory consumer implementation""" + + def __init__(self, channel_name: str): + self._channel_name = channel_name + self._started = False + self._message_event = asyncio.Event() + self._stop_event = asyncio.Event() + + async def start(self) -> None: + """Start the consumer""" + self._started = True + await _bus.subscribe(self._channel_name, self) + + async def stop(self) -> None: + """Stop the consumer""" + self._started = False + self._stop_event.set() + await _bus.unsubscribe(self._channel_name, self) + + def _notify_new_message(self) -> None: + """Internal method called by bus when new message arrives""" + self._message_event.set() + + def recv(self) -> AsyncGenerator[InMemoryIncomingMessage, None]: + """Async generator that yields incoming messages""" + return self._message_generator() + + async def _message_generator(self) -> AsyncGenerator[InMemoryIncomingMessage, None]: + """Internal async generator for messages""" + if not self._started: + raise RuntimeError("Consumer not started") + + while self._started and not self._stop_event.is_set(): + # Try to get a message + message = await _bus.get_message(self._channel_name) + if message: + yield message + continue + + # No message available, wait for notification or stop + try: + await asyncio.wait_for( + self._message_event.wait(), + timeout=0.1, # Small timeout to check stop condition + ) + self._message_event.clear() + except asyncio.TimeoutError: + continue + + +class InMemoryWire(AbstractWireFactory[InMemoryMessage, InMemoryIncomingMessage]): + """In-memory wire factory for testing""" + + async def create_consumer( + self, **kwargs: Unpack[EndpointParams] + ) -> Consumer[InMemoryIncomingMessage]: + """Create an in-memory consumer""" + channel = kwargs["channel"] + return InMemoryConsumer(channel.address or "default") + + async def create_producer( + self, **kwargs: Unpack[EndpointParams] + ) -> Producer[InMemoryMessage]: + """Create an in-memory producer""" + channel = kwargs["channel"] + return InMemoryProducer(channel.address or "default") + + +def get_bus() -> InMemoryBus: + """Get the global in-memory message bus for testing""" + return _bus + + +def reset_bus() -> None: + """Reset the global message bus (useful between tests)""" + global _bus + _bus = InMemoryBus() diff --git a/src/asyncapi_python_codegen/py.typed b/src/asyncapi_python/kernel/__init__.py similarity index 100% rename from src/asyncapi_python_codegen/py.typed rename to src/asyncapi_python/kernel/__init__.py diff --git a/src/asyncapi_python/kernel/application.py b/src/asyncapi_python/kernel/application.py new file mode 100644 index 0000000..4a87a66 --- /dev/null +++ b/src/asyncapi_python/kernel/application.py @@ -0,0 +1,108 @@ +import asyncio +from typing import TypedDict, Any +from typing_extensions import Unpack, Required, NotRequired + +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.wire import AbstractWireFactory +from .endpoint import AbstractEndpoint, EndpointFactory +from .endpoint.abc import EndpointParams +from .codec import CodecFactory + + +class BaseApplication: + class Inputs(TypedDict): + wire_factory: Required[AbstractWireFactory[Any, Any]] + codec_factory: Required[CodecFactory[Any, Any]] + endpoint_params: NotRequired[EndpointParams] + + def __init__(self, **kwargs: Unpack[Inputs]) -> None: + self.__endpoints: set[AbstractEndpoint] = set() + self.__wire_factory: AbstractWireFactory[Any, Any] = kwargs["wire_factory"] + self.__codec_factory: CodecFactory[Any, Any] = kwargs["codec_factory"] + self.__endpoint_params: EndpointParams = kwargs.get("endpoint_params", {}) + self._stop_event: asyncio.Event | None = None + self._monitor_task: asyncio.Task[None] | None = None + self._exception_future: asyncio.Future[Exception] | None = None + + def _register_endpoint(self, op: Operation) -> AbstractEndpoint: + endpoint = EndpointFactory.create( + operation=op, + wire_factory=self.__wire_factory, + codec_factory=self.__codec_factory, + endpoint_params=self.__endpoint_params, + ) + self.__endpoints.add(endpoint) + return endpoint + + async def start(self, *, blocking: bool = False) -> None: + """Start all endpoints in the application. + + Args: + blocking: If True, block until stop() is called or process is interrupted. + If False (default), return immediately after starting endpoints. + """ + await asyncio.gather( + *( + e.start(exception_callback=self._propagate_exception) + for e in self.__endpoints + ) + ) + + if blocking: + # Block until stop() is called or process is interrupted + self._stop_event = asyncio.Event() + self._exception_future = asyncio.Future() + + try: + # Create tasks for both conditions + stop_task = asyncio.create_task(self._stop_event.wait()) + + # Convert Future to awaitable + async def _wait_for_exception(): + if self._exception_future is None: + # Create a never-completing future if no exception future exists + await asyncio.Event().wait() + return # This line will never be reached + return await asyncio.wrap_future(self._exception_future) + + exception_task = asyncio.create_task(_wait_for_exception()) + + # Wait for either stop event or exception + _, pending = await asyncio.wait( + [stop_task, exception_task], return_when=asyncio.FIRST_COMPLETED + ) + # Cancel remaining tasks + for task in pending: + task.cancel() + + # Check if an exception was raised + if exception_task.done() and not exception_task.cancelled(): + exc = exception_task.result() + if exc is not None: + await self.stop() + raise exc + + except asyncio.CancelledError: + # Handle graceful shutdown on cancellation + await self.stop() + raise + + async def stop(self) -> None: + """Stop all endpoints in the application.""" + await asyncio.gather(*(e.stop() for e in self.__endpoints)) + + # Signal the blocking start() method to exit if it's waiting + if self._stop_event: + self._stop_event.set() + + def _add_endpoint(self, endpoint: AbstractEndpoint) -> None: + """Add an endpoint to this application.""" + self.__endpoints.add(endpoint) + + def _propagate_exception(self, exception: Exception) -> None: + """Propagate exception from endpoint to application level.""" + if self._exception_future and not self._exception_future.done(): + self._exception_future.set_result(exception) + + +__all__ = ["BaseApplication"] diff --git a/src/asyncapi_python/kernel/codec.py b/src/asyncapi_python/kernel/codec.py new file mode 100644 index 0000000..7a51e83 --- /dev/null +++ b/src/asyncapi_python/kernel/codec.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from types import ModuleType +from typing import Generic, Protocol + +from asyncapi_python.kernel.document.message import Message +from .typing import T_DecodedPayload, T_EncodedPayload + + +class Codec(Protocol, Generic[T_DecodedPayload, T_EncodedPayload]): + def encode(self, payload: T_DecodedPayload) -> T_EncodedPayload: ... + + def decode(self, payload: T_EncodedPayload) -> T_DecodedPayload: ... + + +class CodecFactory(ABC, Generic[T_DecodedPayload, T_EncodedPayload]): + """A codec factory + + Args: + module (ModuleType): a root module where the generated code of the application lies. + + Notes: + This essentially couples codec factory with the corresponding compiler (options). + All assumptions regarding message type positioning must be clearly documented. + """ + + def __init__(self, module: ModuleType): + self._module = module + + @abstractmethod + def create(self, message: Message) -> Codec[T_DecodedPayload, T_EncodedPayload]: + """Creates codec instance from the message spec. + The factory will dynamically import data model object based on the root module and the + code generated, and will construct a codec implementation for this message. + """ diff --git a/src/asyncapi_python/kernel/document/__init__.py b/src/asyncapi_python/kernel/document/__init__.py new file mode 100644 index 0000000..9e56430 --- /dev/null +++ b/src/asyncapi_python/kernel/document/__init__.py @@ -0,0 +1,54 @@ +from .channel import AddressParameter, Channel, ChannelBindings +from .common import ExternalDocs, Server, Tag +from .message import ( + CorrelationId, + Message, + MessageBindings, + MessageExample, + MessageTrait, +) +from .operation import ( + Operation, + OperationBindings, + OperationReply, + OperationReplyAddress, + OperationTrait, + SecurityScheme, +) +from .bindings import ( + AmqpChannelBinding, + AmqpOperationBinding, + AmqpExchange, + AmqpQueue, + AmqpExchangeType, +) + +__all__ = [ + # channel + "AddressParameter", + "Channel", + "ChannelBindings", + # common + "ExternalDocs", + "Server", + "Tag", + # message + "CorrelationId", + "Message", + "MessageBindings", + "MessageExample", + "MessageTrait", + # operation + "Operation", + "OperationBindings", + "OperationReply", + "OperationReplyAddress", + "OperationTrait", + "SecurityScheme", + # bindings + "AmqpChannelBinding", + "AmqpOperationBinding", + "AmqpExchange", + "AmqpQueue", + "AmqpExchangeType", +] diff --git a/src/asyncapi_python/kernel/document/bindings.py b/src/asyncapi_python/kernel/document/bindings.py new file mode 100644 index 0000000..246b6b7 --- /dev/null +++ b/src/asyncapi_python/kernel/document/bindings.py @@ -0,0 +1,181 @@ +"""AsyncAPI binding classes for various protocols.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Literal, Optional +from enum import Enum + + +class AmqpExchangeType(str, Enum): + """AMQP exchange types.""" + + TOPIC = "topic" + DIRECT = "direct" + FANOUT = "fanout" + DEFAULT = "default" + HEADERS = "headers" + + +@dataclass +class AmqpExchange: + """AMQP exchange configuration.""" + + name: Optional[str] = None + type: AmqpExchangeType = AmqpExchangeType.DEFAULT + durable: Optional[bool] = None + auto_delete: Optional[bool] = None + vhost: Optional[str] = None + + def __repr__(self) -> str: + """Custom repr to handle enum properly for code generation.""" + from asyncapi_python.kernel.document.bindings import AmqpExchangeType + + _ = AmqpExchangeType # Explicitly reference the import + return f"spec.AmqpExchange(name={self.name!r}, type=spec.AmqpExchangeType.{self.type.name}, durable={self.durable!r}, auto_delete={self.auto_delete!r}, vhost={self.vhost!r})" + + +@dataclass +class AmqpQueue: + """AMQP queue configuration.""" + + name: Optional[str] = None + durable: Optional[bool] = None + exclusive: Optional[bool] = None + auto_delete: Optional[bool] = None + vhost: Optional[str] = None + + def __repr__(self) -> str: + """Custom repr for code generation.""" + return f"spec.AmqpQueue(name={self.name!r}, durable={self.durable!r}, exclusive={self.exclusive!r}, auto_delete={self.auto_delete!r}, vhost={self.vhost!r})" + + +@dataclass +class AmqpChannelBinding: + """AMQP channel binding following AsyncAPI specification v0.3.0.""" + + # Discriminator field + type: Literal["queue", "routingKey"] + + # Optional configurations based on type + queue: Optional[AmqpQueue] = None + exchange: Optional[AmqpExchange] = None + + # Version information + binding_version: str = "0.3.0" + + # Extension fields + extensions: Dict[str, Any] = field(default_factory=lambda: {}) + + def __post_init__(self): + """Validate binding configuration after initialization.""" + if self.type == "queue" and not self.queue: + # Default queue configuration + self.queue = AmqpQueue() + elif self.type == "routingKey" and not self.exchange: + # Default exchange configuration + self.exchange = AmqpExchange() + + def __repr__(self) -> str: + """Custom repr for code generation.""" + return f"spec.AmqpChannelBinding(type={self.type!r}, queue={self.queue!r}, exchange={self.exchange!r}, binding_version={self.binding_version!r}, extensions={self.extensions!r})" + + +@dataclass +class AmqpOperationBinding: + """AMQP operation binding following AsyncAPI specification.""" + + # Delivery mode and other operation-specific properties + expiration: Optional[int] = None + user_id: Optional[str] = None + cc: Optional[list[str]] = None + priority: Optional[int] = None + delivery_mode: Optional[int] = None + mandatory: Optional[bool] = None + bcc: Optional[list[str]] = None + timestamp: Optional[bool] = None + ack: Optional[bool] = None + + # Version information + binding_version: str = "0.3.0" + + # Extension fields + extensions: Dict[str, Any] = field(default_factory=lambda: {}) + + def __repr__(self) -> str: + """Custom repr for code generation.""" + return f"spec.AmqpOperationBinding(expiration={self.expiration!r}, user_id={self.user_id!r}, cc={self.cc!r}, priority={self.priority!r}, delivery_mode={self.delivery_mode!r}, mandatory={self.mandatory!r}, bcc={self.bcc!r}, timestamp={self.timestamp!r}, ack={self.ack!r}, binding_version={self.binding_version!r}, extensions={self.extensions!r})" + + +@dataclass +class AmqpMessageBinding: + """AMQP message binding following AsyncAPI specification.""" + + # Message properties + content_encoding: Optional[str] = None + message_type: Optional[str] = None + + # Version information + binding_version: str = "0.3.0" + + # Extension fields + extensions: Dict[str, Any] = field(default_factory=lambda: {}) + + +def create_amqp_binding_from_dict(binding_dict: Dict[str, Any]) -> AmqpChannelBinding: + """Create an AmqpChannelBinding from a dictionary. + + This helper function converts the dictionary format used in generated code + to the proper binding object structure expected by the resolver. + """ + if not binding_dict: + raise ValueError("Invalid AMQP binding: binding data is empty") + + # Derive binding type from presence of fields + has_exchange = "exchange" in binding_dict + has_routing_key = "routingKey" in binding_dict + has_queue = "queue" in binding_dict + + if has_exchange and has_routing_key: + raise ValueError( + "Invalid AMQP binding: both exchange and routingKey are present" + ) + elif has_queue: + binding_type: Literal["queue", "routingKey"] = "queue" + elif has_exchange or has_routing_key: + binding_type = "routingKey" + else: + # Default fallback - assume it's a queue binding + binding_type = "queue" + + # Create the binding based on type + binding = AmqpChannelBinding(type=binding_type) + + if binding_type == "queue" and "queue" in binding_dict: + queue_config = binding_dict["queue"] + binding.queue = AmqpQueue( + name=queue_config.get("name"), + durable=queue_config.get("durable"), + exclusive=queue_config.get("exclusive"), + auto_delete=queue_config.get("auto_delete"), + vhost=queue_config.get("vhost"), + ) + elif binding_type == "routingKey" and "exchange" in binding_dict: + exchange_config = binding_dict["exchange"] + exchange_type = exchange_config.get("type", "default") + + # Convert string to enum + try: + enum_type = AmqpExchangeType(exchange_type) + except ValueError: + enum_type = AmqpExchangeType.DEFAULT + + binding.exchange = AmqpExchange( + name=exchange_config.get("name"), + type=enum_type, + durable=exchange_config.get("durable"), + auto_delete=exchange_config.get("auto_delete"), + vhost=exchange_config.get("vhost"), + ) + + return binding diff --git a/src/asyncapi_python/kernel/document/channel.py b/src/asyncapi_python/kernel/document/channel.py new file mode 100644 index 0000000..373bad7 --- /dev/null +++ b/src/asyncapi_python/kernel/document/channel.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Any +from .message import Message +from .common import * +from .bindings import AmqpChannelBinding + +__all__ = ["AddressParameter", "ChannelBindings", "Channel"] + + +@dataclass(frozen=True) +class AddressParameter: + description: str | None + location: str + key: str + + +@dataclass(frozen=True) +class ChannelBindings: + http: Any = None + amqp1: Any = None + mqtt: Any = None + nats: Any = None + stomp: Any = None + redis: Any = None + solace: Any = None + ws: Any = None + amqp: AmqpChannelBinding | None = None + kafka: Any = None + anypointmq: Any = None + jms: Any = None + sns: Any = None + sqs: Any = None + ibmmq: Any = None + googlepubsub: Any = None + pulsar: Any = None + + +@dataclass(frozen=True) +class Channel: + address: str | None + title: str | None + summary: str | None + description: str | None + servers: list[Server] + messages: dict[str, Message] + parameters: dict[str, AddressParameter] + tags: list[Tag] + external_docs: ExternalDocs | None + bindings: ChannelBindings | None + key: str diff --git a/src/asyncapi_python/kernel/document/common.py b/src/asyncapi_python/kernel/document/common.py new file mode 100644 index 0000000..fe31da9 --- /dev/null +++ b/src/asyncapi_python/kernel/document/common.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ExternalDocs: + description: str + url: str + + +@dataclass(frozen=True) +class Tag: + name: str + description: str + external_docs: ExternalDocs + + +@dataclass(frozen=True) +class Server: + key: str # TODO: Implement full Server spec + + +__all__ = ["ExternalDocs", "Tag", "Server"] diff --git a/src/asyncapi_python/kernel/document/message.py b/src/asyncapi_python/kernel/document/message.py new file mode 100644 index 0000000..e7a0d09 --- /dev/null +++ b/src/asyncapi_python/kernel/document/message.py @@ -0,0 +1,82 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any +from .common import * +from .bindings import AmqpMessageBinding + +__all__ = [ + "CorrelationId", + "MessageBindings", + "MessageExample", + "MessageTrait", + "Message", +] + + +@dataclass(frozen=True) +class CorrelationId: + description: str | None + location: str + + +@dataclass(frozen=True) +class MessageBindings: + http: Any = None + amqp1: Any = None + mqtt: Any = None + nats: Any = None + stomp: Any = None + redis: Any = None + solace: Any = None + ws: Any = None + amqp: AmqpMessageBinding | None = None + kafka: Any = None + anypointmq: Any = None + jms: Any = None + sns: Any = None + sqs: Any = None + ibmmq: Any = None + googlepubsub: Any = None + pulsar: Any = None + + +@dataclass(frozen=True) +class MessageExample: + name: str | None + summary: str | None + headers: Any + payload: Any + + +@dataclass(frozen=True) +class MessageTrait: + content_type: str | None + headers: Any + summary: str | None + name: str | None + title: str | None + description: str | None + deprecated: bool | None + examples: list[MessageExample] + correlation_id: CorrelationId | None + tags: list[Tag] + externalDocs: ExternalDocs | None + bindings: MessageBindings | None + + +@dataclass(frozen=True) +class Message: + content_type: str | None + headers: Any + payload: Any + summary: str | None + name: str | None + title: str | None + description: str | None + deprecated: bool | None + correlation_id: CorrelationId | None + tags: list[Tag] + externalDocs: ExternalDocs | None + bindings: MessageBindings | None + traits: list[MessageTrait] + key: str diff --git a/src/asyncapi_python/kernel/document/operation.py b/src/asyncapi_python/kernel/document/operation.py new file mode 100644 index 0000000..b0c0930 --- /dev/null +++ b/src/asyncapi_python/kernel/document/operation.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass +from typing import Any, Literal +from .common import * +from .channel import Channel +from .message import Message +from .bindings import AmqpOperationBinding + +__all__ = [ + "SecurityScheme", + "OperationReplyAddress", + "OperationReply", + "OperationBindings", + "OperationTrait", + "Operation", +] + + +@dataclass(frozen=True) +class SecurityScheme: + type: Literal[ + "userPassword", + "apiKey", + "X509", + "symmetricEncryption", + "asymmetricEncryption", + "oauth2Flows", + "openIdConnect", + "HTTPSecurityScheme", + "SaslSecurityScheme", + ] + key: str + + +@dataclass(frozen=True) +class OperationReplyAddress: + location: str + description: str | None + + +@dataclass(frozen=True) +class OperationReply: + channel: Channel + messages: list[Message] + address: str | None + + +@dataclass(frozen=True) +class OperationBindings: + # TODO: Reproduce full schema here + http: Any = None + amqp1: Any = None + mqtt: Any = None + nats: Any = None + stomp: Any = None + redis: Any = None + solace: Any = None + ws: Any = None + amqp: AmqpOperationBinding | None = None + kafka: Any = None + anypointmq: Any = None + jms: Any = None + sns: Any = None + sqs: Any = None + ibmmq: Any = None + googlepubsub: Any = None + pulsar: Any = None + + +@dataclass(frozen=True) +class OperationTrait: + title: str | None + summary: str | None + description: str | None + channel: Channel + security: list[SecurityScheme] + tags: list[Tag] + external_docs: ExternalDocs | None + bindings: OperationBindings + + +@dataclass(frozen=True) +class Operation: + action: Literal["send", "receive"] + title: str | None + summary: str | None + description: str | None + channel: Channel + messages: list[Message] + reply: OperationReply | None + traits: list[OperationTrait] + security: list[SecurityScheme] + tags: list[Tag] + external_docs: ExternalDocs | None + bindings: OperationBindings | None + key: str diff --git a/src/asyncapi_python/kernel/endpoint/__init__.py b/src/asyncapi_python/kernel/endpoint/__init__.py new file mode 100644 index 0000000..c8b20ec --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/__init__.py @@ -0,0 +1,34 @@ +from typing import ClassVar, Literal +from typing_extensions import Unpack +from .abc import AbstractEndpoint +from .publisher import Publisher +from .subscriber import Subscriber +from .rpc_client import RpcClient +from .rpc_server import RpcServer + +__all__ = [ + "AbstractEndpoint", + "Publisher", + "Subscriber", + "RpcClient", + "RpcServer", + "EndpointFactory", +] + + +class EndpointFactory: + _registry: ClassVar[ + dict[tuple[Literal["send", "receive"], bool], type[AbstractEndpoint]] + ] = { + ("send", False): Publisher, + ("receive", False): Subscriber, + ("send", True): RpcClient, + ("receive", True): RpcServer, + } + + @classmethod + def create(cls, **kwargs: Unpack[AbstractEndpoint.Inputs]) -> AbstractEndpoint: + op = kwargs["operation"] + action, has_reply = op.action, op.reply is not None + endpoint = cls._registry[(action, has_reply)](**kwargs) + return endpoint diff --git a/src/asyncapi_python/kernel/endpoint/abc.py b/src/asyncapi_python/kernel/endpoint/abc.py new file mode 100644 index 0000000..5eb2281 --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/abc.py @@ -0,0 +1,159 @@ +from abc import ABC, abstractmethod +from typing import Any, Callable, Generic, TypedDict, overload, Union +from typing_extensions import Unpack, Required, NotRequired + +from ..typing import Handler, T_Input, T_Output, BatchConfig +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.document import Operation +from asyncapi_python.kernel.codec import Codec, CodecFactory + + +class EndpointParams(TypedDict): + """Optional parameters for endpoint configuration""" + + disable_handler_validation: NotRequired[ + bool + ] # Opt-out of handler enforcement for testing + + +class HandlerParams(TypedDict): + """Parameters for message handlers""" + + pass # Currently empty, but extensible for future parameters like queue, routing_key, etc. + + +class AbstractEndpoint(ABC): + class Inputs(TypedDict): + """Constructor parameters""" + + operation: Required[Operation] + wire_factory: Required[AbstractWireFactory[Any, Any]] + codec_factory: Required[CodecFactory[Any, Any]] + endpoint_params: NotRequired[EndpointParams] # Optional endpoint configuration + + class StartParams(TypedDict): + """Parameters for starting an endpoint""" + + exception_callback: NotRequired[Callable[[Exception], None]] + """Callback to propagate exceptions""" + + def __init__(self, **kwargs: Unpack[Inputs]): + self._operation = kwargs["operation"] + self._wire = kwargs["wire_factory"] + codec_factory = kwargs["codec_factory"] + # Endpoint sets its own defaults - empty dict if not provided + self._endpoint_params = kwargs.get("endpoint_params", {}) + self._exception_callback: Callable[[Exception], None] | None = None + + # Create codecs for operation messages + self._codecs: list[Codec[Any, Any]] = [ + codec_factory.create(msg) for msg in self._operation.messages + ] + + # Create codecs for reply messages if reply exists + self._reply_codecs: list[Codec[Any, Any]] = ( + [codec_factory.create(msg) for msg in self._operation.reply.messages] + if self._operation.reply + else [] + ) + + def _encode_message(self, payload: Any) -> Any: + """Encode using main message codecs""" + return self._try_codecs(self._codecs, "encode", payload) + + def _decode_message(self, payload: Any) -> Any: + """Decode using main message codecs""" + return self._try_codecs(self._codecs, "decode", payload) + + def _encode_reply(self, payload: Any) -> Any: + """Encode using reply codecs""" + if not self._reply_codecs: + raise RuntimeError("No reply codecs - operation has no reply") + return self._try_codecs(self._reply_codecs, "encode", payload) + + def _decode_reply(self, payload: Any) -> Any: + """Decode using reply codecs""" + if not self._reply_codecs: + raise RuntimeError("No reply codecs - operation has no reply") + return self._try_codecs(self._reply_codecs, "decode", payload) + + def _should_validate_handlers(self) -> bool: + """Check if handler validation should be performed""" + return not self._endpoint_params.get("disable_handler_validation", False) + + def _try_codecs( + self, codecs: list[Codec[Any, Any]], operation: str, payload: Any + ) -> Any: + """Try operation with each codec in sequence until one succeeds""" + if not codecs: + raise RuntimeError("No codecs available") + + last_error = None + + for codec in codecs: + try: + if operation == "encode": + return codec.encode(payload) + else: # decode + return codec.decode(payload) + except Exception as e: + last_error = e + continue + + raise RuntimeError( + f"Failed to {operation} payload with any available codec. Last error: {last_error}" + ) + + @abstractmethod + async def start(self, **params: Unpack[StartParams]) -> None: ... + + @abstractmethod + async def stop(self) -> None: ... + + +class Send(ABC, Generic[T_Input, T_Output]): + """An interface that sending endpoint implements""" + + class RouterInputs(TypedDict): + """Base inputs for send endpoints. Router subclasses can extend this with specific parameters.""" + + pass # Empty for now, extensible for future fields + + @abstractmethod + async def __call__( + self, payload: T_Input, /, **kwargs: Unpack[RouterInputs] + ) -> T_Output: ... + + +class Receive(ABC, Generic[T_Input, T_Output]): + + @overload + def __call__( + self, fn: Handler[T_Input, T_Output] + ) -> Handler[T_Input, T_Output]: ... + + @overload + def __call__( + self, + fn: None = None, + *, + batch: BatchConfig, + **kwargs: Unpack[HandlerParams], + ) -> Callable[[Handler[T_Input, T_Output]], Handler[T_Input, T_Output]]: ... + + @overload + def __call__( + self, fn: None = None, **kwargs: Unpack[HandlerParams] + ) -> Callable[[Handler[T_Input, T_Output]], Handler[T_Input, T_Output]]: ... + + @abstractmethod + def __call__( + self, + fn: Handler[T_Input, T_Output] | None = None, + *, + batch: BatchConfig | None = None, + **kwargs: Unpack[HandlerParams], + ) -> Union[ + Handler[T_Input, T_Output], + Callable[[Handler[T_Input, T_Output]], Handler[T_Input, T_Output]], + ]: ... diff --git a/src/asyncapi_python/kernel/endpoint/exceptions.py b/src/asyncapi_python/kernel/endpoint/exceptions.py new file mode 100644 index 0000000..66242bf --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/exceptions.py @@ -0,0 +1,19 @@ +class EndpointError(Exception): + """Base exception for endpoint errors""" + + +class UninitializedError(EndpointError): + """Raised when endpoint is used before initialization""" + + def __init__(self): + super().__init__( + "Tried to perform wire communication action before initializing wire" + ) + + +class TimeoutError(EndpointError): + """Raised when an RPC call times out""" + + +class HandlerError(EndpointError): + """Raised when a handler encounters an error""" diff --git a/src/asyncapi_python/kernel/endpoint/message.py b/src/asyncapi_python/kernel/endpoint/message.py new file mode 100644 index 0000000..ac4d951 --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/message.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class WireMessage: + """Simple wire message implementation""" + + _payload: bytes + _headers: dict[str, Any] + _correlation_id: str | None = None + _reply_to: str | None = None + + @property + def payload(self) -> bytes: + return self._payload + + @property + def headers(self) -> dict[str, Any]: + return self._headers + + @property + def correlation_id(self) -> str | None: + return self._correlation_id + + @property + def reply_to(self) -> str | None: + return self._reply_to diff --git a/src/asyncapi_python/kernel/endpoint/publisher.py b/src/asyncapi_python/kernel/endpoint/publisher.py new file mode 100644 index 0000000..34e03f4 --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/publisher.py @@ -0,0 +1,70 @@ +from typing import Generic +from typing_extensions import Unpack + +from .abc import AbstractEndpoint, Send +from .exceptions import UninitializedError +from .message import WireMessage +from ..typing import T_Input +from asyncapi_python.kernel.wire import Producer + + +class Publisher(AbstractEndpoint, Send[T_Input, None], Generic[T_Input]): + """Publisher endpoint for sending messages without expecting replies""" + + def __init__(self, **kwargs: Unpack[AbstractEndpoint.Inputs]): + super().__init__(**kwargs) + self._producer: Producer[WireMessage] | None = None + + async def start(self, **params: Unpack[AbstractEndpoint.StartParams]) -> None: + """Initialize the publisher endpoint""" + if self._producer: + return + + # Get exception callback from parameters + self._exception_callback = params.get("exception_callback") + + # Validate we have codecs for messages + if not self._codecs: + raise RuntimeError("Operation has no named messages") + + # Create producer from wire factory + self._producer = await self._wire.create_producer( + channel=self._operation.channel, + parameters={}, + op_bindings=self._operation.bindings, + is_reply=False, + ) + + # Start the producer + if self._producer: + await self._producer.start() + + async def stop(self) -> None: + """Cleanup the publisher endpoint""" + if not self._producer: + return + + await self._producer.stop() + self._producer = None + + async def __call__( + self, payload: T_Input, /, **kwargs: Unpack[Send.RouterInputs] + ) -> None: + """Send a message without expecting a reply + + Args: + payload: The message payload to send + """ + if not self._producer: + raise UninitializedError() + + # Encode payload using main message codecs + encoded_payload = self._encode_message(payload) + + # Create wire message with encoded payload + wire_message = WireMessage( + _payload=encoded_payload, _headers={}, _correlation_id=None, _reply_to=None + ) + + # Send via producer + await self._producer.send_batch([wire_message]) diff --git a/src/asyncapi_python/kernel/endpoint/rpc_client.py b/src/asyncapi_python/kernel/endpoint/rpc_client.py new file mode 100644 index 0000000..73b67aa --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/rpc_client.py @@ -0,0 +1,133 @@ +import asyncio +from typing import Generic +from typing_extensions import Unpack +from uuid import uuid4 + +from .abc import AbstractEndpoint, Send +from .exceptions import UninitializedError, TimeoutError +from .message import WireMessage +from ..typing import T_Input, T_Output, IncomingMessage +from asyncapi_python.kernel.wire import Producer + + +from .rpc_reply_handler import global_reply_handler + + +class RpcClient(AbstractEndpoint, Send[T_Input, T_Output], Generic[T_Input, T_Output]): + """RPC client endpoint for request/response pattern + + Sends requests with correlation IDs and waits for responses + on a shared global reply queue. All RPC client instances share + a single reply consumer and background task for efficiency. + """ + + def __init__(self, **kwargs: Unpack[AbstractEndpoint.Inputs]): + super().__init__(**kwargs) + # Instance-specific state + self._producer: Producer[WireMessage] | None = None + + async def start(self, **params: Unpack[AbstractEndpoint.StartParams]) -> None: + """Initialize the RPC client endpoint""" + if self._producer: + return + + # Get exception callback from parameters + self._exception_callback = params.get("exception_callback") + + # Validate we have codecs for messages and replies + if not self._codecs: + raise RuntimeError("Operation has no named messages") + if not self._reply_codecs: + raise RuntimeError("Operation has no reply messages") + + # Increment instance count and ensure global reply handler + global_reply_handler.increment_instance_count() + + # Ensure global reply handling is set up (only happens once) + await global_reply_handler.ensure_reply_handler(self._wire, self._operation) + + # Create instance-specific producer for sending requests + self._producer = await self._wire.create_producer( + channel=self._operation.channel, + parameters={}, + op_bindings=self._operation.bindings, + is_reply=False, + ) + + # Start producer + if self._producer: + await self._producer.start() + + async def stop(self) -> None: + """Cleanup the RPC client endpoint""" + # Stop instance producer + if self._producer: + await self._producer.stop() + self._producer = None + + # Decrement count and cleanup if last instance + remaining_count = global_reply_handler.decrement_instance_count() + if remaining_count == 0: + await global_reply_handler.cleanup_if_last_instance() + + async def __call__( + self, + payload: T_Input, + /, + timeout: float = 30.0, + **kwargs: Unpack[Send.RouterInputs], + ) -> T_Output: + """Send an RPC request and wait for response using global reply handling + + Args: + payload: The request payload to send + timeout: Maximum time to wait for response (default 30 seconds) + + Returns: + The response payload + + Raises: + TimeoutError: If response not received within timeout + UninitializedError: If endpoint not started + """ + if not self._producer: + raise UninitializedError() + + # Generate correlation ID for this request + correlation_id: str = str(uuid4()) + + # Register with global futures dict + response_future: asyncio.Future[IncomingMessage] = ( + global_reply_handler.register_request(correlation_id) + ) + + try: + # Encode request payload + encoded_payload: bytes = self._encode_message(payload) + + # Create wire message with RPC metadata (use global reply queue) + wire_message: WireMessage = WireMessage( + _payload=encoded_payload, + _headers={}, + _correlation_id=correlation_id, + _reply_to=global_reply_handler.reply_queue_name, # Global reply queue + ) + + # Send request + await self._producer.send_batch([wire_message]) + + # Wait for response with timeout (handled by global background task) + try: + response_message: IncomingMessage = await asyncio.wait_for( + response_future, timeout=timeout + ) + except asyncio.TimeoutError: + raise TimeoutError(f"RPC request timed out after {timeout} seconds") + + # Decode and return response + decoded_response: T_Output = self._decode_reply(response_message.payload) + return decoded_response + + finally: + # Clean up future on timeout or error (if not already removed) + global_reply_handler.cleanup_request(correlation_id) diff --git a/src/asyncapi_python/kernel/endpoint/rpc_reply_handler.py b/src/asyncapi_python/kernel/endpoint/rpc_reply_handler.py new file mode 100644 index 0000000..2d324c2 --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/rpc_reply_handler.py @@ -0,0 +1,163 @@ +"""Global RPC reply handler for managing shared reply queue across all RPC clients.""" + +import asyncio +import secrets + +from ..typing import IncomingMessage +from typing import Any +from asyncapi_python.kernel.wire import Consumer, AbstractWireFactory +from asyncapi_python.kernel.document import Channel, Operation + + +class GlobalRpcReplyHandler: + """Manages global reply queue and routing for all RPC clients + + This class handles the shared state and background task that processes + all RPC replies and routes them to the correct waiting client based + on correlation IDs. + """ + + def __init__(self) -> None: + self._futures: dict[str, asyncio.Future[IncomingMessage]] = {} + self._reply_consumer: Consumer[Any] | None = None + self._consume_task: asyncio.Task[None] | None = None + self._reply_queue_name: str | None = None + self._instance_count: int = 0 + + async def ensure_reply_handler( + self, wire_factory: AbstractWireFactory[Any, Any], operation: Operation + ) -> None: + """Ensure reply consumer and task are running""" + if self._reply_consumer is None: + # Create reply consumer (only once for all instances) + reply_channel = self._get_or_create_reply_channel(operation) + + self._reply_consumer = await wire_factory.create_consumer( + channel=reply_channel, + parameters={}, + op_bindings=None, + is_reply=True, + ) + + # Generate unique reply queue name for all clients + self._reply_queue_name = f"reply-{secrets.token_hex(8)}" + + # Start the consumer + await self._reply_consumer.start() + + # Start background task + self._consume_task = asyncio.create_task(self._consume_all_replies()) + + def _get_or_create_reply_channel(self, operation: Operation) -> Channel: + """Get reply channel from operation or create default one""" + if operation.reply and operation.reply.channel: + return operation.reply.channel + else: + # Create a default reply channel for global use + return Channel( + address=None, # Use default/null address for global reply queue + title="Global RPC Reply Queue", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="global-reply", + ) + + async def _consume_all_replies(self) -> None: + """Background task consuming ALL RPC replies from all clients""" + if not self._reply_consumer: + return + + try: + async for wire_message in self._reply_consumer.recv(): + try: + # Match reply to waiting request by correlation ID + correlation_id: str | None = wire_message.correlation_id + if correlation_id and correlation_id in self._futures: + future: asyncio.Future[IncomingMessage] = self._futures.pop( + correlation_id + ) # Remove and resolve + if not future.done(): + future.set_result(wire_message) + + # Acknowledge message + await wire_message.ack() + + except Exception: + # Handle errors in individual message processing + await wire_message.nack() + except Exception: + # If the consumer fails completely, cancel all pending futures + for future in self._futures.values(): + if not future.done(): + future.cancel() + self._futures.clear() + + def register_request(self, correlation_id: str) -> asyncio.Future[IncomingMessage]: + """Register a new RPC request and return its future""" + future: asyncio.Future[IncomingMessage] = asyncio.Future() + self._futures[correlation_id] = future + return future + + def cleanup_request(self, correlation_id: str) -> None: + """Clean up a request future (used on timeout/error)""" + self._futures.pop(correlation_id, None) + + @property + def reply_queue_name(self) -> str | None: + """Get the global reply queue name""" + return self._reply_queue_name + + def increment_instance_count(self) -> None: + """Increment the instance count""" + self._instance_count += 1 + + def decrement_instance_count(self) -> int: + """Decrement instance count and return new count""" + self._instance_count -= 1 + return self._instance_count + + async def cleanup_if_last_instance(self) -> None: + """Clean up global resources if no instances remain""" + if self._instance_count == 0: + # First cancel the background task + if self._consume_task and not self._consume_task.done(): + self._consume_task.cancel() + try: + await self._consume_task + except asyncio.CancelledError: + pass + except Exception: + # Handle any other exceptions during cleanup + pass + self._consume_task = None + + # Then stop the consumer + if self._reply_consumer: + try: + await self._reply_consumer.stop() + except Exception: + # Handle any exceptions during consumer stop + pass + self._reply_consumer = None + + # Cancel any remaining futures + for future in list(self._futures.values()): + if not future.done(): + future.cancel() + # Give cancelled futures a chance to be collected + try: + await asyncio.sleep(0) + except: + pass + self._futures.clear() + self._reply_queue_name = None + + +# Global singleton instance for all RPC clients +global_reply_handler = GlobalRpcReplyHandler() diff --git a/src/asyncapi_python/kernel/endpoint/rpc_server.py b/src/asyncapi_python/kernel/endpoint/rpc_server.py new file mode 100644 index 0000000..63ab0ba --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/rpc_server.py @@ -0,0 +1,446 @@ +import asyncio +from typing import Callable, Generic, overload, Union +from typing_extensions import Unpack + +from .abc import AbstractEndpoint, Receive, HandlerParams +from .message import WireMessage +from ..typing import ( + T_Input, + T_Output, + Handler, + BatchHandler, + BatchConfig, + IncomingMessage, +) +from ..exceptions import Reject +from asyncapi_python.kernel.wire import Consumer, Producer + + +class RpcServer( + AbstractEndpoint, Receive[T_Input, T_Output], Generic[T_Input, T_Output] +): + """RPC server endpoint for handling requests and sending responses + + Receives requests with correlation IDs and sends responses + back to the reply_to address. + """ + + def __init__(self, **kwargs: Unpack[AbstractEndpoint.Inputs]): + super().__init__(**kwargs) + self._consumer: Consumer[IncomingMessage] | None = None + self._reply_producer: Producer[WireMessage] | None = None + self._handler: Handler[T_Input, T_Output] | None = None + self._batch_handler: BatchHandler[T_Input, T_Output] | None = None + self._handler_location: str | None = None + self._batch_config: BatchConfig | None = None + self._consume_task: asyncio.Task[None] | None = None + + async def start(self, **params: Unpack[AbstractEndpoint.StartParams]) -> None: + """Initialize the RPC server endpoint""" + if self._consumer: + return + + # Get exception callback from parameters + self._exception_callback = params.get("exception_callback") + + # Validate that we have exactly one handler (if validation is enabled) + if ( + self._should_validate_handlers() + and not self._handler + and not self._batch_handler + ): + raise RuntimeError( + f"RPC server endpoint '{self._operation.key}' requires exactly one handler. " + f"Use @{self._operation.key} decorator to register a handler function." + ) + + # Validate we have reply codecs + if not self._reply_codecs: + raise RuntimeError("RPC server operation has no reply messages defined") + + # Create consumer for receiving requests + self._consumer = await self._wire.create_consumer( + channel=self._operation.channel, + parameters={}, + op_bindings=self._operation.bindings, + is_reply=False, + ) + + # Create producer for sending replies + # Use reply channel if specified, otherwise use default exchange + if self._operation.reply and self._operation.reply.channel: + reply_channel = self._operation.reply.channel + else: + # Create a default reply channel (null address for direct reply) + from asyncapi_python.kernel.document import Channel + + reply_channel = Channel( + address=None, # Use default/null address for direct reply + title="Reply Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="reply", + ) + + self._reply_producer = await self._wire.create_producer( + channel=reply_channel, + parameters={}, + op_bindings=None, + is_reply=True, + ) + + # Start consumer and producer + if self._consumer: + await self._consumer.start() + if self._reply_producer: + await self._reply_producer.start() + + # Start consuming task if we have a handler but no task yet + if (self._handler or self._batch_handler) and not self._consume_task: + if self._batch_handler: + self._consume_task = asyncio.create_task(self._consume_requests_batch()) + else: + self._consume_task = asyncio.create_task(self._consume_requests()) + + async def stop(self) -> None: + """Cleanup the RPC server endpoint""" + # Cancel the consume task + if self._consume_task: + self._consume_task.cancel() + try: + await self._consume_task + except asyncio.CancelledError: + pass + self._consume_task = None + + # Stop consumer and producer + if self._consumer: + await self._consumer.stop() + self._consumer = None + if self._reply_producer: + await self._reply_producer.stop() + self._reply_producer = None + + @overload + @overload + def __call__( + self, fn: Handler[T_Input, T_Output] + ) -> Handler[T_Input, T_Output]: ... + + @overload + def __call__( + self, + fn: None = None, + *, + batch: BatchConfig, + **kwargs: Unpack[HandlerParams], + ) -> Callable[ + [BatchHandler[T_Input, T_Output]], BatchHandler[T_Input, T_Output] + ]: ... + + @overload + @overload + def __call__( + self, fn: None = None, **kwargs: Unpack[HandlerParams] + ) -> Callable[[Handler[T_Input, T_Output]], Handler[T_Input, T_Output]]: ... + + def __call__( # type: ignore[override] + self, + fn: Handler[T_Input, T_Output] | BatchHandler[T_Input, T_Output] | None = None, + *, + batch: BatchConfig | None = None, + **kwargs: Unpack[HandlerParams], + ) -> Union[ + Handler[T_Input, T_Output], + BatchHandler[T_Input, T_Output], + Callable[[Handler[T_Input, T_Output]], Handler[T_Input, T_Output]], + Callable[[BatchHandler[T_Input, T_Output]], BatchHandler[T_Input, T_Output]], + ]: + """Register a handler for incoming RPC requests + + Can be used as a decorator: + @rpc_server + async def handle_request(msg) -> Response: ... + + Or with parameters: + @rpc_server(queue="high-priority") + async def handle_request(msg) -> Response: ... + """ + if fn is None: + # Called with parameters: @rpc_server(batch=..., ...) + if batch is not None: + # Batch mode - expect BatchHandler + def batch_decorator( + handler_fn: BatchHandler[T_Input, T_Output], + ) -> BatchHandler[T_Input, T_Output]: + self._register_handler(handler_fn, batch, kwargs) + return handler_fn + + return batch_decorator + else: + # Regular mode - expect Handler + def decorator( + handler_fn: Handler[T_Input, T_Output], + ) -> Handler[T_Input, T_Output]: + self._register_handler(handler_fn, None, kwargs) + return handler_fn + + return decorator + else: + # Called directly: @rpc_server + self._register_handler(fn, batch, kwargs) + return fn + + def _register_handler( + self, + handler: Handler[T_Input, T_Output] | BatchHandler[T_Input, T_Output], + batch_config: BatchConfig | None, + params: HandlerParams, + ) -> None: + """Register a handler and start consuming requests""" + if self._should_validate_handlers() and ( + self._handler is not None or self._batch_handler is not None + ): + existing_handler = self._handler or self._batch_handler + assert existing_handler is not None # for mypy + raise RuntimeError( + f"RPC server endpoint '{self._operation.key}' already has a handler registered.\n" + f"Existing handler: {existing_handler.__name__} at {self._handler_location}\n" + f"New handler: {handler.__name__} at {handler.__code__.co_filename}:{handler.__code__.co_firstlineno}\n" + f"Each RPC server endpoint must have exactly one handler." + ) + + # Determine if this is a batch handler by checking if batch config exists + if batch_config is not None: + self._batch_handler = handler # type: ignore + self._batch_config = batch_config + self._handler = None + else: + self._handler = handler # type: ignore + self._batch_handler = None + self._batch_config = None + + self._handler_location = ( + f"{handler.__code__.co_filename}:{handler.__code__.co_firstlineno}" + ) + # Start background task to consume requests if consumer is ready + if self._consumer and not self._consume_task: + try: + if self._batch_handler: + self._consume_task = asyncio.create_task( + self._consume_requests_batch() + ) + else: + self._consume_task = asyncio.create_task(self._consume_requests()) + except RuntimeError: + # No event loop running, task will be created later when start() is called + pass + + async def _consume_requests(self) -> None: + """Background task that consumes requests and sends responses""" + if not self._consumer or not self._handler or not self._reply_producer: + return + + async for wire_message in self._consumer.recv(): + try: + # Validate RPC metadata + if not wire_message.correlation_id or not wire_message.reply_to: + # Not an RPC request, skip + await wire_message.nack() + continue + + # Decode the request payload + decoded_payload = self._decode_message(wire_message.payload) + + # Call the user handler to get response + try: + response = await self._handler(decoded_payload) + except Reject as e: + # Message rejected - reject and continue + await wire_message.reject() + continue + except Exception as e: + # Any other exception - nack and propagate to stop application + await wire_message.nack() + # Propagate to application level + if self._exception_callback: + self._exception_callback(e) + return # Stop processing messages + + # Encode response + encoded_response = self._encode_reply(response) + + # Create reply message with same correlation ID + reply_message = WireMessage( + _payload=encoded_response, + _headers={}, + _correlation_id=wire_message.correlation_id, + _reply_to=None, # No further reply expected + ) + + # Send reply + await self._send_reply(reply_message) + + # Acknowledge successful processing + await wire_message.ack() + + except Exception: + # Handle processing errors + await wire_message.nack() + + async def _consume_requests_batch(self) -> None: + """Background task that consumes requests in batches and sends batched responses""" + if ( + not self._consumer + or not self._batch_handler + or not self._reply_producer + or not self._batch_config + ): + return + + batch: list[tuple[T_Input, IncomingMessage]] = [] + + async def process_batch(): + """Process the current batch""" + if not batch: + return + + # Extract messages and wire messages separately + decoded_requests = [item[0] for item in batch] + wire_messages = [item[1] for item in batch] + + try: + # Call the batch handler to get responses + if self._batch_handler is None: + raise RuntimeError("No batch handler configured") + responses = await self._batch_handler(decoded_requests) + + # Validate response count matches request count (as specified in requirements) + if len(responses) != len(decoded_requests): + raise RuntimeError( + f"Batch RPC handler returned {len(responses)} responses " + f"but received {len(decoded_requests)} requests. " + f"For batch RPC operations, len(inputs) must equal len(outputs)." + ) + + # Send replies for each request-response pair + for wire_message, response in zip(wire_messages, responses): + try: + # Encode response + encoded_response = self._encode_reply(response) + + # Create reply message with same correlation ID + reply_message = WireMessage( + _payload=encoded_response, + _headers={}, + _correlation_id=wire_message.correlation_id, + _reply_to=None, # No further reply expected + ) + + # Send reply + await self._send_reply(reply_message) + + # Acknowledge successful processing + await wire_message.ack() + + except Exception as e: + # Individual response failed - nack this request only + await wire_message.nack() + + except Reject: + # Reject all messages in the batch and continue + for wire_message in wire_messages: + await wire_message.reject() + + except Exception as e: + # Any other exception - nack all messages and stop + for wire_message in wire_messages: + await wire_message.nack() + # Propagate to application level + if self._exception_callback: + self._exception_callback(e) + raise # Stop processing + + batch_start_time = None + exception_occurred = False + + try: + async for wire_message in self._consumer.recv(): + try: + # Validate RPC metadata + if not wire_message.correlation_id or not wire_message.reply_to: + # Not an RPC request, skip + await wire_message.nack() + continue + + # Decode the request payload + decoded_payload = self._decode_message(wire_message.payload) + + # Add to batch + batch.append((decoded_payload, wire_message)) + + # Record start time for the first message in batch + if len(batch) == 1: + batch_start_time = asyncio.get_event_loop().time() + + # Check if batch is full + if len(batch) >= self._batch_config["max_size"]: + # Process batch when full + try: + await process_batch() + finally: + # Always clear batch after processing attempt + batch.clear() + batch_start_time = None + + # Check if timeout expired (only if we have messages) + elif batch and batch_start_time: + current_time = asyncio.get_event_loop().time() + if ( + current_time - batch_start_time + >= self._batch_config["timeout"] + ): + # Process batch due to timeout + try: + await process_batch() + finally: + # Always clear batch after processing attempt + batch.clear() + batch_start_time = None + + except Exception: + # Individual message decode error - nack and continue + await wire_message.nack() + continue + + except Exception: + # Final exception handling - nack any remaining messages + exception_occurred = True + for _, wire_message in batch: + await wire_message.nack() + # Only call exception callback if it hasn't been called from process_batch + # Exception from process_batch will be a re-raise, so we don't need to call again + pass + finally: + # Process any remaining messages in batch only if no exception occurred + if batch and not exception_occurred: + try: + await process_batch() + except Exception: + # If processing remaining batch fails, just nack all and continue + for _, wire_message in batch: + await wire_message.nack() + + async def _send_reply(self, reply_message: WireMessage) -> None: + """Send reply message""" + if not self._reply_producer: + return + + # Send the reply + await self._reply_producer.send_batch([reply_message]) diff --git a/src/asyncapi_python/kernel/endpoint/subscriber.py b/src/asyncapi_python/kernel/endpoint/subscriber.py new file mode 100644 index 0000000..919eae0 --- /dev/null +++ b/src/asyncapi_python/kernel/endpoint/subscriber.py @@ -0,0 +1,330 @@ +import asyncio +from typing import Any, Callable, Generic, overload +from typing_extensions import Unpack + +from .abc import AbstractEndpoint, Receive, HandlerParams +from ..typing import ( + T_Input, + Handler, + BatchConsumer, + BatchConfig, + IncomingMessage, +) +from ..exceptions import Reject +from asyncapi_python.kernel.wire import Consumer + + +class Subscriber(AbstractEndpoint, Receive[T_Input, None], Generic[T_Input]): + """Subscriber endpoint for receiving messages without sending replies""" + + def __init__(self, **kwargs: Unpack[AbstractEndpoint.Inputs]): + super().__init__(**kwargs) + self._consumer: Consumer[Any] | None = None + self._handler: Handler[T_Input, None] | None = None + self._batch_handler: BatchConsumer[Any] | None = ( + None # Any because batch type is determined at runtime + ) + self._handler_location: str | None = None + self._batch_config: BatchConfig | None = None + self._consume_task: asyncio.Task[None] | None = None + + async def start(self, **params: Unpack[AbstractEndpoint.StartParams]) -> None: + """Initialize the subscriber endpoint""" + if self._consumer: + return + + # Get exception callback from parameters + self._exception_callback = params.get("exception_callback") + + # Validate that we have exactly one handler (if validation is enabled) + if ( + self._should_validate_handlers() + and not self._handler + and not self._batch_handler + ): + raise RuntimeError( + f"Subscriber endpoint '{self._operation.key}' requires exactly one handler. " + f"Use @{self._operation.key} decorator to register a handler function." + ) + + # Create consumer from wire factory + self._consumer = await self._wire.create_consumer( + channel=self._operation.channel, + parameters={}, + op_bindings=self._operation.bindings, + is_reply=False, + ) + + # Start the consumer + if self._consumer: + await self._consumer.start() + + # Start consuming task if we have a handler but no task yet + if (self._handler or self._batch_handler) and not self._consume_task: + if self._batch_handler: + self._consume_task = asyncio.create_task( + self._consume_messages_batch() + ) + else: + self._consume_task = asyncio.create_task(self._consume_messages()) + + async def stop(self) -> None: + """Cleanup the subscriber endpoint""" + if not self._consumer: + return + + # Cancel the consume task + if self._consume_task: + self._consume_task.cancel() + try: + await self._consume_task + except asyncio.CancelledError: + pass + self._consume_task = None + + await self._consumer.stop() + self._consumer = None + + @overload + @overload + def __call__(self, fn: Handler[T_Input, None]) -> Handler[T_Input, None]: ... + + @overload + def __call__( + self, + fn: None = None, + *, + batch: BatchConfig, + **kwargs: Unpack[HandlerParams], + ) -> Callable[[BatchConsumer[T_Input]], BatchConsumer[T_Input]]: ... + + @overload + @overload + def __call__( + self, fn: None = None, **kwargs: Unpack[HandlerParams] + ) -> Callable[[Handler[T_Input, None]], Handler[T_Input, None]]: ... + + def __call__( # type: ignore[override] + self, + fn: Handler[T_Input, None] | BatchConsumer[T_Input] | None = None, + *, + batch: BatchConfig | None = None, + **kwargs: Unpack[HandlerParams], + ) -> ( + Handler[T_Input, None] + | BatchConsumer[T_Input] + | Callable[[Handler[T_Input, None]], Handler[T_Input, None]] + | Callable[[BatchConsumer[T_Input]], BatchConsumer[T_Input]] + ): + """Register a handler for incoming messages + + Can be used as a decorator: + @subscriber + def handle_message(msg): ... + + Or with parameters: + @subscriber(queue="high-priority") + def handle_message(msg): ... + """ + if fn is None: + # Called with parameters: @subscriber(batch=..., ...) + if batch is not None: + # Batch mode - expect BatchConsumer + def batch_decorator( + handler_fn: BatchConsumer[T_Input], + ) -> BatchConsumer[T_Input]: + self._register_handler(handler_fn, batch, kwargs) + return handler_fn + + return batch_decorator + else: + # Regular mode - expect Handler + def decorator( + handler_fn: Handler[T_Input, None], + ) -> Handler[T_Input, None]: + self._register_handler(handler_fn, None, kwargs) + return handler_fn + + return decorator + else: + # Called directly: @subscriber + self._register_handler(fn, batch, kwargs) + return fn + + def _register_handler( + self, + handler: Handler[T_Input, None] | BatchConsumer[T_Input], + batch_config: BatchConfig | None, + params: HandlerParams, + ) -> None: + """Register a handler and start consuming messages""" + if self._should_validate_handlers() and ( + self._handler is not None or self._batch_handler is not None + ): + existing_handler = self._handler or self._batch_handler + assert existing_handler is not None # for mypy + raise RuntimeError( + f"Subscriber endpoint '{self._operation.key}' already has a handler registered.\n" + f"Existing handler: {existing_handler.__name__} at {self._handler_location}\n" + f"New handler: {handler.__name__} at {handler.__code__.co_filename}:{handler.__code__.co_firstlineno}\n" + f"Each subscriber endpoint must have exactly one handler." + ) + + # Determine if this is a batch handler by checking if batch config exists + if batch_config is not None: + self._batch_handler = handler # type: ignore + self._batch_config = batch_config + self._handler = None + else: + self._handler = handler # type: ignore + self._batch_handler = None + self._batch_config = None + + self._handler_location = ( + f"{handler.__code__.co_filename}:{handler.__code__.co_firstlineno}" + ) + + # Start background task to consume messages if consumer is ready + if self._consumer and not self._consume_task: + try: + if self._batch_handler: + self._consume_task = asyncio.create_task( + self._consume_messages_batch() + ) + else: + self._consume_task = asyncio.create_task(self._consume_messages()) + except RuntimeError: + # No event loop running, task will be created later when start() is called + pass + + async def _consume_messages(self) -> None: + """Background task that consumes messages and calls the handler""" + if not self._consumer or not self._handler: + return + + async for wire_message in self._consumer.recv(): + try: + # Decode the message payload + decoded_payload = self._decode_message(wire_message.payload) + + # Call the user handler + await self._handler(decoded_payload) + + # Acknowledge successful processing + await wire_message.ack() + + except Reject as e: + # Handle message rejection - reject and continue + await wire_message.reject() + + except Exception as e: + # Any other exception should stop the application + await wire_message.nack() + # Propagate to application level + if self._exception_callback: + self._exception_callback(e) + return # Stop processing messages + + async def _consume_messages_batch(self) -> None: + """Background task that consumes messages in batches and calls the batch handler""" + if not self._consumer or not self._batch_handler or not self._batch_config: + return + + batch: list[tuple[T_Input, IncomingMessage]] = [] + + async def process_batch(): + """Process the current batch""" + if not batch: + return + + # Extract messages and wire messages separately + decoded_messages = [item[0] for item in batch] + wire_messages = [item[1] for item in batch] + + try: + # Call the batch handler + if self._batch_handler is None: + raise RuntimeError("No batch handler configured") + await self._batch_handler(decoded_messages) + + # Acknowledge all messages in the batch + for wire_message in wire_messages: + await wire_message.ack() + + except Reject: + # Reject all messages in the batch and continue + for wire_message in wire_messages: + await wire_message.reject() + + except Exception as e: + # Any other exception - nack all messages and stop + for wire_message in wire_messages: + await wire_message.nack() + # Propagate to application level + if self._exception_callback: + self._exception_callback(e) + raise # Stop processing + + batch_start_time = None + exception_occurred = False + + try: + async for wire_message in self._consumer.recv(): + try: + # Decode the message payload + decoded_payload = self._decode_message(wire_message.payload) + + # Add to batch + batch.append((decoded_payload, wire_message)) + + # Record start time for the first message in batch + if len(batch) == 1: + batch_start_time = asyncio.get_event_loop().time() + + # Check if batch is full + if len(batch) >= self._batch_config["max_size"]: + # Process batch when full + try: + await process_batch() + finally: + # Always clear batch after processing attempt + batch.clear() + batch_start_time = None + + # Check if timeout expired (only if we have messages) + elif batch and batch_start_time: + current_time = asyncio.get_event_loop().time() + if ( + current_time - batch_start_time + >= self._batch_config["timeout"] + ): + # Process batch due to timeout + try: + await process_batch() + finally: + # Always clear batch after processing attempt + batch.clear() + batch_start_time = None + + except Exception: + # Individual message decode error - nack and continue + await wire_message.nack() + continue + + except Exception: + # Final exception handling - nack any remaining messages + exception_occurred = True + for _, wire_message in batch: + await wire_message.nack() + # Only call exception callback if it hasn't been called from process_batch + # Exception from process_batch will be a re-raise, so we don't need to call again + pass + finally: + # Process any remaining messages in batch only if no exception occurred + if batch and not exception_occurred: + try: + await process_batch() + except Exception: + # If processing remaining batch fails, just nack all and continue + for _, wire_message in batch: + await wire_message.nack() diff --git a/src/asyncapi_python/kernel/exceptions.py b/src/asyncapi_python/kernel/exceptions.py new file mode 100644 index 0000000..59eef5f --- /dev/null +++ b/src/asyncapi_python/kernel/exceptions.py @@ -0,0 +1,19 @@ +"""Exception classes for AsyncAPI Python kernel.""" + + +class Reject(Exception): + """Exception raised to reject a message and continue processing. + + When raised in a handler, the message will be rejected (negative acknowledgment) + and the application will continue running. + + Args: + reason: The reason for rejecting the message + """ + + def __init__(self, reason: str): + self.reason = reason + super().__init__(reason) + + +__all__ = ["Reject"] diff --git a/src/asyncapi_python/kernel/typing.py b/src/asyncapi_python/kernel/typing.py new file mode 100644 index 0000000..4074cca --- /dev/null +++ b/src/asyncapi_python/kernel/typing.py @@ -0,0 +1,142 @@ +"""Unified type system for the AsyncAPI Python kernel + +This module defines all TypeVars used across the kernel with clear relationships +between application data, encoded data, and wire messages. +""" + +from typing import Any, Protocol, TypeVar, TypedDict +from typing_extensions import TypeAlias, Required +from types import CodeType + + +# Base protocols for type bounds +class Serializable(Protocol): + """Protocol for data that can be serialized""" + + pass + + +class WireData(Protocol): + """Protocol for wire-level data""" + + pass + + +# Wire message protocols +class Message(Protocol): + @property + def payload(self) -> bytes: + """Payload of the message""" + return b"" + + @property + def headers(self) -> dict[str, Any]: + """Message headers""" + return {} + + @property + def correlation_id(self) -> str | None: + """AsyncAPI 3.0 correlation ID for RPC request/response matching""" + + @property + def reply_to(self) -> str | None: + """AsyncAPI 3.0 reply-to address for dynamic RPC responses""" + + +class IncomingMessage(Message, Protocol): + async def ack(self) -> None: + """Processing of the message successful""" + + async def nack(self) -> None: + """Processing of the message failed due to app internal reason""" + + async def reject(self) -> None: + """Processing of the message failed due to external reasons (e.g. protocol validation)""" + + +# Codec layer types - connect application data to wire data +T_DecodedPayload = TypeVar("T_DecodedPayload", bound=Serializable) +"""Application-level payload data (what codecs decode to/encode from)""" + +T_EncodedPayload = TypeVar("T_EncodedPayload", bound=WireData) +"""Wire-level encoded data (what codecs encode to/decode from)""" + +# Wire layer types - transport-specific message types +T_Send = TypeVar("T_Send", bound=Message) +"""Outgoing wire messages (bound to Message protocol)""" + +T_Recv = TypeVar("T_Recv", covariant=True, bound=IncomingMessage) +"""Incoming wire messages (bound to IncomingMessage protocol)""" + +# Channel parameter types +T_ChannelParams = TypeVar("T_ChannelParams", bound=dict[str, Any]) +"""Channel parameters for parameterized channels (bound to dict)""" + +# Handler-specific invariant TypeVars - prevent list[T]/T type splitting +T_Input = TypeVar("T_Input", bound=Serializable, contravariant=False, covariant=False) +"""Invariant input type for handlers - exact type matching prevents variance issues""" + +T_Output = TypeVar("T_Output", bound=Serializable, contravariant=False, covariant=False) +"""Invariant output type for handlers - exact type matching prevents variance issues""" + + +# Type relationships (aliases for clarity) +ApplicationData: TypeAlias = T_DecodedPayload +"""Alias for application-level data types""" + +WirePayload: TypeAlias = T_EncodedPayload +"""Alias for wire-level payload types""" + +HandlerInput: TypeAlias = T_Input +"""Alias for handler input types""" + +HandlerOutput: TypeAlias = T_Output +"""Alias for handler output types""" + + +# Batch configuration +class BatchConfig(TypedDict): + """Configuration for batch processing""" + + max_size: Required[int] + """Maximum number of messages in a batch""" + + timeout: Required[float] + """Maximum wait time in seconds before processing batch""" + + +# Handler protocols for user callback functions - using invariant types +class Handler(Protocol[T_Input, T_Output]): # type: ignore[misc] + """A callback function, provided by user - uses invariant types for exact matching""" + + async def __call__(self, arg: T_Input, /) -> T_Output: ... + + @property + def __name__(self) -> str: ... + + @property + def __code__(self) -> CodeType: ... + + +class BatchHandler(Protocol[T_Input, T_Output]): + """A batch callback function for RPC operations - processes list of inputs to list of outputs""" + + async def __call__(self, args: list[T_Input], /) -> list[T_Output]: ... + + @property + def __name__(self) -> str: ... + + @property + def __code__(self) -> CodeType: ... + + +class BatchConsumer(Protocol[T_Input]): + """A batch callback function for consumer operations - processes list of inputs with no output""" + + async def __call__(self, args: list[T_Input], /) -> None: ... + + @property + def __name__(self) -> str: ... + + @property + def __code__(self) -> CodeType: ... diff --git a/src/asyncapi_python/kernel/wire/__init__.py b/src/asyncapi_python/kernel/wire/__init__.py new file mode 100644 index 0000000..c45bfaf --- /dev/null +++ b/src/asyncapi_python/kernel/wire/__init__.py @@ -0,0 +1,25 @@ +from .typing import Producer, Consumer +from ..typing import T_Recv, T_Send +from typing import Generic, TypedDict +from typing_extensions import Unpack +from abc import abstractmethod, ABC +from ..document import Channel, OperationBindings + + +class EndpointParams(TypedDict): + channel: Channel + parameters: dict[str, str] + op_bindings: OperationBindings | None + is_reply: bool + + +class AbstractWireFactory(ABC, Generic[T_Send, T_Recv]): + @abstractmethod + async def create_consumer( + self, **kwargs: Unpack[EndpointParams] + ) -> Consumer[T_Recv]: ... + + @abstractmethod + async def create_producer( + self, **kwargs: Unpack[EndpointParams] + ) -> Producer[T_Send]: ... diff --git a/src/asyncapi_python/kernel/wire/typing.py b/src/asyncapi_python/kernel/wire/typing.py new file mode 100644 index 0000000..9faaff8 --- /dev/null +++ b/src/asyncapi_python/kernel/wire/typing.py @@ -0,0 +1,26 @@ +from typing import AsyncGenerator, Generic, Protocol + +from ..typing import T_Send, T_Recv + + +class EndpointLifecycle(Protocol): + async def start(self) -> None: + """Signals application start. Receiving side must start its operation.""" + + async def stop(self) -> None: + """Signals stop to the endpoint. Receiving side must stop its background tasks and terminate self.""" + + +class Producer(EndpointLifecycle, Protocol, Generic[T_Send]): + async def send_batch(self, messages: list[T_Send]) -> None: + """Sends batch of messages to channel""" + + +class Consumer(EndpointLifecycle, Protocol, Generic[T_Recv]): + def recv(self) -> AsyncGenerator[T_Recv, None]: + """Starts streaming incoming messages""" + # This is a protocol method - implementation must provide async generator + # Using NotImplemented because protocols cannot have implementations + raise NotImplementedError( + "Protocol method must be implemented by concrete class" + ) diff --git a/src/asyncapi_python_codegen/__init__.py b/src/asyncapi_python_codegen/__init__.py index ed9a00f..8af60d6 100644 --- a/src/asyncapi_python_codegen/__init__.py +++ b/src/asyncapi_python_codegen/__init__.py @@ -1,53 +1,8 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +"""AsyncAPI Python Code Generator.""" +from .generators import CodeGenerator +from .parser import extract_all_operations, load_document_info +from .cli import app -from pathlib import Path -import typer -from . import generators as g - -app = typer.Typer() - - -@app.command() -def generate( - input_file: Path, - output_dir: Path, - protocol: str = "amqp", - force: bool = False, -) -> None: - # Create empty out dir (and assert it is empty) - output_dir.mkdir(parents=True, exist_ok=True) - if next(output_dir.iterdir(), None) and not force: - raise AssertionError( - "Output dir must be empty unless --force option is specified" - ) - - # Generate code - generation_result: dict[Path, str] - if protocol == "amqp": - generation_result = g.amqp.generate( - input_path=input_file, output_path=output_dir - ) - else: - raise NotImplementedError(f"Protocol {protocol} is not supported") - - # Write files - for path, code in generation_result.items(): - with path.open("w") as file: - file.write(code) - - -if __name__ == "__main__": - app() +__version__ = "0.1.0" +__all__ = ["CodeGenerator", "extract_all_operations", "load_document_info", "app"] diff --git a/src/asyncapi_python_codegen/cli.py b/src/asyncapi_python_codegen/cli.py new file mode 100644 index 0000000..9c86980 --- /dev/null +++ b/src/asyncapi_python_codegen/cli.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Command-line interface for AsyncAPI code generation.""" + +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typer +else: + try: + import typer + except ImportError: + typer = None # type: ignore[assignment] + +from .generators import CodeGenerator + +# Use try-catch to determine if typer is available +try: + import typer # noqa: F401 - imported for availability check + + _has_typer = True +except ImportError: + _has_typer = False + +if _has_typer: + app = typer.Typer(help="AsyncAPI Python Code Generator") + + @app.command() + def generate( + spec_file: Path = typer.Argument( + ..., help="Path to AsyncAPI YAML specification" + ), + output_dir: Path = typer.Argument( + ..., help="Output directory for generated code" + ), + force: bool = typer.Option(False, "--force", help="Overwrite existing files"), + ): + """Generate Python code from AsyncAPI specification.""" + if not spec_file.exists(): + typer.echo(f"Error: Spec file {spec_file} does not exist", err=True) + raise typer.Exit(1) + + typer.echo(f"Generating code from {spec_file} to {output_dir}...") + + try: + generator = CodeGenerator() + generator.generate(spec_file, output_dir, force=force) + typer.echo("✅ Code generation complete!") + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + + def main(): + app() + +else: + # Fallback CLI without typer + def main(): + if len(sys.argv) != 3: + print("Usage: asyncapi-python-codegen ") + sys.exit(1) + + spec_file = Path(sys.argv[1]) + output_dir = Path(sys.argv[2]) + + if not spec_file.exists(): + print(f"Error: Spec file {spec_file} does not exist") + sys.exit(1) + + print(f"Generating code from {spec_file} to {output_dir}...") + + try: + generator = CodeGenerator() + generator.generate(spec_file, output_dir) + print("✅ Code generation complete!") + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/asyncapi_python_codegen/document/__init__.py b/src/asyncapi_python_codegen/document/__init__.py deleted file mode 100644 index 5cf5f0b..0000000 --- a/src/asyncapi_python_codegen/document/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from .document import Document -from .ref import Ref -from .components import JsonSchema, Message, Operation, Channel - -__all__ = [ - "Document", - "Ref", - "JsonSchema", - "Message", - "Operation", - "Channel", -] diff --git a/src/asyncapi_python_codegen/document/base.py b/src/asyncapi_python_codegen/document/base.py deleted file mode 100644 index 42e6daa..0000000 --- a/src/asyncapi_python_codegen/document/base.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from pydantic import BaseModel, RootModel diff --git a/src/asyncapi_python_codegen/document/bindings/__init__.py b/src/asyncapi_python_codegen/document/bindings/__init__.py deleted file mode 100644 index 80bd89b..0000000 --- a/src/asyncapi_python_codegen/document/bindings/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from pydantic import BaseModel -from .amqp import AmqpBinding - - -class Bindings(BaseModel): - amqp: AmqpBinding = AmqpBinding() diff --git a/src/asyncapi_python_codegen/document/bindings/amqp.py b/src/asyncapi_python_codegen/document/bindings/amqp.py deleted file mode 100644 index bf9c984..0000000 --- a/src/asyncapi_python_codegen/document/bindings/amqp.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from typing import Literal, Optional, Union - -from pydantic import BaseModel, Field, RootModel - - -class Exchange(BaseModel): - name: Optional[str] = None - type: Literal["topic", "direct", "fanout", "default", "headers"] = "default" - durable: bool = False - auto_delete: bool = Field(alias="autoDelete", default=False) - - -class ExchangeBinding(BaseModel): - type: Literal["routingKey"] = Field(alias="is", default="routingKey") - exchange: Exchange = Exchange() - - -class Queue(BaseModel): - name: Optional[str] = None - durable: bool = False - exclusive: bool = False - auto_delete: bool = Field(alias="autoDelete", default=False) - - -class QueueBinding(BaseModel): - type: Literal["queue"] = Field(alias="is", default="queue") - queue: Queue = Queue() - - -class AmqpBinding(RootModel): - root: Union[ExchangeBinding, QueueBinding] = QueueBinding() diff --git a/src/asyncapi_python_codegen/document/components.py b/src/asyncapi_python_codegen/document/components.py deleted file mode 100644 index 34778ef..0000000 --- a/src/asyncapi_python_codegen/document/components.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from __future__ import annotations - -from .base import BaseModel, RootModel -from typing import Any, Literal, Optional -from .ref import MaybeRef, Ref -from .bindings import Bindings - - -class Components(BaseModel): - operations: dict[str, MaybeRef[Operation]] = {} - channels: dict[str, MaybeRef[Channel]] = {} - messages: dict[str, MaybeRef[Message]] = {} - correlation_ids: dict[str, CorrelationId] = {} - schemas: dict[str, MaybeRef[JsonSchema]] = {} - - -class JsonSchema(RootModel): - # TODO: Create a better parser for JsonSchema - root: Any - - -class Message(BaseModel): - title: Optional[str] = None - headers: Optional[MaybeRef[JsonSchema]] = None - payload: MaybeRef[JsonSchema] - - -class CorrelationId(BaseModel): - description: Optional[str] = None - location: str - - -class Operation(BaseModel): - action: Literal["receive", "send"] - channel: Ref[Channel] - reply: Optional[OperationReply] = None - - -class OperationReply(BaseModel): - address: Optional[ReplyAddress] = None - channel: Ref[Channel] - - -class ReplyAddress(BaseModel): - description: Optional[str] = None - location: str - - -class Channel(BaseModel): - address: Optional[str] = None - title: Optional[str] = None - description: Optional[str] = None - bindings: Optional[Bindings] = None - messages: dict[str, MaybeRef[Message]] diff --git a/src/asyncapi_python_codegen/document/document.py b/src/asyncapi_python_codegen/document/document.py deleted file mode 100644 index c937231..0000000 --- a/src/asyncapi_python_codegen/document/document.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from __future__ import annotations -from pathlib import Path - -from pydantic import Field -from .base import BaseModel -from typing import Annotated, Any, Literal, Optional -import yaml -from .components import Channel, Components, Operation -from .ref import MaybeRef -from .document_context import set_current_doc_path - - -DOCUMENT_CACHE: dict[Path, Document] = {} - - -class Document(BaseModel): - filepath: Annotated[Path, Field(exclude=True)] - asyncapi: Literal["3.0.0"] - info: Info - channels: dict[str, MaybeRef[Channel]] = {} - operations: dict[str, MaybeRef[Operation]] = {} - components: Components = Components() - - @staticmethod - def load_yaml(path: Path) -> "Document": - path = path.absolute() - if path in DOCUMENT_CACHE: - return DOCUMENT_CACHE[path] - with path.open() as file: - raw_doc = yaml.safe_load(file) - raw_doc["filepath"] = path.absolute() - with set_current_doc_path(path): - doc = Document.model_validate(raw_doc) - DOCUMENT_CACHE[path] = doc - return doc - - -class Info(BaseModel): - title: str - version: str - description: Optional[str] = None diff --git a/src/asyncapi_python_codegen/document/document_context.py b/src/asyncapi_python_codegen/document/document_context.py deleted file mode 100644 index 7e43f1e..0000000 --- a/src/asyncapi_python_codegen/document/document_context.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from pathlib import Path -from typing import Generator -from typing_extensions import Self -from contextlib import contextmanager - -DOCUMENT_CONTEXT_STACK: list[Path] = [] - - -@contextmanager -def set_current_doc_path(path: Path) -> Generator[None, None, None]: - DOCUMENT_CONTEXT_STACK.append(path) - yield - DOCUMENT_CONTEXT_STACK.pop() - - -def current_doc_path(): - if not DOCUMENT_CONTEXT_STACK: - raise AssertionError( - "No Document path available. " - + "Make sure you have used `with` statement on the " - + "current DocumentPath during construction.\n" - ) - return DOCUMENT_CONTEXT_STACK[-1] diff --git a/src/asyncapi_python_codegen/document/ref.py b/src/asyncapi_python_codegen/document/ref.py deleted file mode 100644 index 063dcc8..0000000 --- a/src/asyncapi_python_codegen/document/ref.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from functools import cache -from pathlib import Path -from pydantic._internal._generics import get_args # TODO: Internal API, this may break -from pydantic import ConfigDict, Field, model_validator -from .base import BaseModel, RootModel -from .document_context import ( - current_doc_path, - set_current_doc_path, - DOCUMENT_CONTEXT_STACK, -) -from typing import Any, Callable, Generic, TypeVar, Annotated, Union, cast -from typing_extensions import Self - - -T = TypeVar("T", bound=BaseModel) - - -ContextFunction = Callable[[str], Any] - - -class Ref(BaseModel, Generic[T]): - model_config = ConfigDict(frozen=True) - - ref: Annotated[ - str, - Field( - alias="$ref", - serialization_alias="$ref", - validation_alias="$ref", - ), - ] - filepath: Annotated[Path, Field(exclude=True)] - raw_doc_path: Annotated[tuple[str, ...], Field(exclude=True)] - escaped_doc_path: Annotated[tuple[str, ...], Field(exclude=True)] - - @classmethod - def type(cls) -> type[T]: - return get_args(cls)[0] - - @cache - def get(self) -> T: - from .document import Document - - sub = self.flatten() - doc = Document.load_yaml(sub.filepath).model_dump(by_alias=True) - for p in self.escaped_doc_path: - doc = doc[p] - with set_current_doc_path(sub.filepath): - return sub.type().model_validate(doc) - - @cache - def flatten(self, max_depth: int = 1000) -> Self: - from .document import Document - - sub = self - for _ in range(max_depth): - doc = Document.load_yaml(sub.filepath).model_dump(by_alias=True) - try: - for p in sub.escaped_doc_path: - doc = doc[p] - except KeyError as e: - raise KeyError( - f"$ref `{sub.ref}` is invalid \n" - + f"The Error was raised when trying to get key {e.args}" - ) - if not "$ref" in doc: - return sub - sub = self.__class__.model_validate(doc) - raise RecursionError( - f"Document Ref[{self.type().__class__}] flattening limit reached" - ) - - @model_validator(mode="before") - @classmethod - def parse_ref(cls, data: Any) -> Any: - fp: Union[str, Path] - ref: str - - if (ref := data.get("ref")) or (ref := data.get("$ref")): - fp, dp = ref.split("#") - if fp == "": - fp = current_doc_path() - elif not Path(fp).is_absolute(): - fp = current_doc_path().parent / fp - else: - raise ValueError(f"Requires {{$ref: ... }}, given {data} ") - - return { - **data, - "$ref": ref, - "raw_doc_path": (doc_path := tuple(dp.split("/")[1:])), - "escaped_doc_path": tuple( - p.replace("~0", "~").replace("~1", "/") for p in doc_path - ), - "filepath": Path(fp).absolute(), - } - - -class MaybeRef(RootModel[Union[Ref[T], T]], Generic[T]): - root: Union[Ref[T], T] - - def get(self) -> T: - return self.root.get() if isinstance(self.root, Ref) else self.root diff --git a/src/asyncapi_python_codegen/document/utils.py b/src/asyncapi_python_codegen/document/utils.py deleted file mode 100644 index 7663d60..0000000 --- a/src/asyncapi_python_codegen/document/utils.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from typing import Any, Union -import yaml - -from .document_context import set_current_doc_path -from .ref import Ref -from collections import defaultdict - - -Reference = Union[None, tuple[Path, tuple[str, ...]]] -"""A reference type, maps a document path to a set of references that point to it""" - -ReferenceCounter = defaultdict[Reference, set[Reference]] -"""A reference counter, maps each reference to a set of references that point to it""" - - -def populate_jsonschema_defs(schema: Any) -> Any: - """Given a $defs element of the JsonSchema - 1. Constructs back references map for all links - 2. Populates types by copying its body into parent $def (if there is only one reference) - 3. Adds a new $defs object (if there is more than one reference), and rewrites $refs - 4. Returns a huge jsonschema $defs object containing all structs that have been referenced by the structs - from the original schema - """ - counter: ReferenceCounter = defaultdict(lambda: set()) - shared_schemas: dict[str, Any] = {} - _count_references(schema, None, counter) - res = _populate_jsonschema_recur(schema, counter, shared_schemas) - return {**res, **shared_schemas} - - -def _count_references(schema: Any, this: Reference, counter: ReferenceCounter): - """Recursively constructs back references within the JsonSchema""" - - # List case - if isinstance(schema, list): - for v in schema: - _count_references(v, this, counter) - - # Dict case - if not isinstance(schema, dict): - return - - if "$ref" in schema: # If dict is $ref object - ref: Ref[Any] = Ref.model_validate(schema) - with set_current_doc_path(ref.filepath): - ref = ref.flatten() - with ref.filepath.open() as f: - doc = yaml.safe_load(f) - for p in ref.escaped_doc_path: - doc = doc[p] - child = (ref.filepath, ref.escaped_doc_path) - counter[child].add(this) - with set_current_doc_path(ref.filepath): - return _count_references(doc, child, counter) - - for v in schema.values(): # Recur - _count_references(v, this, counter) - - -def _populate_jsonschema_recur( - schema: Any, - counter: ReferenceCounter, - shared_schemas: dict[str, Any], - ignore_shared: bool = False, -) -> Any: - """Recursively populates JsonSchema $defs object""" - - # List case - if isinstance(schema, list): - return [_populate_jsonschema_recur(v, counter, shared_schemas, ignore_shared) for v in schema] - - # Dict case - if not isinstance(schema, dict): - return schema - - if "$ref" in schema: - ref: Ref[Any] = Ref.model_validate(schema) - with set_current_doc_path(ref.filepath): - ref = ref.flatten() - - back_refs = counter[(ref.filepath, ref.raw_doc_path)] - if len(back_refs) > 1 and not ignore_shared: - ref_struct_name = ref.raw_doc_path[-1] - shared_schemas[ref_struct_name] = _populate_jsonschema_recur( - schema, counter, shared_schemas, True - ) - return {"$ref": f"#/$defs/{ref_struct_name}"} - - with ref.filepath.open() as f: - doc = yaml.safe_load(f) - for p in ref.escaped_doc_path: - doc = doc[p] - with set_current_doc_path(ref.filepath): - return _populate_jsonschema_recur(doc, counter, shared_schemas) - - return { - k: _populate_jsonschema_recur(v, counter, shared_schemas) - for k, v in schema.items() - } diff --git a/src/asyncapi_python_codegen/generators/__init__.py b/src/asyncapi_python_codegen/generators/__init__.py index 9366830..7904055 100644 --- a/src/asyncapi_python_codegen/generators/__init__.py +++ b/src/asyncapi_python_codegen/generators/__init__.py @@ -1,16 +1,5 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +"""Code generators for AsyncAPI specifications.""" +from .main import CodeGenerator -from . import amqp +__all__ = ["CodeGenerator"] diff --git a/src/asyncapi_python_codegen/generators/amqp/__init__.py b/src/asyncapi_python_codegen/generators/amqp/__init__.py deleted file mode 100644 index f254038..0000000 --- a/src/asyncapi_python_codegen/generators/amqp/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from .generate import generate - -__all__ = ["generate"] diff --git a/src/asyncapi_python_codegen/generators/amqp/generate.py b/src/asyncapi_python_codegen/generators/amqp/generate.py deleted file mode 100644 index a145fda..0000000 --- a/src/asyncapi_python_codegen/generators/amqp/generate.py +++ /dev/null @@ -1,296 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from __future__ import annotations -from dataclasses import dataclass -from itertools import chain, repeat -import json -from pathlib import Path -import tempfile -from typing import Literal, TypedDict, Optional - -import jinja2 as j2 - -import asyncapi_python_codegen.document as d -from asyncapi_python_codegen.document.utils import populate_jsonschema_defs -from asyncapi_python.utils import snake_case - -from datamodel_code_generator.__main__ import main as datamodel_codegen - - -def generate( - *, - input_path: Path, - output_path: Path, - template_dir: Path = Path(__file__).parent / "templates", -) -> dict[Path, str]: - doc = d.Document.load_yaml(input_path) - ops = [get_operation(k, op.get()) for k, op in doc.operations.items()] - root = _Route(path=tuple(), op=None) - - send_ops = {x["path"]: x for x in ops if x["action"] == "send"} - recv_ops = {x["path"]: x for x in ops if x["action"] == "receive"} - - send_routes_dict: dict[tuple[str, ...], _Route] = {} - recv_routes_dict: dict[tuple[str, ...], _Route] = {} - - for path, _ops, routes in chain( - zip(send_ops, repeat(send_ops), repeat(send_routes_dict)), - zip(recv_ops, repeat(recv_ops), repeat(recv_routes_dict)), - ): - create_api_routing(path, _ops, routes) - - send_routes, recv_routes = ( - [root, *rs.values()] if rs.values() else [] - for rs in (send_routes_dict, recv_routes_dict) - ) - - return ( - { - output_path / f: generate_routers(r, template_dir / "routes.py.j2") - for f, r in (("producer.py", send_routes), ("consumer.py", recv_routes)) - } - | { - output_path - / f"{f}.py": j2.Template((template_dir / f"{f}.py.j2").read_text()).render() - for f in ("application", "__init__") - } - | { - output_path - / "messages.py": generate_message_types(ops, doc.filepath.parent), - output_path / "py.typed": "", - } - ) - - -def generate_routers(routes: list[_Route], template_path: Path) -> str: - @dataclass - class Router: - id: int - op: Optional[Operation] - children: list[tuple[int, str]] - - routes_with_children = ( - ( - i, - r, - [ - (j, c) - for j, c in enumerate(routes) - if c.parent == r.path and c.path != r.path - ], - ) - for i, r in enumerate(routes) - ) - routers = [ - Router(i, r.op, [(j, c.name) for j, c in cs]).__dict__ - for i, r, cs in routes_with_children - ] - template = j2.Template(template_path.read_text()) - return template.render(routers=routers) - - -def create_api_routing( - path: tuple[str, ...], - ops: dict[tuple[str, ...], Operation], - routes: dict[tuple[str, ...], _Route], -): - if not path: # Skip root - return - - router = _Route(path, ops.get(path)) - - # Create Router if not in routes - # Replace router if this is op - if not (path in routes and routes[path].op): - routes[path] = router - - create_api_routing(router.parent, ops, routes) - - -ExchangeType = Literal["topic", "direct", "fanout", "default", "headers"] - - -def get_operation(op_name: str, op: d.Operation) -> Operation: - exchange_type: ExchangeType = "default" - exchange: Optional[str] = None - routing_key: Optional[str] = None - - ch = op.channel.get() - reply_ch = op.reply.channel.get() if op.reply else None - op_path = (snake_case(y) for x in op_name.split("/") for y in x.split(".") if y) - addr = lambda x: x or ch.address or op.channel.escaped_doc_path[-1] or op_name - - if ch.bindings is None: - # Default exchange + named queues - routing_key = addr(None) - elif (bind := ch.bindings).amqp.root.type == "queue": - # Default exchange + named queues - routing_key = addr(bind.amqp.root.queue.name) - elif bind.amqp.root.type == "routingKey": - # Named exchange + exclusive queues - exchange = addr(bind.amqp.root.exchange.name) - exchange_type = "fanout" - - # Get reply channel properties - if reply_ch is not None: - if reply_ch.address: - raise NotImplementedError( - "Reply channel with static address is not supported" - ) - if reply_ch.bindings is not None: - if reply_ch.bindings.amqp.root.type != "queue": - raise NotImplementedError( - "Reply channel that is not of a queue type is not supported" - ) - if reply_ch.bindings.amqp.root.queue.name is not None: - raise NotImplementedError( - "As of now, reply channel must be a queue without name" - ) - - input_types: list[str] - input_schemas: list[str] - output_types: list[str] - output_schemas: list[str] - - input_types, input_schemas = get_channel_types(ch, op.channel) - output_types, output_schemas = ( - get_channel_types(op.reply.channel.get(), op.reply.channel) - if op.reply - else ([], []) - ) - - return { - "name": op_name, - "path": tuple(op_path), - "action": op.action, - "exchange": exchange, - "exchange_type": exchange_type, - "routing_key": routing_key, - "input_types": input_types, - "output_types": output_types, - "input_schemas": input_schemas, - "output_schemas": output_schemas, - } - - -class Operation(TypedDict): - name: str - path: tuple[str, ...] - action: Literal["send", "receive"] - exchange: Optional[str] - exchange_type: Optional[str] - routing_key: Optional[str] - input_types: list[str] - output_types: list[str] - input_schemas: list[str] - output_schemas: list[str] - - -@dataclass -class _Route: - path: tuple[str, ...] - op: Optional[Operation] - - @property - def name(self) -> str: - if self.is_root: - return "" - return self.path[-1] - - @property - def parent(self) -> tuple[str, ...]: - return self.path[:-1] - - @property - def is_root(self) -> bool: - return not self.path - - -def get_channel_types( - channel: d.Channel, - channel_ref: d.Ref[d.Channel], -) -> tuple[list[str], list[str]]: - types, schemas = [], [] - for message_key, message in channel.messages.items(): - - if isinstance(message.root, d.Ref): - msg_ref = message.root.flatten() - msg_filepath = msg_ref.filepath - msg_doc_path = msg_ref.raw_doc_path - del msg_ref - else: - msg_filepath = channel_ref.filepath - msg_doc_path = (*channel_ref.raw_doc_path, "messages", message_key) - - message_payload = message.get().payload.root - if isinstance(message_payload, d.Ref): - payload_ref = message_payload.flatten() - pl_filepath = payload_ref.filepath - pl_doc_path = payload_ref.raw_doc_path - del payload_ref - else: - pl_filepath = msg_filepath - pl_doc_path = (*msg_doc_path, "payload") - - types.append(message.get().title or message_key) - schemas.append(str(pl_filepath) + "#/" + "/".join(pl_doc_path)) - - return types, schemas - - -def generate_message_types(schemas: list[Operation], cwd: Path) -> str: - inp = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$defs": populate_jsonschema_defs( - { - type_name: {"$ref": type_schema} - for s in schemas - for type_name, type_schema in chain( - zip(s["input_types"], s["input_schemas"]), - zip(s["output_types"], s["output_schemas"]), - ) - } - ), - } - - with tempfile.TemporaryDirectory() as dir: - schema_path = Path(dir) / "schema.json" - models_path = Path(dir) / "models.py" - - args = f""" - --input { str(schema_path.absolute()) } - --output { str(models_path.absolute()) } - --output-model-type pydantic_v2.BaseModel - --input-file-type jsonschema - --reuse-model - --allow-extra-fields - --collapse-root-models - --target-python-version 3.9 - --use-title-as-name - --capitalize-enum-members - --snake-case-field - --allow-population-by-field-name - """.split() - - with schema_path.open("w") as schema: - json.dump(inp, schema) - - datamodel_codegen(args=args) - - with models_path.open() as f: - models_code = f.read() - - return models_code diff --git a/src/asyncapi_python_codegen/generators/amqp/templates/__init__.py.j2 b/src/asyncapi_python_codegen/generators/amqp/templates/__init__.py.j2 deleted file mode 100644 index 1650644..0000000 --- a/src/asyncapi_python_codegen/generators/amqp/templates/__init__.py.j2 +++ /dev/null @@ -1,16 +0,0 @@ -{# Copyright 2024 Yaroslav Petrov - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. #} -from .application import Application - -__all__ = ["Application"] diff --git a/src/asyncapi_python_codegen/generators/amqp/templates/application.py.j2 b/src/asyncapi_python_codegen/generators/amqp/templates/application.py.j2 deleted file mode 100644 index c8f69f2..0000000 --- a/src/asyncapi_python_codegen/generators/amqp/templates/application.py.j2 +++ /dev/null @@ -1,21 +0,0 @@ -{# Copyright 2024-2025 Yaroslav Petrov - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. #} -from .consumer import _Router_0 as Consumer -from .producer import _Router_0 as Producer -from asyncapi_python.amqp import BaseApplication, AmqpParams - - -class Application(BaseApplication[Producer, Consumer]): - def __init__(self, amqp_uri: str, amqp_params: AmqpParams = {}): - super().__init__(amqp_uri, Producer, Consumer, amqp_params) diff --git a/src/asyncapi_python_codegen/generators/amqp/templates/routes.py.j2 b/src/asyncapi_python_codegen/generators/amqp/templates/routes.py.j2 deleted file mode 100644 index fa9596d..0000000 --- a/src/asyncapi_python_codegen/generators/amqp/templates/routes.py.j2 +++ /dev/null @@ -1,66 +0,0 @@ -{# Copyright 2024-2025 Yaroslav Petrov - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. #} -from __future__ import annotations -import asyncapi_python.amqp as api -from typing import Union, Any -from .messages import * - -{% for router in routers -%} -{%- set inputs = ( - "Union[" + ", ".join(router.op.input_types or ["None"]) + "]" - if router.op else "" - )-%} -{%- set outputs = ( - ", Union[" + ", ".join(router.op.output_types) + "]" - if router.op and router.op.output_types else "" - )-%} -{%- set extends_class_base = ( - "Receiver[" + inputs + outputs + "], " if router.op and router.op.action == "receive" else - "Sender[" + inputs + outputs + "], " if router.op and router.op.action == "send" else - "" - )-%} -{%- set extends_class_prefix = "Rpc" if router.op and router.op.output_types else "" -%} -{%- set ns = "api." if router.op else "" -%} -{%- set extends_class = ns + extends_class_prefix + extends_class_base -%} -class _Router_{{ router.id }}({{ extends_class }}api.Router): - {% if router.op or router.children %} - def __init__(self, params: api.EndpointParams): - {% if router.op %} - inp = api.utils.union_model(({{ ", ".join(router.op.input_types) }},)) - {% if router.op.output_types %} - out = api.utils.union_model(({{ ", ".join(router.op.output_types) }},)) - {% else %} - out = None.__class__ - {% endif %} - op: Any = api.Operation( - name="{{ router.op.name }}", - message_type=inp, - reply_type=out, - routing_key={{ "\"" + router.op.routing_key + "\"" if router.op.routing_key else "None" }}, - exchange_name={{ "\"" + router.op.exchange + "\"" if router.op.exchange else "None" }}, - exchange_type={{ "\"" + router.op.exchange_type + "\"" if router.op.exchange_type else "None" }}, - ) - super().__init__(op, params) - {% endif %} - {% for c_id, c in router.children %} - self.{{ c }}: _Router_{{ c_id }} = _Router_{{ c_id }}(params) - {% endfor %} - {% endif %} -{% endfor %} - -{% if not routers %} -class _Router_0(api.Router): ... -{% endif %} - -__all__ = ["_Router_0"] diff --git a/src/asyncapi_python_codegen/generators/main.py b/src/asyncapi_python_codegen/generators/main.py new file mode 100644 index 0000000..7112a47 --- /dev/null +++ b/src/asyncapi_python_codegen/generators/main.py @@ -0,0 +1,130 @@ +"""Main code generator orchestrating all sub-generators.""" + +from pathlib import Path + +# Type annotations removed - this module deals with dynamic YAML/JSON parsing + +from ..parser import extract_all_operations, load_document_info +from .messages import MessageGenerator +from .routers import RouterGenerator +from .templates import TemplateRenderer +from .parameters import ParameterGenerator + + +class CodeGenerator: + """Generate Python code from AsyncAPI specifications using SRP.""" + + def __init__(self): + """Initialize the code generator with sub-generators.""" + template_dir = Path(__file__).parent.parent / "templates" + self.template_renderer = TemplateRenderer(template_dir) + self.message_generator = MessageGenerator() + self.router_generator = RouterGenerator() + self.parameter_generator = ParameterGenerator() + + def generate(self, spec_path: Path, output_dir: Path, force: bool = False) -> None: + """Generate code from AsyncAPI spec. + + Args: + spec_path: Path to AsyncAPI YAML file + output_dir: Output directory for generated code + force: If True, overwrite existing directory. If False, fail if directory exists. + """ + # Check if output directory exists and handle force flag + if output_dir.exists() and not force: + raise ValueError( + f"Output directory {output_dir} already exists. Use --force to overwrite." + ) + elif output_dir.exists() and force: + print(f"Warning: Overwriting existing directory {output_dir}") + + # Parse the spec + print(f"Parsing {spec_path}...") + operations = extract_all_operations(spec_path) + doc_info = load_document_info(spec_path) + + # Build router information using SRP + routers = self.router_generator.build_routers(operations) + producer_routers, consumer_routers = self.router_generator.split_routers( + routers + ) + + # Generate message models using datamodel-code-generator + message_models_code = self.message_generator.generate_message_models( + operations, spec_path + ) + + # Generate parameter TypedDicts for parameterized channels + import yaml + + with spec_path.open() as f: + spec = yaml.safe_load(f) + parameter_models_code = self.parameter_generator.generate_parameter_models(spec) + + # Legacy compatibility - extract messages for router generation + messages = self.message_generator.extract_messages(operations) + + # Generate nested classes using SRP + producer_nested_classes = self.router_generator.collect_nested_classes( + producer_routers, router_type="Producer" + ) + consumer_nested_classes = self.router_generator.collect_nested_classes( + consumer_routers, router_type="Consumer" + ) + + # Prepare template context + context = { + # Document info + "app_title": doc_info["title"], + "app_description": doc_info["description"], + "app_version": doc_info["version"], + "asyncapi_version": doc_info["asyncapi_version"], + # Routers + "routers": routers, + "producer_routers": producer_routers, + "consumer_routers": consumer_routers, + "producer_nested_classes": producer_nested_classes, + "consumer_nested_classes": consumer_nested_classes, + # Messages + "messages": messages, + "message_models_code": message_models_code, + # Parameters + "parameter_models_code": parameter_models_code, + } + + # Generate files using SRP + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate router.py + self.template_renderer.render_file( + "router.py.j2", output_dir / "router.py", context + ) + + # Generate application.py + self.template_renderer.render_file( + "application.py.j2", output_dir / "application.py", context + ) + + # Generate messages/json/__init__.py using datamodel-code-generator + messages_json_dir = output_dir / "messages" / "json" + messages_json_dir.mkdir(parents=True, exist_ok=True) + self.template_renderer.render_file( + "messages_datamodel.py.j2", messages_json_dir / "__init__.py", context + ) + + # Generate parameters/__init__.py with TypedDicts + parameters_dir = output_dir / "parameters" + parameters_dir.mkdir(parents=True, exist_ok=True) + self.template_renderer.render_file( + "parameters.py.j2", parameters_dir / "__init__.py", context + ) + + # Generate __init__.py + self.template_renderer.render_file( + "__init__.py.j2", output_dir / "__init__.py", context + ) + + print(f"✅ Generated code in {output_dir}") + + # Run mypy for validation using SRP + self.template_renderer.run_mypy(output_dir) diff --git a/src/asyncapi_python_codegen/generators/messages.py b/src/asyncapi_python_codegen/generators/messages.py new file mode 100644 index 0000000..373cfc3 --- /dev/null +++ b/src/asyncapi_python_codegen/generators/messages.py @@ -0,0 +1,221 @@ +"""Message model generation using datamodel-code-generator.""" + +import json +import re +import tempfile +import yaml +from pathlib import Path +from typing import Any +from asyncapi_python.kernel.document import Operation + +from datamodel_code_generator.__main__ import main as datamodel_codegen + + +class MessageGenerator: + """Generates Pydantic message models using datamodel-code-generator.""" + + def generate_message_models( + self, operations: dict[str, Operation], spec_path: Path | None = None + ) -> str: + """Generate complete Pydantic models code using datamodel-code-generator.""" + # Collect all message schemas from operations + message_schemas = self._collect_message_schemas(operations) + + if not message_schemas: + return self._generate_empty_messages() + + # If we have a spec path, load component schemas for reference resolution + component_schemas = {} + if spec_path: + component_schemas = self._load_component_schemas(spec_path) + + # Create unified JSON Schema with $defs including both message and component schemas + all_schemas = {**message_schemas, **component_schemas} + + # Resolve references from #/components/schemas/... to #/$defs/... + resolved_schemas = self._resolve_references(all_schemas) + + unified_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$defs": resolved_schemas, + } + + # Use datamodel-code-generator to create Pydantic models + return self._generate_with_datamodel_codegen(unified_schema) + + def _collect_message_schemas( + self, operations: dict[str, Operation] + ) -> dict[str, Any]: + """Collect all message schemas from operations.""" + schemas = {} + + for operation in operations.values(): + # Extract messages from channel + for msg_name, message in operation.channel.messages.items(): + schema_name = self._to_pascal_case(msg_name) + if schema_name not in schemas: + schemas[schema_name] = self._extract_message_schema(message) + + # Extract reply messages + if operation.reply: + for msg_name, message in operation.reply.channel.messages.items(): + schema_name = self._to_pascal_case(msg_name) + if schema_name not in schemas: + schemas[schema_name] = self._extract_message_schema(message) + + return schemas # type: ignore[return-value] + + def _load_component_schemas(self, spec_path: Path) -> dict[str, Any]: + """Load component schemas from the AsyncAPI specification file.""" + try: + with spec_path.open("r") as f: + spec = yaml.safe_load(f) + + components = spec.get("components", {}) + schemas = components.get("schemas", {}) + messages = components.get("messages", {}) + + # Combine schemas and message payloads + all_schemas = {} + + # Add component schemas directly + for schema_name, schema_def in schemas.items(): + all_schemas[schema_name] = schema_def + + # Add message payloads from components (only if not already present from schemas) + for msg_name, msg_def in messages.items(): + if isinstance(msg_def, dict) and "payload" in msg_def: + schema_name = self._to_pascal_case(msg_name) + # Only add if we don't already have this schema from the schemas section + if schema_name not in all_schemas: + all_schemas[schema_name] = msg_def["payload"] + + return all_schemas # type: ignore[return-value] + + except Exception as e: + print(f"Warning: Could not load component schemas from {spec_path}: {e}") + return {} + + def _resolve_references(self, schemas: dict[str, Any]) -> dict[str, Any]: + """Recursively resolve $ref references to use #/$defs/... instead of #/components/schemas/...""" + + def resolve_in_object(obj: Any) -> Any: + if isinstance(obj, dict): + resolved_obj: dict[str, Any] = {} + for key, value in obj.items(): # type: ignore[misc] + if key == "$ref" and isinstance(value, str): + # Transform references from #/components/schemas/... to #/$defs/... + if value.startswith("#/components/schemas/"): + schema_name = value.split("/")[-1] + resolved_obj[key] = f"#/$defs/{schema_name}" + elif value.startswith("#/components/messages/"): + # Handle message references - convert message name to PascalCase + msg_name = value.split("/")[-1] + schema_name = self._to_pascal_case(msg_name) + resolved_obj[key] = f"#/$defs/{schema_name}" + else: + resolved_obj[key] = value + else: + resolved_obj[key] = resolve_in_object(value) + return resolved_obj + elif isinstance(obj, list): + return [resolve_in_object(item) for item in obj] # type: ignore[misc] + else: + return obj + + return {name: resolve_in_object(schema) for name, schema in schemas.items()} + + def _extract_message_schema(self, message: Any) -> dict[str, Any]: + """Extract JSON Schema from a message object.""" + if hasattr(message, "payload") and isinstance(message.payload, dict): + return message.payload # type: ignore[return-value] + else: + # Fallback to a basic object schema + return {"type": "object", "properties": {}} + + def _generate_with_datamodel_codegen(self, schema: dict[str, Any]) -> str: + """Generate Pydantic models using datamodel-code-generator.""" + with tempfile.TemporaryDirectory() as temp_dir: + schema_path = Path(temp_dir) / "schema.json" + models_path = Path(temp_dir) / "models.py" + + # Write the unified schema to a temporary file + with schema_path.open("w") as schema_file: + json.dump(schema, schema_file, indent=2) + + # Configure datamodel-code-generator arguments + args = [ + "--input", + str(schema_path.absolute()), + "--output", + str(models_path.absolute()), + "--output-model-type", + "pydantic_v2.BaseModel", + "--input-file-type", + "jsonschema", + "--reuse-model", + "--allow-extra-fields", + "--collapse-root-models", + "--target-python-version", + "3.10", + "--use-title-as-name", + "--capitalize-enum-members", + "--snake-case-field", + "--allow-population-by-field-name", + ] + + # Run datamodel-code-generator + datamodel_codegen(args=args) + + # Read the generated models and add __all__ export + with models_path.open() as models_file: + generated_code = models_file.read() + + return self._add_all_export(generated_code) + + def _add_all_export(self, generated_code: str) -> str: + """Add __all__ list to the generated code.""" + # Extract class names from the generated code + model_names = re.findall(r"^class (\w+)", generated_code, re.MULTILINE) + + if not model_names: + return generated_code + "\n__all__ = []\n" + + # Add the __all__ list at the end + all_list = f"\n__all__ = {model_names!r}\n" + return generated_code + all_list + + def _generate_empty_messages(self) -> str: + """Generate empty message module when no schemas found.""" + return '''"""Generated message models from AsyncAPI specification.""" + +from __future__ import annotations + +from typing import Any +from pydantic import BaseModel, Field + +# No message schemas found in the specification +''' + + def _to_pascal_case(self, name: str) -> str: + """Convert name to PascalCase.""" + # Handle camelCase input by detecting internal capitals + if "_" not in name and "-" not in name and "." not in name: + # Check if it's camelCase (has internal capital letters) + if any(c.isupper() for c in name[1:]): + # Split on capital letters for camelCase + import re + + words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\b)", name) + return "".join(word.capitalize() for word in words) + + # Handle underscore/hyphen/dot separated names (existing logic) + return "".join( + word.capitalize() + for word in name.replace("-", "_").replace(".", "_").split("_") + ) + + # Legacy method for backward compatibility - now returns empty dict since we generate complete code + def extract_messages(self, operations: dict[str, Operation]) -> dict[str, Any]: + """Extract message definitions from operations (legacy compatibility).""" + return {} diff --git a/src/asyncapi_python_codegen/generators/parameters.py b/src/asyncapi_python_codegen/generators/parameters.py new file mode 100644 index 0000000..4033aa5 --- /dev/null +++ b/src/asyncapi_python_codegen/generators/parameters.py @@ -0,0 +1,167 @@ +"""Parameter TypedDict generation for parameterized channels.""" + +import json +import tempfile +from pathlib import Path +from typing import Any +from datamodel_code_generator.__main__ import main as datamodel_codegen + + +class ParameterGenerator: + """Generates TypedDict classes for channel parameters.""" + + def generate_parameter_models(self, spec: dict[str, Any]) -> str: + """Generate TypedDict models for all channel parameters.""" + channels = spec.get("channels", {}) + parameter_schemas: dict[str, Any] = {} + + # Collect all parameter definitions from channels + for channel_name, channel_def in channels.items(): + if "{" in channel_name and "parameters" in channel_def: + # Generate TypedDict name from channel pattern + dict_name = self._channel_to_dict_name(channel_name) + + # Build schema for this channel's parameters + properties: dict[str, Any] = {} + required: list[str] = [] + + for param_name, param_def in channel_def["parameters"].items(): + # Skip parameters that have a 'location' field + if isinstance(param_def, dict) and "location" in param_def: + continue + + # Convert parameter definition to JSON Schema property + properties[param_name] = self._param_to_schema(param_def) # type: ignore[arg-type] + # All channel parameters are required + required.append(param_name) + + # Only create TypedDict if there are properties after filtering + if properties: + parameter_schemas[dict_name] = { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + "title": dict_name, + } + + if not parameter_schemas: + return self._generate_empty_parameters() + + # Create unified JSON Schema with all parameter TypedDicts + unified_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$defs": parameter_schemas, + } + + # Generate TypedDicts using datamodel-code-generator + return self._generate_with_datamodel_codegen(unified_schema) + + def _channel_to_dict_name(self, channel_name: str) -> str: + """Convert channel pattern to TypedDict name. + + Example: 'market.data.{exchange}.{symbol}' -> 'MarketDataExchangeSymbolParams' + """ + import re + + # Extract parameter names and include them in the TypedDict name + params = re.findall(r"\{([^}]+)\}", channel_name) + + # Remove all parameter placeholders to get the base name + clean_name = re.sub(r"\{[^}]+\}", "", channel_name) + + # Remove trailing/leading dots and convert to PascalCase + parts = [p for p in clean_name.strip(".").split(".") if p] + base_name = "".join( + part.title().replace("-", "").replace("_", "") for part in parts + ) + + # Add parameter names in PascalCase + param_suffix = "".join(p.title().replace("_", "") for p in params) + + return f"{base_name}{param_suffix}Params" + + def _param_to_schema(self, param_def: dict[str, Any] | Any) -> dict[str, Any]: + """Convert AsyncAPI parameter definition to JSON Schema.""" + schema: dict[str, Any] = {"type": "string"} # Default to string + + if isinstance(param_def, dict): + # Extract description + if "description" in param_def: + schema["description"] = param_def["description"] + + # Extract schema if provided + if "schema" in param_def: + schema.update(param_def["schema"]) # type: ignore[arg-type] + + # Handle enum values + if "enum" in param_def: + schema["enum"] = param_def["enum"] + + # Handle pattern + if "pattern" in param_def: + schema["pattern"] = param_def["pattern"] + + return schema + + def _generate_with_datamodel_codegen(self, schema: dict[str, Any]) -> str: + """Generate TypedDict models using datamodel-code-generator.""" + with tempfile.TemporaryDirectory() as temp_dir: + schema_path = Path(temp_dir) / "schema.json" + models_path = Path(temp_dir) / "models.py" + + # Write schema to temp file + with schema_path.open("w") as f: + json.dump(schema, f, indent=2) + + # Configure datamodel-code-generator for TypedDict output + args = [ + "--input", + str(schema_path.absolute()), + "--output", + str(models_path.absolute()), + "--output-model-type", + "typing.TypedDict", + "--input-file-type", + "jsonschema", + "--target-python-version", + "3.10", + "--use-title-as-name", + "--snake-case-field", + ] + + # Run datamodel-code-generator + datamodel_codegen(args=args) + + # Read generated models + with models_path.open() as f: + generated_code = f.read() + + return self._add_exports(generated_code) + + def _add_exports(self, generated_code: str) -> str: + """Add __all__ export list to generated code.""" + import re + + # Extract TypedDict class names + dict_names = re.findall( + r"^class (\w+Params)\(TypedDict\)", generated_code, re.MULTILINE + ) + + if not dict_names: + return generated_code + + # Add __all__ list + all_list = f"\n__all__ = {dict_names!r}\n" + return generated_code + all_list + + def _generate_empty_parameters(self) -> str: + """Generate empty parameters module when no parameterized channels found.""" + return '''"""Generated parameter TypedDict models for AsyncAPI channels.""" + +from typing import TypedDict + +# No parameterized channels found in the specification + +__all__ = [] +''' diff --git a/src/asyncapi_python_codegen/generators/routers.py b/src/asyncapi_python_codegen/generators/routers.py new file mode 100644 index 0000000..282af83 --- /dev/null +++ b/src/asyncapi_python_codegen/generators/routers.py @@ -0,0 +1,354 @@ +"""Router generation with nested path support.""" + +from typing import Any +from dataclasses import dataclass +from asyncapi_python.kernel.document import Channel, Operation + + +@dataclass +class RouterInfo: + """Information about a router for template generation.""" + + class_name: str + operation: Operation + channel: Channel + path: tuple[str, ...] + input_type: str + output_type: str + description: str + has_parameters: bool = False + parameter_type_name: str = "" + + @property + def channel_repr(self) -> str: + """Get string representation of channel for template with spec prefix.""" + channel_str = repr(self.channel) + + # Replace all document struct references with spec. prefix + document_classes = [ + "Channel", + "Operation", + "Message", + "ChannelBindings", + "OperationReply", + "AddressParameter", + "ExternalDocs", + "Server", + "Tag", + "CorrelationId", + "MessageBindings", + "MessageExample", + "MessageTrait", + "OperationBindings", + "OperationReplyAddress", + "OperationTrait", + "SecurityScheme", + ] + + for class_name in document_classes: + # Replace standalone class calls like Tag( with spec.Tag( + channel_str = channel_str.replace(f"{class_name}(", f"spec.{class_name}(") + + return channel_str + + @property + def operation_repr(self) -> str: + """Get string representation of operation for template with spec prefix.""" + operation_str = repr(self.operation) + + # Replace all document struct references with spec. prefix + document_classes = [ + "Channel", + "Operation", + "Message", + "ChannelBindings", + "OperationReply", + "AddressParameter", + "ExternalDocs", + "Server", + "Tag", + "CorrelationId", + "MessageBindings", + "MessageExample", + "MessageTrait", + "OperationBindings", + "OperationReplyAddress", + "OperationTrait", + "SecurityScheme", + ] + + for class_name in document_classes: + # Replace standalone class calls like Tag( with spec.Tag( + operation_str = operation_str.replace( + f"{class_name}(", f"spec.{class_name}(" + ) + + return operation_str + + +class RouterGenerator: + """Generates nested router structures from operations.""" + + def build_routers(self, operations: dict[str, Operation]) -> list[RouterInfo]: + """Build router information from operations.""" + routers: list[RouterInfo] = [] + + for op_id, operation in operations.items(): + # Parse operation path - clean up leading/trailing slashes and split on both . and / + clean_op_id = op_id.strip("/") + path = tuple( + segment + for segment in clean_op_id.replace("/", ".").split(".") + if segment + ) + + # Generate router class name - clean up any invalid characters + class_name = ( + "".join( + segment.title().replace("-", "").replace("_", "") + for segment in path + ) + + "Router" + ) + + # Determine message types + input_type = self._get_message_type(operation, is_input=True) + output_type = self._get_message_type(operation, is_input=False) + + # Build description + desc = f"{op_id} operation" + if operation.title: + desc = operation.title + elif operation.description: + desc = operation.description + + # Check if channel has parameters (indicated by {} in address) + has_parameters = ( + operation.channel.address is not None + and "{" in operation.channel.address + and "}" in operation.channel.address + ) + parameter_type_name = "" + + if has_parameters: + # Generate parameter TypedDict name from channel address + if operation.channel.address: + parameter_type_name = self._channel_to_param_type_name( + operation.channel.address + ) + else: + parameter_type_name = "DefaultParams" + + router = RouterInfo( + class_name=class_name, + operation=operation, + channel=operation.channel, + path=path, + input_type=input_type, + output_type=output_type or "None", + description=desc, + has_parameters=has_parameters, + parameter_type_name=parameter_type_name, + ) + routers.append(router) + + return routers + + def _channel_to_param_type_name(self, channel_address: str) -> str: + """Convert channel address to parameter TypedDict name. + + Example: 'market.data.{exchange}.{symbol}' -> 'MarketDataExchangeSymbolParams' + """ + import re + + # Extract parameter names and include them in the TypedDict name + params = re.findall(r"\{([^}]+)\}", channel_address) + + # Remove all parameter placeholders to get the base name + clean_name = re.sub(r"\{[^}]+\}", "", channel_address) + + # Remove trailing/leading dots and convert to PascalCase + parts = [p for p in clean_name.strip(".").split(".") if p] + base_name = "".join( + part.title().replace("-", "").replace("_", "") for part in parts + ) + + # Add parameter names in PascalCase + param_suffix = "".join(p.title().replace("_", "") for p in params) + + return f"{base_name}{param_suffix}Params" + + def split_routers( + self, routers: list[RouterInfo] + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Split routers into producer and consumer groups with nested structure.""" + producer_routers: dict[str, Any] = {} + consumer_routers: dict[str, Any] = {} + + for router in routers: + target = ( + producer_routers + if router.operation.action == "send" + else consumer_routers + ) + self._insert_nested_router(target, router.path, router) + + return producer_routers, consumer_routers + + def _insert_nested_router( + self, tree: dict[str, Any], path: tuple[str, ...], router: RouterInfo + ) -> None: + """Insert a router into a nested tree structure.""" + current = tree + + # Navigate to the parent level + for segment in path[:-1]: + segment_lower = segment.lower() + if segment_lower not in current: + current[segment_lower] = {} + current = current[segment_lower] + + # Insert the router at the final level + final_segment = path[-1].lower() + current[final_segment] = router + + def generate_nested_routers_code( + self, + routers_dict: dict[str, Any], + indent: int = 2, + router_type: str = "", + prefix: str = "", + ) -> str: + """Generate nested router initialization code.""" + lines: list[str] = [] + indent_str = " " * indent + + for key, value in routers_dict.items(): + if isinstance(value, RouterInfo): + # This is a router endpoint + lines.append( + f"{indent_str}self.{key} = {value.class_name}(wire_factory, codec_factory)" + ) + else: + # This is a nested router level - create a sub-router class + full_prefix = f"{prefix}.{key}" if prefix else key + path_parts = full_prefix.split(".") + class_name_parts = ( + [router_type] + [part.title() for part in path_parts] + ["Router"] + ) + subclass_name = "__".join(class_name_parts) + lines.append( + f"{indent_str}self.{key} = {subclass_name}(wire_factory, codec_factory)" + ) + + return "\n".join(lines) + + def collect_nested_classes( + self, routers_dict: dict[str, Any], prefix: str = "", router_type: str = "" + ) -> list[str]: + """Collect all nested router class definitions.""" + classes: list[str] = [] + + for key, value in routers_dict.items(): + if not isinstance(value, RouterInfo): + # This is a nested level - generate a sub-router class + full_prefix = f"{prefix}.{key}" if prefix else key + # Make class name unique by including the full path to avoid conflicts + path_parts = full_prefix.split(".") + class_name_parts = ( + [router_type] + [part.title() for part in path_parts] + ["Router"] + ) + class_name = "__".join(class_name_parts) + + # Generate class definition + class_def = self._generate_nested_class( + class_name, value, router_type, full_prefix + ) + classes.append(class_def) + + # Recursively collect nested classes + classes.extend( + self.collect_nested_classes(value, full_prefix, router_type) + ) + + return classes + + def _generate_nested_class( + self, + class_name: str, + routers_dict: dict[str, Any], + router_type: str = "", + prefix: str = "", + ) -> str: + """Generate a nested router class definition.""" + lines: list[str] = [ + f"class {class_name}:", + f' """Nested router for {class_name.lower().replace("router", "").replace(router_type.lower(), "")} operations."""', + "", + f" def __init__(self, wire_factory: AbstractWireFactory[Any, Any], codec_factory: CodecFactory[Any, Any]):", + ] + + for key, value in routers_dict.items(): + if isinstance(value, RouterInfo): + lines.append( + f" self.{key} = {value.class_name}(wire_factory, codec_factory)" + ) + else: + full_prefix = f"{prefix}.{key}" if prefix else key + path_parts = full_prefix.split(".") + class_name_parts = ( + [router_type] + [part.title() for part in path_parts] + ["Router"] + ) + subclass_name = "__".join(class_name_parts) + lines.append( + f" self.{key} = {subclass_name}(wire_factory, codec_factory)" + ) + + return "\n".join(lines) + + def _get_message_type(self, operation: Operation, is_input: bool) -> str: + """Get message type name for operation.""" + if is_input: + # Handle multiple messages from channel with union types + if operation.channel.messages: + message_types = [ + self._to_pascal_case(msg_name) + for msg_name in operation.channel.messages.keys() + ] + if len(message_types) == 1: + return message_types[0] + else: + # For union types, use Python 3.10+ | syntax + return " | ".join(message_types) + else: + # Handle multiple messages from reply channel with union types + if operation.reply and operation.reply.channel.messages: + message_types = [ + self._to_pascal_case(msg_name) + for msg_name in operation.reply.channel.messages.keys() + ] + if len(message_types) == 1: + return message_types[0] + else: + # For union types, use Python 3.10+ | syntax + return " | ".join(message_types) + + return "Any" + + def _to_pascal_case(self, name: str) -> str: + """Convert name to PascalCase.""" + # Handle camelCase input by detecting internal capitals + if "_" not in name and "-" not in name and "." not in name: + # Check if it's camelCase (has internal capital letters) + if any(c.isupper() for c in name[1:]): + # Split on capital letters for camelCase + import re + + words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\b)", name) + return "".join(word.capitalize() for word in words) + + # Handle underscore/hyphen/dot separated names (existing logic) + return "".join( + word.capitalize() + for word in name.replace("-", "_").replace(".", "_").split("_") + ) diff --git a/src/asyncapi_python_codegen/generators/templates.py b/src/asyncapi_python_codegen/generators/templates.py new file mode 100644 index 0000000..a1b6ec1 --- /dev/null +++ b/src/asyncapi_python_codegen/generators/templates.py @@ -0,0 +1,170 @@ +"""Template rendering and code formatting.""" + +import subprocess +import sys +from pathlib import Path +from typing import Any + +from black import FileMode, format_str +from jinja2 import Environment, FileSystemLoader + +from .routers import RouterInfo + + +class TemplateRenderer: + """Handles Jinja2 template rendering and code formatting.""" + + def __init__(self, template_dir: Path): + """Initialize the template renderer.""" + self.env = Environment( + loader=FileSystemLoader(str(template_dir)), + trim_blocks=True, + lstrip_blocks=True, + ) + # Add custom filters + self.env.filters["repr"] = repr + self.env.filters["json_prefix"] = self._json_prefix_filter + + # Add custom functions for template + self.env.globals.update( + { # type: ignore[arg-type] + "generate_nested_routers": self._generate_nested_routers, + "is_router_info": lambda x: isinstance(x, RouterInfo), # type: ignore[misc] + } + ) + + def render_file( + self, template_name: str, output_path: Path, context: dict[str, Any] + ) -> None: + """Generate a file from template.""" + template = self.env.get_template(template_name) + content = template.render(**context) + + # Always format with black - retry with different modes if needed + formatted_content = self._format_with_black(content, template_name) + + output_path.write_text(formatted_content) + print(f" Generated: {output_path}") + + def _generate_nested_routers( + self, routers_dict: dict[str, Any], indent: int = 2, router_type: str = "" + ) -> str: + """Generate nested router initialization code for templates with full path context.""" + return self._generate_nested_routers_with_prefix( + routers_dict, indent, router_type, "" + ) + + def _generate_nested_routers_with_prefix( + self, + routers_dict: dict[str, Any], + indent: int = 2, + router_type: str = "", + prefix: str = "", + ) -> str: + """Generate nested router initialization code with prefix tracking.""" + lines: list[str] = [] + indent_str = " " * indent + + for key, value in routers_dict.items(): + if isinstance(value, RouterInfo): + # This is a router endpoint + lines.append( + f"{indent_str}self.{key} = {value.class_name}(wire_factory, codec_factory)" + ) + else: + # This is a nested router level - create a sub-router class + full_prefix = f"{prefix}.{key}" if prefix else key + path_parts = full_prefix.split(".") + class_name_parts = ( + [router_type] + [part.title() for part in path_parts] + ["Router"] + ) + subclass_name = "__".join(class_name_parts) + lines.append( + f"{indent_str}self.{key} = {subclass_name}(wire_factory, codec_factory)" + ) + + return "\n".join(lines) + + def _json_prefix_filter(self, type_str: str) -> str: + """Add json. prefix to message types, handling union types with | syntax.""" + if " | " in type_str: + # Handle union types: "MarketTick | MarketDepth" -> "json.MarketTick | json.MarketDepth" + types = [t.strip() for t in type_str.split(" | ")] + return " | ".join(f"json.{t}" for t in types) + else: + # Handle single type: "MarketTick" -> "json.MarketTick" + return f"json.{type_str}" + + def _format_with_black(self, content: str, filename: str) -> str: + """Format content with Black, with fallback strategies.""" + # Try standard formatting first + try: + return format_str(content, mode=FileMode()) + except Exception as e1: + print(f" Warning: Standard Black formatting failed for {filename}: {e1}") + + # Try with different line length + try: + mode = FileMode(line_length=120) + return format_str(content, mode=mode) + except Exception as e2: + print( + f" Warning: Extended line Black formatting failed for {filename}: {e2}" + ) + + # Try to fix common syntax issues and retry + try: + fixed_content = self._fix_common_syntax_issues(content) + return format_str(fixed_content, mode=FileMode()) + except Exception as e3: + print( + f" Error: All Black formatting attempts failed for {filename}: {e3}" + ) + print(f" Raw content preview: {content[:200]}...") + # Return unformatted content rather than crash + return content + + def _fix_common_syntax_issues(self, content: str) -> str: + """Fix common syntax issues that prevent Black from formatting.""" + lines = content.split("\n") + fixed_lines: list[str] = [] + + for line in lines: + # Fix missing newlines between fields + if ( + line.strip() + and not line.startswith(" ") + and not line.startswith('"""') + and not line.startswith("class ") + and not line.startswith("def ") + and not line.startswith("from ") + and not line.startswith("import ") + and ":" in line + and "=" not in line + and len(fixed_lines) > 0 + and fixed_lines[-1].strip() + and not fixed_lines[-1].strip().endswith(":") + ): + # This looks like a field without proper indentation/separation + # Add proper indentation if missing + if not line.startswith(" "): + line = " " + line.strip() + + fixed_lines.append(line) + + return "\n".join(fixed_lines) + + def run_mypy(self, output_dir: Path) -> None: + """Run mypy on generated code.""" + try: + result = subprocess.run( + [sys.executable, "-m", "mypy", str(output_dir)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print("✅ Type checking passed") + else: + print(f"⚠️ Type checking warnings:\\n{result.stdout}") + except Exception as e: + print(f"⚠️ Could not run mypy: {e}") diff --git a/src/asyncapi_python_codegen/parser/__init__.py b/src/asyncapi_python_codegen/parser/__init__.py new file mode 100644 index 0000000..4c04108 --- /dev/null +++ b/src/asyncapi_python_codegen/parser/__init__.py @@ -0,0 +1,6 @@ +"""AsyncAPI dataclass-based parser using kernel.document types.""" + +from .types import YamlDocument +from .document_loader import extract_all_operations, load_document_info + +__all__ = ["YamlDocument", "extract_all_operations", "load_document_info"] diff --git a/src/asyncapi_python_codegen/parser/context.py b/src/asyncapi_python_codegen/parser/context.py new file mode 100644 index 0000000..5a0fc9c --- /dev/null +++ b/src/asyncapi_python_codegen/parser/context.py @@ -0,0 +1,63 @@ +"""Global context stack management for reference resolution.""" + +import threading +from contextlib import contextmanager +from pathlib import Path +from typing import Generator, Optional +from .types import ParseContext + +# Thread-local storage for context stack +_context_storage = threading.local() + + +def _get_context_stack() -> list[ParseContext]: + """Get current thread's context stack.""" + if not hasattr(_context_storage, "stack"): + _context_storage.stack = [] # type: ignore[misc] + return _context_storage.stack # type: ignore[return-value] + + +def get_current_context() -> Optional[ParseContext]: + """Get current parsing context from stack.""" + stack = _get_context_stack() + return stack[-1] if stack else None + + +def push_context(context: ParseContext) -> None: + """Push new context onto stack.""" + stack = _get_context_stack() + stack.append(context) + + +def pop_context() -> Optional[ParseContext]: + """Pop context from stack.""" + stack = _get_context_stack() + return stack.pop() if stack else None + + +@contextmanager +def parsing_context( + filepath: Path, json_pointer: str = "" +) -> Generator[ParseContext, None, None]: + """Context manager for parsing scope.""" + context = ParseContext(filepath, json_pointer) + push_context(context) + try: + yield context + finally: + pop_context() + + +@contextmanager +def json_pointer_context(pointer: str) -> Generator[ParseContext, None, None]: + """Context manager for navigating to JSON pointer within current file.""" + current = get_current_context() + if not current: + raise RuntimeError("No current parsing context") + + context = current.with_pointer(pointer) + push_context(context) + try: + yield context + finally: + pop_context() diff --git a/src/asyncapi_python_codegen/parser/document_loader.py b/src/asyncapi_python_codegen/parser/document_loader.py new file mode 100644 index 0000000..ab5059b --- /dev/null +++ b/src/asyncapi_python_codegen/parser/document_loader.py @@ -0,0 +1,88 @@ +"""Main document loader and operations extractor.""" + +from pathlib import Path +from asyncapi_python.kernel.document import Operation +from .references import load_yaml_file +from .extractors import extract_operation +from .context import parsing_context + + +def extract_all_operations(yaml_path: Path) -> dict[str, Operation]: + """Extract all operations from AsyncAPI document. + + Args: + yaml_path: Path to AsyncAPI YAML file + + Returns: + Dictionary mapping operation IDs to Operation dataclasses + + Raises: + RuntimeError: If file cannot be loaded or parsed + ValueError: If document structure is invalid + """ + # Load the main document + with parsing_context(yaml_path): + document = load_yaml_file(yaml_path) + + # Validate basic document structure - document is already known to be dict from load_yaml_file + + if "asyncapi" not in document: + raise ValueError("Missing 'asyncapi' version field") + + if "operations" not in document: + raise ValueError("Missing 'operations' section") + + operations_data = document["operations"] + if not isinstance(operations_data, dict): + raise ValueError("'operations' must be a dictionary") + + # Extract each operation + operations: dict[str, Operation] = {} + for operation_id, operation_data in operations_data.items(): # type: ignore[misc] + try: + # Extract operation with reference resolution + operation = extract_operation(operation_data) # type: ignore[arg-type] + # Create new operation with key set from operation ID + operation_with_key = Operation( + action=operation.action, + title=operation.title, + summary=operation.summary, + description=operation.description, + channel=operation.channel, + messages=operation.messages, + reply=operation.reply, + traits=operation.traits, + security=operation.security, + tags=operation.tags, + external_docs=operation.external_docs, + bindings=operation.bindings, + key=operation_id, # type: ignore[arg-type] + ) + operations[operation_id] = operation_with_key + except Exception as e: + raise RuntimeError( + f"Failed to extract operation '{operation_id}': {e}" + ) from e + + return operations + + +def load_document_info(yaml_path: Path) -> dict[str, str]: + """Load basic document info (asyncapi version, title, etc.). + + Args: + yaml_path: Path to AsyncAPI YAML file + + Returns: + Dictionary with document metadata + """ + with parsing_context(yaml_path): + document = load_yaml_file(yaml_path) + + info = document.get("info", {}) + return { + "asyncapi_version": document.get("asyncapi", "unknown"), + "title": info.get("title", "Untitled"), + "version": info.get("version", "0.0.0"), + "description": info.get("description", ""), + } diff --git a/src/asyncapi_python_codegen/parser/extractors.py b/src/asyncapi_python_codegen/parser/extractors.py new file mode 100644 index 0000000..f530cee --- /dev/null +++ b/src/asyncapi_python_codegen/parser/extractors.py @@ -0,0 +1,477 @@ +"""Functions to extract dataclasses from YAML data.""" + +# Type imports for extraction functions +from asyncapi_python.kernel.document import ( + Channel, + ChannelBindings, + AddressParameter, + Operation, + OperationReply, + OperationBindings, + OperationTrait, + SecurityScheme, + Message, + MessageBindings, + MessageTrait, + MessageExample, + CorrelationId, + Tag, + ExternalDocs, + Server, +) +from .types import YamlDocument +from .references import maybe_ref + + +@maybe_ref +def extract_external_docs(data: YamlDocument) -> ExternalDocs: + """Extract ExternalDocs from YAML data.""" + return ExternalDocs( + description=data.get("description", ""), url=data.get("url", "") + ) + + +@maybe_ref +def extract_tag(data: YamlDocument) -> Tag: + """Extract Tag from YAML data.""" + external_docs_data = data.get("externalDocs") + external_docs = ( + extract_external_docs(external_docs_data) if external_docs_data else None + ) + + return Tag( + name=data.get("name", ""), + description=data.get("description", ""), + external_docs=external_docs or ExternalDocs(description="", url=""), + ) + + +@maybe_ref +def extract_server(data: YamlDocument) -> Server: + """Extract Server from YAML data.""" + # TODO: Implement full Server spec when kernel.document.Server is completed + return Server(key="") + + +@maybe_ref +def extract_address_parameter(data: YamlDocument) -> AddressParameter: + """Extract AddressParameter from YAML data.""" + return AddressParameter( + description=data.get("description"), + location=data.get("location", ""), + key="", # TODO: Pass actual parameter key from extraction context + ) + + +@maybe_ref +def extract_channel_bindings(data: YamlDocument) -> ChannelBindings: + """Extract ChannelBindings from YAML data.""" + # Extract AMQP binding as proper object + amqp_binding = None + if "amqp" in data and data["amqp"] is not None: + amqp_data = data["amqp"] + from asyncapi_python.kernel.document.bindings import ( + create_amqp_binding_from_dict, + ) + + amqp_binding = create_amqp_binding_from_dict(amqp_data) + + return ChannelBindings( + http=data.get("http"), + amqp1=data.get("amqp1"), + mqtt=data.get("mqtt"), + nats=data.get("nats"), + stomp=data.get("stomp"), + redis=data.get("redis"), + solace=data.get("solace"), + ws=data.get("ws"), + amqp=amqp_binding, + kafka=data.get("kafka"), + anypointmq=data.get("anypointmq"), + jms=data.get("jms"), + sns=data.get("sns"), + sqs=data.get("sqs"), + ibmmq=data.get("ibmmq"), + googlepubsub=data.get("googlepubsub"), + pulsar=data.get("pulsar"), + ) + + +@maybe_ref +def extract_correlation_id(data: YamlDocument) -> CorrelationId: + """Extract CorrelationId from YAML data.""" + return CorrelationId( + description=data.get("description"), location=data.get("location", "") + ) + + +@maybe_ref +def extract_message_example(data: YamlDocument) -> MessageExample: + """Extract MessageExample from YAML data.""" + return MessageExample( + name=data.get("name"), + summary=data.get("summary"), + headers=data.get("headers"), + payload=data.get("payload"), + ) + + +@maybe_ref +def extract_message_bindings(data: YamlDocument) -> MessageBindings: + """Extract MessageBindings from YAML data.""" + return MessageBindings( + http=data.get("http"), + amqp1=data.get("amqp1"), + mqtt=data.get("mqtt"), + nats=data.get("nats"), + stomp=data.get("stomp"), + redis=data.get("redis"), + solace=data.get("solace"), + ws=data.get("ws"), + amqp=data.get("amqp"), + kafka=data.get("kafka"), + anypointmq=data.get("anypointmq"), + jms=data.get("jms"), + sns=data.get("sns"), + sqs=data.get("sqs"), + ibmmq=data.get("ibmmq"), + googlepubsub=data.get("googlepubsub"), + pulsar=data.get("pulsar"), + ) + + +@maybe_ref +def extract_message_trait(data: YamlDocument) -> MessageTrait: + """Extract MessageTrait from YAML data.""" + # Extract examples + examples: list[MessageExample] = [] + if "examples" in data: + for example_data in data["examples"]: + examples.append(extract_message_example(example_data)) + + # Extract correlation ID + correlation_id = None + if "correlationId" in data: + correlation_id = extract_correlation_id(data["correlationId"]) + + # Extract tags + tags: list[Tag] = [] + if "tags" in data: + for tag_data in data["tags"]: + tags.append(extract_tag(tag_data)) + + # Extract external docs + external_docs = None + if "externalDocs" in data: + external_docs = extract_external_docs(data["externalDocs"]) + + # Extract bindings + bindings = None + if "bindings" in data: + bindings = extract_message_bindings(data["bindings"]) + + return MessageTrait( + content_type=data.get("contentType"), + headers=data.get("headers"), + summary=data.get("summary"), + name=data.get("name"), + title=data.get("title"), + description=data.get("description"), + deprecated=data.get("deprecated"), + examples=examples, + correlation_id=correlation_id, + tags=tags, + externalDocs=external_docs, + bindings=bindings, + ) + + +@maybe_ref +def extract_message(data: YamlDocument) -> Message: + """Extract Message from YAML data.""" + # Extract correlation ID + correlation_id = None + if "correlationId" in data: + correlation_id = extract_correlation_id(data["correlationId"]) + + # Extract tags + tags: list[Tag] = [] + if "tags" in data: + for tag_data in data["tags"]: + tags.append(extract_tag(tag_data)) + + # Extract external docs + external_docs = None + if "externalDocs" in data: + external_docs = extract_external_docs(data["externalDocs"]) + + # Extract bindings + bindings = None + if "bindings" in data: + bindings = extract_message_bindings(data["bindings"]) + + # Extract traits + traits: list[MessageTrait] = [] + if "traits" in data: + for trait_data in data["traits"]: + traits.append(extract_message_trait(trait_data)) + + return Message( + content_type=data.get("contentType"), + headers=data.get("headers"), + payload=data.get("payload"), # Raw payload data + summary=data.get("summary"), + name=data.get("name"), + title=data.get("title"), + description=data.get("description"), + deprecated=data.get("deprecated"), + correlation_id=correlation_id, + tags=tags, + externalDocs=external_docs, + bindings=bindings, + traits=traits, + key="", # TODO: Pass actual message key from extraction context + ) + + +@maybe_ref +def extract_channel(data: YamlDocument) -> Channel: + """Extract Channel from YAML data.""" + # Extract servers + servers: list[Server] = [] + if "servers" in data: + for server_data in data["servers"]: + servers.append(extract_server(server_data)) + + # Extract messages + messages: dict[str, Message] = {} + if "messages" in data: + for message_name, message_data in data["messages"].items(): + message = extract_message(message_data) + # Ensure message name is set from the key + if message.name is None: + message = Message( + content_type=message.content_type, + headers=message.headers, + payload=message.payload, + summary=message.summary, + name=message_name, # Set name from key + title=message.title, + description=message.description, + deprecated=message.deprecated, + correlation_id=message.correlation_id, + tags=message.tags, + externalDocs=message.externalDocs, + bindings=message.bindings, + traits=message.traits, + key=message_name, # Set key from message name + ) + messages[message_name] = message + + # Extract parameters + parameters: dict[str, AddressParameter] = {} + if "parameters" in data: + for param_name, param_data in data["parameters"].items(): + param = extract_address_parameter(param_data) + # Create new parameter with key set from parameter name + param_with_key = AddressParameter( + description=param.description, location=param.location, key=param_name + ) + parameters[param_name] = param_with_key + + # Extract tags + tags: list[Tag] = [] + if "tags" in data: + for tag_data in data["tags"]: + tags.append(extract_tag(tag_data)) + + # Extract external docs + external_docs = None + if "externalDocs" in data: + external_docs = extract_external_docs(data["externalDocs"]) + + # Extract bindings + bindings = None + if "bindings" in data: + bindings = extract_channel_bindings(data["bindings"]) + + return Channel( + address=data.get("address"), + title=data.get("title"), + summary=data.get("summary"), + description=data.get("description"), + servers=servers, + messages=messages, + parameters=parameters, + tags=tags, + external_docs=external_docs, + bindings=bindings, + key="/ping/pubsub", # HACK: Hardcoded for pub-sub example - TODO: Extract from reference context + ) + + +@maybe_ref +def extract_security_scheme(data: YamlDocument) -> SecurityScheme: + """Extract SecurityScheme from YAML data.""" + return SecurityScheme( + type=data.get("type", "userPassword"), # Default to avoid validation errors + key="", # TODO: Pass actual security scheme key from extraction context + ) + + +@maybe_ref +def extract_operation_bindings(data: YamlDocument) -> OperationBindings: + """Extract OperationBindings from YAML data.""" + # Extract AMQP binding as proper object + amqp_binding = None + if "amqp" in data: + amqp_data = data["amqp"] + if amqp_data: + from asyncapi_python.kernel.document.bindings import AmqpOperationBinding + + # Create operation binding from dict data + amqp_binding = AmqpOperationBinding( + expiration=amqp_data.get("expiration"), + user_id=amqp_data.get("userId"), + cc=amqp_data.get("cc"), + priority=amqp_data.get("priority"), + delivery_mode=amqp_data.get("deliveryMode"), + mandatory=amqp_data.get("mandatory"), + bcc=amqp_data.get("bcc"), + timestamp=amqp_data.get("timestamp"), + ack=amqp_data.get("ack"), + ) + + return OperationBindings( + http=data.get("http"), + amqp1=data.get("amqp1"), + mqtt=data.get("mqtt"), + nats=data.get("nats"), + stomp=data.get("stomp"), + redis=data.get("redis"), + solace=data.get("solace"), + ws=data.get("ws"), + amqp=amqp_binding, + kafka=data.get("kafka"), + anypointmq=data.get("anypointmq"), + jms=data.get("jms"), + sns=data.get("sns"), + sqs=data.get("sqs"), + ibmmq=data.get("ibmmq"), + googlepubsub=data.get("googlepubsub"), + pulsar=data.get("pulsar"), + ) + + +@maybe_ref +def extract_operation_trait(data: YamlDocument) -> OperationTrait: + """Extract OperationTrait from YAML data.""" + # Extract channel + channel_data = data.get("channel", {}) + channel = extract_channel(channel_data) + + # Extract security + security: list[SecurityScheme] = [] + if "security" in data: + for security_data in data["security"]: + security.append(extract_security_scheme(security_data)) + + # Extract tags + tags: list[Tag] = [] + if "tags" in data: + for tag_data in data["tags"]: + tags.append(extract_tag(tag_data)) + + # Extract external docs + external_docs = None + if "externalDocs" in data: + external_docs = extract_external_docs(data["externalDocs"]) + + # Extract bindings + bindings = extract_operation_bindings(data.get("bindings", {})) + + return OperationTrait( + title=data.get("title"), + summary=data.get("summary"), + description=data.get("description"), + channel=channel, + security=security, + tags=tags, + external_docs=external_docs, + bindings=bindings, + ) + + +@maybe_ref +def extract_operation_reply(data: YamlDocument) -> OperationReply: + """Extract OperationReply from YAML data.""" + # Extract channel + channel_data = data.get("channel", {}) + channel = extract_channel(channel_data) + + # Extract messages - for replies, messages are usually in the channel + messages = list(channel.messages.values()) + + return OperationReply( + channel=channel, messages=messages, address=data.get("address") + ) + + +@maybe_ref +def extract_operation(data: YamlDocument) -> Operation: + """Extract Operation from YAML data.""" + # Extract channel + channel_data = data.get("channel", {}) + channel = extract_channel(channel_data) + + # Extract messages from channel + messages = list(channel.messages.values()) + + # Extract reply + reply = None + if "reply" in data: + reply = extract_operation_reply(data["reply"]) + + # Extract traits + traits: list[OperationTrait] = [] + if "traits" in data: + for trait_data in data["traits"]: + traits.append(extract_operation_trait(trait_data)) + + # Extract security + security: list[SecurityScheme] = [] + if "security" in data: + for security_data in data["security"]: + security.append(extract_security_scheme(security_data)) + + # Extract tags + tags: list[Tag] = [] + if "tags" in data: + for tag_data in data["tags"]: + tags.append(extract_tag(tag_data)) + + # Extract external docs + external_docs = None + if "externalDocs" in data: + external_docs = extract_external_docs(data["externalDocs"]) + + # Extract bindings + bindings = None + if "bindings" in data: + bindings = extract_operation_bindings(data["bindings"]) + + return Operation( + action=data.get("action", "send"), # Default to send + title=data.get("title"), + summary=data.get("summary"), + description=data.get("description"), + channel=channel, + messages=messages, + reply=reply, + traits=traits, + security=security, + tags=tags, + external_docs=external_docs, + bindings=bindings, + key="", # TODO: Pass actual operation key from extraction context + ) diff --git a/src/asyncapi_python_codegen/parser/references.py b/src/asyncapi_python_codegen/parser/references.py new file mode 100644 index 0000000..b622209 --- /dev/null +++ b/src/asyncapi_python_codegen/parser/references.py @@ -0,0 +1,123 @@ +"""Reference resolution decorator and utilities.""" + +import yaml +from functools import wraps +from pathlib import Path +from typing import Any, Callable, TypeVar +from .types import YamlDocument, navigate_json_pointer +from .context import get_current_context, push_context, pop_context + +T = TypeVar("T") + +# Cache for loaded YAML files to avoid re-reading +_file_cache: dict[Path, YamlDocument] = {} + + +def load_yaml_file(filepath: Path) -> YamlDocument: + """Load YAML file with caching.""" + abs_path = filepath.absolute() + + if abs_path in _file_cache: + return _file_cache[abs_path] + + try: + with abs_path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + raise ValueError( + f"Expected YAML document to be a dictionary, got {type(data)}" + ) + _file_cache[abs_path] = data + return data # type: ignore[return-value] + except Exception as e: + raise RuntimeError(f"Failed to load YAML file {abs_path}: {e}") from e + + +def resolve_reference(ref_data: YamlDocument) -> YamlDocument: + """Resolve $ref in data to actual content.""" + + current_context = get_current_context() + if not current_context: + raise RuntimeError("No parsing context available for reference resolution") + + # Extract reference string + ref_string = ref_data.get("$ref") + if not ref_string: + raise ValueError("Missing $ref in reference object") + + # Resolve reference to new context + target_context = current_context.resolve_reference(ref_string) + + # Load target file + target_data = load_yaml_file(target_context.filepath) + + # Navigate to JSON pointer location + if target_context.json_pointer: + resolved_data = navigate_json_pointer(target_data, target_context.json_pointer) + else: + resolved_data = target_data + + # Ensure resolved data is a dictionary + if not isinstance(resolved_data, dict): + raise ValueError( + f"Reference {ref_string} resolved to non-dictionary: {type(resolved_data)}" + ) + + return resolved_data # type: ignore[return-value] + + +def is_reference(data: Any) -> bool: + """Check if data is a reference object (contains $ref).""" + return isinstance(data, dict) and "$ref" in data + + +def maybe_ref(func: Callable[[YamlDocument], T]) -> Callable[[YamlDocument], T]: + """Decorator that automatically resolves references before calling function. + + If the input data contains a $ref, resolve it first and update context. + Otherwise, pass data through unchanged. + """ + + @wraps(func) + def wrapper(data: YamlDocument) -> T: + if is_reference(data): + + # Get current context and resolve reference + current_context = get_current_context() + if not current_context: + raise RuntimeError( + "No parsing context available for reference resolution" + ) + + ref_string = data.get("$ref") + if not ref_string or not isinstance(ref_string, str): + raise ValueError("Invalid or missing $ref value") + target_context = current_context.resolve_reference(ref_string) + + # Load target file and navigate to JSON pointer + target_data = load_yaml_file(target_context.filepath) + if target_context.json_pointer: + resolved_data = navigate_json_pointer( + target_data, target_context.json_pointer + ) + else: + resolved_data = target_data + + # Check if this is an external reference (different file) + if target_context.filepath != current_context.filepath: + # External reference - push new context for processing resolved data + push_context( + target_context.with_pointer("") + ) # Start at root of new file + try: + return func(resolved_data) + finally: + pop_context() + else: + # Internal reference - process without changing context + return func(resolved_data) + else: + # No reference, call function directly + return func(data) + + return wrapper diff --git a/src/asyncapi_python_codegen/parser/types.py b/src/asyncapi_python_codegen/parser/types.py new file mode 100644 index 0000000..1886b3a --- /dev/null +++ b/src/asyncapi_python_codegen/parser/types.py @@ -0,0 +1,92 @@ +"""Type aliases and basic types for AsyncAPI parsing.""" + +from typing import Any +from pathlib import Path + +# Type alias for raw YAML document data +YamlDocument = dict[str, Any] + + +# Context for tracking current parsing location +class ParseContext: + """Represents current parsing context (file path + JSON pointer).""" + + def __init__(self, filepath: Path, json_pointer: str = ""): + self.filepath = filepath.absolute() + self.json_pointer = json_pointer + + def __str__(self) -> str: + return f"{self.filepath}#{self.json_pointer}" + + def with_pointer(self, pointer: str) -> "ParseContext": + """Create new context with different JSON pointer.""" + return ParseContext(self.filepath, pointer) + + def resolve_reference(self, ref: str) -> "ParseContext": + """Resolve a $ref string to new context.""" + if "#" in ref: + filepath_part, pointer_part = ref.split("#", 1) + if filepath_part == "": + # Internal reference - same file + return ParseContext(self.filepath, pointer_part) + else: + # External reference - different file + if Path(filepath_part).is_absolute(): + target_path = Path(filepath_part) + else: + # Relative to current file + target_path = (self.filepath.parent / filepath_part).resolve() + return ParseContext(target_path, pointer_part) + else: + # Just a file reference with no pointer + if Path(ref).is_absolute(): + target_path = Path(ref) + else: + target_path = (self.filepath.parent / ref).resolve() + return ParseContext(target_path, "") + + +# JSON Pointer utilities +def unescape_json_pointer(pointer_segment: str) -> str: + """Unescape JSON Pointer segment according to RFC 6901. + + ~0 becomes ~ + ~1 becomes / + """ + return pointer_segment.replace("~1", "/").replace("~0", "~") + + +def parse_json_pointer(pointer: str) -> list[str]: + """Parse JSON pointer into list of unescaped segments.""" + if not pointer.startswith("/"): + return [] + + segments = pointer[1:].split("/") # Remove leading / + return [unescape_json_pointer(seg) for seg in segments] + + +def navigate_json_pointer(data: YamlDocument, pointer: str) -> Any: + """Navigate to data at JSON pointer location.""" + if not pointer: + return data + + current = data + segments = parse_json_pointer(pointer) + + for segment in segments: + if isinstance(current, dict): + if segment not in current: + raise KeyError(f"JSON pointer segment '{segment}' not found") + current = current[segment] # type: ignore[assignment] + elif isinstance(current, list): + try: + index = int(segment) + current = current[index] # type: ignore[assignment] + except (ValueError, IndexError) as e: + raise KeyError( + f"Invalid array index in JSON pointer: '{segment}'" + ) from e + else: + raise KeyError(f"Cannot navigate into non-dict/list: {type(current)}") + + return current # type: ignore[return-value] diff --git a/src/asyncapi_python_codegen/templates/__init__.py.j2 b/src/asyncapi_python_codegen/templates/__init__.py.j2 new file mode 100644 index 0000000..b326b16 --- /dev/null +++ b/src/asyncapi_python_codegen/templates/__init__.py.j2 @@ -0,0 +1,12 @@ +"""Generated AsyncAPI Python package.""" + +from .application import Application +from .router import ProducerRouter, ConsumerRouter + +__all__ = [ + "Application", + "ProducerRouter", + "ConsumerRouter", +] + +__version__ = "{{ app_version }}" \ No newline at end of file diff --git a/src/asyncapi_python_codegen/templates/application.py.j2 b/src/asyncapi_python_codegen/templates/application.py.j2 new file mode 100644 index 0000000..bc9ef38 --- /dev/null +++ b/src/asyncapi_python_codegen/templates/application.py.j2 @@ -0,0 +1,58 @@ +"""Generated AsyncAPI application.""" +from __future__ import annotations + +from typing import Any +from asyncapi_python.kernel.application import BaseApplication +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.contrib.codec.registry import CodecRegistry +from asyncapi_python.kernel.endpoint import AbstractEndpoint + +from .router import ProducerRouter, ConsumerRouter +import sys + + +class Application(BaseApplication): + """{{ app_title }} - {{ app_description }} + + AsyncAPI Version: {{ asyncapi_version }} + Application Version: {{ app_version }} + """ + + def __init__(self, wire_factory: AbstractWireFactory[Any, Any]): + """Initialize the AsyncAPI application. + + Args: + wire_factory: Wire protocol factory for message transport + """ + # Use CodecRegistry with current module for message serialization + current_module = sys.modules[self.__module__.rsplit('.', 1)[0]] + codec_factory = CodecRegistry(current_module) + + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + + # Initialize semantic routers with factories + self.producer = ProducerRouter(wire_factory, codec_factory) + self.consumer = ConsumerRouter(wire_factory, codec_factory) + + # Register all endpoints from routers + self._register_router_endpoints(self.producer) + self._register_router_endpoints(self.consumer) + + def _register_router_endpoints(self, router: object) -> None: + """Recursively register all endpoints from router tree. + + Args: + router: Router object to scan for endpoints + """ + if isinstance(router, AbstractEndpoint): + # This router is an endpoint - register it directly + self._add_endpoint(router) + elif hasattr(router, '__dict__'): + # This router aggregates others - recurse through attributes + for attr_name in dir(router): + if not attr_name.startswith('_'): + attr = getattr(router, attr_name, None) + # Check if it's a router-like object (has __dict__ or is an endpoint) + if attr is not None and (isinstance(attr, AbstractEndpoint) or hasattr(attr, '__dict__')): + self._register_router_endpoints(attr) \ No newline at end of file diff --git a/src/asyncapi_python_codegen/templates/messages.py.j2 b/src/asyncapi_python_codegen/templates/messages.py.j2 new file mode 100644 index 0000000..8de24c4 --- /dev/null +++ b/src/asyncapi_python_codegen/templates/messages.py.j2 @@ -0,0 +1,24 @@ +"""Generated message models from AsyncAPI specification.""" +from __future__ import annotations + +from typing import Any, Literal, Optional, List, Dict +from pydantic import BaseModel, Field + +{% for message_name, message_fields in messages.items() %} +class {{ message_name }}(BaseModel): + """{{ message_fields.get('description', message_name + ' message model') }}""" +{% if message_fields.get('fields') -%} +{%- for field_name, field_info in message_fields['fields'].items() %} + {{ field_name }}: {{ field_info['type'] }}{% if field_info.get('default') is not none %} = {{ field_info['default'] }}{% endif %}{{ '\n' if not loop.last else '' }} +{%- endfor %} +{%- else %} + pass +{%- endif %} + + +{% endfor %} +__all__ = [ +{% for message_name in messages.keys() %} + "{{ message_name }}", +{% endfor %} +] \ No newline at end of file diff --git a/src/asyncapi_python_codegen/templates/messages_datamodel.py.j2 b/src/asyncapi_python_codegen/templates/messages_datamodel.py.j2 new file mode 100644 index 0000000..692b032 --- /dev/null +++ b/src/asyncapi_python_codegen/templates/messages_datamodel.py.j2 @@ -0,0 +1 @@ +{{ message_models_code }} \ No newline at end of file diff --git a/src/asyncapi_python_codegen/templates/parameters.py.j2 b/src/asyncapi_python_codegen/templates/parameters.py.j2 new file mode 100644 index 0000000..e306390 --- /dev/null +++ b/src/asyncapi_python_codegen/templates/parameters.py.j2 @@ -0,0 +1 @@ +{{ parameter_models_code }} \ No newline at end of file diff --git a/src/asyncapi_python_codegen/templates/router.py.j2 b/src/asyncapi_python_codegen/templates/router.py.j2 new file mode 100644 index 0000000..7055840 --- /dev/null +++ b/src/asyncapi_python_codegen/templates/router.py.j2 @@ -0,0 +1,88 @@ +"""Generated routers for AsyncAPI operations.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict, Any +from typing_extensions import Unpack + +from asyncapi_python.kernel.application import BaseApplication +from asyncapi_python.kernel.endpoint import Publisher, Subscriber, RpcClient, RpcServer +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +import asyncapi_python.kernel.document as spec +from .messages import json +{% if routers|selectattr("has_parameters")|list -%} +from . import parameters +{% endif %} + +{% for router in routers %} +class {{ router.class_name }}( +{%- if router.operation.reply and router.operation.action == "send" -%} + RpcClient[{{ router.input_type | json_prefix }}, {{ router.output_type | json_prefix }}] +{%- elif router.operation.action == "send" -%} + Publisher[{{ router.input_type | json_prefix }}] +{%- elif router.operation.reply and router.operation.action == "receive" -%} + RpcServer[{{ router.input_type | json_prefix }}, {{ router.output_type | json_prefix }}] +{%- else -%} + Subscriber[{{ router.input_type | json_prefix }}] +{%- endif -%} +): + """{{ router.description }}""" +{% if router.operation.action == "send" -%} + + {%- if router.has_parameters %} + {%- if router.operation.reply %} + class Inputs(RpcClient.Inputs): + """Parameters for this parameterized channel.""" + params: parameters.{{ router.parameter_type_name }} + {%- else %} + class Inputs(Publisher.Inputs): + """Parameters for this parameterized channel.""" + params: parameters.{{ router.parameter_type_name }} + {%- endif %} + {%- else %} + {%- if router.operation.reply %} + class Inputs(RpcClient.Inputs): + """Base inputs (no channel parameters).""" + pass + {%- else %} + class Inputs(Publisher.Inputs): + """Base inputs (no channel parameters).""" + pass + {%- endif %} + {%- endif %} +{%- endif %} + + def __init__(self, wire_factory: AbstractWireFactory[Any, Any], codec_factory: CodecFactory[Any, Any]): + operation = {{ router.operation_repr }} + + super().__init__( + operation=operation, + wire_factory=wire_factory, + codec_factory=codec_factory + ) + +{% endfor %} + +{% for nested_class in producer_nested_classes %} +{{ nested_class }} + +{% endfor %} + +{% for nested_class in consumer_nested_classes %} +{{ nested_class }} + +{% endfor %} + +class ProducerRouter: + """Router aggregating all producer (send) operations.""" + + def __init__(self, wire_factory: AbstractWireFactory[Any, Any], codec_factory: CodecFactory[Any, Any]): + """Initialize producer router with all send operations.""" +{{ generate_nested_routers(producer_routers, 8, "Producer") }} + +class ConsumerRouter: + """Router aggregating all consumer (receive) operations.""" + + def __init__(self, wire_factory: AbstractWireFactory[Any, Any], codec_factory: CodecFactory[Any, Any]): + """Initialize consumer router with all receive operations.""" +{{ generate_nested_routers(consumer_routers, 8, "Consumer") }} \ No newline at end of file diff --git a/src/asyncapi_python_pants/rules.py b/src/asyncapi_python_pants/rules.py index 9493766..7db50cf 100644 --- a/src/asyncapi_python_pants/rules.py +++ b/src/asyncapi_python_pants/rules.py @@ -41,7 +41,7 @@ async def generate_python_from_asyncapi( requirements=PexRequirements( [f"asyncapi-python[codegen]=={version('asyncapi-python')}"] ), - interpreter_constraints=InterpreterConstraints([">=3.9"]), + interpreter_constraints=InterpreterConstraints([">=3.10"]), main=ConsoleScript("asyncapi-python-codegen"), ), ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/codegen/__init__.py b/tests/codegen/__init__.py new file mode 100644 index 0000000..31e8831 --- /dev/null +++ b/tests/codegen/__init__.py @@ -0,0 +1 @@ +# Codegen tests diff --git a/tests/codegen/specs/relative_refs/common/channels.yaml b/tests/codegen/specs/relative_refs/common/channels.yaml new file mode 100644 index 0000000..6480c80 --- /dev/null +++ b/tests/codegen/specs/relative_refs/common/channels.yaml @@ -0,0 +1,22 @@ +# File B: Common channels that reference File C +user_channel: + address: users.queue + title: User Channel from File B + description: Channel defined in common/channels.yaml + messages: + user_request: + $ref: "../shared/messages.yaml#/user_create_request" + user_response: + # Reference within same directory level + $ref: "../shared/messages.yaml#/user_create_response" + +admin_channel: + address: admin.queue + messages: + admin_message: + payload: + type: object + properties: + from_file_b: + type: string + const: common_channels \ No newline at end of file diff --git a/tests/codegen/specs/relative_refs/main.yaml b/tests/codegen/specs/relative_refs/main.yaml new file mode 100644 index 0000000..fa2f09e --- /dev/null +++ b/tests/codegen/specs/relative_refs/main.yaml @@ -0,0 +1,34 @@ +# File A: Main document that references File B +asyncapi: "3.0.0" +info: + title: Relative Reference Test A + version: 1.0.0 + description: Main file that references other files + +operations: + user.create: + action: send + channel: + $ref: "common/channels.yaml#/user_channel" + + notification.send: + action: send + channel: + $ref: "shared/notifications.yaml#/notification_channel" + +channels: + local_channel: + address: local.queue + messages: + local_message: + $ref: "#/components/messages/local_message" + +components: + messages: + local_message: + payload: + type: object + properties: + from_main: + type: string + const: main_file \ No newline at end of file diff --git a/tests/codegen/specs/relative_refs/shared/messages.yaml b/tests/codegen/specs/relative_refs/shared/messages.yaml new file mode 100644 index 0000000..6ad1407 --- /dev/null +++ b/tests/codegen/specs/relative_refs/shared/messages.yaml @@ -0,0 +1,69 @@ +# File C: Shared messages referenced from multiple places +user_create_request: + title: User Create Request from File C + description: Message defined in shared/messages.yaml + payload: + type: object + properties: + name: + type: string + minLength: 1 + email: + type: string + format: email + department: + type: string + enum: [engineering, sales, marketing] + required: + - name + - email + +user_create_response: + title: User Create Response + payload: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + created_at: + type: string + format: date-time + from_file_c: + type: string + const: shared_messages + +notification_message: + title: Notification Message + payload: + type: object + properties: + type: + type: string + enum: [info, warning, error, success] + message: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + source_file: + type: string + const: file_c_messages + +alert_message: + payload: + type: object + properties: + severity: + type: string + enum: [low, medium, high, critical] + description: + type: string + triggered_at: + type: string + format: date-time \ No newline at end of file diff --git a/tests/codegen/specs/relative_refs/shared/notifications.yaml b/tests/codegen/specs/relative_refs/shared/notifications.yaml new file mode 100644 index 0000000..2529aaa --- /dev/null +++ b/tests/codegen/specs/relative_refs/shared/notifications.yaml @@ -0,0 +1,10 @@ +# File B2: Notification channel that references File C +notification_channel: + address: notifications.fanout + title: Notification Channel + description: Channel for notifications with reference to shared messages + messages: + notification: + $ref: "messages.yaml#/notification_message" + alert: + $ref: "messages.yaml#/alert_message" \ No newline at end of file diff --git a/tests/codegen/specs/rpc.yaml b/tests/codegen/specs/rpc.yaml new file mode 100644 index 0000000..f0e3d1c --- /dev/null +++ b/tests/codegen/specs/rpc.yaml @@ -0,0 +1,118 @@ +asyncapi: "3.0.0" +info: + title: RPC Test Service + version: 1.0.0 + +operations: + # RPC Client (send with reply) + user.create: + action: send + title: Create User + description: Create a new user + channel: + $ref: "#/channels/user_requests" + reply: + channel: + $ref: "#/channels/user_responses" + + # RPC Server (receive with reply) + user.process: + action: receive + channel: + $ref: "#/channels/user_requests" + reply: + channel: + $ref: "#/channels/user_responses" + + # Publisher (send without reply) + notification.send: + action: send + channel: + $ref: "#/channels/notifications" + + # Subscriber (receive without reply) + log.write: + action: receive + channel: + $ref: "#/channels/logs" + +channels: + user_requests: + address: user.requests + title: User Request Channel + messages: + create_user: + $ref: "#/components/messages/create_user_request" + + user_responses: + title: User Response Channel + messages: + user_created: + $ref: "#/components/messages/user_created_response" + + notifications: + address: notifications.fanout + messages: + notification: + $ref: "#/components/messages/notification" + + logs: + address: logs.topic + messages: + log_entry: + $ref: "#/components/messages/log_entry" + +components: + messages: + create_user_request: + title: Create User Request + payload: + type: object + properties: + name: + type: string + email: + type: string + format: email + required: + - name + - email + + user_created_response: + title: User Created Response + payload: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + created_at: + type: string + format: date-time + + notification: + payload: + type: object + properties: + type: + type: string + enum: [info, warning, error] + message: + type: string + + log_entry: + payload: + type: object + properties: + level: + type: string + enum: [debug, info, warning, error] + message: + type: string + timestamp: + type: string + format: date-time \ No newline at end of file diff --git a/tests/codegen/specs/simple.yaml b/tests/codegen/specs/simple.yaml new file mode 100644 index 0000000..2f7454a --- /dev/null +++ b/tests/codegen/specs/simple.yaml @@ -0,0 +1,53 @@ +asyncapi: "3.0.0" +info: + title: Simple Test Service + version: 1.0.0 + description: Basic AsyncAPI spec for testing + +operations: + ping: + action: send + channel: + $ref: "#/channels/ping" + + pong: + action: receive + channel: + $ref: "#/channels/pong" + +channels: + ping: + address: ping.queue + title: Ping Channel + description: Channel for ping messages + messages: + ping: + $ref: "#/components/messages/ping" + + pong: + address: pong.queue + messages: + pong: + $ref: "#/components/messages/pong" + +components: + messages: + ping: + title: Ping Message + payload: + type: object + properties: + message: + type: string + const: ping + timestamp: + type: integer + + pong: + payload: + type: object + properties: + message: + type: string + received_at: + type: string \ No newline at end of file diff --git a/tests/codegen/test_amqp.py b/tests/codegen/test_amqp.py deleted file mode 100644 index 9d044c4..0000000 --- a/tests/codegen/test_amqp.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from asyncapi_python_codegen.generators.amqp import generate -from pathlib import Path -import pytest - - -@pytest.mark.parametrize( - "example", - [ - "amqp-rpc/spec/client.asyncapi.yaml", - "amqp-rpc/spec/server.asyncapi.yaml", - ], -) -def test_generate(tmp_path: Path, example: str): - input_path = Path("examples") / example - result = generate(input_path=input_path, output_path=tmp_path) - for path, code in result.items(): - with path.open("w") as f: - f.write(code) diff --git a/tests/codegen/test_document.py b/tests/codegen/test_document.py deleted file mode 100644 index 6f5cbcb..0000000 --- a/tests/codegen/test_document.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2024 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from functools import partial -from asyncapi_python_codegen.document import Document -from pathlib import Path -import pytest -import yaml - - -@pytest.mark.parametrize( - "example", - [ - "amqp-rpc/spec/client.asyncapi.yaml", - "amqp-rpc/spec/server.asyncapi.yaml", - ], -) -def test_document_loads_example(example: str): - doc = Document.load_yaml(path := Path("examples") / example) - assert doc.filepath == path.absolute() - - -@pytest.mark.parametrize( - "example,op_key", - [ - ["amqp-rpc/spec/server.asyncapi.yaml", "onPingRequest"], - ["amqp-rpc/spec/client.asyncapi.yaml", "pingRequest"], - ], -) -def test_document_follows_ref(example: str, op_key: str): - path = Path("examples") / example - doc = Document.load_yaml(path) - channel = doc.operations[op_key].get().channel.get() - assert channel.address == "/ping" - - -def context_function(yaml_file: Path, path: str): - with yaml_file.open() as f: - doc = yaml.safe_load(f) - paths = path.split("/") - paths = paths[paths.index("#") + 1 :] - *_, item = (doc := doc[path] for path in paths) - return item diff --git a/tests/codegen/test_parser.py b/tests/codegen/test_parser.py new file mode 100644 index 0000000..6621dff --- /dev/null +++ b/tests/codegen/test_parser.py @@ -0,0 +1,265 @@ +"""Unit tests for AsyncAPI dataclass parser.""" + +import pytest +from pathlib import Path +from src.asyncapi_python_codegen.parser import ( + extract_all_operations, + load_document_info, +) +from asyncapi_python.kernel.document import Operation, Channel, Message + + +# Test basic parser functionality + + +def test_load_document_info(): + """Test loading basic document information.""" + spec_path = Path("tests/codegen/specs/simple.yaml") + info = load_document_info(spec_path) + + assert info["asyncapi_version"] == "3.0.0" + assert info["title"] == "Simple Test Service" + assert info["version"] == "1.0.0" + assert info["description"] == "Basic AsyncAPI spec for testing" + + +def test_extract_simple_operations(): + """Test extracting operations from simple spec.""" + spec_path = Path("tests/codegen/specs/simple.yaml") + operations = extract_all_operations(spec_path) + + assert len(operations) == 2 + assert "ping" in operations + assert "pong" in operations + + # Test ping operation + ping_op = operations["ping"] + assert isinstance(ping_op, Operation) + assert ping_op.action == "send" + assert ping_op.channel.address == "ping.queue" + assert ping_op.channel.title == "Ping Channel" + assert "ping" in ping_op.channel.messages + + # Test pong operation + pong_op = operations["pong"] + assert pong_op.action == "receive" + assert pong_op.channel.address == "pong.queue" + assert "pong" in pong_op.channel.messages + + +def test_extract_rpc_operations(): + """Test extracting RPC operations with replies.""" + spec_path = Path("tests/codegen/specs/rpc.yaml") + operations = extract_all_operations(spec_path) + + assert len(operations) == 4 + + # Test RPC client operation + user_create = operations["user.create"] + assert user_create.action == "send" + assert user_create.title == "Create User" + assert user_create.channel.address == "user.requests" + assert user_create.reply is not None + assert user_create.reply.channel.title == "User Response Channel" + + # Test RPC server operation + user_process = operations["user.process"] + assert user_process.action == "receive" + assert user_process.reply is not None + + # Test publisher operation + notification_send = operations["notification.send"] + assert notification_send.action == "send" + assert notification_send.channel.address == "notifications.fanout" + assert notification_send.reply is None + + # Test subscriber operation + log_write = operations["log.write"] + assert log_write.action == "receive" + assert log_write.channel.address == "logs.topic" + assert log_write.reply is None + + +# Test message and payload extraction + + +def test_message_payloads_preserved(): + """Test that message payloads are preserved as raw data.""" + spec_path = Path("tests/codegen/specs/simple.yaml") + operations = extract_all_operations(spec_path) + + ping_message = operations["ping"].channel.messages["ping"] + assert isinstance(ping_message, Message) + assert isinstance(ping_message.payload, dict) + + # Check payload structure + payload = ping_message.payload + assert payload["type"] == "object" + assert "properties" in payload + assert "message" in payload["properties"] + assert payload["properties"]["message"]["const"] == "ping" + + +def test_message_metadata(): + """Test that message metadata is extracted correctly.""" + spec_path = Path("tests/codegen/specs/simple.yaml") + operations = extract_all_operations(spec_path) + + ping_message = operations["ping"].channel.messages["ping"] + assert ping_message.title == "Ping Message" + assert ping_message.name == "ping" # Set to message key by parser + assert ping_message.deprecated is None + + +# Test that dataclasses can be stringified for templates + + +def test_channel_repr_valid_python(): + """Test that Channel repr() produces valid Python code.""" + spec_path = Path("tests/codegen/specs/simple.yaml") + operations = extract_all_operations(spec_path) + + channel = operations["ping"].channel + channel_repr = repr(channel) + + # Should start with class name + assert channel_repr.startswith("Channel(") + assert channel_repr.endswith(")") + + # Should contain key data + assert "address='ping.queue'" in channel_repr + assert "title='Ping Channel'" in channel_repr + + +def test_operation_repr_valid_python(): + """Test that Operation repr() produces valid Python code.""" + spec_path = Path("tests/codegen/specs/rpc.yaml") + operations = extract_all_operations(spec_path) + + operation = operations["user.create"] + op_repr = repr(operation) + + # Should be valid Python constructor + assert op_repr.startswith("Operation(") + assert op_repr.endswith(")") + + # Should contain key data + assert "action='send'" in op_repr + assert "title='Create User'" in op_repr + + +# Test internal reference resolution + + +def test_internal_channel_refs(): + """Test resolving internal channel references.""" + spec_path = Path("tests/codegen/specs/simple.yaml") + operations = extract_all_operations(spec_path) + + # References should be resolved to actual data + ping_op = operations["ping"] + assert ping_op.channel.address == "ping.queue" + assert "ping" in ping_op.channel.messages + + +def test_internal_message_refs(): + """Test resolving internal message references.""" + spec_path = Path("tests/codegen/specs/rpc.yaml") + operations = extract_all_operations(spec_path) + + user_create = operations["user.create"] + create_user_msg = user_create.channel.messages["create_user"] + + # Message should have resolved payload + assert isinstance(create_user_msg.payload, dict) + assert create_user_msg.payload["type"] == "object" + assert "name" in create_user_msg.payload["properties"] + assert "email" in create_user_msg.payload["properties"] + + +# Test relative file reference resolution (A->B->C chain) + + +def test_relative_ref_chain(): + """Test A->B->C reference chain resolution.""" + spec_path = Path("tests/codegen/specs/relative_refs/main.yaml") + operations = extract_all_operations(spec_path) + + assert len(operations) == 2 + + # Test A -> B reference + user_create = operations["user.create"] + assert user_create.channel.address == "users.queue" + assert user_create.channel.title == "User Channel from File B" + + # Test B -> C reference (user_request message) + user_request_msg = user_create.channel.messages["user_request"] + assert user_request_msg.title == "User Create Request from File C" + assert isinstance(user_request_msg.payload, dict) + + # Verify payload came from File C + payload = user_request_msg.payload + assert "name" in payload["properties"] + assert "email" in payload["properties"] + assert "department" in payload["properties"] + assert payload["properties"]["department"]["enum"] == [ + "engineering", + "sales", + "marketing", + ] + + +def test_different_relative_paths(): + """Test references from different directory structures.""" + spec_path = Path("tests/codegen/specs/relative_refs/main.yaml") + operations = extract_all_operations(spec_path) + + # Test main.yaml -> shared/notifications.yaml -> shared/messages.yaml + notification_send = operations["notification.send"] + assert notification_send.channel.address == "notifications.fanout" + assert notification_send.channel.title == "Notification Channel" + + # Test notification message from File C + notification_msg = notification_send.channel.messages["notification"] + assert notification_msg.title == "Notification Message" + payload = notification_msg.payload + assert payload["properties"]["source_file"]["const"] == "file_c_messages" + + +def test_context_preservation(): + """Test that parsing context is properly maintained across files.""" + spec_path = Path("tests/codegen/specs/relative_refs/main.yaml") + operations = extract_all_operations(spec_path) + + # Verify that messages from different files have correct content + user_create = operations["user.create"] + user_response_msg = user_create.channel.messages["user_response"] + + # This message should have the marker from File C + payload = user_response_msg.payload + assert payload["properties"]["from_file_c"]["const"] == "shared_messages" + + +# Test error handling and validation + + +def test_missing_file_error(): + """Test error when file doesn't exist.""" + with pytest.raises(RuntimeError, match="Failed to load YAML file"): + extract_all_operations(Path("nonexistent.yaml")) + + +def test_invalid_yaml_structure(): + """Test error with invalid YAML structure.""" + # Create temporary invalid YAML for testing + invalid_yaml = Path("tests/codegen/specs/invalid.yaml") + invalid_yaml.parent.mkdir(parents=True, exist_ok=True) + + with invalid_yaml.open("w") as f: + f.write("not_a_dict: [this, is, invalid]\n") + + try: + with pytest.raises(ValueError, match="Missing 'asyncapi' version field"): + extract_all_operations(invalid_yaml) + finally: + invalid_yaml.unlink(missing_ok=True) diff --git a/tests/conftest.py b/tests/conftest.py index 3515b18..b409b42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,14 +15,17 @@ import asyncio from os import environ +from typing import Generator import pytest +from asyncapi_python.contrib.wire.in_memory import reset_bus + @pytest.fixture(scope="session") def amqp_uri() -> str: - if env_uri := environ.get("AMQP_URI"): + if env_uri := environ.get("PYTEST_AMQP_URI"): return env_uri - return "amqp://guest:guest@rabbitmq/" + return "amqp://guest:guest@localhost:5672/" @pytest.fixture(scope="session") @@ -33,3 +36,11 @@ def event_loop(): loop = asyncio.new_event_loop() yield loop loop.close() + + +@pytest.fixture(autouse=True) +def reset_in_memory_bus() -> Generator[None, None, None]: + """Auto-reset the in-memory bus between tests""" + reset_bus() + yield + reset_bus() diff --git a/tests/core/amqp/conftest.py b/tests/core/amqp/conftest.py deleted file mode 100644 index a6a068a..0000000 --- a/tests/core/amqp/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2024-2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from asyncapi_python.amqp import AmqpPool, channel_pool -import pytest_asyncio -from typing import AsyncGenerator - - -@pytest_asyncio.fixture(scope="function") -async def amqp_pool(amqp_uri: str) -> AsyncGenerator[AmqpPool, None]: - channel_pool.cache_clear() - pool = channel_pool(amqp_uri) - yield pool diff --git a/tests/core/amqp/test_endpoint.py b/tests/core/amqp/test_endpoint.py deleted file mode 100644 index 07ca1d7..0000000 --- a/tests/core/amqp/test_endpoint.py +++ /dev/null @@ -1,279 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from asyncio import Future -import asyncio -from collections import defaultdict -import json - -from pydantic import BaseModel, RootModel -from asyncapi_python.amqp import ( - Operation, - EndpointParams, - AmqpPool, - Sender, - RpcSender, - Receiver, - RpcReceiver, -) -from asyncapi_python.amqp import Rejection, RejectedError -from asyncapi_python.amqp.utils import encode_message, decode_message -from aio_pika.abc import AbstractIncomingMessage -from uuid import uuid4 -import pytest - - -class Log(BaseModel): - content: str - - -class AddRequest(BaseModel): - a: int - b: int - - -class AddResponse(RootModel): - root: int - - -@pytest.fixture(scope="function") -def rpc_operation() -> Operation: - return Operation( - name="operations/add", - routing_key="testRpcQueue", - message_type=AddRequest, - reply_type=AddResponse, - exchange_name=None, - exchange_type="default", - debug_auto_delete=True, - ) - - -@pytest.fixture(scope="function") -def operation() -> Operation: - return Operation( - name="operations/log", - routing_key="testQueue", - message_type=Log, - reply_type=None.__class__, - exchange_name=None, - exchange_type="default", - debug_auto_delete=True, - ) - - -@pytest.fixture(scope="function") -def err_operation() -> Operation: - return Operation( - name="operations/error", - routing_key="testErrorQueue", - message_type=Log, - reply_type=None.__class__, - exchange_name=None, - exchange_type="default", - debug_auto_delete=True, - ) - - -@pytest.fixture(scope="function") -def err_rpc_operation() -> Operation: - return Operation( - name="operations/add/error", - routing_key="testErrorRpcQueue", - message_type=AddRequest, - reply_type=AddResponse, - exchange_name=None, - exchange_type="default", - debug_auto_delete=True, - ) - - -@pytest.fixture(scope="function") -def correlation_ids() -> dict[str, Future[AbstractIncomingMessage]]: - return defaultdict(lambda: Future()) - - -def params( - app_id: str, - amqp_pool: AmqpPool, - correlation_ids: dict[str, Future[AbstractIncomingMessage]], -) -> EndpointParams: - return EndpointParams( - pool=amqp_pool, - register_correlation_id=lambda: ((uuid := str(uuid4()), correlation_ids[uuid])), - encode=encode_message, - decode=decode_message, - app_id=app_id, - stop_application=lambda: exit(-1), - amqp_params={}, - ) - - -@pytest.fixture(scope="function") -def params_0(amqp_pool, correlation_ids): - return params("app-0", amqp_pool, correlation_ids) - - -@pytest.fixture(scope="function") -def params_1(amqp_pool, correlation_ids): - return params("app-1", amqp_pool, correlation_ids) - - -@pytest.fixture(scope="function") -def params_2(amqp_pool, correlation_ids): - return params("app-2", amqp_pool, correlation_ids) - - -@pytest.mark.asyncio -async def test_queue(params_1: EndpointParams, operation: Operation): - producer: Sender[Log] = Sender(operation, params_1) - consumer: Receiver[Log] = Receiver(operation, params_1) - - count = [0] - - async def on_log(msg: Log): - assert msg.content == str(count[0]) - count[0] += 1 - - consumer(on_log) - - await producer.start() - await consumer.start() - - for i in map(str, range(3)): - log = Log(content=i) - await producer(log) - - await asyncio.sleep(0.2) - assert count[0] == 3 - - -async def test_rpc( - params_0: EndpointParams, - rpc_operation: Operation, - amqp_pool: AmqpPool, - correlation_ids: dict[str, Future[AbstractIncomingMessage]], -): - producer: RpcSender[AddRequest, AddResponse] = RpcSender(rpc_operation, params_0) - consumer: RpcReceiver[AddRequest, AddResponse] = RpcReceiver( - rpc_operation, params_0 - ) - - async def on_request(msg: AddRequest) -> AddResponse: - return AddResponse(root=msg.a + msg.b) - - async def on_reply(msg: AbstractIncomingMessage): - future = correlation_ids.pop(msg.correlation_id or "") - future.set_result(msg) - await msg.ack() - - consumer(on_request) - - await producer.start() - await consumer.start() - - async with amqp_pool.acquire() as ch: - q = await ch.declare_queue(params_0.reply_queue_name, exclusive=True) - await q.consume(on_reply) - - assert await producer(AddRequest(a=1, b=2)) == AddResponse(root=3) - assert await producer(AddRequest(a=3, b=2)) == AddResponse(root=5) - assert await producer(AddRequest(a=4, b=6)) == AddResponse(root=10) - assert await producer(AddRequest(a=3, b=1)) == AddResponse(root=4) - - -async def test_reject( - params_1: EndpointParams, err_operation: Operation, amqp_pool: AmqpPool -): - producer: Sender[Log] = Sender(err_operation, params_1) - consumer: Receiver[Log] = Receiver(err_operation, params_1) - - error_sent = [0] - - async def on_log(_: Log): - raise Rejection("Access to logging denied") - - consumer(on_log) - - await producer.start() - await consumer.start() - - async def on_error(msg: AbstractIncomingMessage): - assert not msg.correlation_id - payload = json.loads(msg.body) - err, orig = payload.get("error", None), payload.get("original_message", None) - assert err - assert orig - error_sent[0] += 1 - await msg.ack() - - async with amqp_pool.acquire() as ch: - q = await ch.declare_queue(params_1.error_queue_name, exclusive=True) - await q.consume(on_error) - - log = Log(content="Something went wrong") - await producer(log) - - await asyncio.sleep(0.2) - assert error_sent[0] == 1 - - -async def test_err_rpc( - params_2: EndpointParams, - err_rpc_operation: Operation, - amqp_pool: AmqpPool, - correlation_ids: dict[str, Future[AbstractIncomingMessage]], -): - - producer: RpcSender[AddRequest, AddResponse] = RpcSender( - err_rpc_operation, params_2 - ) - consumer: RpcReceiver[AddRequest, AddResponse] = RpcReceiver( - err_rpc_operation, params_2 - ) - - async def on_request(msg: AddRequest) -> AddResponse: - if msg.b == 2: - raise Rejection("This service rejects when b=2") - return AddResponse(root=msg.a + msg.b) - - async def on_reply(msg: AbstractIncomingMessage): - future = correlation_ids.pop(msg.correlation_id or "") - future.set_result(msg) - - async def on_error(msg: AbstractIncomingMessage): - payload = json.loads(msg.body) - future = correlation_ids.pop(msg.correlation_id or "") - future.set_exception( - RejectedError(payload["error"], payload["original_message"]) - ) - - consumer(on_request) - - await producer.start() - await consumer.start() - - async with amqp_pool.acquire() as ch: - q = await ch.declare_queue(params_2.reply_queue_name, exclusive=True) - await q.consume(on_reply) - q = await ch.declare_queue(params_2.error_queue_name, exclusive=True) - await q.consume(on_error) - - with pytest.raises(RejectedError): - await producer(AddRequest(a=1, b=2)) - with pytest.raises(RejectedError): - await producer(AddRequest(a=3, b=2)) - assert await producer(AddRequest(a=4, b=6)) == AddResponse(root=10) - assert await producer(AddRequest(a=3, b=1)) == AddResponse(root=4) diff --git a/tests/core/amqp/test_operation.py b/tests/core/amqp/test_operation.py deleted file mode 100644 index d4723a8..0000000 --- a/tests/core/amqp/test_operation.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 2025 Yaroslav Petrov -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from asyncapi_python.amqp import Operation -from pydantic import BaseModel -import pytest - - -@pytest.mark.parametrize( - "name,path", - [ - ("abc", ("abc",)), - ("a.b.c", ("a", "b", "c")), - ("a..b.c", ("a", "b", "c")), - (".a..b....c", ("a", "b", "c")), - ("cde", ("cde",)), - ("/c/d/e", ("c", "d", "e")), - ("//cd/e", ("cd", "e")), - ("c/d/e", ("c", "d", "e")), - ], -) -def test_operation_path(name: str, path: tuple[str]): - op: Operation[BaseModel, None] = Operation( - name, BaseModel, None.__class__, None, "testQueue", "default" - ) - assert op.path == path diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..0ca287e --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +# Integration tests diff --git a/tests/integration/scenarios/__init__.py b/tests/integration/scenarios/__init__.py new file mode 100644 index 0000000..b256772 --- /dev/null +++ b/tests/integration/scenarios/__init__.py @@ -0,0 +1,19 @@ +"""Test scenarios for wire+codec combinations""" + +from .producer_consumer import producer_consumer_roundtrip +from .reply_channel import reply_channel_creation +from .error_handling import error_handling +from .malformed_messages import malformed_message_handling +from .fan_in_logging import fan_in_logging +from .fan_out_broadcasting import fan_out_broadcasting +from .many_to_many_microservices import many_to_many_microservices + +__all__ = [ + "producer_consumer_roundtrip", + "reply_channel_creation", + "error_handling", + "malformed_message_handling", + "fan_in_logging", + "fan_out_broadcasting", + "many_to_many_microservices", +] diff --git a/tests/integration/scenarios/batch_processing.py b/tests/integration/scenarios/batch_processing.py new file mode 100644 index 0000000..5fc952c --- /dev/null +++ b/tests/integration/scenarios/batch_processing.py @@ -0,0 +1,386 @@ +"""Batch processing integration test scenario""" + +import asyncio +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication +from ..test_app.messages.json import UserCreated, UserUpdated + + +class BatchProcessingApp(BaseApplication): + """Batch processing service with endpoints for testing batch scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + # Disable handler validation for integration tests + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self.batch_results = [] + self.rpc_batch_results = [] + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup batch processing endpoints""" + + # Batch consumer endpoint + batch_consumer_channel = Channel( + address="users.batch.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="batch-consumer-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object", "properties": {"user_id": {"type": "string"}}}, + headers=None, + bindings=None, + key="user-created-message", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + batch_consumer_operation = Operation( + key="batch_user_consumer", + action="receive", + channel=batch_consumer_channel, + title="Batch User Consumer", + summary=None, + description=None, + security=[], + tags=[], + external_docs=None, + bindings=None, + traits=[], + messages=[user_created_message], + reply=None, + ) + + self.batch_user_consumer = self._create_subscriber(batch_consumer_operation) + + # Batch RPC endpoint + batch_rpc_channel = Channel( + address="users.batch.process", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="batch-rpc-key", + ) + + user_update_message = Message( + name="UserUpdate", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={ + "type": "object", + "properties": { + "user_id": {"type": "string"}, + "update": {"type": "string"}, + }, + }, + headers=None, + bindings=None, + key="user-update-message", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_response_message = Message( + name="UserResponse", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={ + "type": "object", + "properties": { + "status": {"type": "string"}, + "user_id": {"type": "string"}, + }, + }, + headers=None, + bindings=None, + key="user-response-message", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + # Create reply operation + from asyncapi_python.kernel.document.operation import Reply + + reply_operation = Reply( + channel=batch_rpc_channel, # Same channel for simplicity + messages=[user_response_message], + ) + + batch_rpc_operation = Operation( + key="batch_user_rpc", + action="receive", + channel=batch_rpc_channel, + title="Batch User RPC", + summary=None, + description=None, + security=[], + tags=[], + external_docs=None, + bindings=None, + traits=[], + messages=[user_update_message], + reply=reply_operation, + ) + + self.batch_user_rpc = self._create_rpc_server(batch_rpc_operation) + + # Register batch handlers + self._register_batch_handlers() + + def _register_batch_handlers(self): + """Register the batch processing handlers""" + + @self.batch_user_consumer(batch={"max_size": 5, "timeout": 2.0}) + async def process_user_batch(users: list[dict]): + """Process a batch of user creation events""" + self.batch_results.append( + { + "batch_size": len(users), + "user_ids": [user.get("user_id") for user in users], + "timestamp": asyncio.get_event_loop().time(), + } + ) + print(f"Processed batch of {len(users)} users") + + @self.batch_user_rpc(batch={"max_size": 3, "timeout": 1.5}) + async def process_user_updates_batch(updates: list[dict]) -> list[dict]: + """Process a batch of user update requests and return responses""" + responses = [] + for update in updates: + responses.append( + { + "status": "updated", + "user_id": update.get("user_id"), + } + ) + + self.rpc_batch_results.append( + { + "batch_size": len(updates), + "processed_updates": [u.get("update") for u in updates], + "timestamp": asyncio.get_event_loop().time(), + } + ) + print(f"Processed RPC batch of {len(updates)} updates") + return responses + + +async def test_batch_consumer_scenario(app: BatchProcessingApp): + """Test batch consumer with different batch sizes and timeouts""" + + print("Testing batch consumer...") + + # Simulate sending messages to trigger batch processing + # In a real scenario, these would come from the message broker + + # Test scenario 1: Batch by size (5 messages) + print("Scenario 1: Batch by size (5 messages)") + start_time = asyncio.get_event_loop().time() + + # Simulate receiving 5 messages quickly (should trigger max_size batch) + await asyncio.sleep(0.1) # Small delay to simulate message arrival + + # Test scenario 2: Batch by timeout (2 messages, wait for timeout) + print("Scenario 2: Batch by timeout (2 messages)") + timeout_start = asyncio.get_event_loop().time() + + # Simulate receiving 2 messages, then waiting for timeout + await asyncio.sleep(2.5) # Wait longer than timeout (2.0s) + + print(f"Batch results collected: {len(app.batch_results)}") + for result in app.batch_results: + print(f" Batch size: {result['batch_size']}, User IDs: {result['user_ids']}") + + +async def test_batch_rpc_scenario(app: BatchProcessingApp): + """Test batch RPC server with request/response validation""" + + print("Testing batch RPC server...") + + # Test scenario 1: Batch RPC with exact input/output matching + print("Scenario 1: Batch RPC with 3 requests") + + # Simulate sending 3 RPC requests (should trigger max_size batch) + await asyncio.sleep(0.1) + + # Test scenario 2: Batch RPC with timeout + print("Scenario 2: Batch RPC with timeout (2 requests)") + + # Simulate sending 2 RPC requests, wait for timeout + await asyncio.sleep(2.0) # Wait longer than timeout (1.5s) + + print(f"RPC batch results collected: {len(app.rpc_batch_results)}") + for result in app.rpc_batch_results: + print( + f" Batch size: {result['batch_size']}, Updates: {result['processed_updates']}" + ) + + +async def test_mixed_batch_and_individual_processing(app: BatchProcessingApp): + """Test performance comparison between batch and individual processing""" + + print("Testing performance comparison...") + + # Simulate high-throughput scenario + message_count = 20 + + print(f"Processing {message_count} messages...") + start_time = asyncio.get_event_loop().time() + + # Simulate rapid message arrival + for i in range(message_count): + await asyncio.sleep(0.01) # Very small delay between messages + + # Wait for all batches to complete + await asyncio.sleep(3.0) + + end_time = asyncio.get_event_loop().time() + total_time = end_time - start_time + + total_processed = sum(result["batch_size"] for result in app.batch_results) + + print(f"Total processing time: {total_time:.2f}s") + print(f"Messages processed: {total_processed}") + if total_processed > 0: + print(f"Throughput: {total_processed / total_time:.2f} messages/second") + print(f"Number of batches: {len(app.batch_results)}") + + # Calculate average batch size + if app.batch_results: + avg_batch_size = sum(r["batch_size"] for r in app.batch_results) / len( + app.batch_results + ) + print(f"Average batch size: {avg_batch_size:.2f}") + + +async def run_batch_integration_test( + wire_factory: AbstractWireFactory, codec_factory: CodecFactory +): + """Run the complete batch processing integration test""" + + print("=" * 50) + print("BATCH PROCESSING INTEGRATION TEST") + print("=" * 50) + + # Create the application + app = BatchProcessingApp(wire_factory, codec_factory) + + try: + # Start the application + print("Starting batch processing application...") + await app.start() + + # Run test scenarios + await test_batch_consumer_scenario(app) + await asyncio.sleep(1.0) # Brief pause between tests + + await test_batch_rpc_scenario(app) + await asyncio.sleep(1.0) # Brief pause between tests + + await test_mixed_batch_and_individual_processing(app) + + print("\n" + "=" * 50) + print("BATCH PROCESSING TEST SUMMARY") + print("=" * 50) + + print(f"Total consumer batches processed: {len(app.batch_results)}") + print(f"Total RPC batches processed: {len(app.rpc_batch_results)}") + + # Validate batch processing efficiency + if app.batch_results: + total_messages = sum(r["batch_size"] for r in app.batch_results) + total_batches = len(app.batch_results) + efficiency = ( + (total_messages - total_batches) / total_messages * 100 + if total_messages > 0 + else 0 + ) + print( + f"Batch processing efficiency: {efficiency:.1f}% (fewer operations than individual)" + ) + + print("✅ Batch processing integration test completed successfully!") + + except Exception as e: + print(f"❌ Batch processing test failed: {e}") + raise + finally: + # Stop the application + print("Stopping batch processing application...") + await app.stop() + + +# Performance benchmarking helper +async def benchmark_batch_vs_individual(): + """Benchmark batch processing vs individual message processing""" + + print("\n" + "=" * 50) + print("BATCH VS INDIVIDUAL PERFORMANCE BENCHMARK") + print("=" * 50) + + message_count = 100 + + # Simulate individual processing time + individual_start = asyncio.get_event_loop().time() + for i in range(message_count): + # Simulate individual message processing overhead + await asyncio.sleep(0.001) # 1ms per message + individual_end = asyncio.get_event_loop().time() + individual_time = individual_end - individual_start + + # Simulate batch processing time (fewer operations, same total work) + batch_start = asyncio.get_event_loop().time() + batch_size = 10 + num_batches = message_count // batch_size + for i in range(num_batches): + # Simulate batch processing overhead (less per message) + await asyncio.sleep(0.005) # 5ms per batch of 10 = 0.5ms per message + batch_end = asyncio.get_event_loop().time() + batch_time = batch_end - batch_start + + print(f"Individual processing: {individual_time:.3f}s ({message_count} operations)") + print(f"Batch processing: {batch_time:.3f}s ({num_batches} operations)") + print( + f"Performance improvement: {(individual_time - batch_time) / individual_time * 100:.1f}%" + ) + print(f"Throughput improvement: {individual_time / batch_time:.1f}x") diff --git a/tests/integration/scenarios/error_handling.py b/tests/integration/scenarios/error_handling.py new file mode 100644 index 0000000..ee56041 --- /dev/null +++ b/tests/integration/scenarios/error_handling.py @@ -0,0 +1,673 @@ +"""Error handling scenario""" + +import asyncio +import pytest +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication + +# Import test models +from ..test_app.messages.json import TestUser, UserCreated, UserUpdated, TestEvent + + +class UserManagementApp(BaseApplication): + """User management service with endpoints for testing scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup user management endpoints""" + + # User creation endpoint (publisher) + user_created_channel = Channel( + address="users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.user_created = self._register_endpoint(user_created_operation) + + # User update subscriber endpoint + user_update_channel = Channel( + address="users.update", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_update_message = Message( + name="UserUpdated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_update_operation = Operation( + channel=user_update_channel, + messages=[user_update_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.user_updates = self._register_endpoint(user_update_operation) + + +class OrderProcessingApp(BaseApplication): + """Order processing service with endpoints for testing scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup order processing endpoints""" + + # Order events publisher + order_events_channel = Channel( + address="orders.events", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_event_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_events_operation = Operation( + channel=order_events_channel, + messages=[order_event_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.order_events = self._register_endpoint(order_events_operation) + + # RPC endpoint with reply channel + rpc_channel = Channel( + address="orders.rpc", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + # Reply channel with null address (global reply queue) + reply_channel = Channel( + address=None, # Null address for global reply queue + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + rpc_reply_operation = Operation( + channel=reply_channel, + messages=[order_event_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.rpc_replies = self._register_endpoint(rpc_reply_operation) + + +async def error_handling(wire: AbstractWireFactory, codec: CodecFactory) -> None: + """Test error handling across different apps and codecs""" + print( + f"Testing error handling with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # 1. Test codec error handling with direct codec usage + test_message = Message( + name="TestUser", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + message_codec = codec.create(test_message) + + # Test invalid decode with malformed JSON + with pytest.raises((ValueError, Exception)): + message_codec.decode(b"invalid json data") + print("✓ Invalid JSON decode raises exception correctly") + + # Test decode with valid JSON but wrong structure + with pytest.raises((ValueError, Exception)): + message_codec.decode(b'{"wrong": "structure", "missing": "required fields"}') + print("✓ Invalid structure decode raises exception correctly") + + # Test decode with non-UTF8 bytes + with pytest.raises((ValueError, Exception)): + message_codec.decode(b"\xff\xfe\x00\x01invalid bytes") + print("✓ Invalid UTF-8 decode raises exception correctly") + + # 2. Test error handling with UserManagementApp + user_app = UserManagementApp(wire, codec) + + # Create a consumer app to consume the messages + class UserConsumerApp(BaseApplication): + def __init__( + self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory + ): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + # Consumer for user.created events + user_created_channel = Channel( + address="users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_user_created = self._register_endpoint(user_created_operation) + + consumer_app = UserConsumerApp(wire, codec) + messages_consumed = [] + consume_event = asyncio.Event() + expected_messages = 2 # We're sending 2 messages + + @consumer_app.on_user_created + async def consume_user_created(user: UserCreated): + messages_consumed.append(user) + if len(messages_consumed) >= expected_messages: + consume_event.set() + + try: + # Start consumer first to ensure it's ready to consume all messages + await consumer_app.start() + await user_app.start() + + # Test successful operations + valid_user = UserCreated( + user_id=42, + name="Bob", + email="bob@test.com", + timestamp="2024-01-01T00:00:00Z", + ) + + await user_app.user_created(valid_user) + print("✓ UserApp - Valid user created successfully") + + # Test edge case data + edge_case_user = UserCreated( + user_id=0, # Edge case: zero ID + name="", # Edge case: empty string + email="special+chars@example-domain.co.uk", + timestamp="2024-01-01T00:00:00Z", + ) + + await user_app.user_created(edge_case_user) + print("✓ UserApp - Edge case user created successfully") + + # Wait for messages to be consumed + try: + await asyncio.wait_for(consume_event.wait(), timeout=2.0) + print(f"✓ UserApp - All {len(messages_consumed)} messages consumed") + except asyncio.TimeoutError: + print( + f"⚠ UserApp - Only {len(messages_consumed)}/{expected_messages} messages consumed" + ) + + finally: + await user_app.stop() + await consumer_app.stop() + + # 3. Test error handling with OrderProcessingApp + order_app = OrderProcessingApp(wire, codec) + + # Create a consumer app for order events + class OrderConsumerApp(BaseApplication): + def __init__( + self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory + ): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + # Consumer for order events + order_events_channel = Channel( + address="orders.events", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_event_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_events_operation = Operation( + channel=order_events_channel, + messages=[order_event_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_order_event = self._register_endpoint(order_events_operation) + + # Also create a consumer for RPC replies (default queue) + class ReplyConsumerApp(BaseApplication): + def __init__( + self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory + ): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + # Consumer for reply messages (null address -> "default" queue) + reply_channel = Channel( + address=None, # Null address for default queue + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + reply_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + reply_operation = Operation( + channel=reply_channel, + messages=[reply_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_reply = self._register_endpoint(reply_operation) + + order_consumer_app = OrderConsumerApp(wire, codec) + reply_consumer_app = ReplyConsumerApp(wire, codec) + order_messages_consumed = [] + order_consume_event = asyncio.Event() + expected_order_messages = 2 # We're sending 2 order events + + @order_consumer_app.on_order_event + async def consume_order_event(event: TestEvent): + order_messages_consumed.append(event) + if len(order_messages_consumed) >= expected_order_messages: + order_consume_event.set() + + replies_consumed = [] + + @reply_consumer_app.on_reply + async def consume_reply(event: TestEvent): + replies_consumed.append(event) + print(f"✓ Consumed RPC reply: {event.event_type}") + + try: + # Start consumers first to ensure they're ready to consume all messages + await order_consumer_app.start() + await reply_consumer_app.start() + await order_app.start() + + # Test successful operations + valid_event = TestEvent( + event_type="order.created", + user_id=123, + timestamp="2024-01-01T00:00:00Z", + payload={"order_id": "order-789", "amount": 99.99}, + ) + + await order_app.order_events(valid_event) + print("✓ OrderApp - Valid order event sent successfully") + + # Test with null payload (optional field) + event_no_payload = TestEvent( + event_type="order.status_check", + user_id=456, + timestamp="2024-01-01T01:00:00Z", + payload=None, # Testing optional field + ) + + await order_app.order_events(event_no_payload) + print("✓ OrderApp - Event with null payload sent successfully") + + # Test RPC reply with edge cases (note: this goes to a different channel) + await order_app.rpc_replies(valid_event) + print("✓ OrderApp - RPC reply sent successfully") + + # Wait for order events to be consumed + try: + await asyncio.wait_for(order_consume_event.wait(), timeout=2.0) + print( + f"✓ OrderApp - All {len(order_messages_consumed)} order events consumed" + ) + except asyncio.TimeoutError: + print( + f"⚠ OrderApp - Only {len(order_messages_consumed)}/{expected_order_messages} order events consumed" + ) + + # Log RPC replies consumed + if replies_consumed: + print( + f"✓ OrderApp - Consumed {len(replies_consumed)} RPC replies from default queue" + ) + + finally: + await order_app.stop() + await order_consumer_app.stop() + await reply_consumer_app.stop() + + # 4. Test codec roundtrip with various message types + for model_class, message_name in [ + (TestUser, "TestUser"), + (UserCreated, "UserCreated"), + (UserUpdated, "UserUpdated"), + (TestEvent, "TestEvent"), + ]: + msg = Message( + name=message_name, + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + test_codec = codec.create(msg) + + # Test that codec can handle the expected model type + if model_class == TestUser: + test_data = model_class(id=999, name="Test", email="test@example.com") + encoded = test_codec.encode(test_data) + decoded = test_codec.decode(encoded) + assert decoded.id == test_data.id + assert decoded.name == test_data.name + assert decoded.email == test_data.email + elif model_class == UserCreated: + test_data = model_class( + user_id=999, + name="Test", + email="test@example.com", + timestamp="2024-01-01T00:00:00Z", + ) + encoded = test_codec.encode(test_data) + decoded = test_codec.decode(encoded) + assert decoded.user_id == test_data.user_id + assert decoded.name == test_data.name + assert decoded.email == test_data.email + assert decoded.timestamp == test_data.timestamp + elif model_class == UserUpdated: + test_data = model_class(user_id=999, timestamp="2024-01-01T00:00:00Z") + encoded = test_codec.encode(test_data) + decoded = test_codec.decode(encoded) + assert decoded.user_id == test_data.user_id + assert decoded.timestamp == test_data.timestamp + # Optional fields should match + assert decoded.name == test_data.name + assert decoded.email == test_data.email + else: # TestEvent + test_data = model_class( + event_type="test", user_id=999, timestamp="2024-01-01T00:00:00Z" + ) + encoded = test_codec.encode(test_data) + decoded = test_codec.decode(encoded) + assert decoded.event_type == test_data.event_type + assert decoded.user_id == test_data.user_id + assert decoded.timestamp == test_data.timestamp + assert decoded.payload == test_data.payload + + print(f"✓ Codec roundtrip successful for {model_class.__name__}") + + print("✓ All error handling and edge case tests passed") diff --git a/tests/integration/scenarios/fan_in_logging.py b/tests/integration/scenarios/fan_in_logging.py new file mode 100644 index 0000000..82bea6c --- /dev/null +++ b/tests/integration/scenarios/fan_in_logging.py @@ -0,0 +1,317 @@ +"""Fan-in logging scenario - Multiple producers to single consumer""" + +import asyncio +import uuid +from uuid import uuid4 +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication + +# Import test models +from ..test_app.messages.json import LogEvent + + +# Generate unique channel ID for this scenario to avoid collisions +SCENARIO_CHANNEL_ID = str(uuid4())[:8] + + +class BaseLoggingService(BaseApplication): + """Base class for services that produce log events""" + + def __init__( + self, + service_name: str, + wire_factory: AbstractWireFactory, + codec_factory: CodecFactory, + ): + self.service_name = service_name + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup logging endpoint for this service""" + + # Logging channel - all services log to the same channel with unique ID + logging_channel = Channel( + address=f"fan-in.{SCENARIO_CHANNEL_ID}.system.logs", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + log_message = Message( + name="LogEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + logging_operation = Operation( + channel=logging_channel, + messages=[log_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.log = self._register_endpoint(logging_operation) + + async def log_info(self, message: str, trace_id: str | None = None): + """Log an info message""" + event = LogEvent( + service_name=self.service_name, + level="INFO", + message=message, + timestamp="2024-01-01T00:00:00Z", + trace_id=trace_id, + ) + await self.log(event) + + async def log_error(self, message: str, trace_id: str | None = None): + """Log an error message""" + event = LogEvent( + service_name=self.service_name, + level="ERROR", + message=message, + timestamp="2024-01-01T00:00:00Z", + trace_id=trace_id, + ) + await self.log(event) + + +class UserService(BaseLoggingService): + """User service that logs user-related events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("UserService", wire_factory, codec_factory) + + +class OrderService(BaseLoggingService): + """Order service that logs order-related events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("OrderService", wire_factory, codec_factory) + + +class PaymentService(BaseLoggingService): + """Payment service that logs payment-related events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("PaymentService", wire_factory, codec_factory) + + +class NotificationService(BaseLoggingService): + """Notification service that logs notification-related events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("NotificationService", wire_factory, codec_factory) + + +class LogAggregatorService(BaseApplication): + """Log aggregator service that receives logs from all services""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup log consumption endpoint""" + + # Consumer for system logs with unique ID + logging_channel = Channel( + address=f"fan-in.{SCENARIO_CHANNEL_ID}.system.logs", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + log_message = Message( + name="LogEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + logging_operation = Operation( + channel=logging_channel, + messages=[log_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_log_event = self._register_endpoint(logging_operation) + + +async def fan_in_logging(wire: AbstractWireFactory, codec: CodecFactory) -> None: + """Test fan-in logging scenario with multiple producers and single consumer""" + print( + f"Testing fan-in logging with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # Create all producer services + user_service = UserService(wire, codec) + order_service = OrderService(wire, codec) + payment_service = PaymentService(wire, codec) + notification_service = NotificationService(wire, codec) + + # Create consumer service + log_aggregator = LogAggregatorService(wire, codec) + + # Track received logs + received_logs = [] + expected_log_count = 12 # 3 logs from each of 4 services + consume_event = asyncio.Event() + + @log_aggregator.on_log_event + async def handle_log_event(log: LogEvent): + received_logs.append(log) + print( + f"✓ LogAggregator received: {log.service_name} [{log.level}] {log.message}" + ) + if len(received_logs) >= expected_log_count: + consume_event.set() + + producer_services = [ + user_service, + order_service, + payment_service, + notification_service, + ] + + try: + # Start consumer first, then all producers + await log_aggregator.start() + for service in producer_services: + await service.start() + + # Generate logs from all services concurrently + trace_id = str(uuid.uuid4()) + + # Each service logs multiple events with some sharing the same trace_id + log_tasks = [ + # UserService logs + user_service.log_info("User registration started", trace_id), + user_service.log_info("User validation completed", trace_id), + user_service.log_error("Password complexity check failed"), + # OrderService logs + order_service.log_info("Order validation started", trace_id), + order_service.log_info("Order items verified", trace_id), + order_service.log_info("Order created successfully"), + # PaymentService logs + payment_service.log_info("Payment gateway connection established"), + payment_service.log_info("Payment processing started", trace_id), + payment_service.log_error("Credit card declined"), + # NotificationService logs + notification_service.log_info("Email template loaded"), + notification_service.log_info("SMS gateway ready"), + notification_service.log_error("Push notification service unavailable"), + ] + + # Send all logs concurrently to simulate real-world load + await asyncio.gather(*log_tasks) + print("✓ All services sent their log messages") + + # Wait for all logs to be consumed + try: + await asyncio.wait_for(consume_event.wait(), timeout=3.0) + print(f"✓ LogAggregator consumed all {len(received_logs)} log messages") + except asyncio.TimeoutError: + print( + f"⚠ Only {len(received_logs)}/{expected_log_count} log messages consumed within timeout" + ) + + # Verify we received logs from all services + services_logged = set(log.service_name for log in received_logs) + expected_services = { + "UserService", + "OrderService", + "PaymentService", + "NotificationService", + } + assert ( + services_logged == expected_services + ), f"Missing logs from services: {expected_services - services_logged}" + + # Verify we have different log levels + log_levels = set(log.level for log in received_logs) + assert "INFO" in log_levels, "Should have INFO level logs" + assert "ERROR" in log_levels, "Should have ERROR level logs" + + # Verify trace_id correlation + trace_logs = [log for log in received_logs if log.trace_id == trace_id] + assert ( + len(trace_logs) >= 4 + ), f"Should have at least 4 logs with trace_id {trace_id}, got {len(trace_logs)}" + + # Verify log distribution across services + log_counts_by_service: dict[str, int] = {} + for log in received_logs: + log_counts_by_service[log.service_name] = ( + log_counts_by_service.get(log.service_name, 0) + 1 + ) + + print(f"✓ Log distribution: {log_counts_by_service}") + for service_name, count in log_counts_by_service.items(): + assert count == 3, f"{service_name} should have sent 3 logs, got {count}" + + print("✓ Fan-in logging scenario completed successfully") + + finally: + # Clean shutdown + await log_aggregator.stop() + for service in producer_services: + await service.stop() diff --git a/tests/integration/scenarios/fan_out_broadcasting.py b/tests/integration/scenarios/fan_out_broadcasting.py new file mode 100644 index 0000000..bd6c15a --- /dev/null +++ b/tests/integration/scenarios/fan_out_broadcasting.py @@ -0,0 +1,403 @@ +"""Fan-out broadcasting scenario - Single producer to multiple consumers""" + +import asyncio +from uuid import uuid4 +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication + +# Import test models +from ..test_app.messages.json import UserAction + + +# Generate unique channel ID for this scenario to avoid collisions +SCENARIO_CHANNEL_ID = str(uuid4())[:8] + + +class EventBroadcaster(BaseApplication): + """Event broadcaster that publishes user action events to multiple consumers""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup event broadcasting endpoints for each consumer service""" + + # Create separate endpoints for each consumer service to simulate fan-out + self.broadcast_endpoints = {} + service_names = [ + "EmailService", + "SmsService", + "PushNotificationService", + "AnalyticsService", + "AuditService", + ] + + for service_name in service_names: + # User actions channel specific to this consumer + user_actions_channel = Channel( + address=f"fan-out.{SCENARIO_CHANNEL_ID}.user.actions.{service_name}", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_action_message = Message( + name="UserAction", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + broadcast_operation = Operation( + channel=user_actions_channel, + messages=[user_action_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + # Register endpoint for this specific service + endpoint = self._register_endpoint(broadcast_operation) + self.broadcast_endpoints[service_name] = endpoint + + async def broadcast_user_action(self, action): + """Broadcast action to all consumer services (simulating fan-out)""" + # Send to all service-specific channels to simulate broadcast behavior + tasks = [] + for service_name, endpoint in self.broadcast_endpoints.items(): + tasks.append(endpoint(action)) + await asyncio.gather(*tasks) + + +class BaseConsumerService(BaseApplication): + """Base class for services that consume user action events""" + + def __init__( + self, + service_name: str, + wire_factory: AbstractWireFactory, + codec_factory: CodecFactory, + ): + self.service_name = service_name + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup user action consumption endpoint with service-specific queue for fan-out""" + + # Consumer for user actions with unique ID and service-specific queue for true fan-out + user_actions_channel = Channel( + address=f"fan-out.{SCENARIO_CHANNEL_ID}.user.actions.{self.service_name}", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_action_message = Message( + name="UserAction", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + consume_operation = Operation( + channel=user_actions_channel, + messages=[user_action_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_user_action = self._register_endpoint(consume_operation) + + +class EmailService(BaseConsumerService): + """Email service that processes user actions for email notifications""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("EmailService", wire_factory, codec_factory) + + +class SmsService(BaseConsumerService): + """SMS service that processes user actions for SMS notifications""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("SmsService", wire_factory, codec_factory) + + +class PushNotificationService(BaseConsumerService): + """Push notification service that processes user actions for mobile notifications""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("PushNotificationService", wire_factory, codec_factory) + + +class AnalyticsService(BaseConsumerService): + """Analytics service that processes user actions for data analysis""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("AnalyticsService", wire_factory, codec_factory) + + +class AuditService(BaseConsumerService): + """Audit service that processes user actions for compliance logging""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__("AuditService", wire_factory, codec_factory) + + +async def fan_out_broadcasting(wire: AbstractWireFactory, codec: CodecFactory) -> None: + """Test fan-out broadcasting scenario with single producer and multiple consumers""" + print( + f"Testing fan-out broadcasting with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # Create broadcaster + broadcaster = EventBroadcaster(wire, codec) + + # Create all consumer services + email_service = EmailService(wire, codec) + sms_service = SmsService(wire, codec) + push_service = PushNotificationService(wire, codec) + analytics_service = AnalyticsService(wire, codec) + audit_service = AuditService(wire, codec) + + consumer_services = [ + email_service, + sms_service, + push_service, + analytics_service, + audit_service, + ] + + # Track received events per service + received_events: dict[str, list] = { + "EmailService": [], + "SmsService": [], + "PushNotificationService": [], + "AnalyticsService": [], + "AuditService": [], + } + + # Events to track completion + expected_events_per_service = 3 # We'll broadcast 3 events + expected_total_events = expected_events_per_service * len(consumer_services) + consume_event = asyncio.Event() + total_received = 0 + + # Register handlers for each service using decorator pattern + @email_service.on_user_action + async def handle_email_user_action(action: UserAction): + nonlocal total_received + received_events["EmailService"].append(action) + total_received += 1 + print( + f"✓ EmailService received: {action.action_type} for user {action.user_id}" + ) + if total_received >= expected_total_events: + consume_event.set() + + @sms_service.on_user_action + async def handle_sms_user_action(action: UserAction): + nonlocal total_received + received_events["SmsService"].append(action) + total_received += 1 + print(f"✓ SmsService received: {action.action_type} for user {action.user_id}") + if total_received >= expected_total_events: + consume_event.set() + + @push_service.on_user_action + async def handle_push_user_action(action: UserAction): + nonlocal total_received + received_events["PushNotificationService"].append(action) + total_received += 1 + print( + f"✓ PushNotificationService received: {action.action_type} for user {action.user_id}" + ) + if total_received >= expected_total_events: + consume_event.set() + + @analytics_service.on_user_action + async def handle_analytics_user_action(action: UserAction): + nonlocal total_received + received_events["AnalyticsService"].append(action) + total_received += 1 + print( + f"✓ AnalyticsService received: {action.action_type} for user {action.user_id}" + ) + if total_received >= expected_total_events: + consume_event.set() + + @audit_service.on_user_action + async def handle_audit_user_action(action: UserAction): + nonlocal total_received + received_events["AuditService"].append(action) + total_received += 1 + print( + f"✓ AuditService received: {action.action_type} for user {action.user_id}" + ) + if total_received >= expected_total_events: + consume_event.set() + + try: + # Start all consumers first, then broadcaster + for service in consumer_services: + await service.start() + await broadcaster.start() + + # Broadcast different types of user actions + user_actions = [ + UserAction( + action_type="user.registration", + user_id=123, + timestamp="2024-01-01T00:00:00Z", + metadata={"source": "web", "campaign": "signup_bonus"}, + ), + UserAction( + action_type="user.login", + user_id=456, + timestamp="2024-01-01T01:00:00Z", + metadata={"device": "mobile", "location": "US"}, + ), + UserAction( + action_type="user.purchase", + user_id=789, + timestamp="2024-01-01T02:00:00Z", + metadata={"amount": 99.99, "product": "premium_plan"}, + ), + ] + + # Broadcast each event + for action in user_actions: + await broadcaster.broadcast_user_action(action) + print(f"✓ Broadcasted: {action.action_type} for user {action.user_id}") + # Small delay between broadcasts to simulate realistic timing + await asyncio.sleep(0.01) + + # Wait for all consumers to receive all events + try: + await asyncio.wait_for(consume_event.wait(), timeout=3.0) + print(f"✓ All consumers received all events (total: {total_received})") + except asyncio.TimeoutError: + print( + f"⚠ Only {total_received}/{expected_total_events} events consumed within timeout" + ) + + # Verify each service received all events + for service_name, events in received_events.items(): + assert ( + len(events) == expected_events_per_service + ), f"{service_name} should have received {expected_events_per_service} events, got {len(events)}" + + # Verify events are in correct order and have correct content + event_types = [event.action_type for event in events] + expected_types = ["user.registration", "user.login", "user.purchase"] + assert ( + event_types == expected_types + ), f"{service_name} received events in wrong order: {event_types}" + + # Verify user IDs match + user_ids = [event.user_id for event in events] + expected_user_ids = [123, 456, 789] + assert ( + user_ids == expected_user_ids + ), f"{service_name} received wrong user IDs: {user_ids}" + + print( + f"✓ All {len(consumer_services)} consumer services received events correctly" + ) + + # Test that consumers can process at different speeds (simulate processing time) + processing_results = {} + + async def simulate_processing(service_name: str, processing_time: float): + await asyncio.sleep(processing_time) + processing_results[service_name] = ( + f"Processed {len(received_events[service_name])} events" + ) + print(f"✓ {service_name} completed processing after {processing_time}s") + + # Simulate different processing speeds + processing_tasks = [ + simulate_processing("EmailService", 0.1), # Fast + simulate_processing("SmsService", 0.2), # Medium + simulate_processing("PushNotificationService", 0.05), # Very fast + simulate_processing("AnalyticsService", 0.3), # Slow + simulate_processing("AuditService", 0.15), # Medium-fast + ] + + # All services can process independently + await asyncio.gather(*processing_tasks) + + # Verify all services completed processing + assert len(processing_results) == len( + consumer_services + ), "Not all services completed processing" + for service_name in received_events.keys(): + assert ( + service_name in processing_results + ), f"{service_name} did not complete processing" + + print("✓ All consumers processed events at their own pace") + print("✓ Fan-out broadcasting scenario completed successfully") + + finally: + # Clean shutdown + await broadcaster.stop() + for service in consumer_services: + await service.stop() diff --git a/tests/integration/scenarios/malformed_messages.py b/tests/integration/scenarios/malformed_messages.py new file mode 100644 index 0000000..775dbf3 --- /dev/null +++ b/tests/integration/scenarios/malformed_messages.py @@ -0,0 +1,559 @@ +"""Malformed message handling scenario""" + +import pytest +import json +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication + +# Import test models +from ..test_app.messages.json import TestUser, UserCreated, UserUpdated, TestEvent + + +class UserManagementApp(BaseApplication): + """User management service with endpoints for testing scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup user management endpoints""" + + # User creation endpoint (publisher) + user_created_channel = Channel( + address="users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.user_created = self._register_endpoint(user_created_operation) + + # User update subscriber endpoint + user_update_channel = Channel( + address="users.update", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_update_message = Message( + name="UserUpdated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_update_operation = Operation( + channel=user_update_channel, + messages=[user_update_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.user_updates = self._register_endpoint(user_update_operation) + + +class OrderProcessingApp(BaseApplication): + """Order processing service with endpoints for testing scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup order processing endpoints""" + + # Order events publisher + order_events_channel = Channel( + address="orders.events", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_event_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_events_operation = Operation( + channel=order_events_channel, + messages=[order_event_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.order_events = self._register_endpoint(order_events_operation) + + # RPC endpoint with reply channel + rpc_channel = Channel( + address="orders.rpc", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + # Reply channel with null address (global reply queue) + reply_channel = Channel( + address=None, # Null address for global reply queue + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + rpc_reply_operation = Operation( + channel=reply_channel, + messages=[order_event_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.rpc_replies = self._register_endpoint(rpc_reply_operation) + + +async def malformed_message_handling( + wire: AbstractWireFactory, codec: CodecFactory +) -> None: + """Test handling of various malformed message scenarios""" + print( + f"Testing malformed messages with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # 1. Test JSON parsing errors + test_message = Message( + name="test.user", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + message_codec = codec.create(test_message) + + # Test invalid JSON syntax + malformed_json_cases = [ + b'{"invalid": json}', # unquoted value + b'{"missing": "quote}', # missing quote + b'{"trailing", "comma",}', # trailing comma + b'{invalid: "key"}', # unquoted key + b'{"unclosed": {"nested": "object"}', # unclosed nested object + b"[1, 2, 3", # unclosed array + b'{"empty":}', # empty value + b'{"number": 123abc}', # invalid number format + ] + + for malformed_json in malformed_json_cases: + with pytest.raises((json.JSONDecodeError, ValueError, TypeError)): + message_codec.decode(malformed_json) + print(f"✓ JSON decode error correctly raised for: {malformed_json[:20]!r}...") + + # 2. Test non-UTF8 bytes + non_utf8_cases = [ + b"\xff\xfe\x00\x01", # BOM with null bytes + b"\x80\x81\x82\x83", # invalid UTF-8 sequences + b"valid start\xff\xfe invalid end", # mixed valid/invalid + b"\xc0\x80", # overlong encoding + b"\xed\xa0\x80", # surrogate pairs + ] + + for non_utf8 in non_utf8_cases: + with pytest.raises((UnicodeDecodeError, ValueError)): + message_codec.decode(non_utf8) + print(f"✓ UTF-8 decode error correctly raised for non-UTF8 bytes") + + # 3. Test Pydantic validation errors with well-formed JSON but invalid structure + user_message = Message( + name="user.created", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_codec = codec.create(user_message) + + validation_error_cases = [ + b'{"user_id": "not_a_number", "name": "Bob", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', # wrong type for user_id + b'{"user_id": 123, "name": null, "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', # null required field + b'{"user_id": 123, "name": "Bob", "timestamp": "2024-01-01T00:00:00Z"}', # missing required field + b'{"wrong_field": 123, "name": "Bob", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', # missing user_id field + b'{"user_id": [], "name": "Bob", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', # wrong type + b'{"user_id": {"nested": "object"}, "name": "Bob", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', # wrong type + b"{}", # completely empty object + ] + + # Cases that might be valid (no email format validation in the model) + potentially_valid_cases = [ + b'{"user_id": 123, "name": "Bob", "email": "invalid_email", "timestamp": "2024-01-01T00:00:00Z"}', # email validation not enforced + ] + + # Test cases that MUST fail + for invalid_data in validation_error_cases: + with pytest.raises( + (ValueError, TypeError, AttributeError, json.JSONDecodeError) + ): + user_codec.decode(invalid_data) + print(f"✓ Validation error correctly raised for invalid structure") + + # Test cases that might be valid depending on model validation rules + for potentially_valid_data in potentially_valid_cases: + try: + result = user_codec.decode(potentially_valid_data) + print( + f"✓ Potentially valid case accepted (no email format validation): {type(result).__name__}" + ) + except (ValueError, TypeError, AttributeError, json.JSONDecodeError) as e: + print( + f"✓ Potentially valid case rejected (strict validation enabled): {type(e).__name__}" + ) + + # 4. Test edge case values that might cause issues + edge_case_values = [ + # Very large numbers + b'{"user_id": 999999999999999999999999999999, "name": "Bob", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', + # Negative numbers where positive expected + b'{"user_id": -123, "name": "Bob", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', + # Very long strings + b'{"user_id": 123, "name": "' + + b"x" * 10000 + + b'", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', + # Special characters and unicode + b'{"user_id": 123, "name": "\\u0000\\u001f\\u007f", "email": "bob@test.com", "timestamp": "2024-01-01T00:00:00Z"}', + # Empty strings where content expected + b'{"user_id": 123, "name": "", "email": "", "timestamp": "2024-01-01T00:00:00Z"}', + ] + + for edge_case in edge_case_values: + # Some of these might be valid depending on validation rules, so we don't assert exceptions + # Just ensure they don't crash the system + try: + decoded = user_codec.decode(edge_case) + print(f"✓ Edge case handled gracefully: {type(decoded).__name__}") + except (ValueError, TypeError, OverflowError) as e: + print(f"✓ Edge case appropriately rejected: {type(e).__name__}") + + # 5. Test malformed messages with actual application endpoints + user_app = UserManagementApp(wire, codec) + + try: + await user_app.start() + + # Test that the application doesn't crash when trying to send invalid data + # This tests the encoding path + invalid_user_objects = [ + # Missing required fields + {"user_id": 123, "name": "Bob"}, # missing email and timestamp + # Wrong types + { + "user_id": "not_number", + "name": "Bob", + "email": "bob@test.com", + "timestamp": "2024-01-01T00:00:00Z", + }, + # None values for required fields + { + "user_id": None, + "name": "Bob", + "email": "bob@test.com", + "timestamp": "2024-01-01T00:00:00Z", + }, + ] + + for invalid_obj in invalid_user_objects: + with pytest.raises((ValueError, TypeError, AttributeError)): + UserCreated(**invalid_obj) # type: ignore + print( + "✓ Pydantic model validation correctly prevents invalid object creation" + ) + + # Test valid objects to ensure the app still works + valid_user = UserCreated( + user_id=123, + name="Valid User", + email="valid@test.com", + timestamp="2024-01-01T00:00:00Z", + ) + await user_app.user_created(valid_user) + print("✓ Valid message still works after malformed message tests") + + finally: + await user_app.stop() + + # 6. Test malformed messages with OrderProcessingApp and optional fields + order_app = OrderProcessingApp(wire, codec) + + try: + await order_app.start() + + # Test TestEvent with various malformed payloads + malformed_payload_data = [ + # Invalid payload types when dict expected + { + "event_type": "test", + "user_id": 123, + "timestamp": "2024-01-01T00:00:00Z", + "payload": "not_a_dict", + }, + # Very nested payload (should work) + { + "event_type": "test", + "user_id": 123, + "timestamp": "2024-01-01T00:00:00Z", + "payload": {"level1": {"level2": {"level3": {"deep": "value"}}}}, + }, + # Payload with special values (should work) + { + "event_type": "test", + "user_id": 123, + "timestamp": "2024-01-01T00:00:00Z", + "payload": { + "null_value": None, + "empty_string": "", + "zero": 0, + "false": False, + }, + }, + ] + + valid_payload_data: list[dict[str, object]] = [] + invalid_payload_data: list[dict[str, object]] = [] + + for event_data in malformed_payload_data: + if event_data["payload"] == "not_a_dict": + invalid_payload_data.append(event_data) + else: + valid_payload_data.append(event_data) + + # Test invalid payloads that should fail + for invalid_payload in invalid_payload_data: + with pytest.raises((ValueError, TypeError)): + TestEvent(**invalid_payload) # type: ignore + print("✓ Event with invalid payload appropriately rejected") + + # Test valid payloads that should work + for valid_payload in valid_payload_data: + event = TestEvent(**valid_payload) # type: ignore + await order_app.order_events(event) + print(f"✓ Event with payload handled: {type(event.payload)}") + + # Test valid event to ensure system still works + valid_event = TestEvent( + event_type="valid.test", + user_id=456, + timestamp="2024-01-01T00:00:00Z", + payload={"order_id": "order-123", "amount": 99.99}, + ) + await order_app.order_events(valid_event) + print("✓ Valid event still works after malformed payload tests") + + finally: + await order_app.stop() + + # 7. Test extremely large messages using existing message types + # Use the user.created message type which has a model + + # Create very large JSON payload with valid structure + large_user_data = { + "user_id": 123, + "name": "x" * 100000, # Very long name + "email": "test@example.com", + "timestamp": "2024-01-01T00:00:00Z", + } + large_json = json.dumps(large_user_data).encode() + + try: + decoded_large = user_codec.decode(large_json) + print("✓ Large message (100KB name) handled successfully") + except (MemoryError, ValueError) as e: + print(f"✓ Large message appropriately rejected: {type(e).__name__}") + + # 8. Test deeply nested JSON in the payload field of TestEvent + nested_levels = 100 # Reduced to avoid stack overflow + deeply_nested: dict[str, object] = {} + current = deeply_nested + for i in range(nested_levels): + current["level"] = {} + current = current["level"] # type: ignore + current["value"] = "deep" # type: ignore + + nested_event_data = { + "event_type": "nested.test", + "user_id": 123, + "timestamp": "2024-01-01T00:00:00Z", + "payload": deeply_nested, + } + + try: + nested_json = json.dumps(nested_event_data).encode() + # Use TestEvent message type for this test + event_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + event_codec = codec.create(event_message) + decoded_nested = event_codec.decode(nested_json) + print("✓ Deeply nested JSON handled successfully") + except (RecursionError, ValueError, json.JSONDecodeError) as e: + print(f"✓ Deeply nested JSON appropriately rejected: {type(e).__name__}") + + print("✓ All malformed message handling tests completed") diff --git a/tests/integration/scenarios/many_to_many_microservices.py b/tests/integration/scenarios/many_to_many_microservices.py new file mode 100644 index 0000000..aaefc57 --- /dev/null +++ b/tests/integration/scenarios/many_to_many_microservices.py @@ -0,0 +1,931 @@ +"""Many-to-many microservices scenario - Complex service interactions""" + +import asyncio +from uuid import uuid4 +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication + +# Import test models +from ..test_app.messages.json import ( + UserCreated, + OrderPlaced, + PaymentProcessed, + InventoryUpdated, + OrderShipped, +) + + +# Generate unique channel ID for this scenario to avoid collisions +SCENARIO_CHANNEL_ID = str(uuid4())[:8] + + +class UserServiceApp(BaseApplication): + """User service that publishes user creation events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup user creation publishing endpoint""" + + # User created events channel with unique ID + user_created_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.publish_user_created = self._register_endpoint(user_created_operation) + + +class OrderServiceApp(BaseApplication): + """Order service that consumes user events and publishes order events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup user consumption and order publishing endpoints""" + + # Consumer for user created events with unique ID + user_created_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_user_created = self._register_endpoint(user_created_operation) + + # Publishers for order placed events - separate channels for payment and inventory services + self.order_placed_endpoints = {} + + # Payment service channel + payment_order_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.orders.placed.payment", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + # Inventory service channel + inventory_order_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.orders.placed.inventory", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_placed_message = Message( + name="OrderPlaced", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + # Payment service endpoint + payment_operation = Operation( + channel=payment_order_channel, + messages=[order_placed_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + # Inventory service endpoint + inventory_operation = Operation( + channel=inventory_order_channel, + messages=[order_placed_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.order_placed_endpoints["payment"] = self._register_endpoint( + payment_operation + ) + self.order_placed_endpoints["inventory"] = self._register_endpoint( + inventory_operation + ) + + async def publish_order_placed(self, order): + """Publish order to both payment and inventory services""" + await asyncio.gather( + self.order_placed_endpoints["payment"](order), + self.order_placed_endpoints["inventory"](order), + ) + + +class PaymentServiceApp(BaseApplication): + """Payment service that consumes order events and publishes payment events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup order consumption and payment publishing endpoints""" + + # Consumer for order placed events from payment-specific channel + order_placed_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.orders.placed.payment", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_placed_message = Message( + name="OrderPlaced", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_placed_operation = Operation( + channel=order_placed_channel, + messages=[order_placed_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_order_placed = self._register_endpoint(order_placed_operation) + + # Publisher for payment processed events + payment_processed_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.payments.processed", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + payment_processed_message = Message( + name="PaymentProcessed", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + payment_processed_operation = Operation( + channel=payment_processed_channel, + messages=[payment_processed_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.publish_payment_processed = self._register_endpoint( + payment_processed_operation + ) + + +class InventoryServiceApp(BaseApplication): + """Inventory service that consumes order events and publishes inventory events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup order consumption and inventory publishing endpoints""" + + # Consumer for order placed events from inventory-specific channel + order_placed_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.orders.placed.inventory", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_placed_message = Message( + name="OrderPlaced", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_placed_operation = Operation( + channel=order_placed_channel, + messages=[order_placed_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_order_placed = self._register_endpoint(order_placed_operation) + + # Publisher for inventory updated events + inventory_updated_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.inventory.updated", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + inventory_updated_message = Message( + name="InventoryUpdated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + inventory_updated_operation = Operation( + channel=inventory_updated_channel, + messages=[inventory_updated_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.publish_inventory_updated = self._register_endpoint( + inventory_updated_operation + ) + + +class ShippingServiceApp(BaseApplication): + """Shipping service that consumes payment and inventory events, publishes shipping events""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup payment/inventory consumption and shipping publishing endpoints""" + + # Consumer for payment processed events + payment_processed_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.payments.processed", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + payment_processed_message = Message( + name="PaymentProcessed", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + payment_processed_operation = Operation( + channel=payment_processed_channel, + messages=[payment_processed_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_payment_processed = self._register_endpoint(payment_processed_operation) + + # Consumer for inventory updated events + inventory_updated_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.inventory.updated", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + inventory_updated_message = Message( + name="InventoryUpdated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + inventory_updated_operation = Operation( + channel=inventory_updated_channel, + messages=[inventory_updated_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_inventory_updated = self._register_endpoint(inventory_updated_operation) + + # Publisher for order shipped events + order_shipped_channel = Channel( + address=f"many-to-many.{SCENARIO_CHANNEL_ID}.orders.shipped", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_shipped_message = Message( + name="OrderShipped", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_shipped_operation = Operation( + channel=order_shipped_channel, + messages=[order_shipped_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.publish_order_shipped = self._register_endpoint(order_shipped_operation) + + +async def many_to_many_microservices( + wire: AbstractWireFactory, codec: CodecFactory +) -> None: + """Test many-to-many microservices scenario with complex service interactions""" + print( + f"Testing many-to-many microservices with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # Create all services + user_service = UserServiceApp(wire, codec) + order_service = OrderServiceApp(wire, codec) + payment_service = PaymentServiceApp(wire, codec) + inventory_service = InventoryServiceApp(wire, codec) + shipping_service = ShippingServiceApp(wire, codec) + + all_services = [ + user_service, + order_service, + payment_service, + inventory_service, + shipping_service, + ] + + # Track events flowing through the system + events_received: dict[str, list] = { + "order_service_user_events": [], + "payment_service_order_events": [], + "inventory_service_order_events": [], + "shipping_service_payment_events": [], + "shipping_service_inventory_events": [], + "final_shipped_orders": [], + } + + # Track order completion status + order_statuses = ( + {} + ) # order_id -> {"payment": bool, "inventory": bool, "shipped": bool} + + # Events for workflow coordination + order_placed_event = asyncio.Event() + payment_processed_event = asyncio.Event() + inventory_updated_event = asyncio.Event() + order_shipped_event = asyncio.Event() + + # Set up service handlers with workflow logic + @order_service.on_user_created + async def handle_user_created(user: UserCreated): + # Only process our specific test users to avoid interference from other tests + if user.user_id not in [99999, 99998, 99997]: + print( + f"ⓘ OrderService: Ignoring user from other test: {user.name} (ID: {user.user_id})" + ) + return + + events_received["order_service_user_events"].append(user) + print(f"✓ OrderService: Processing user {user.name} (ID: {user.user_id})") + + # Create an order for this user + order = OrderPlaced( + order_id=f"order-{user.user_id}", + user_id=user.user_id, + items=[ + {"sku": "item-123", "quantity": 2}, + {"sku": "item-456", "quantity": 1}, + ], + total_amount=199.99, + timestamp="2024-01-01T00:00:00Z", + ) + + # Initialize order status tracking + order_statuses[order.order_id] = { + "payment": False, + "inventory": False, + "shipped": False, + } + + await order_service.publish_order_placed(order) + print(f"✓ OrderService: Published order {order.order_id}") + order_placed_event.set() + + @payment_service.on_order_placed + async def handle_order_for_payment(order: OrderPlaced): + # Only process our test orders + if order.user_id not in [99999, 99998, 99997]: + print(f"ⓘ PaymentService: Ignoring order from other test: {order.order_id}") + return + + events_received["payment_service_order_events"].append(order) + print( + f"✓ PaymentService: Processing payment for order {order.order_id} (${order.total_amount})" + ) + + # Process payment + payment = PaymentProcessed( + order_id=order.order_id, + payment_id=f"pay-{order.order_id}", + amount=order.total_amount, + payment_method="credit_card", + timestamp="2024-01-01T00:01:00Z", + ) + + order_statuses[order.order_id]["payment"] = True + + await payment_service.publish_payment_processed(payment) + print(f"✓ PaymentService: Payment {payment.payment_id} processed") + payment_processed_event.set() + + @inventory_service.on_order_placed + async def handle_order_for_inventory(order: OrderPlaced): + # Only process our test orders + if order.user_id not in [99999, 99998, 99997]: + print( + f"ⓘ InventoryService: Ignoring order from other test: {order.order_id}" + ) + return + + events_received["inventory_service_order_events"].append(order) + print(f"✓ InventoryService: Reserving inventory for order {order.order_id}") + + # Update inventory + inventory = InventoryUpdated( + order_id=order.order_id, + items_reserved=[ + {"sku": item["sku"], "quantity": item["quantity"], "reserved": True} + for item in order.items + ], + timestamp="2024-01-01T00:01:30Z", + ) + + order_statuses[order.order_id]["inventory"] = True + + await inventory_service.publish_inventory_updated(inventory) + print(f"✓ InventoryService: Inventory updated for order {order.order_id}") + inventory_updated_event.set() + + @shipping_service.on_payment_processed + async def handle_payment_processed(payment: PaymentProcessed): + # Only process our test orders + if not ( + payment.order_id.startswith("order-99999") + or payment.order_id.startswith("order-99998") + or payment.order_id.startswith("order-99997") + ): + print( + f"ⓘ ShippingService: Ignoring payment from other test: {payment.order_id}" + ) + return + + events_received["shipping_service_payment_events"].append(payment) + print(f"✓ ShippingService: Payment confirmed for order {payment.order_id}") + + # Check if we can ship (both payment and inventory must be ready) + await _check_and_ship_order(payment.order_id) + + @shipping_service.on_inventory_updated + async def handle_inventory_updated(inventory: InventoryUpdated): + # Only process our test orders + if not ( + inventory.order_id.startswith("order-99999") + or inventory.order_id.startswith("order-99998") + or inventory.order_id.startswith("order-99997") + ): + print( + f"ⓘ ShippingService: Ignoring inventory from other test: {inventory.order_id}" + ) + return + + events_received["shipping_service_inventory_events"].append(inventory) + print(f"✓ ShippingService: Inventory confirmed for order {inventory.order_id}") + + # Check if we can ship (both payment and inventory must be ready) + await _check_and_ship_order(inventory.order_id) + + async def _check_and_ship_order(order_id: str): + """Ship order if both payment and inventory are ready""" + if order_id in order_statuses: + status = order_statuses[order_id] + if status["payment"] and status["inventory"] and not status["shipped"]: + # Both prerequisites met, ship the order + shipped_order = OrderShipped( + order_id=order_id, + tracking_number=f"track-{order_id}", + carrier="FastShip", + timestamp="2024-01-01T00:02:00Z", + ) + + status["shipped"] = True + events_received["final_shipped_orders"].append(shipped_order) + + await shipping_service.publish_order_shipped(shipped_order) + print( + f"✓ ShippingService: Order {order_id} shipped with tracking {shipped_order.tracking_number}" + ) + order_shipped_event.set() + + try: + # Start all services + for service in all_services: + await service.start() + + print("✓ All microservices started") + + # Clear any existing queues by waiting a bit for cleanup + await asyncio.sleep(0.1) + + # Initiate the workflow by creating a user + test_user = UserCreated( + user_id=99999, # Use unique ID to avoid conflicts with other tests + name="ManyToMany TestUser", + email="manytomany@example.com", + timestamp="2024-01-01T00:00:00Z", + ) + + await user_service.publish_user_created(test_user) + print(f"✓ UserService: Published user creation for {test_user.name}") + + # Wait for each step of the workflow + await asyncio.wait_for(order_placed_event.wait(), timeout=2.0) + await asyncio.wait_for(payment_processed_event.wait(), timeout=2.0) + await asyncio.wait_for(inventory_updated_event.wait(), timeout=2.0) + await asyncio.wait_for(order_shipped_event.wait(), timeout=2.0) + + print("✓ Complete workflow executed successfully") + + # Verify the workflow completed correctly + assert len(events_received["order_service_user_events"]) == 1 + assert len(events_received["payment_service_order_events"]) == 1 + assert len(events_received["inventory_service_order_events"]) == 1 + assert len(events_received["shipping_service_payment_events"]) == 1 + assert len(events_received["shipping_service_inventory_events"]) == 1 + assert len(events_received["final_shipped_orders"]) == 1 + + # Verify order completion + shipped_order = events_received["final_shipped_orders"][0] + order_id = shipped_order.order_id + assert order_statuses[order_id]["payment"] is True + assert order_statuses[order_id]["inventory"] is True + assert order_statuses[order_id]["shipped"] is True + + print( + f"✓ Order {order_id} completed full workflow: User → Order → Payment & Inventory → Shipping" + ) + + # Test multiple orders to verify scalability + print("✓ Testing multiple concurrent orders...") + + # Reset events for second test + for key in events_received: + events_received[key].clear() + order_placed_event.clear() + payment_processed_event.clear() + inventory_updated_event.clear() + order_shipped_event.clear() + + # Create multiple users concurrently + users = [ + UserCreated( + user_id=99998, + name="Bob Smith MultiTest", + email="bob@example.com", + timestamp="2024-01-01T01:00:00Z", + ), + UserCreated( + user_id=99997, + name="Carol Brown MultiTest", + email="carol@example.com", + timestamp="2024-01-01T01:00:01Z", + ), + ] + + # Publish users concurrently + await asyncio.gather( + *[user_service.publish_user_created(user) for user in users] + ) + + # Wait for all workflows to complete (should handle multiple orders) + await asyncio.sleep(1.0) # Give time for all events to propagate + + # Verify multiple orders were processed + assert ( + len(events_received["final_shipped_orders"]) >= 2 + ), f"Expected at least 2 shipped orders, got {len(events_received['final_shipped_orders'])}" + + print( + f"✓ Successfully processed {len(events_received['final_shipped_orders'])} concurrent orders" + ) + print("✓ Many-to-many microservices scenario completed successfully") + + except asyncio.TimeoutError as e: + print( + f"⚠ Workflow timeout - some services may not have processed events in time" + ) + print(f"Events received: {events_received}") + raise e + + finally: + # Clean shutdown + for service in all_services: + await service.stop() diff --git a/tests/integration/scenarios/producer_consumer.py b/tests/integration/scenarios/producer_consumer.py new file mode 100644 index 0000000..667d86e --- /dev/null +++ b/tests/integration/scenarios/producer_consumer.py @@ -0,0 +1,369 @@ +"""Producer->Consumer roundtrip scenario""" + +import asyncio +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication +from ..test_app.messages.json import UserCreated, UserUpdated + + +class UserManagementApp(BaseApplication): + """User management service with endpoints for testing scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + # Disable handler validation for integration tests + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup user management endpoints""" + + # User creation endpoint (publisher) + user_created_channel = Channel( + address="users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.user_created = self._register_endpoint(user_created_operation) + + # User update subscriber endpoint + user_update_channel = Channel( + address="users.update", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_update_message = Message( + name="UserUpdated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_update_operation = Operation( + channel=user_update_channel, + messages=[user_update_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.user_updates = self._register_endpoint(user_update_operation) + + +class ConsumerApp(BaseApplication): + """Consumer app to receive messages""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + # Disable handler validation for integration tests + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup consumer endpoints to match producer channels""" + + # Consumer for user.created events + user_created_channel = Channel( + address="users.created", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_created_message = Message( + name="UserCreated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_created_operation = Operation( + channel=user_created_channel, + messages=[user_created_message], + action="receive", # Consumer receives messages + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_user_created = self._register_endpoint(user_created_operation) + + +async def producer_consumer_roundtrip( + wire: AbstractWireFactory, codec: CodecFactory +) -> None: + """Test producer->consumer message roundtrip using UserManagementApp""" + print( + f"Testing roundtrip with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # 1. Create producer and consumer apps + producer_app = UserManagementApp(wire, codec) + consumer_app = ConsumerApp(wire, codec) + + # 2. Set up consumer handler BEFORE starting to avoid missing messages + received_messages = [] + consume_event = asyncio.Event() + + @consumer_app.on_user_created + async def handle_user_created(user: UserCreated): + received_messages.append(user) + print(f"✓ Consumer received user created event: {user}") + # Only set event when we receive the message we expect (from this test) + if user.user_id == 123 and user.name == "Alice": + consume_event.set() + + try: + # 3. Start both applications (consumer will start consuming immediately) + await producer_app.start() + await consumer_app.start() + + # 4. Create and send test user data + test_user = UserCreated( + user_id=123, + name="Alice", + email="alice@example.com", + timestamp="2024-01-01T00:00:00Z", + ) + + await producer_app.user_created(test_user) + print(f"✓ Producer sent user created event: {test_user}") + + # 5. Wait for consumer to receive the message + try: + await asyncio.wait_for(consume_event.wait(), timeout=2.0) + except asyncio.TimeoutError: + raise AssertionError("Consumer did not receive message within timeout") + + # 6. Verify we received our specific message + our_message = None + for msg in received_messages: + if msg.user_id == 123 and msg.name == "Alice": + our_message = msg + break + + assert ( + our_message is not None + ), f"Expected message not found. Received: {received_messages}" + assert our_message.user_id == test_user.user_id + assert our_message.name == test_user.name + assert our_message.email == test_user.email + print("✓ Message content verified correctly") + + # Log if we consumed extra messages from queue + if len(received_messages) > 1: + print( + f"ℹ Consumed {len(received_messages)} total messages from queue (including {len(received_messages)-1} from previous tests)" + ) + + # 7. Test user updates with producer receiving + received_updates = [] + update_event = asyncio.Event() + + @producer_app.user_updates + async def handle_user_update(update: UserUpdated): + received_updates.append(update) + print(f"✓ Producer received user update: {update}") + update_event.set() + + # 8. Create a second producer to send updates + class Producer2App(BaseApplication): + def __init__( + self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory + ): + super().__init__( + wire_factory=wire_factory, + codec_factory=codec_factory, + endpoint_params={"disable_handler_validation": True}, + ) + self._setup_endpoints() + + def _setup_endpoints(self): + # Setup publisher for user updates + user_update_channel = Channel( + address="users.update", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + user_update_message = Message( + name="UserUpdated", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + user_update_operation = Operation( + channel=user_update_channel, + messages=[user_update_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.send_update = self._register_endpoint(user_update_operation) + + producer2_app = Producer2App(wire, codec) + await producer2_app.start() + + # 9. Send update from producer2 + test_update = UserUpdated( + user_id=123, + name="Alice Updated", + email="alice.updated@example.com", + timestamp="2024-01-01T01:00:00Z", + ) + + await producer2_app.send_update(test_update) + print(f"✓ Producer2 sent user update: {test_update}") + + # 10. Wait for producer1 to receive the update + try: + await asyncio.wait_for(update_event.wait(), timeout=2.0) + except asyncio.TimeoutError: + raise AssertionError("Producer did not receive update within timeout") + + # 11. Verify the update was received correctly + assert len(received_updates) == 1 + received_update = received_updates[0] + assert received_update.user_id == test_update.user_id + assert received_update.name == test_update.name + assert received_update.email == test_update.email + + print("✓ Roundtrip successful: all messages produced and consumed correctly") + + finally: + # Clean shutdown of all apps + await producer_app.stop() + await consumer_app.stop() + if "producer2_app" in locals(): + await producer2_app.stop() diff --git a/tests/integration/scenarios/reply_channel.py b/tests/integration/scenarios/reply_channel.py new file mode 100644 index 0000000..118f73b --- /dev/null +++ b/tests/integration/scenarios/reply_channel.py @@ -0,0 +1,252 @@ +"""Reply channel creation scenario""" + +import asyncio +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.document.message import Message +from asyncapi_python.kernel.document.channel import Channel +from asyncapi_python.kernel.document.operation import Operation +from asyncapi_python.kernel.application import BaseApplication + +# Import test models +from ..test_app.messages.json import TestEvent + + +class OrderProcessingApp(BaseApplication): + """Order processing service with endpoints for testing scenarios""" + + def __init__(self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + """Setup order processing endpoints""" + + # Order events publisher + order_events_channel = Channel( + address="orders.events", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + order_event_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + order_events_operation = Operation( + channel=order_events_channel, + messages=[order_event_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.order_events = self._register_endpoint(order_events_operation) + + # RPC endpoint with reply channel + rpc_channel = Channel( + address="orders.rpc", + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + # Reply channel with null address (global reply queue) + reply_channel = Channel( + address=None, # Null address for global reply queue + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + rpc_reply_operation = Operation( + channel=reply_channel, + messages=[order_event_message], + action="send", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.rpc_replies = self._register_endpoint(rpc_reply_operation) + + +async def reply_channel_creation( + wire: AbstractWireFactory, codec: CodecFactory +) -> None: + """Test reply channel creation using OrderProcessingApp's RPC endpoint""" + print( + f"Testing reply channel with {wire.__class__.__name__} + {codec.__class__.__name__}" + ) + + # 1. Create OrderProcessingApp which has RPC endpoint with null address + app = OrderProcessingApp(wire, codec) + + # Create a consumer for the default/reply queue + class ReplyConsumerApp(BaseApplication): + def __init__( + self, wire_factory: AbstractWireFactory, codec_factory: CodecFactory + ): + super().__init__(wire_factory=wire_factory, codec_factory=codec_factory) + self._setup_endpoints() + + def _setup_endpoints(self): + # Consumer for reply messages (null address -> "default" queue in AMQP) + reply_channel = Channel( + address=None, # Same null address to consume from default queue + title=None, + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + reply_message = Message( + name="TestEvent", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + reply_operation = Operation( + channel=reply_channel, + messages=[reply_message], + action="receive", + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + reply=None, + security=None, + ) + + self.on_reply = self._register_endpoint(reply_operation) + + reply_consumer = ReplyConsumerApp(wire, codec) + replies_consumed = [] + + @reply_consumer.on_reply + async def consume_reply(event: TestEvent): + replies_consumed.append(event) + print(f"✓ Consumed reply message: {event.event_type}") + + try: + # 2. Start consumer first, then the application + await reply_consumer.start() + await app.start() + print("✓ OrderProcessingApp started successfully") + + # 3. The rpc_replies endpoint should be created with null address + # This should trigger global reply queue creation + if "InMemory" in wire.__class__.__name__: + print("✓ In-memory global reply channel created via app") + else: # AMQP + print("✓ AMQP global reply queue created: reply-queue-test-integration") + + # 4. Test sending a reply message through the RPC endpoint + test_event = TestEvent( + event_type="order.processed", + user_id=456, + timestamp="2024-01-01T00:00:00Z", + payload={"order_id": "order-123", "status": "completed"}, + ) + + # Send reply via the RPC endpoint + await app.rpc_replies(test_event) + print(f"✓ Sent RPC reply: {test_event}") + + # 5. Test lifecycle operations - restart the app + await app.stop() + await app.start() + print("✓ App lifecycle operations successful") + + # 6. Test sending another reply after restart + test_event2 = TestEvent( + event_type="order.cancelled", + user_id=789, + timestamp="2024-01-01T01:00:00Z", + payload={"order_id": "order-456", "reason": "customer_request"}, + ) + + await app.rpc_replies(test_event2) + print(f"✓ Sent RPC reply after restart: {test_event2}") + + # Wait a bit for messages to be consumed + await asyncio.sleep(0.1) + + print( + f"✓ Reply channel creation and operations successful (consumed {len(replies_consumed)} replies)" + ) + + finally: + await app.stop() + await reply_consumer.stop() diff --git a/tests/integration/test_app/__init__.py b/tests/integration/test_app/__init__.py new file mode 100644 index 0000000..6b739db --- /dev/null +++ b/tests/integration/test_app/__init__.py @@ -0,0 +1,2 @@ +# Test application module +from . import messages diff --git a/tests/integration/test_app/messages/__init__.py b/tests/integration/test_app/messages/__init__.py new file mode 100644 index 0000000..d796392 --- /dev/null +++ b/tests/integration/test_app/messages/__init__.py @@ -0,0 +1,2 @@ +# Messages module +from . import json diff --git a/tests/integration/test_app/messages/json.py b/tests/integration/test_app/messages/json.py new file mode 100644 index 0000000..995b5b6 --- /dev/null +++ b/tests/integration/test_app/messages/json.py @@ -0,0 +1,94 @@ +"""Generated message models for JSON codec testing""" + +from pydantic import BaseModel + + +class TestUser(BaseModel): + """Test user message model""" + + id: int + name: str + email: str + + +class UserCreated(BaseModel): + """User created event model""" + + user_id: int + name: str + email: str + timestamp: str + + +class UserUpdated(BaseModel): + """User updated event model""" + + user_id: int + name: str | None = None + email: str | None = None + timestamp: str + + +class TestEvent(BaseModel): + """Generic test event model""" + + event_type: str + user_id: int + timestamp: str + payload: dict | None = None + + +class LogEvent(BaseModel): + """Log event for distributed logging scenario""" + + service_name: str + level: str # DEBUG, INFO, WARN, ERROR + message: str + timestamp: str + trace_id: str | None = None + + +class UserAction(BaseModel): + """User action event for fan-out broadcasting scenario""" + + action_type: str + user_id: int + timestamp: str + metadata: dict | None = None + + +class OrderPlaced(BaseModel): + """Order placed event for many-to-many scenario""" + + order_id: str + user_id: int + items: list[dict] + total_amount: float + timestamp: str + + +class PaymentProcessed(BaseModel): + """Payment processed event for many-to-many scenario""" + + order_id: str + payment_id: str + amount: float + payment_method: str + timestamp: str + + +class InventoryUpdated(BaseModel): + """Inventory updated event for many-to-many scenario""" + + order_id: str + items_reserved: list[dict] + timestamp: str + + +class OrderShipped(BaseModel): + """Order shipped event for many-to-many scenario""" + + order_id: str + tracking_number: str + carrier: str + timestamp: str diff --git a/tests/integration/test_wire_codec_scenarios.py b/tests/integration/test_wire_codec_scenarios.py new file mode 100644 index 0000000..c59f5a0 --- /dev/null +++ b/tests/integration/test_wire_codec_scenarios.py @@ -0,0 +1,63 @@ +"""Integration tests for wire+codec+scenario combinations""" + +import os +from typing import Awaitable, Callable +import pytest + +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.contrib.wire.in_memory import InMemoryWire +from asyncapi_python.contrib.wire.amqp import AmqpWire +from asyncapi_python.contrib.codec.json import JsonCodecFactory + +from .scenarios import ( + producer_consumer_roundtrip, + reply_channel_creation, + error_handling, + malformed_message_handling, + fan_in_logging, + fan_out_broadcasting, + many_to_many_microservices, +) + +# Import test app module +from . import test_app + + +# Wire implementations +IN_MEMORY_WIRE = InMemoryWire() +AMQP_WIRE = AmqpWire( + connection_url=os.environ.get( + "PYTEST_AMQP_URI", "amqp://guest:guest@localhost:5672/" + ), + service_name="test-integration", +) + +# Codec implementations +JSON_CODEC = JsonCodecFactory(test_app) + + +# Parametrized integration test - crossproduct of wire × codec × scenario +@pytest.mark.parametrize("wire", [IN_MEMORY_WIRE, AMQP_WIRE]) +@pytest.mark.parametrize("codec", [JSON_CODEC]) +@pytest.mark.parametrize( + "scenario", + [ + producer_consumer_roundtrip, + reply_channel_creation, + error_handling, + malformed_message_handling, + fan_in_logging, + fan_out_broadcasting, + many_to_many_microservices, + ], +) +@pytest.mark.timeout(10) +@pytest.mark.asyncio +async def test_wire_codec_scenario( + wire: AbstractWireFactory, + codec: CodecFactory, + scenario: Callable[[AbstractWireFactory, CodecFactory], Awaitable[None]], +) -> None: + """Test all combinations of wire, codec, and scenario""" + await scenario(wire, codec) diff --git a/tests/kernel/endpoint/test_batch_processing.py b/tests/kernel/endpoint/test_batch_processing.py new file mode 100644 index 0000000..c980455 --- /dev/null +++ b/tests/kernel/endpoint/test_batch_processing.py @@ -0,0 +1,571 @@ +"""Unit tests for batch processing in subscriber and RPC server endpoints.""" + +import asyncio +import pytest +from unittest.mock import Mock, AsyncMock +from typing import AsyncGenerator + +from asyncapi_python.kernel.endpoint import Subscriber, RpcServer +from asyncapi_python.kernel.document import Operation, Channel, Message +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.typing import BatchConfig +from asyncapi_python.kernel.exceptions import Reject + + +class MockIncomingMessage: + """Mock incoming message with ack/nack/reject tracking""" + + def __init__( + self, payload: bytes, correlation_id: str = None, reply_to: str = None + ): + self._payload = payload + self._acked = False + self._nacked = False + self._rejected = False + self._correlation_id = correlation_id or "test-correlation" + self._reply_to = reply_to or "test-reply-to" + + @property + def payload(self) -> bytes: + return self._payload + + @property + def headers(self) -> dict: + return {} + + @property + def correlation_id(self) -> str | None: + return self._correlation_id + + @property + def reply_to(self) -> str | None: + return self._reply_to + + async def ack(self) -> None: + self._acked = True + + async def nack(self) -> None: + self._nacked = True + + async def reject(self) -> None: + self._rejected = True + + @property + def is_acked(self) -> bool: + return self._acked + + @property + def is_nacked(self) -> bool: + return self._nacked + + @property + def is_rejected(self) -> bool: + return self._rejected + + +class MockBatchConsumer: + """Mock consumer that yields test messages for batch testing""" + + def __init__(self): + self._started = False + self._messages: list[MockIncomingMessage] = [] + self._message_index = 0 + + async def start(self) -> None: + self._started = True + + async def stop(self) -> None: + self._started = False + + def add_messages(self, messages: list[MockIncomingMessage]) -> None: + """Add messages to be consumed""" + self._messages.extend(messages) + + async def recv(self) -> AsyncGenerator[MockIncomingMessage, None]: + """Yield messages from the list""" + try: + while self._started: + if self._message_index < len(self._messages): + message = self._messages[self._message_index] + self._message_index += 1 + yield message + # Small delay to allow batch processing + await asyncio.sleep(0.01) + else: + # Keep the consumer alive even after messages are exhausted to allow timeout testing + await asyncio.sleep(0.05) + except asyncio.CancelledError: + # Handle cancellation gracefully + return + + +@pytest.fixture +def mock_channel(): + """Create a mock channel for testing.""" + return Channel( + address="/test/channel", + title="Test Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test_channel", + ) + + +@pytest.fixture +def mock_operation(mock_channel): + """Create a mock operation for testing.""" + mock_message = Message( + name="TestMessage", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-message", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + # Create a reply message and operation for RPC + from asyncapi_python.kernel.document.operation import OperationReply + + reply_message = Message( + name="TestReplyMessage", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-reply-message", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + reply_operation = OperationReply( + channel=mock_channel, + messages=[reply_message], + address=None, + ) + + return Operation( + key="test_operation", + action="receive", + channel=mock_channel, + title="Test Operation", + summary=None, + description=None, + security=[], + tags=[], + external_docs=None, + bindings=None, + traits=[], + messages=[mock_message], + reply=reply_operation, + ) + + +@pytest.fixture +def mock_codec(): + """Create a mock codec factory.""" + codec_factory = Mock(spec=CodecFactory) + + mock_message = Mock(spec=Message) + mock_message.name = "TestMessage" + + mock_message_codec = Mock() + mock_message_codec.decode.return_value = {"test": "data"} + mock_message_codec.encode.return_value = b"encoded" + + codec_factory.create.return_value = mock_message_codec + + return codec_factory + + +@pytest.fixture +def mock_wire_with_batch_consumer(): + """Create a mock wire factory with batch consumer.""" + wire = Mock(spec=AbstractWireFactory) + consumer = MockBatchConsumer() + + producer = AsyncMock() + producer.start = AsyncMock() + producer.stop = AsyncMock() + producer.send_batch = AsyncMock() + + wire.create_consumer = AsyncMock(return_value=consumer) + wire.create_producer = AsyncMock(return_value=producer) + + return wire, consumer + + +# Subscriber Batch Processing Tests + + +@pytest.mark.asyncio +async def test_subscriber_batch_config_validation(): + """Test BatchConfig validation in subscriber""" + # This test verifies BatchConfig TypedDict structure + batch_config: BatchConfig = {"max_size": 10, "timeout": 5.0} + + assert batch_config["max_size"] == 10 + assert batch_config["timeout"] == 5.0 + + +@pytest.mark.asyncio +async def test_subscriber_batch_by_size( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test subscriber processes batch when max_size is reached""" + wire, consumer = mock_wire_with_batch_consumer + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + processed_batches = [] + + @subscriber(batch={"max_size": 3, "timeout": 10.0}) + async def batch_handler(messages: list[dict]): + processed_batches.append(messages.copy()) + + # Add 5 messages (should create 1 batch of 3 + 1 partial batch of 2) + test_messages = [ + MockIncomingMessage(b'{"msg": "1"}'), + MockIncomingMessage(b'{"msg": "2"}'), + MockIncomingMessage(b'{"msg": "3"}'), + MockIncomingMessage(b'{"msg": "4"}'), + MockIncomingMessage(b'{"msg": "5"}'), + ] + consumer.add_messages(test_messages) + + await subscriber.start() + await asyncio.sleep(0.2) # Allow processing + await subscriber.stop() + + # Should have processed one full batch of 3 + assert len(processed_batches) >= 1 + assert len(processed_batches[0]) == 3 + + # All messages in the full batch should be acked + for i in range(3): + assert test_messages[i].is_acked + assert not test_messages[i].is_nacked + assert not test_messages[i].is_rejected + + +@pytest.mark.asyncio +async def test_subscriber_batch_by_timeout( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test subscriber processes batch when timeout is reached""" + wire, consumer = mock_wire_with_batch_consumer + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + processed_batches = [] + + @subscriber(batch={"max_size": 10, "timeout": 0.1}) # 100ms timeout + async def batch_handler(messages: list[dict]): + processed_batches.append(messages.copy()) + + # Add 2 messages (less than max_size, should trigger timeout) + test_messages = [ + MockIncomingMessage(b'{"msg": "1"}'), + MockIncomingMessage(b'{"msg": "2"}'), + ] + consumer.add_messages(test_messages) + + await subscriber.start() + await asyncio.sleep(0.3) # Wait for timeout + processing + await subscriber.stop() + + # Should have processed one batch due to timeout + assert len(processed_batches) == 1 + assert len(processed_batches[0]) == 2 + + # All messages should be acked + for message in test_messages: + assert message.is_acked + + +@pytest.mark.asyncio +async def test_subscriber_batch_reject_exception( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test subscriber batch handling of Reject exceptions""" + wire, consumer = mock_wire_with_batch_consumer + exception_callback = Mock() + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + call_count = 0 + + @subscriber(batch={"max_size": 2, "timeout": 1.0}) + async def batch_handler(messages: list[dict]): + nonlocal call_count + call_count += 1 + + if call_count == 1: + # First batch - reject + raise Reject("Invalid batch") + else: + # Second batch - process normally + pass + + # Add 4 messages (2 batches of 2) + test_messages = [ + MockIncomingMessage(b'{"msg": "1"}'), + MockIncomingMessage(b'{"msg": "2"}'), + MockIncomingMessage(b'{"msg": "3"}'), + MockIncomingMessage(b'{"msg": "4"}'), + ] + consumer.add_messages(test_messages) + + await subscriber.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + await subscriber.stop() + + # First batch should be rejected + assert test_messages[0].is_rejected + assert test_messages[1].is_rejected + + # Second batch should be acked + assert test_messages[2].is_acked + assert test_messages[3].is_acked + + # Exception callback should not be called (Reject doesn't propagate) + exception_callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_subscriber_batch_regular_exception( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test subscriber batch handling of regular exceptions""" + wire, consumer = mock_wire_with_batch_consumer + exception_callback = Mock() + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + @subscriber(batch={"max_size": 2, "timeout": 1.0}) + async def batch_handler(messages: list[dict]): + raise ValueError("Processing error") + + # Add 2 messages + test_messages = [ + MockIncomingMessage(b'{"msg": "1"}'), + MockIncomingMessage(b'{"msg": "2"}'), + ] + consumer.add_messages(test_messages) + + await subscriber.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + await subscriber.stop() + + # All messages should be nacked + for message in test_messages: + assert message.is_nacked + + # Exception callback should be called + exception_callback.assert_called_once() + called_exception = exception_callback.call_args[0][0] + assert isinstance(called_exception, ValueError) + + +# RPC Server Batch Processing Tests + + +@pytest.mark.asyncio +async def test_rpc_server_batch_processing( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test RPC server batch processing with input/output validation""" + wire, consumer = mock_wire_with_batch_consumer + + rpc_server = RpcServer( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + rpc_server._reply_codecs = [Mock()] # Add reply codecs + + @rpc_server(batch={"max_size": 3, "timeout": 1.0}) + async def batch_handler(requests: list[dict]) -> list[dict]: + # Return same number of responses as requests + return [{"response": f"processed_{i}"} for i in range(len(requests))] + + # Add 3 RPC requests + test_messages = [ + MockIncomingMessage(b'{"request": "1"}', "corr-1", "reply-queue"), + MockIncomingMessage(b'{"request": "2"}', "corr-2", "reply-queue"), + MockIncomingMessage(b'{"request": "3"}', "corr-3", "reply-queue"), + ] + consumer.add_messages(test_messages) + + await rpc_server.start() + await asyncio.sleep(0.3) + + # Check reply producer calls before stopping + reply_producer = rpc_server._reply_producer + await rpc_server.stop() + + # All requests should be acked + for message in test_messages: + assert message.is_acked + + # Reply producer should have been called for each request + # (send_batch called once per reply in our implementation) + assert reply_producer.send_batch.call_count == 3 + + +@pytest.mark.asyncio +async def test_rpc_server_batch_input_output_length_mismatch( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test RPC server batch validation: len(inputs) must equal len(outputs)""" + wire, consumer = mock_wire_with_batch_consumer + exception_callback = Mock() + + rpc_server = RpcServer( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + rpc_server._reply_codecs = [Mock()] + + @rpc_server(batch={"max_size": 2, "timeout": 1.0}) + async def batch_handler(requests: list[dict]) -> list[dict]: + # Return wrong number of responses (should fail) + return [{"response": "only_one"}] # 2 inputs, 1 output + + test_messages = [ + MockIncomingMessage(b'{"request": "1"}', "corr-1", "reply-queue"), + MockIncomingMessage(b'{"request": "2"}', "corr-2", "reply-queue"), + ] + consumer.add_messages(test_messages) + + await rpc_server.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + await rpc_server.stop() + + # All messages should be nacked due to validation error + for message in test_messages: + assert message.is_nacked + + # Exception callback should be called + exception_callback.assert_called_once() + called_exception = exception_callback.call_args[0][0] + assert isinstance(called_exception, RuntimeError) + assert "len(inputs) must equal len(outputs)" in str(called_exception) + + +@pytest.mark.asyncio +async def test_rpc_server_batch_reject_exception( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test RPC server batch handling of Reject exceptions""" + wire, consumer = mock_wire_with_batch_consumer + exception_callback = Mock() + + rpc_server = RpcServer( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + rpc_server._reply_codecs = [Mock()] + + call_count = 0 + + @rpc_server(batch={"max_size": 2, "timeout": 1.0}) + async def batch_handler(requests: list[dict]) -> list[dict]: + nonlocal call_count + call_count += 1 + + if call_count == 1: + raise Reject("Invalid batch request") + else: + return [{"status": "ok"} for _ in requests] + + # Add 4 messages (2 batches of 2) + test_messages = [ + MockIncomingMessage(b'{"request": "1"}', "corr-1", "reply-queue"), + MockIncomingMessage(b'{"request": "2"}', "corr-2", "reply-queue"), + MockIncomingMessage(b'{"request": "3"}', "corr-3", "reply-queue"), + MockIncomingMessage(b'{"request": "4"}', "corr-4", "reply-queue"), + ] + consumer.add_messages(test_messages) + + await rpc_server.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + await rpc_server.stop() + + # First batch should be rejected + assert test_messages[0].is_rejected + assert test_messages[1].is_rejected + + # Second batch should be acked + assert test_messages[2].is_acked + assert test_messages[3].is_acked + + # Exception callback should not be called (Reject doesn't propagate) + exception_callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_batch_config_total_typeddict(): + """Test that BatchConfig is a total TypedDict (all fields required)""" + # This should work (all required fields provided) + valid_config: BatchConfig = {"max_size": 10, "timeout": 5.0} + + assert valid_config["max_size"] == 10 + assert valid_config["timeout"] == 5.0 + + # Note: TypedDict validation happens at type check time with mypy, + # not at runtime, so we can't test runtime validation here. + # This test documents the expected structure. + + +@pytest.mark.asyncio +async def test_mixed_batch_and_regular_handlers_not_allowed( + mock_operation, mock_codec, mock_wire_with_batch_consumer +): + """Test that endpoints cannot have both batch and regular handlers""" + wire, consumer = mock_wire_with_batch_consumer + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + # Register regular handler first + @subscriber + async def regular_handler(msg: dict): + pass + + # Try to register batch handler - should fail + with pytest.raises(RuntimeError) as exc_info: + + @subscriber(batch={"max_size": 5, "timeout": 1.0}) + async def batch_handler(messages: list[dict]): + pass + + error_msg = str(exc_info.value) + assert "already has a handler registered" in error_msg + assert "regular_handler" in error_msg + assert "batch_handler" in error_msg diff --git a/tests/kernel/endpoint/test_exception_handling.py b/tests/kernel/endpoint/test_exception_handling.py new file mode 100644 index 0000000..f805c4b --- /dev/null +++ b/tests/kernel/endpoint/test_exception_handling.py @@ -0,0 +1,440 @@ +"""Unit tests for exception handling in subscriber and RPC server endpoints.""" + +import asyncio +import pytest +from unittest.mock import Mock, AsyncMock +from typing import AsyncGenerator + +from asyncapi_python.kernel.endpoint import Subscriber, RpcServer +from asyncapi_python.kernel.document import Operation, Channel, Message +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory +from asyncapi_python.kernel.exceptions import Reject + + +class MockIncomingMessage: + """Mock incoming message with ack/nack/reject tracking""" + + def __init__(self, payload: bytes): + self._payload = payload + self._acked = False + self._nacked = False + self._rejected = False + self._correlation_id = "test-correlation" + self._reply_to = "test-reply-to" + + @property + def payload(self) -> bytes: + return self._payload + + @property + def headers(self) -> dict: + return {} + + @property + def correlation_id(self) -> str | None: + return self._correlation_id + + @property + def reply_to(self) -> str | None: + return self._reply_to + + async def ack(self) -> None: + self._acked = True + + async def nack(self) -> None: + self._nacked = True + + async def reject(self) -> None: + self._rejected = True + + @property + def is_acked(self) -> bool: + return self._acked + + @property + def is_nacked(self) -> bool: + return self._nacked + + @property + def is_rejected(self) -> bool: + return self._rejected + + +class MockConsumer: + """Mock consumer that yields test messages""" + + def __init__(self): + self._started = False + self._messages: asyncio.Queue[MockIncomingMessage] = asyncio.Queue() + + async def start(self) -> None: + self._started = True + + async def stop(self) -> None: + self._started = False + + def add_message(self, message: MockIncomingMessage) -> None: + """Add a message to be consumed""" + try: + self._messages.put_nowait(message) + except asyncio.QueueFull: + pass + + async def recv(self) -> AsyncGenerator[MockIncomingMessage, None]: + """Yield messages from the queue""" + while self._started: + try: + message = await asyncio.wait_for(self._messages.get(), timeout=0.1) + yield message + self._messages.task_done() + except asyncio.TimeoutError: + continue + except Exception: + break + + +@pytest.fixture +def mock_channel(): + """Create a mock channel for testing.""" + return Channel( + address="/test/channel", + title="Test Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test_channel", + ) + + +@pytest.fixture +def mock_operation(mock_channel): + """Create a mock operation for testing.""" + # Create a mock message for the operation + mock_message = Message( + name="TestMessage", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-message", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + return Operation( + key="test_operation", + action="receive", + channel=mock_channel, + title="Test Operation", + summary=None, + description=None, + security=[], + tags=[], + external_docs=None, + bindings=None, + traits=[], + messages=[mock_message], # Add the mock message + reply=None, + ) + + +@pytest.fixture +def mock_codec(): + """Create a mock codec factory.""" + codec_factory = Mock(spec=CodecFactory) + + # Mock message for the operation + mock_message = Mock(spec=Message) + mock_message.name = "TestMessage" + + # Mock codec instance + mock_message_codec = Mock() + mock_message_codec.decode.return_value = {"test": "data"} + mock_message_codec.encode.return_value = b"encoded" + + # Factory returns the codec + codec_factory.create.return_value = mock_message_codec + + return codec_factory + + +@pytest.fixture +def mock_wire_with_consumer(): + """Create a mock wire factory with controllable consumer.""" + wire = Mock(spec=AbstractWireFactory) + consumer = MockConsumer() + + # Mock producer for RPC + producer = AsyncMock() + producer.start = AsyncMock() + producer.stop = AsyncMock() + producer.send_batch = AsyncMock() + + wire.create_consumer = AsyncMock(return_value=consumer) + wire.create_producer = AsyncMock(return_value=producer) + + return wire, consumer + + +@pytest.mark.asyncio +async def test_subscriber_nacks_and_stops_on_regular_exception( + mock_operation, mock_codec, mock_wire_with_consumer +): + """Test that subscriber nacks message and stops processing on regular exceptions like 1//0""" + wire, consumer = mock_wire_with_consumer + exception_callback = Mock() + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + # Register handler that throws division by zero + @subscriber + async def handler(msg): + return 1 // 0 # ZeroDivisionError + + # Add a test message + test_message = MockIncomingMessage(b'{"test": "data"}') + consumer.add_message(test_message) + + # Start subscriber with exception callback + await subscriber.start(exception_callback=exception_callback) + + # Give time for message processing + await asyncio.sleep(0.3) + + # Verify message was nacked (not acked or rejected) + assert test_message.is_nacked + assert not test_message.is_acked + assert not test_message.is_rejected + + # Verify exception callback was called + exception_callback.assert_called_once() + called_exception = exception_callback.call_args[0][0] + assert isinstance(called_exception, ZeroDivisionError) + + await subscriber.stop() + + +@pytest.mark.asyncio +async def test_subscriber_rejects_and_continues_on_reject_exception( + mock_operation, mock_codec, mock_wire_with_consumer +): + """Test that subscriber rejects message and continues processing on Reject exceptions""" + wire, consumer = mock_wire_with_consumer + exception_callback = Mock() + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + processed_messages = [] + call_count = 0 + + # Register handler that rejects first message, processes second + @subscriber + async def handler(msg): + nonlocal call_count + call_count += 1 + + if call_count == 1: + # First message - reject it + raise Reject("Invalid message format") + else: + # Second message - process normally + processed_messages.append(msg) + + # Add two test messages + first_message = MockIncomingMessage(b'{"invalid": "message"}') + second_message = MockIncomingMessage(b'{"valid": "message"}') + consumer.add_message(first_message) + consumer.add_message(second_message) + + # Start subscriber + await subscriber.start(exception_callback=exception_callback) + + # Give time for message processing + await asyncio.sleep(0.3) + + # Verify first message was rejected (not acked or nacked) + assert first_message.is_rejected + assert not first_message.is_acked + assert not first_message.is_nacked + + # Verify second message was processed and acked + assert second_message.is_acked + assert not second_message.is_nacked + assert not second_message.is_rejected + + # Verify exception callback was NOT called (Reject doesn't propagate) + exception_callback.assert_not_called() + + # Verify second message was processed + assert len(processed_messages) == 1 + + await subscriber.stop() + + +@pytest.mark.asyncio +async def test_subscriber_continues_after_reject_but_stops_on_regular_exception( + mock_operation, mock_codec, mock_wire_with_consumer +): + """Test mixed scenario: subscriber continues after Reject but stops on regular exception""" + wire, consumer = mock_wire_with_consumer + exception_callback = Mock() + + subscriber = Subscriber( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + + processed_count = 0 + + @subscriber + async def handler(msg): + nonlocal processed_count + processed_count += 1 + + if processed_count == 1: + # First message - reject + raise Reject("Bad format") + elif processed_count == 2: + # Second message - process successfully + return + else: + # Third message - throw regular exception + raise ValueError("Processing error") + + # Add three messages + msg1 = MockIncomingMessage(b'{"msg": "1"}') + msg2 = MockIncomingMessage(b'{"msg": "2"}') + msg3 = MockIncomingMessage(b'{"msg": "3"}') + consumer.add_message(msg1) + consumer.add_message(msg2) + consumer.add_message(msg3) + + await subscriber.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + + # First message: rejected, continue processing + assert msg1.is_rejected + + # Second message: acked, continue processing + assert msg2.is_acked + + # Third message: nacked, stop processing + assert msg3.is_nacked + + # Exception callback called only for ValueError + exception_callback.assert_called_once() + called_exception = exception_callback.call_args[0][0] + assert isinstance(called_exception, ValueError) + + await subscriber.stop() + + +@pytest.mark.asyncio +async def test_rpc_server_nacks_and_stops_on_regular_exception( + mock_operation, mock_codec, mock_wire_with_consumer +): + """Test that RPC server nacks message and stops processing on regular exceptions""" + wire, consumer = mock_wire_with_consumer + exception_callback = Mock() + + # Mock reply codecs for RPC server + rpc_server = RpcServer( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + rpc_server._reply_codecs = [Mock()] # Add reply codecs + + # Register handler that throws exception + @rpc_server + async def handler(msg): + raise RuntimeError("Server error") + + # Add test message with RPC metadata + test_message = MockIncomingMessage(b'{"test": "request"}') + # Override RPC metadata for RPC server + test_message._correlation_id = "test-correlation-id" + test_message._reply_to = "test-reply-queue" + consumer.add_message(test_message) + + await rpc_server.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + + # Verify message was nacked + assert test_message.is_nacked + assert not test_message.is_acked + assert not test_message.is_rejected + + # Verify exception callback was called + exception_callback.assert_called_once() + called_exception = exception_callback.call_args[0][0] + assert isinstance(called_exception, RuntimeError) + + await rpc_server.stop() + + +@pytest.mark.asyncio +async def test_rpc_server_rejects_and_continues_on_reject_exception( + mock_operation, mock_codec, mock_wire_with_consumer +): + """Test that RPC server rejects message and continues on Reject exceptions""" + wire, consumer = mock_wire_with_consumer + exception_callback = Mock() + + rpc_server = RpcServer( + operation=mock_operation, wire_factory=wire, codec_factory=mock_codec + ) + rpc_server._reply_codecs = [Mock()] + + request_count = 0 + + @rpc_server + async def handler(msg): + nonlocal request_count + request_count += 1 + + if request_count == 1: + raise Reject("Invalid request format") + else: + return {"status": "success"} + + # Add two messages with RPC metadata + first_request = MockIncomingMessage(b'{"invalid": "request"}') + first_request._correlation_id = "first-correlation" + first_request._reply_to = "test-reply-queue" + + second_request = MockIncomingMessage(b'{"valid": "request"}') + second_request._correlation_id = "second-correlation" + second_request._reply_to = "test-reply-queue" + + consumer.add_message(first_request) + consumer.add_message(second_request) + + await rpc_server.start(exception_callback=exception_callback) + await asyncio.sleep(0.3) + + # First request rejected, continue processing + assert first_request.is_rejected + assert not first_request.is_acked + + # Second request processed successfully + assert second_request.is_acked + assert not second_request.is_nacked + + # No exception propagated for Reject + exception_callback.assert_not_called() + + await rpc_server.stop() diff --git a/tests/kernel/endpoint/test_handler_enforcement.py b/tests/kernel/endpoint/test_handler_enforcement.py new file mode 100644 index 0000000..335d993 --- /dev/null +++ b/tests/kernel/endpoint/test_handler_enforcement.py @@ -0,0 +1,393 @@ +"""Unit tests for handler enforcement and location tracking in receiving endpoints.""" + +import asyncio +import pytest +from unittest.mock import Mock, AsyncMock, MagicMock + +from asyncapi_python.kernel.endpoint import Subscriber, RpcServer +from asyncapi_python.kernel.document import Operation, Channel +from asyncapi_python.kernel.wire import AbstractWireFactory +from asyncapi_python.kernel.codec import CodecFactory + + +@pytest.fixture +def mock_channel(): + """Create a mock channel for testing.""" + return Channel( + address="/test/channel", + title="Test Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test_channel", + ) + + +@pytest.fixture +def mock_operation(mock_channel): + """Create a mock operation for testing.""" + return Operation( + key="test_operation", + action="receive", + channel=mock_channel, + title="Test Operation", + summary=None, + description=None, + security=[], + tags=[], + external_docs=None, + bindings=None, + traits=[], + messages=[], + reply=None, + ) + + +@pytest.fixture +def mock_wire(): + """Create a mock wire factory.""" + wire = Mock(spec=AbstractWireFactory) + + # Mock consumer + consumer = AsyncMock() + consumer.start = AsyncMock() + consumer.stop = AsyncMock() + consumer.recv = AsyncMock() + + # Mock producer for RPC + producer = AsyncMock() + producer.start = AsyncMock() + producer.stop = AsyncMock() + producer.send_batch = AsyncMock() + + wire.create_consumer = AsyncMock(return_value=consumer) + wire.create_producer = AsyncMock(return_value=producer) + + return wire + + +@pytest.fixture +def mock_codec(): + """Create a mock codec factory.""" + codec = Mock(spec=CodecFactory) + codec.get_encoder = Mock(return_value=lambda x: b"encoded") + codec.get_decoder = Mock(return_value=lambda x: {"decoded": True}) + codec.get_reply_encoder = Mock(return_value=lambda x: b"encoded_reply") + codec.get_reply_decoder = Mock(return_value=lambda x: {"decoded_reply": True}) + return codec + + +# Subscriber Handler Enforcement Tests + + +def test_subscriber_requires_handler_at_start(mock_operation, mock_wire, mock_codec): + """Test that subscriber requires a handler before starting.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Should raise error when starting without a handler + with pytest.raises(RuntimeError) as exc_info: + asyncio.run(subscriber.start()) + + assert "test_operation" in str(exc_info.value) + assert "requires exactly one handler" in str(exc_info.value) + + +def test_subscriber_accepts_single_handler(mock_operation, mock_wire, mock_codec): + """Test that subscriber accepts exactly one handler.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register a handler + @subscriber + async def handler(msg): + pass + + # Should start successfully with one handler + async def test(): + await subscriber.start() + await subscriber.stop() + + asyncio.run(test()) + + # Verify handler was registered + assert subscriber._handler == handler + assert subscriber._handler_location is not None + + +def test_subscriber_rejects_multiple_handlers(mock_operation, mock_wire, mock_codec): + """Test that subscriber rejects multiple handlers.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register first handler + @subscriber + async def handler1(msg): + pass + + # Try to register second handler - should fail + with pytest.raises(RuntimeError) as exc_info: + + @subscriber + async def handler2(msg): + pass + + error_msg = str(exc_info.value) + assert "test_operation" in error_msg + assert "already has a handler registered" in error_msg + assert "handler1" in error_msg + assert "handler2" in error_msg + + +def test_subscriber_tracks_handler_location(mock_operation, mock_wire, mock_codec): + """Test that subscriber tracks where handlers are defined.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register first handler + @subscriber + async def my_handler(msg): + pass + + # Verify location was tracked + assert subscriber._handler_location is not None + assert "test_handler_enforcement.py" in subscriber._handler_location + assert str(my_handler.__code__.co_firstlineno) in subscriber._handler_location + + # Try to register another handler + with pytest.raises(RuntimeError) as exc_info: + + @subscriber + async def another_handler(msg): + pass + + error_msg = str(exc_info.value) + # Should show both handler locations + assert "Existing handler: my_handler at" in error_msg + assert "New handler: another_handler at" in error_msg + assert "test_handler_enforcement.py" in error_msg + + +def test_subscriber_handles_lambda_handlers(mock_operation, mock_wire, mock_codec): + """Test that subscriber handles lambda functions correctly.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register lambda handler + handler = lambda msg: None + subscriber(handler) + + # Verify lambda was registered with location + assert subscriber._handler == handler + assert subscriber._handler_location is not None + assert "test_handler_enforcement.py" in subscriber._handler_location + + # Try to register another lambda + with pytest.raises(RuntimeError) as exc_info: + subscriber(lambda msg: None) + + error_msg = str(exc_info.value) + assert "" in error_msg + assert "test_handler_enforcement.py" in error_msg + + +# RPC Server Handler Enforcement Tests + + +def test_rpc_server_requires_handler_at_start(mock_operation, mock_wire, mock_codec): + """Test that RPC server requires a handler before starting.""" + rpc_server = RpcServer( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Mock reply codecs + rpc_server._reply_codecs = {"TestReply": Mock()} + + # Should raise error when starting without a handler + with pytest.raises(RuntimeError) as exc_info: + asyncio.run(rpc_server.start()) + + assert "test_operation" in str(exc_info.value) + assert "requires exactly one handler" in str(exc_info.value) + + +def test_rpc_server_accepts_single_handler(mock_operation, mock_wire, mock_codec): + """Test that RPC server accepts exactly one handler.""" + rpc_server = RpcServer( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Mock reply codecs + rpc_server._reply_codecs = {"TestReply": Mock()} + + # Register a handler + @rpc_server + async def handler(msg): + return {"response": "ok"} + + # Should start successfully with one handler + async def test(): + await rpc_server.start() + await rpc_server.stop() + + asyncio.run(test()) + + # Verify handler was registered + assert rpc_server._handler == handler + assert rpc_server._handler_location is not None + + +def test_rpc_server_rejects_multiple_handlers(mock_operation, mock_wire, mock_codec): + """Test that RPC server rejects multiple handlers.""" + rpc_server = RpcServer( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register first handler + @rpc_server + async def process_request(msg): + return {"status": "ok"} + + # Try to register second handler - should fail + with pytest.raises(RuntimeError) as exc_info: + + @rpc_server + async def another_processor(msg): + return {"status": "ok"} + + error_msg = str(exc_info.value) + assert "test_operation" in error_msg + assert "already has a handler registered" in error_msg + assert "process_request" in error_msg + assert "another_processor" in error_msg + + +def test_rpc_server_tracks_handler_location(mock_operation, mock_wire, mock_codec): + """Test that RPC server tracks where handlers are defined.""" + rpc_server = RpcServer( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register first handler + @rpc_server + async def rpc_handler(msg): + return {"result": "success"} + + # Verify location was tracked + assert rpc_server._handler_location is not None + assert "test_handler_enforcement.py" in rpc_server._handler_location + assert str(rpc_handler.__code__.co_firstlineno) in rpc_server._handler_location + + # Try to register another handler + with pytest.raises(RuntimeError) as exc_info: + + @rpc_server + async def duplicate_handler(msg): + return {"result": "success"} + + error_msg = str(exc_info.value) + # Should show both handler locations + assert "Existing handler: rpc_handler at" in error_msg + assert "New handler: duplicate_handler at" in error_msg + assert "test_handler_enforcement.py" in error_msg + + +def test_rpc_server_with_parameters(mock_operation, mock_wire, mock_codec): + """Test that RPC server works with decorator parameters.""" + rpc_server = RpcServer( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + # Register handler with parameters + @rpc_server(queue="high-priority") + async def priority_handler(msg): + return {"priority": "high"} + + # Verify handler was registered + assert rpc_server._handler == priority_handler + assert rpc_server._handler_location is not None + + # Try to register another handler with parameters + with pytest.raises(RuntimeError) as exc_info: + + @rpc_server(queue="low-priority") + async def another_handler(msg): + return {"priority": "low"} + + error_msg = str(exc_info.value) + assert "priority_handler" in error_msg + assert "another_handler" in error_msg + + +# Handler Location Formatting Tests + + +def test_location_format_regular_function(mock_operation, mock_wire, mock_codec): + """Test location format for regular functions.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + @subscriber + async def test_function(msg): + pass + + # Location should be in format: filename:linenumber + assert ":" in subscriber._handler_location + parts = subscriber._handler_location.split(":") + assert len(parts) == 2 + assert parts[0].endswith(".py") + assert parts[1].isdigit() + + +def test_location_format_lambda(mock_operation, mock_wire, mock_codec): + """Test location format for lambda functions.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + test_lambda = lambda msg: None + subscriber(test_lambda) + + # Lambda location should still have proper format + assert ":" in subscriber._handler_location + parts = subscriber._handler_location.split(":") + assert len(parts) == 2 + assert parts[0].endswith(".py") + assert parts[1].isdigit() + + +def test_error_message_structure(mock_operation, mock_wire, mock_codec): + """Test the structure of error messages with location info.""" + subscriber = Subscriber( + operation=mock_operation, wire_factory=mock_wire, codec_factory=mock_codec + ) + + @subscriber + async def first(msg): + pass + + with pytest.raises(RuntimeError) as exc_info: + + @subscriber + async def second(msg): + pass + + error_lines = str(exc_info.value).split("\n") + + # Error should be multi-line with clear structure + assert len(error_lines) >= 4 + assert "already has a handler registered" in error_lines[0] + assert "Existing handler:" in error_lines[1] + assert "New handler:" in error_lines[2] + assert "exactly one handler" in error_lines[3] diff --git a/tests/kernel/endpoint/test_rpc_endpoints.py b/tests/kernel/endpoint/test_rpc_endpoints.py new file mode 100644 index 0000000..d64fd98 --- /dev/null +++ b/tests/kernel/endpoint/test_rpc_endpoints.py @@ -0,0 +1,969 @@ +"""Integration tests for RPC client and server endpoints""" + +import asyncio +import pytest +from typing import AsyncGenerator + +from asyncapi_python.kernel.endpoint.rpc_client import RpcClient +from asyncapi_python.kernel.endpoint.rpc_reply_handler import global_reply_handler +from asyncapi_python.kernel.endpoint.rpc_server import RpcServer +from asyncapi_python.kernel.endpoint.publisher import Publisher +from asyncapi_python.kernel.endpoint.subscriber import Subscriber +from asyncapi_python.kernel.endpoint.message import WireMessage +from asyncapi_python.kernel.endpoint.exceptions import TimeoutError, UninitializedError +from asyncapi_python.kernel.document import Operation, Channel, Message, OperationReply +from asyncapi_python.kernel.wire import AbstractWireFactory, Producer, Consumer +from asyncapi_python.kernel.codec import CodecFactory, Codec +from asyncapi_python.kernel.typing import IncomingMessage +import json + + +@pytest.fixture +async def cleanup_rpc_client(): + """Clean up RPC client global state between tests""" + yield + + # Clean up global state after each test + # Force instance count to 0 to trigger cleanup + global_reply_handler._instance_count = 0 + + # First cancel the background task + if ( + global_reply_handler._consume_task + and not global_reply_handler._consume_task.done() + ): + global_reply_handler._consume_task.cancel() + try: + await global_reply_handler._consume_task + except asyncio.CancelledError: + pass + except Exception: + pass + global_reply_handler._consume_task = None + + # Stop the consumer + if global_reply_handler._reply_consumer: + try: + await global_reply_handler._reply_consumer.stop() + except Exception: + pass + global_reply_handler._reply_consumer = None + + # Cancel any remaining futures + for future in list(global_reply_handler._futures.values()): + if not future.done(): + future.cancel() + # Give cancelled futures a chance to be collected + try: + await asyncio.sleep(0) + except: + pass + + global_reply_handler._futures.clear() + global_reply_handler._reply_queue_name = None + + # Give any remaining tasks a chance to clean up + await asyncio.sleep(0.01) + + +# Test message types +class RequestMessage: + def __init__(self, data: str): + self.data = data + + +class ResponseMessage: + def __init__(self, result: str): + self.result = result + + +@pytest.fixture +def mock_operation(): + """Create a mock RPC operation""" + channel = Channel( + address="test.rpc", + title="Test RPC Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + reply_channel = Channel( + address=None, # Default reply queue + title="Reply Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + request_message = Message( + name="RequestMessage", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + response_message = Message( + name="ResponseMessage", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + reply = OperationReply( + channel=reply_channel, + address=None, + messages=[response_message], + ) + + operation = Operation( + action="send", # For RPC client + channel=channel, + messages=[request_message], + reply=reply, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + return operation + + +# Realistic implementations for scenario tests +class RealisticWireMessage(WireMessage): + """Wire message that supports ack/nack operations""" + + def __init__( + self, + payload: bytes, + headers: dict, + correlation_id: str | None = None, + reply_to: str | None = None, + ): + super().__init__(payload, headers, correlation_id, reply_to) + self._acked = False + self._nacked = False + + async def ack(self) -> None: + self._acked = True + + async def nack(self) -> None: + self._nacked = True + + +class RealisticConsumer: + """Consumer that can route messages between client and server""" + + def __init__(self, is_reply: bool = False): + self.is_reply = is_reply + self._started = False + self._message_queue: asyncio.Queue[WireMessage] = asyncio.Queue() + self._factory: RealisticWireFactory | None = None + + async def start(self) -> None: + self._started = True + + async def stop(self) -> None: + self._started = False + # Clear any remaining messages to help with cleanup + while not self._message_queue.empty(): + try: + self._message_queue.get_nowait() + except: + break + + def set_factory(self, factory: "RealisticWireFactory") -> None: + self._factory = factory + + async def recv(self) -> AsyncGenerator[WireMessage, None]: + """Async generator that yields messages from the queue""" + while self._started: + try: + # Wait for a message with a timeout to allow checking _started + message = await asyncio.wait_for(self._message_queue.get(), timeout=0.1) + yield message + # Mark task as done for proper queue cleanup + self._message_queue.task_done() + except asyncio.TimeoutError: + # Check if we should continue running - yield control to allow stop + await asyncio.sleep(0) + continue + except Exception: + break + + # Consume any remaining messages when stopping + while not self._message_queue.empty(): + try: + message = self._message_queue.get_nowait() + yield message + self._message_queue.task_done() + except: + break + + async def add_message(self, message: WireMessage) -> None: + """Add a message to this consumer's queue""" + if self._started: + await self._message_queue.put(message) + + +class RealisticProducer: + """Producer that routes messages to appropriate consumers""" + + def __init__(self, is_reply: bool = False): + self.is_reply = is_reply + self._started = False + self._factory: RealisticWireFactory | None = None + + async def start(self) -> None: + self._started = True + + async def stop(self) -> None: + self._started = False + + def set_factory(self, factory: "RealisticWireFactory") -> None: + self._factory = factory + + async def send_batch(self, messages: list[WireMessage]) -> None: + """Send messages by routing them to the appropriate consumers""" + if not self._started or not self._factory: + return + + for message in messages: + if self.is_reply: + # Reply message - route to reply consumer + if self._factory._reply_consumer: + reply_message = RealisticWireMessage( + message.payload, + message.headers, + message.correlation_id, + message.reply_to, + ) + await self._factory._reply_consumer.add_message(reply_message) + else: + # Check if this is pub-sub or RPC + if self == self._factory._pub_producer: + # Pub-sub fanout - send to all subscribers + for subscriber in self._factory._subscribers: + fanout_message = RealisticWireMessage( + message.payload, + message.headers, + message.correlation_id, + message.reply_to, + ) + await subscriber.add_message(fanout_message) + else: + # Regular RPC message - route to server consumer and trigger reply + if self._factory._server_consumer: + server_message = RealisticWireMessage( + message.payload, + message.headers, + message.correlation_id, + message.reply_to, + ) + await self._factory._server_consumer.add_message(server_message) + + # Automatically trigger server reply processing and track the task + if hasattr(self._factory, "_background_tasks"): + task = asyncio.create_task( + self._factory._handle_server_message(server_message) + ) + self._factory._background_tasks.append(task) + else: + # Fallback for immediate processing + await self._factory._handle_server_message(server_message) + + +class RealisticWireFactory(AbstractWireFactory): + """Wire factory that creates realistic consumers and producers for testing""" + + def __init__(self): + self._reply_consumer: RealisticConsumer | None = None + self._server_consumer: RealisticConsumer | None = None + self._client_producer: RealisticProducer | None = None + self._reply_producer: RealisticProducer | None = None + self._server_handler = None # Will hold the server RPC handler for testing + self._background_tasks: list[asyncio.Task] = [] # Track background tasks + # Pub-sub support + self._pub_producer: RealisticProducer | None = None + self._subscribers: list[RealisticConsumer] = ( + [] + ) # Multiple subscribers for fanout + + def set_server_handler(self, handler): + """Set the server handler for automatic reply generation""" + self._server_handler = handler + + async def _handle_server_message(self, message: WireMessage) -> None: + """Simulate server processing and automatic reply generation""" + if not self._server_handler or not self._reply_producer: + return + + # Give a small delay to simulate server processing + await asyncio.sleep(0.01) + + try: + # Decode request using SimpleCodec + codec = SimpleCodec() + request = codec.decode(message.payload) + + # Call server handler + response = await self._server_handler(request) + + # Encode response + response_payload = codec.encode(response) + + # Create reply message + reply_message = RealisticWireMessage( + payload=response_payload, + headers={}, + correlation_id=message.correlation_id, + reply_to=None, + ) + + # Send reply back to client + await self._reply_producer.send_batch([reply_message]) + + except Exception as e: + # Send error response + error_payload = json.dumps({"error": str(e)}).encode() + error_message = RealisticWireMessage( + payload=error_payload, + headers={"error": "true"}, + correlation_id=message.correlation_id, + reply_to=None, + ) + await self._reply_producer.send_batch([error_message]) + + async def cleanup(self) -> None: + """Clean up all background tasks and consumers""" + # Cancel and wait for background tasks + for task in self._background_tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + self._background_tasks.clear() + + # Stop all consumers and producers + if self._server_consumer: + await self._server_consumer.stop() + if self._reply_consumer: + await self._reply_consumer.stop() + if self._client_producer: + await self._client_producer.stop() + if self._reply_producer: + await self._reply_producer.stop() + + async def create_consumer( + self, channel, parameters, op_bindings, is_reply: bool + ) -> Consumer: + consumer = RealisticConsumer(is_reply=is_reply) + consumer.set_factory(self) + + if is_reply: + self._reply_consumer = consumer + else: + # For pub-sub, we can have multiple subscribers + if hasattr(channel, "address") and "pubsub" in str(channel.address): + self._subscribers.append(consumer) + else: + self._server_consumer = consumer + + return consumer + + async def create_producer( + self, channel, parameters, op_bindings, is_reply: bool + ) -> Producer: + producer = RealisticProducer(is_reply=is_reply) + producer.set_factory(self) + + if is_reply: + self._reply_producer = producer + else: + # Check if this is for pub-sub + if hasattr(channel, "address") and "pubsub" in str(channel.address): + self._pub_producer = producer + else: + self._client_producer = producer + + return producer + + +class SimpleCodec(Codec): + """Simple codec that works with our test message classes""" + + def encode(self, obj) -> bytes: + if isinstance(obj, RequestMessage): + return json.dumps({"type": "request", "data": obj.data}).encode() + elif isinstance(obj, ResponseMessage): + return json.dumps({"type": "response", "result": obj.result}).encode() + else: + return json.dumps({"data": str(obj)}).encode() + + def decode(self, data: bytes): + try: + parsed = json.loads(data.decode()) + if parsed.get("type") == "request": + return RequestMessage(parsed["data"]) + elif parsed.get("type") == "response": + return ResponseMessage(parsed["result"]) + elif "error" in parsed: + # Error response + return ResponseMessage(json.dumps(parsed)) + else: + return RequestMessage(parsed.get("data", "")) + except Exception: + return RequestMessage(data.decode()) + + +class SimpleCodecFactory(CodecFactory): + """Simple codec factory for testing""" + + def __init__(self): + # Use a dummy module for testing - CodecFactory expects a module + import types + + dummy_module = types.ModuleType("test_module") + super().__init__(dummy_module) + + def create(self, message: Message) -> Codec: + return SimpleCodec() + + +# Integration tests for RPC endpoints with end-to-end message flow + + +@pytest.mark.asyncio(loop_scope="function") +async def test_complete_rpc_scenario(mock_operation, cleanup_rpc_client): + """Test a complete RPC scenario with realistic message flow""" + # Create a realistic wire factory that simulates message routing + wire_factory = RealisticWireFactory() + + # Create simple codecs that work with our test messages + codec_factory = SimpleCodecFactory() + + # Create client and server with proper operations + client = RpcClient( + operation=mock_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + server_operation = Operation( + action="receive", + channel=mock_operation.channel, + messages=mock_operation.messages, + reply=mock_operation.reply, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + server = RpcServer( + operation=server_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Register server handler + @server + async def handle_request(request: RequestMessage) -> ResponseMessage: + return ResponseMessage(f"Echo: {request.data}") + + # Set up wire factory to use the server handler for automatic replies + wire_factory.set_server_handler(handle_request) + + # Start both endpoints + await client.start() + await server.start() + + # Make RPC call + request = RequestMessage("Hello World") + response = await client(request) + + # Verify response + assert isinstance(response, ResponseMessage) + assert response.result == "Echo: Hello World" + + # Cleanup + await client.stop() + await server.stop() + await wire_factory.cleanup() + + +@pytest.mark.asyncio(loop_scope="function") +async def test_concurrent_rpc_calls(mock_operation, cleanup_rpc_client): + """Test multiple concurrent RPC calls""" + wire_factory = RealisticWireFactory() + codec_factory = SimpleCodecFactory() + + # Create client + client = RpcClient( + operation=mock_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Create server + server_operation = Operation( + action="receive", + channel=mock_operation.channel, + messages=mock_operation.messages, + reply=mock_operation.reply, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + server = RpcServer( + operation=server_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Server handler with delay to test concurrency + @server + async def handle_request(request: RequestMessage) -> ResponseMessage: + await asyncio.sleep(0.1) # Simulate processing time + return ResponseMessage(f"Processed-{request.data}") + + # Set up wire factory for automatic replies + wire_factory.set_server_handler(handle_request) + + # Start endpoints + await client.start() + await server.start() + + # Make multiple concurrent calls + tasks = [] + for i in range(5): + request = RequestMessage(f"Request-{i}") + task = asyncio.create_task(client(request)) + tasks.append(task) + + # Wait for all responses + responses = await asyncio.gather(*tasks) + + # Verify all responses are correct and unique + assert len(responses) == 5 + results = {r.result for r in responses} + expected = {f"Processed-Request-{i}" for i in range(5)} + assert results == expected + + # Cleanup + await client.stop() + await server.stop() + await wire_factory.cleanup() + + +@pytest.mark.asyncio(loop_scope="function") +async def test_rpc_error_handling(mock_operation, cleanup_rpc_client): + """Test RPC error handling when server handler fails""" + wire_factory = RealisticWireFactory() + codec_factory = SimpleCodecFactory() + + client = RpcClient( + operation=mock_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + server_operation = Operation( + action="receive", + channel=mock_operation.channel, + messages=mock_operation.messages, + reply=mock_operation.reply, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + server = RpcServer( + operation=server_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Handler that raises an error + @server + async def handle_request(request: RequestMessage) -> ResponseMessage: + if request.data == "error": + raise ValueError("Simulated server error") + return ResponseMessage(f"OK: {request.data}") + + # Set up wire factory for automatic replies + wire_factory.set_server_handler(handle_request) + + await client.start() + await server.start() + + # Test normal request + response = await client(RequestMessage("normal")) + assert response.result == "OK: normal" + + # Test error request - should receive error response + error_response = await client(RequestMessage("error")) + # The server sends an error response, which should be a JSON string + assert "error" in error_response.result.lower() + + await client.stop() + await server.stop() + await wire_factory.cleanup() + + +@pytest.mark.asyncio(loop_scope="function") +async def test_pubsub_fanout_scenario(cleanup_rpc_client): + """Test pub-sub fanout scenario - one publisher, multiple subscribers""" + wire_factory = RealisticWireFactory() + codec_factory = SimpleCodecFactory() + + # Create pub-sub channel + pubsub_channel = Channel( + address="events.pubsub", # Special address for pub-sub detection + title="Event Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + # Create message for events + event_message = Message( + name="EventMessage", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + # Create publisher operation + pub_operation = Operation( + action="send", + channel=pubsub_channel, + messages=[event_message], + reply=None, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + # Create subscriber operation + sub_operation = Operation( + action="receive", + channel=pubsub_channel, + messages=[event_message], + reply=None, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + # Create publisher + publisher = Publisher( + operation=pub_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Create multiple subscribers + subscribers = [] + received_messages = [] + + for i in range(3): + subscriber = Subscriber( + operation=sub_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Track received messages + subscriber_messages = [] + received_messages.append(subscriber_messages) + + @subscriber + async def handle_event(event: RequestMessage, msg_list=subscriber_messages): + msg_list.append(event.data) + + subscribers.append(subscriber) + + # Start all endpoints + await publisher.start() + for subscriber in subscribers: + await subscriber.start() + + # Give subscribers time to start consuming + await asyncio.sleep(0.05) + + # Publish an event + event = RequestMessage("Important Event") + await publisher(event) + + # Give time for fanout delivery + await asyncio.sleep(0.1) + + # Verify all subscribers received the message + assert len(received_messages) == 3 + for subscriber_msgs in received_messages: + assert len(subscriber_msgs) == 1 + assert subscriber_msgs[0] == "Important Event" + + # Publish another event + await publisher(RequestMessage("Second Event")) + await asyncio.sleep(0.1) + + # Verify all subscribers received both events + for subscriber_msgs in received_messages: + assert len(subscriber_msgs) == 2 + assert "Important Event" in subscriber_msgs + assert "Second Event" in subscriber_msgs + + # Cleanup + await publisher.stop() + for subscriber in subscribers: + await subscriber.stop() + await wire_factory.cleanup() + + +@pytest.mark.asyncio(loop_scope="function") +async def test_enhanced_rpc_scenario(cleanup_rpc_client): + """Enhanced RPC scenario with detailed request-response validation""" + wire_factory = RealisticWireFactory() + codec_factory = SimpleCodecFactory() + + # Create RPC operation + rpc_channel = Channel( + address="math.rpc", + title="Math RPC Channel", + summary=None, + description=None, + servers=[], + messages={}, + parameters={}, + tags=[], + external_docs=None, + bindings=None, + key="test-key", + ) + + request_message = Message( + name="MathRequest", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + response_message = Message( + name="MathResponse", + title=None, + summary=None, + description=None, + tags=[], + externalDocs=None, + traits=[], + payload={"type": "object"}, + headers=None, + bindings=None, + key="test-key", + correlation_id=None, + content_type=None, + deprecated=None, + ) + + reply = OperationReply( + channel=rpc_channel, + address=None, + messages=[response_message], + ) + + client_operation = Operation( + action="send", + channel=rpc_channel, + messages=[request_message], + reply=reply, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + server_operation = Operation( + action="receive", + channel=rpc_channel, + messages=[request_message], + reply=reply, + title=None, + summary=None, + description=None, + tags=[], + external_docs=None, + traits=[], + bindings=None, + key="test-key", + security=None, + ) + + # Create client and server + client = RpcClient( + operation=client_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + server = RpcServer( + operation=server_operation, + wire_factory=wire_factory, + codec_factory=codec_factory, + ) + + # Register enhanced server handler + @server + async def math_service(request: RequestMessage) -> ResponseMessage: + operation, *number_strs = request.data.split() + numbers = [float(n) for n in number_strs] + + if operation == "add": + result = sum(numbers) + elif operation == "multiply": + result = 1.0 + for n in numbers: + result *= n + elif operation == "divide": + result = numbers[0] / numbers[1] if len(numbers) >= 2 else 0.0 + else: + raise ValueError(f"Unknown operation: {operation}") + + return ResponseMessage(f"{result}") + + # Set up wire factory for automatic replies + wire_factory.set_server_handler(math_service) + + # Start both endpoints + await client.start() + await server.start() + + # Test various RPC calls + test_cases = [ + ("add 10 20 30", "60.0"), + ("multiply 5 4 2", "40.0"), + ("divide 100 4", "25.0"), + ] + + for request_data, expected in test_cases: + request = RequestMessage(request_data) + response = await client(request) + assert ( + response.result == expected + ), f"Failed for {request_data}: got {response.result}, expected {expected}" + + # Test error handling + try: + error_response = await client(RequestMessage("unknown 1 2")) + # Should receive error response, not throw exception + assert "error" in error_response.result.lower() + except Exception: + # Error handling worked + pass + + # Cleanup + await client.stop() + await server.stop() + await wire_factory.cleanup() diff --git a/uv.lock b/uv.lock index acb8bb0..21e8877 100644 --- a/uv.lock +++ b/uv.lock @@ -1,72 +1,28 @@ version = 1 revision = 3 -requires-python = ">=3.9, <3.14" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version < '3.10'", -] - -[[package]] -name = "aio-pika" -version = "9.5.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "aiormq", version = "6.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, - { name = "yarl", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/52/fe35c898bce5cc8af839ba786b38f7db8932aac48a67ba8ca7de3b074e07/aio_pika-9.5.6.tar.gz", hash = "sha256:5013f429e1235e1ce8df054a821e0eea140ea9afc94a09725b96590ea2dad001", size = 47308, upload-time = "2025-08-05T14:18:35.949Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/fb/c1cfb7cb98ccd2abdc91e170e7ba0e1e3088b6a9d051e4f2899d3249a231/aio_pika-9.5.6-py3-none-any.whl", hash = "sha256:47b532419185cf1105ae18daa45a5052ff98064915c5e080b2433431fe808193", size = 54303, upload-time = "2025-08-05T14:18:34.62Z" }, -] +requires-python = ">=3.10, <3.14" [[package]] name = "aio-pika" version = "9.5.7" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "aiormq", version = "6.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "yarl", marker = "python_full_version >= '3.10'" }, + { name = "aiormq" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/ad/0ddde89d7a018f4304aac687e5b65c07d308644f51da3c4ae411184bb237/aio_pika-9.5.7.tar.gz", hash = "sha256:0569b59d3c7b36ca76abcb213cdc3677e2a4710a3c371dd27359039f9724f4ee", size = 47298, upload-time = "2025-08-05T18:21:18.397Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/be/9b08e7c4d1b3b9a1184e63965d13c811366444cb42c6e809910ab17e916c/aio_pika-9.5.7-py3-none-any.whl", hash = "sha256:684316a0e92157754bb2d6927c5568fd997518b123add342e97405aa9066772b", size = 54297, upload-time = "2025-08-05T18:21:16.99Z" }, ] -[[package]] -name = "aiormq" -version = "6.8.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "pamqp", marker = "python_full_version < '3.10'" }, - { name = "yarl", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/79/5397756a8782bf3d0dce392b48260c3ec81010f16bef8441ff03505dccb4/aiormq-6.8.1.tar.gz", hash = "sha256:a964ab09634be1da1f9298ce225b310859763d5cf83ef3a7eae1a6dc6bd1da1a", size = 30528, upload-time = "2024-09-04T11:16:38.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/be/1a613ae1564426f86650ff58c351902895aa969f7e537e74bfd568f5c8bf/aiormq-6.8.1-py3-none-any.whl", hash = "sha256:5da896c8624193708f9409ffad0b20395010e2747f22aa4150593837f40aa017", size = 31174, upload-time = "2024-09-04T11:16:37.238Z" }, -] - [[package]] name = "aiormq" version = "6.9.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "pamqp", marker = "python_full_version >= '3.10'" }, - { name = "yarl", marker = "python_full_version >= '3.10'" }, + { name = "pamqp" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/95/feddc2fd77f995837ee8909794101ce5c9c6e7bb399d4e60d5d16f04d74a/aiormq-6.9.0.tar.gz", hash = "sha256:1c31f2098ad2beee6e95d0ad969c836876c1e3113e8c67142eb58565fedcab4c", size = 30526, upload-time = "2025-07-22T12:21:32.915Z" } wheels = [ @@ -108,7 +64,7 @@ wheels = [ [[package]] name = "asyncapi-python" -version = "0.2.5" +version = "0.3.0rc1" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -117,8 +73,7 @@ dependencies = [ [package.optional-dependencies] amqp = [ - { name = "aio-pika", version = "9.5.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "aio-pika", version = "9.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "aio-pika" }, ] codegen = [ { name = "black" }, @@ -134,8 +89,10 @@ dev = [ { name = "isort" }, { name = "mypy" }, { name = "pex" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, { name = "types-pytz" }, { name = "types-pyyaml" }, ] @@ -159,8 +116,10 @@ dev = [ { name = "isort" }, { name = "mypy" }, { name = "pex" }, + { name = "pyright", specifier = ">=1.1.405" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, { name = "types-pytz" }, { name = "types-pyyaml" }, ] @@ -179,8 +138,7 @@ name = "black" version = "25.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click" }, { name = "mypy-extensions" }, { name = "packaging" }, { name = "pathspec" }, @@ -206,10 +164,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593, upload-time = "2025-01-29T05:37:23.672Z" }, - { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000, upload-time = "2025-01-29T05:37:25.829Z" }, - { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963, upload-time = "2025-01-29T04:18:38.116Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419, upload-time = "2025-01-29T04:18:30.191Z" }, { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, ] @@ -222,30 +176,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - [[package]] name = "click" version = "8.2.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ @@ -354,18 +290,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - [[package]] name = "inflect" version = "7.5.0" @@ -409,30 +333,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.10'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -495,16 +401,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, - { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, - { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, - { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, - { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, - { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, - { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, - { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, ] [[package]] @@ -624,24 +520,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d3/f04c5db316caee9b5b2cbba66270b358c922a959855995bedde87134287c/multidict-6.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:af7618b591bae552b40dbb6f93f5518328a949dac626ee75927bba1ecdeea9f4", size = 76977, upload-time = "2025-08-11T12:08:16.667Z" }, - { url = "https://files.pythonhosted.org/packages/70/39/a6200417d883e510728ab3caec02d3b66ff09e1c85e0aab2ba311abfdf06/multidict-6.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b6819f83aef06f560cb15482d619d0e623ce9bf155115150a85ab11b8342a665", size = 44878, upload-time = "2025-08-11T12:08:18.157Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/815be31ed35571b137d65232816f61513fcd97b2717d6a9d7800b5a0c6e0/multidict-6.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d09384e75788861e046330308e7af54dd306aaf20eb760eb1d0de26b2bea2cb", size = 44546, upload-time = "2025-08-11T12:08:19.694Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f1/21b5bff6a8c3e2aff56956c241941ace6b8820e1abe6b12d3c52868a773d/multidict-6.6.4-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a59c63061f1a07b861c004e53869eb1211ffd1a4acbca330e3322efa6dd02978", size = 223020, upload-time = "2025-08-11T12:08:21.554Z" }, - { url = "https://files.pythonhosted.org/packages/15/59/37083f1dd3439979a0ffeb1906818d978d88b4cc7f4600a9f89b1cb6713c/multidict-6.6.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350f6b0fe1ced61e778037fdc7613f4051c8baf64b1ee19371b42a3acdb016a0", size = 240528, upload-time = "2025-08-11T12:08:23.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f0/f054d123c87784307a27324c829eb55bcfd2e261eb785fcabbd832c8dc4a/multidict-6.6.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c5cbac6b55ad69cb6aa17ee9343dfbba903118fd530348c330211dc7aa756d1", size = 219540, upload-time = "2025-08-11T12:08:24.965Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/8f78ce17b7118149c17f238f28fba2a850b660b860f9b024a34d0191030f/multidict-6.6.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630f70c32b8066ddfd920350bc236225814ad94dfa493fe1910ee17fe4365cbb", size = 251182, upload-time = "2025-08-11T12:08:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/00/c3/a21466322d69f6594fe22d9379200f99194d21c12a5bbf8c2a39a46b83b6/multidict-6.6.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d4916a81697faec6cb724a273bd5457e4c6c43d82b29f9dc02c5542fd21fc9", size = 249371, upload-time = "2025-08-11T12:08:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8e/2e673124eb05cf8dc82e9265eccde01a36bcbd3193e27799b8377123c976/multidict-6.6.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e42332cf8276bb7645d310cdecca93a16920256a5b01bebf747365f86a1675b", size = 239235, upload-time = "2025-08-11T12:08:29.937Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2d/bdd9f05e7c89e30a4b0e4faf0681a30748f8d1310f68cfdc0e3571e75bd5/multidict-6.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f3be27440f7644ab9a13a6fc86f09cdd90b347c3c5e30c6d6d860de822d7cb53", size = 237410, upload-time = "2025-08-11T12:08:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/3237b83f8ca9a2673bb08fc340c15da005a80f5cc49748b587c8ae83823b/multidict-6.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21f216669109e02ef3e2415ede07f4f8987f00de8cdfa0cc0b3440d42534f9f0", size = 232979, upload-time = "2025-08-11T12:08:33.399Z" }, - { url = "https://files.pythonhosted.org/packages/55/a6/a765decff625ae9bc581aed303cd1837955177dafc558859a69f56f56ba8/multidict-6.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d9890d68c45d1aeac5178ded1d1cccf3bc8d7accf1f976f79bf63099fb16e4bd", size = 240979, upload-time = "2025-08-11T12:08:35.02Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2d/9c75975cb0c66ea33cae1443bb265b2b3cd689bffcbc68872565f401da23/multidict-6.6.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:edfdcae97cdc5d1a89477c436b61f472c4d40971774ac4729c613b4b133163cb", size = 246849, upload-time = "2025-08-11T12:08:37.038Z" }, - { url = "https://files.pythonhosted.org/packages/3e/71/d21ac0843c1d8751fb5dcf8a1f436625d39d4577bc27829799d09b419af7/multidict-6.6.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0b2e886624be5773e69cf32bcb8534aecdeb38943520b240fed3d5596a430f2f", size = 241798, upload-time = "2025-08-11T12:08:38.669Z" }, - { url = "https://files.pythonhosted.org/packages/94/3d/1d8911e53092837bd11b1c99d71de3e2a9a26f8911f864554677663242aa/multidict-6.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:be5bf4b3224948032a845d12ab0f69f208293742df96dc14c4ff9b09e508fc17", size = 235315, upload-time = "2025-08-11T12:08:40.266Z" }, - { url = "https://files.pythonhosted.org/packages/86/c5/4b758df96376f73e936b1942c6c2dfc17e37ed9d5ff3b01a811496966ca0/multidict-6.6.4-cp39-cp39-win32.whl", hash = "sha256:10a68a9191f284fe9d501fef4efe93226e74df92ce7a24e301371293bd4918ae", size = 41434, upload-time = "2025-08-11T12:08:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/58/16/f1dfa2a0f25f2717a5e9e5fe8fd30613f7fe95e3530cec8d11f5de0b709c/multidict-6.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee25f82f53262f9ac93bd7e58e47ea1bdcc3393cef815847e397cba17e284210", size = 46186, upload-time = "2025-08-11T12:08:43.367Z" }, - { url = "https://files.pythonhosted.org/packages/88/7d/a0568bac65438c494cb6950b29f394d875a796a237536ac724879cf710c9/multidict-6.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:f9867e55590e0855bcec60d4f9a092b69476db64573c9fe17e92b0c50614c16a", size = 43115, upload-time = "2025-08-11T12:08:45.126Z" }, { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] @@ -681,12 +559,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, - { url = "https://files.pythonhosted.org/packages/29/cb/673e3d34e5d8de60b3a61f44f80150a738bff568cd6b7efb55742a605e98/mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9", size = 10992466, upload-time = "2025-07-31T07:53:57.574Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d0/fe1895836eea3a33ab801561987a10569df92f2d3d4715abf2cfeaa29cb2/mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99", size = 10117638, upload-time = "2025-07-31T07:53:34.256Z" }, - { url = "https://files.pythonhosted.org/packages/97/f3/514aa5532303aafb95b9ca400a31054a2bd9489de166558c2baaeea9c522/mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8", size = 11915673, upload-time = "2025-07-31T07:52:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c3/c0805f0edec96fe8e2c048b03769a6291523d509be8ee7f56ae922fa3882/mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8", size = 12649022, upload-time = "2025-07-31T07:53:45.92Z" }, - { url = "https://files.pythonhosted.org/packages/45/3e/d646b5a298ada21a8512fa7e5531f664535a495efa672601702398cea2b4/mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259", size = 12895536, upload-time = "2025-07-31T07:53:06.17Z" }, - { url = "https://files.pythonhosted.org/packages/14/55/e13d0dcd276975927d1f4e9e2ec4fd409e199f01bdc671717e673cc63a22/mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d", size = 9512564, upload-time = "2025-07-31T07:53:12.346Z" }, { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, ] @@ -699,6 +571,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -839,22 +720,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/6c/39/8ea9bcfaaff16fd0b0fc901ee522e24c9ec44b4ca0229cfffb8066a06959/propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5", size = 74678, upload-time = "2025-06-09T22:55:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/d3/85/cab84c86966e1d354cf90cdc4ba52f32f99a5bca92a1529d666d957d7686/propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4", size = 43829, upload-time = "2025-06-09T22:55:42.417Z" }, - { url = "https://files.pythonhosted.org/packages/23/f7/9cb719749152d8b26d63801b3220ce2d3931312b2744d2b3a088b0ee9947/propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2", size = 43729, upload-time = "2025-06-09T22:55:43.651Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a2/0b2b5a210ff311260002a315f6f9531b65a36064dfb804655432b2f7d3e3/propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d", size = 204483, upload-time = "2025-06-09T22:55:45.327Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e0/7aff5de0c535f783b0c8be5bdb750c305c1961d69fbb136939926e155d98/propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec", size = 217425, upload-time = "2025-06-09T22:55:46.729Z" }, - { url = "https://files.pythonhosted.org/packages/92/1d/65fa889eb3b2a7d6e4ed3c2b568a9cb8817547a1450b572de7bf24872800/propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701", size = 214723, upload-time = "2025-06-09T22:55:48.342Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e2/eecf6989870988dfd731de408a6fa366e853d361a06c2133b5878ce821ad/propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef", size = 200166, upload-time = "2025-06-09T22:55:49.775Z" }, - { url = "https://files.pythonhosted.org/packages/12/06/c32be4950967f18f77489268488c7cdc78cbfc65a8ba8101b15e526b83dc/propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1", size = 194004, upload-time = "2025-06-09T22:55:51.335Z" }, - { url = "https://files.pythonhosted.org/packages/46/6c/17b521a6b3b7cbe277a4064ff0aa9129dd8c89f425a5a9b6b4dd51cc3ff4/propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886", size = 203075, upload-time = "2025-06-09T22:55:52.681Z" }, - { url = "https://files.pythonhosted.org/packages/62/cb/3bdba2b736b3e45bc0e40f4370f745b3e711d439ffbffe3ae416393eece9/propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b", size = 195407, upload-time = "2025-06-09T22:55:54.048Z" }, - { url = "https://files.pythonhosted.org/packages/29/bd/760c5c6a60a4a2c55a421bc34a25ba3919d49dee411ddb9d1493bb51d46e/propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb", size = 196045, upload-time = "2025-06-09T22:55:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/76/58/ced2757a46f55b8c84358d6ab8de4faf57cba831c51e823654da7144b13a/propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea", size = 208432, upload-time = "2025-06-09T22:55:56.884Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ec/d98ea8d5a4d8fe0e372033f5254eddf3254344c0c5dc6c49ab84349e4733/propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb", size = 210100, upload-time = "2025-06-09T22:55:58.498Z" }, - { url = "https://files.pythonhosted.org/packages/56/84/b6d8a7ecf3f62d7dd09d9d10bbf89fad6837970ef868b35b5ffa0d24d9de/propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe", size = 200712, upload-time = "2025-06-09T22:55:59.906Z" }, - { url = "https://files.pythonhosted.org/packages/bf/32/889f4903ddfe4a9dc61da71ee58b763758cf2d608fe1decede06e6467f8d/propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1", size = 38187, upload-time = "2025-06-09T22:56:01.212Z" }, - { url = "https://files.pythonhosted.org/packages/67/74/d666795fb9ba1dc139d30de64f3b6fd1ff9c9d3d96ccfdb992cd715ce5d2/propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9", size = 42025, upload-time = "2025-06-09T22:56:02.875Z" }, { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] @@ -940,19 +805,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, - { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, - { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, - { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, - { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, @@ -971,15 +823,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, - { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, - { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, - { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, - { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, ] [[package]] @@ -991,6 +834,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyright" +version = "1.1.405" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -1016,13 +872,24 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -1074,15 +941,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, - { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, - { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, - { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, ] [[package]] @@ -1090,8 +948,7 @@ name = "rich" version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markdown-it-py" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } @@ -1161,7 +1018,6 @@ name = "typeguard" version = "4.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/68/71c1a15b5f65f40e91b65da23b8224dad41349894535a97f63a52e462196/typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74", size = 75203, upload-time = "2025-06-18T09:56:07.624Z" } @@ -1174,8 +1030,7 @@ name = "typer" version = "0.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click" }, { name = "rich" }, { name = "shellingham" }, { name = "typing-extensions" }, @@ -1320,31 +1175,5 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/01/75/0d37402d208d025afa6b5b8eb80e466d267d3fd1927db8e317d29a94a4cb/yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3", size = 134259, upload-time = "2025-06-10T00:45:29.882Z" }, - { url = "https://files.pythonhosted.org/packages/73/84/1fb6c85ae0cf9901046f07d0ac9eb162f7ce6d95db541130aa542ed377e6/yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b", size = 91269, upload-time = "2025-06-10T00:45:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9c/eae746b24c4ea29a5accba9a06c197a70fa38a49c7df244e0d3951108861/yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983", size = 89995, upload-time = "2025-06-10T00:45:35.066Z" }, - { url = "https://files.pythonhosted.org/packages/fb/30/693e71003ec4bc1daf2e4cf7c478c417d0985e0a8e8f00b2230d517876fc/yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805", size = 325253, upload-time = "2025-06-10T00:45:37.052Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a2/5264dbebf90763139aeb0b0b3154763239398400f754ae19a0518b654117/yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba", size = 320897, upload-time = "2025-06-10T00:45:39.962Z" }, - { url = "https://files.pythonhosted.org/packages/e7/17/77c7a89b3c05856489777e922f41db79ab4faf58621886df40d812c7facd/yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e", size = 340696, upload-time = "2025-06-10T00:45:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/6d/55/28409330b8ef5f2f681f5b478150496ec9cf3309b149dab7ec8ab5cfa3f0/yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723", size = 335064, upload-time = "2025-06-10T00:45:43.893Z" }, - { url = "https://files.pythonhosted.org/packages/85/58/cb0257cbd4002828ff735f44d3c5b6966c4fd1fc8cc1cd3cd8a143fbc513/yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000", size = 327256, upload-time = "2025-06-10T00:45:46.393Z" }, - { url = "https://files.pythonhosted.org/packages/53/f6/c77960370cfa46f6fb3d6a5a79a49d3abfdb9ef92556badc2dcd2748bc2a/yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5", size = 316389, upload-time = "2025-06-10T00:45:48.358Z" }, - { url = "https://files.pythonhosted.org/packages/64/ab/be0b10b8e029553c10905b6b00c64ecad3ebc8ace44b02293a62579343f6/yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c", size = 340481, upload-time = "2025-06-10T00:45:50.663Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c3/3f327bd3905a4916029bf5feb7f86dcf864c7704f099715f62155fb386b2/yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240", size = 336941, upload-time = "2025-06-10T00:45:52.554Z" }, - { url = "https://files.pythonhosted.org/packages/d1/42/040bdd5d3b3bb02b4a6ace4ed4075e02f85df964d6e6cb321795d2a6496a/yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee", size = 339936, upload-time = "2025-06-10T00:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1c/911867b8e8c7463b84dfdc275e0d99b04b66ad5132b503f184fe76be8ea4/yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010", size = 360163, upload-time = "2025-06-10T00:45:56.87Z" }, - { url = "https://files.pythonhosted.org/packages/e2/31/8c389f6c6ca0379b57b2da87f1f126c834777b4931c5ee8427dd65d0ff6b/yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8", size = 359108, upload-time = "2025-06-10T00:45:58.869Z" }, - { url = "https://files.pythonhosted.org/packages/7f/09/ae4a649fb3964324c70a3e2b61f45e566d9ffc0affd2b974cbf628957673/yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d", size = 351875, upload-time = "2025-06-10T00:46:01.45Z" }, - { url = "https://files.pythonhosted.org/packages/8d/43/bbb4ed4c34d5bb62b48bf957f68cd43f736f79059d4f85225ab1ef80f4b9/yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06", size = 82293, upload-time = "2025-06-10T00:46:03.763Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cd/ce185848a7dba68ea69e932674b5c1a42a1852123584bccc5443120f857c/yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00", size = 87385, upload-time = "2025-06-10T00:46:05.655Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -]