diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4d58eb..f3aa24c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: - name: Run linting run: | ruff check . - black --check . + ruff format --check . - name: Run type checking run: mypy openintent --ignore-missing-imports diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6c8d0..65b0811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to the OpenIntent SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.1] - 2026-02-27 + +### Fixed + +- **SDK/Server field format mismatches** — Fixed three category of mismatches between the Python SDK client and the protocol server's Pydantic models: + - **`constraints` type mismatch** — `create_intent()`, `create_child_intent()`, and `IntentSpec` sent `constraints` as a `list[str]` (e.g., `["rule1", "rule2"]`), but the server expects `Dict[str, Any]` (e.g., `{"rules": [...]}`). All three methods (sync and async) now accept and send `dict[str, Any]`. `Intent.from_dict()` retains backward compatibility for legacy list-format constraints. + - **`createdBy` → `created_by` for portfolios** — `create_portfolio()` sent `createdBy` and `governancePolicy` (camelCase) but the server's `PortfolioCreate` model expects `created_by` and `governance_policy` (snake_case). Fixed in both sync and async clients. + - **`get_portfolio_intents()` response parsing** — The server returns a raw JSON array from `GET /api/v1/portfolios/{id}/intents`, but the SDK expected a `{"intents": [...]}` wrapper dict, silently returning an empty list. + +- **Silent empty results from list endpoints** — Seven additional list-returning methods used `data.get("key", [])` which silently returned empty lists when the server sent raw JSON arrays. All now use `isinstance(data, list)` detection to handle both raw array and wrapped dict responses: + - `list_portfolios()` — expected `{"portfolios": [...]}` + - `get_intent_portfolios()` — expected `{"portfolios": [...]}` + - `get_attachments()` — expected `{"attachments": [...]}` + - `get_costs()` — expected `{"costs": [], "summary": {}}` + - `get_failures()` — expected `{"failures": [...]}` + - `get_subscriptions()` — expected `{"subscriptions": [...]}` + - `federation_list_agents()` — expected `{"agents": [...]}` + +- **`IntentLease.from_dict()` KeyError on server responses** — `acquire_lease()` threw `KeyError('status')` because the server's `LeaseResponse` model does not include a `status` field (it uses `acquired_at`, `expires_at`, and `released_at` to represent lease state). `IntentLease.from_dict()` now derives status from these fields: `RELEASED` if `released_at` is set, `EXPIRED` if `expires_at` is in the past, otherwise `ACTIVE`. Also handles the field name difference `acquired_at` (server) vs `created_at` (SDK). Backward compatible with the SDK's own serialization format. + +- **Stale database singleton after server restart** — `get_database()` cached the `Database` instance at module level and never checked whether `database_url` changed between calls. When the protocol server restarted on a different port (e.g., `openintent_server_8001.db` → `openintent_server_8002.db`), the singleton kept pointing at the old file. Writes went to the old database; reads came from the new (empty) one — intents appeared created but were invisible to `list_intents`. The singleton now tracks its URL and recreates the connection when the URL changes. + +- **Example and test updates** — All examples (`basic_usage.py`, `openai_multi_agent.py`, `multi_agent/coordinator.py`, `compliance_review/coordinator.py`) and tests updated to use dict-format constraints. + +### Changed + +- All version references updated to 0.14.1 across Python SDK, MCP server package, and changelog. + +--- + ## [0.14.0] - 2026-02-25 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef82b72..fbf22a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,14 +25,21 @@ Thank you for your interest in contributing to the OpenIntent Python SDK! This d We use the following tools for code quality: -- **Black** for code formatting -- **Ruff** for linting +- **Ruff** for linting and code formatting - **mypy** for type checking +- **pre-commit** for automatic formatting on every commit + +Set up pre-commit hooks (runs automatically with `make install-dev`): + +```bash +pip install pre-commit +pre-commit install +``` Before submitting a PR, run: ```bash -black openintent/ +ruff format openintent/ ruff check openintent/ --fix mypy openintent/ ``` diff --git a/Makefile b/Makefile index b4df10c..1aa6943 100644 --- a/Makefile +++ b/Makefile @@ -8,89 +8,90 @@ PORT ?= 8000 MCP_ROLE ?= reader help: ## Show this help message - @echo "OpenIntent SDK — available targets:" - @echo "" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ - awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' - @echo "" - @echo "Variables (override with VAR=value):" - @echo " PORT Server port (default: $(PORT))" - @echo " MCP_ROLE MCP server role (default: $(MCP_ROLE))" - @echo " PYTHON Python binary (default: $(PYTHON))" + @echo "OpenIntent SDK — available targets:" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "Variables (override with VAR=value):" + @echo " PORT Server port (default: $(PORT))" + @echo " MCP_ROLE MCP server role (default: $(MCP_ROLE))" + @echo " PYTHON Python binary (default: $(PYTHON))" install: ## Install SDK with server + all adapters - $(PIP) install -e ".[server,all-adapters]" + $(PIP) install -e ".[server,all-adapters]" install-dev: ## Install SDK with dev + server + all extras - $(PIP) install -e ".[dev,server,all-adapters]" + $(PIP) install -e ".[dev,server,all-adapters]" + @if command -v pre-commit >/dev/null 2>&1; then pre-commit install; fi install-all: ## Install everything including MCP dependencies - $(PIP) install -e ".[dev,server,all-adapters]" - $(PIP) install mcp - npm install -g @openintentai/mcp-server + $(PIP) install -e ".[dev,server,all-adapters]" + $(PIP) install mcp + npm install -g @openintentai/mcp-server server: ## Start the OpenIntent server - openintent-server --port $(PORT) + openintent-server --port $(PORT) test: ## Run the full test suite - $(PYTHON) -m pytest tests/ -v + $(PYTHON) -m pytest tests/ -v test-quick: ## Run tests without slow markers - $(PYTHON) -m pytest tests/ -v -m "not slow" + $(PYTHON) -m pytest tests/ -v -m "not slow" test-mcp: ## Run MCP-related tests only - $(PYTHON) -m pytest tests/ -v -k "mcp" + $(PYTHON) -m pytest tests/ -v -k "mcp" lint: ## Run linter + formatter check + type checker - ruff check openintent/ - black --check openintent/ - mypy openintent/ + ruff check openintent/ + ruff format --check openintent/ + mypy openintent/ format: ## Auto-format code - black openintent/ tests/ - ruff check --fix openintent/ + ruff format openintent/ tests/ examples/ + ruff check --fix openintent/ typecheck: ## Run type checker only - mypy openintent/ + mypy openintent/ setup-mcp: ## Install MCP dependencies (Python + Node) - @echo "Installing Python MCP SDK..." - $(PIP) install mcp - @echo "Installing OpenIntent MCP server (Node)..." - npm install -g @openintentai/mcp-server - @echo "" - @echo "MCP setup complete. Next steps:" - @echo " make server — Start the OpenIntent server" - @echo " make mcp-server — Start the MCP server (in another terminal)" - @echo " make full-stack — Start both together" + @echo "Installing Python MCP SDK..." + $(PIP) install mcp + @echo "Installing OpenIntent MCP server (Node)..." + npm install -g @openintentai/mcp-server + @echo "" + @echo "MCP setup complete. Next steps:" + @echo " make server — Start the OpenIntent server" + @echo " make mcp-server — Start the MCP server (in another terminal)" + @echo " make full-stack — Start both together" mcp-server: ## Start the MCP server (connects to local OpenIntent server) - OPENINTENT_SERVER_URL=http://localhost:$(PORT) \ - OPENINTENT_API_KEY=dev-user-key \ - OPENINTENT_MCP_ROLE=$(MCP_ROLE) \ - $(NPX) -y @openintentai/mcp-server + OPENINTENT_SERVER_URL=http://localhost:$(PORT) \ + OPENINTENT_API_KEY=dev-user-key \ + OPENINTENT_MCP_ROLE=$(MCP_ROLE) \ + $(NPX) -y @openintentai/mcp-server full-stack: ## Start OpenIntent server + MCP server together - @echo "Starting OpenIntent server on port $(PORT)..." - @openintent-server --port $(PORT) & - @sleep 2 - @echo "Starting MCP server (role: $(MCP_ROLE))..." - @OPENINTENT_SERVER_URL=http://localhost:$(PORT) \ - OPENINTENT_API_KEY=dev-user-key \ - OPENINTENT_MCP_ROLE=$(MCP_ROLE) \ - $(NPX) -y @openintentai/mcp-server + @echo "Starting OpenIntent server on port $(PORT)..." + @openintent-server --port $(PORT) & + @sleep 2 + @echo "Starting MCP server (role: $(MCP_ROLE))..." + @OPENINTENT_SERVER_URL=http://localhost:$(PORT) \ + OPENINTENT_API_KEY=dev-user-key \ + OPENINTENT_MCP_ROLE=$(MCP_ROLE) \ + $(NPX) -y @openintentai/mcp-server check: ## Verify installation and connectivity - @echo "Checking Python SDK..." - @$(PYTHON) -c "import openintent; print(f' openintent {openintent.__version__}')" 2>/dev/null || echo " openintent: NOT INSTALLED" - @echo "Checking MCP SDK..." - @$(PYTHON) -c "import mcp; print(' mcp: OK')" 2>/dev/null || echo " mcp: NOT INSTALLED (run: make setup-mcp)" - @echo "Checking MCP server (Node)..." - @$(NPX) -y @openintentai/mcp-server --version 2>/dev/null && echo " mcp-server: OK" || echo " mcp-server: NOT INSTALLED (run: make setup-mcp)" - @echo "Checking OpenIntent server..." - @curl -sf http://localhost:$(PORT)/api/v1/intents > /dev/null 2>&1 && echo " server: RUNNING on port $(PORT)" || echo " server: NOT RUNNING (run: make server)" + @echo "Checking Python SDK..." + @$(PYTHON) -c "import openintent; print(f' openintent {openintent.__version__}')" 2>/dev/null || echo " openintent: NOT INSTALLED" + @echo "Checking MCP SDK..." + @$(PYTHON) -c "import mcp; print(' mcp: OK')" 2>/dev/null || echo " mcp: NOT INSTALLED (run: make setup-mcp)" + @echo "Checking MCP server (Node)..." + @$(NPX) -y @openintentai/mcp-server --version 2>/dev/null && echo " mcp-server: OK" || echo " mcp-server: NOT INSTALLED (run: make setup-mcp)" + @echo "Checking OpenIntent server..." + @curl -sf http://localhost:$(PORT)/api/v1/intents > /dev/null 2>&1 && echo " server: RUNNING on port $(PORT)" || echo " server: NOT RUNNING (run: make server)" clean: ## Remove build artifacts and caches - rm -rf build/ dist/ *.egg-info .pytest_cache .mypy_cache .ruff_cache - find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete 2>/dev/null || true + rm -rf build/ dist/ *.egg-info .pytest_cache .mypy_cache .ruff_cache + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true diff --git a/README.md b/README.md index 2a40bce..72e189e 100644 --- a/README.md +++ b/README.md @@ -714,7 +714,7 @@ pip install -e ".[dev,server]" pytest # Run tests ruff check openintent/ # Lint -black openintent/ # Format +ruff format openintent/ # Format mypy openintent/ # Type check openintent-server # Start dev server ``` diff --git a/docs/changelog.md b/docs/changelog.md index c3367f6..f28770c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,36 @@ All notable changes to the OpenIntent SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.1] - 2026-02-27 + +### Fixed + +- **SDK/Server field format mismatches** — Fixed three category of mismatches between the Python SDK client and the protocol server's Pydantic models: + - **`constraints` type mismatch** — `create_intent()`, `create_child_intent()`, and `IntentSpec` sent `constraints` as a `list[str]` (e.g., `["rule1", "rule2"]`), but the server expects `Dict[str, Any]` (e.g., `{"rules": [...]}`). All three methods (sync and async) now accept and send `dict[str, Any]`. `Intent.from_dict()` retains backward compatibility for legacy list-format constraints. + - **`createdBy` → `created_by` for portfolios** — `create_portfolio()` sent `createdBy` and `governancePolicy` (camelCase) but the server's `PortfolioCreate` model expects `created_by` and `governance_policy` (snake_case). Fixed in both sync and async clients. + - **`get_portfolio_intents()` response parsing** — The server returns a raw JSON array from `GET /api/v1/portfolios/{id}/intents`, but the SDK expected a `{"intents": [...]}` wrapper dict, silently returning an empty list. + +- **Silent empty results from list endpoints** — Seven additional list-returning methods used `data.get("key", [])` which silently returned empty lists when the server sent raw JSON arrays. All now use `isinstance(data, list)` detection to handle both raw array and wrapped dict responses: + - `list_portfolios()` — expected `{"portfolios": [...]}` + - `get_intent_portfolios()` — expected `{"portfolios": [...]}` + - `get_attachments()` — expected `{"attachments": [...]}` + - `get_costs()` — expected `{"costs": [], "summary": {}}` + - `get_failures()` — expected `{"failures": [...]}` + - `get_subscriptions()` — expected `{"subscriptions": [...]}` + - `federation_list_agents()` — expected `{"agents": [...]}` + +- **`IntentLease.from_dict()` KeyError on server responses** — `acquire_lease()` threw `KeyError('status')` because the server's `LeaseResponse` model does not include a `status` field (it uses `acquired_at`, `expires_at`, and `released_at` to represent lease state). `IntentLease.from_dict()` now derives status from these fields: `RELEASED` if `released_at` is set, `EXPIRED` if `expires_at` is in the past, otherwise `ACTIVE`. Also handles the field name difference `acquired_at` (server) vs `created_at` (SDK). Backward compatible with the SDK's own serialization format. + +- **Stale database singleton after server restart** — `get_database()` cached the `Database` instance at module level and never checked whether `database_url` changed between calls. When the protocol server restarted on a different port (e.g., `openintent_server_8001.db` → `openintent_server_8002.db`), the singleton kept pointing at the old file. Writes went to the old database; reads came from the new (empty) one — intents appeared created but were invisible to `list_intents`. The singleton now tracks its URL and recreates the connection when the URL changes. + +- **Example and test updates** — All examples (`basic_usage.py`, `openai_multi_agent.py`, `multi_agent/coordinator.py`, `compliance_review/coordinator.py`) and tests updated to use dict-format constraints. + +### Changed + +- All version references updated to 0.14.1 across Python SDK, MCP server package, and changelog. + +--- + ## [0.14.0] - 2026-02-25 ### Added diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 6554362..2d19be5 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -45,10 +45,12 @@ def main(): intent = client.create_intent( title="Example Research Task", description="Demonstrate OpenIntent SDK capabilities", - constraints=[ - "Must complete within reasonable time", - "Log all significant activities", - ], + constraints={ + "rules": [ + "Must complete within reasonable time", + "Log all significant activities", + ] + }, initial_state={ "phase": "initialization", "progress": 0.0, diff --git a/examples/compliance_review/coordinator.py b/examples/compliance_review/coordinator.py index fe84f20..d1d0d9b 100644 --- a/examples/compliance_review/coordinator.py +++ b/examples/compliance_review/coordinator.py @@ -85,7 +85,7 @@ async def plan( title="Document Extraction", description=f"Extract text and structure from: {document_name}", assign="ocr-agent", - constraints=["max_retries:3", "backoff:exponential"], + constraints={"rules": ["max_retries:3", "backoff:exponential"]}, initial_state={ "phase": "extraction", "retry_policy": { @@ -101,7 +101,7 @@ async def plan( description="Analyze document clauses for compliance issues", assign="analyzer-agent", depends_on=["Document Extraction"], - constraints=["lease_per_section:true"], + constraints={"rules": ["lease_per_section:true"]}, initial_state={ "phase": "analysis", "leasing_enabled": True, @@ -113,7 +113,7 @@ async def plan( description="Assess overall document risk and compliance score", assign="risk-agent", depends_on=["Clause Analysis"], - constraints=["track_costs:true", "budget_usd:1.00"], + constraints={"rules": ["track_costs:true", "budget_usd:1.00"]}, initial_state={ "phase": "risk", "cost_tracking": True, @@ -125,7 +125,7 @@ async def plan( description="Generate comprehensive compliance report", assign="report-agent", depends_on=["Risk Assessment"], - constraints=["output_formats:json,markdown"], + constraints={"rules": ["output_formats:json,markdown"]}, initial_state={ "phase": "report", "output_formats": ["json", "markdown"], diff --git a/examples/multi_agent/coordinator.py b/examples/multi_agent/coordinator.py index 75f19b7..1215c2f 100644 --- a/examples/multi_agent/coordinator.py +++ b/examples/multi_agent/coordinator.py @@ -60,7 +60,9 @@ async def plan(self, topic: str) -> PortfolioSpec: title="Research Phase", description=f"Research the topic: {topic}", assign="research-agent", - constraints=["max_cost_usd:0.50", "required_confidence:0.75"], + constraints={ + "rules": ["max_cost_usd:0.50", "required_confidence:0.75"] + }, initial_state={"phase": "research"}, ), # Phase 2: Writing (depends on Research) @@ -69,7 +71,7 @@ async def plan(self, topic: str) -> PortfolioSpec: description=f"Write a blog post about: {topic}", assign="writing-agent", depends_on=["Research Phase"], # Waits for research - constraints=["max_cost_usd:1.00", "style:engaging"], + constraints={"rules": ["max_cost_usd:1.00", "style:engaging"]}, initial_state={"phase": "writing"}, ), ], diff --git a/examples/openai_multi_agent.py b/examples/openai_multi_agent.py index 3294e08..23d5467 100644 --- a/examples/openai_multi_agent.py +++ b/examples/openai_multi_agent.py @@ -371,11 +371,13 @@ async def create_research_intent(self, topic: str) -> str: intent = await self.client.create_intent( title=f"Research: {topic}", description=f"Conduct research and synthesize findings on: {topic}", - constraints=[ - "Research must be completed before synthesis", - "Only one agent may work on each scope at a time", - "All activities must be logged to the event stream", - ], + constraints={ + "rules": [ + "Research must be completed before synthesis", + "Only one agent may work on each scope at a time", + "All activities must be logged to the event stream", + ] + }, initial_state={ "topic": topic, "research_status": "pending", diff --git a/mcp-server/NPM_SETUP.md b/mcp-server/NPM_SETUP.md new file mode 100644 index 0000000..9cb3b61 --- /dev/null +++ b/mcp-server/NPM_SETUP.md @@ -0,0 +1,89 @@ +# npm Publishing Setup for @openintentai/mcp-server + +This document describes the one-time setup steps needed to publish `@openintentai/mcp-server` to npm. + +## 1. Create the npm Organization + +The package uses the `@openintentai` scope, which requires an npm organization. + +1. Go to [npmjs.com/signup](https://www.npmjs.com/signup) (or log in) +2. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create) +3. Create an organization named `openintent` +4. Choose the **free** plan (public packages only) + +If someone else owns the `openintent` scope, you'll need to either: +- Contact them to be added as a member +- Use a different scope (e.g., `@openintent-ai/mcp-server`) and update `package.json` accordingly + +## 2. Generate an npm Access Token + +1. Go to [npmjs.com/settings/tokens](https://www.npmjs.com/settings/~/tokens) +2. Click **Generate New Token** +3. Select **Automation** type (for CI use) +4. Copy the token (starts with `npm_`) + +## 3. Add the Token to GitHub + +1. Go to your GitHub repo: `github.com/openintent-ai/openintent` +2. Navigate to **Settings > Secrets and variables > Actions** +3. Click **New repository secret** +4. Name: `NPM_TOKEN` +5. Value: paste the npm token from step 2 + +## 4. Create the GitHub Environment + +The publish workflow uses a `npm` environment for protection. + +1. Go to **Settings > Environments** in your GitHub repo +2. Click **New environment** +3. Name: `npm` +4. Optionally add protection rules: + - **Required reviewers**: Add yourself so publishes require manual approval + - **Deployment branches**: Restrict to `main` only + +## 5. Publish + +Publishing happens automatically when you create a GitHub Release: + +1. Go to **Releases > Draft a new release** +2. Create a tag (e.g., `v0.13.1`) +3. Publish the release + +The `publish.yml` workflow triggers on release and: +- Publishes the Python SDK to PyPI (existing behavior) +- Builds and publishes the MCP server to npm (new) + +Both happen in parallel. If one fails, the other still proceeds. + +## Manual First Publish + +For the very first publish, you may want to do it manually to verify everything works: + +```bash +cd reference-implementation/mcp-server +npm install +npm run build +npm login # log in with your npm account +npm publish --access public +``` + +## Version Sync + +The MCP server version should be kept in sync with the Python SDK version. When bumping versions, update: + +1. `reference-implementation/pyproject.toml` (Python SDK) +2. `reference-implementation/openintent/__init__.py` (Python SDK) +3. `reference-implementation/mcp-server/package.json` (MCP server) +4. `reference-implementation/mcp-server/src/index.ts` (hardcoded in Server constructor) + +## Verifying Publication + +After publishing, verify: + +```bash +# Check npm +npm info @openintentai/mcp-server + +# Test npx +npx -y @openintentai/mcp-server --help +``` diff --git a/mcp-server/package.json b/mcp-server/package.json index 32f87a4..d9dfd39 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@openintentai/mcp-server", - "version": "0.14.0", + "version": "0.14.1", "description": "MCP server exposing the OpenIntent Coordination Protocol as MCP tools and resources", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 069135b..15c1fcd 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -30,7 +30,7 @@ async function main() { const server = new Server( { name: "openintent-mcp", - version: "0.14.0", + version: "0.14.1", }, { capabilities: { diff --git a/openintent/__init__.py b/openintent/__init__.py index 26e4df1..a03cb2b 100644 --- a/openintent/__init__.py +++ b/openintent/__init__.py @@ -233,7 +233,7 @@ def get_server() -> tuple[Any, Any, Any]: ) -__version__ = "0.14.0" +__version__ = "0.14.1" __all__ = [ "OpenIntentClient", "AsyncOpenIntentClient", diff --git a/openintent/adapters/gemini_adapter.py b/openintent/adapters/gemini_adapter.py index 733394c..c5ba61e 100644 --- a/openintent/adapters/gemini_adapter.py +++ b/openintent/adapters/gemini_adapter.py @@ -127,7 +127,9 @@ def generate_content( messages_count = ( 1 if isinstance(contents, str) - else len(contents) if isinstance(contents, list) else 1 + else len(contents) + if isinstance(contents, list) + else 1 ) if self._config.log_requests: diff --git a/openintent/agents.py b/openintent/agents.py index 2bcd7bd..183d367 100644 --- a/openintent/agents.py +++ b/openintent/agents.py @@ -634,7 +634,7 @@ class IntentSpec: description: str = "" assign: Optional[str] = None depends_on: Optional[list[str]] = None - constraints: list[str] = field(default_factory=list) + constraints: dict[str, Any] = field(default_factory=dict) initial_state: dict[str, Any] = field(default_factory=dict) def __post_init__(self): diff --git a/openintent/client.py b/openintent/client.py index 4bf05db..02aa18b 100644 --- a/openintent/client.py +++ b/openintent/client.py @@ -206,7 +206,7 @@ def create_intent( self, title: str, description: str = "", - constraints: Optional[list[str]] = None, + constraints: Optional[dict[str, Any]] = None, initial_state: Optional[dict[str, Any]] = None, parent_intent_id: Optional[str] = None, depends_on: Optional[list[str]] = None, @@ -218,7 +218,7 @@ def create_intent( Args: title: Human-readable title for the intent. description: Detailed description of the goal. - constraints: Optional list of constraints. + constraints: Optional constraints dictionary (e.g. {"rules": [...]}). initial_state: Optional initial state data. parent_intent_id: Optional parent intent ID for hierarchical graphs (RFC-0002). depends_on: Optional list of intent IDs this depends on (RFC-0002). @@ -232,7 +232,7 @@ def create_intent( payload: dict[str, Any] = { "title": title, "description": description, - "constraints": constraints or [], + "constraints": constraints or {}, "state": initial_state or {}, "created_by": self.agent_id, } @@ -292,7 +292,7 @@ def create_child_intent( parent_id: str, title: str, description: str = "", - constraints: Optional[list[str]] = None, + constraints: Optional[dict[str, Any]] = None, initial_state: Optional[dict[str, Any]] = None, depends_on: Optional[list[str]] = None, ) -> Intent: @@ -303,7 +303,7 @@ def create_child_intent( parent_id: The parent intent ID. title: Human-readable title for the child intent. description: Detailed description of the goal. - constraints: Optional list of constraints. + constraints: Optional constraints dictionary (e.g. {"rules": [...]}). initial_state: Optional initial state data. depends_on: Optional list of intent IDs this depends on. @@ -313,7 +313,7 @@ def create_child_intent( payload = { "title": title, "description": description, - "constraints": constraints or [], + "constraints": constraints or {}, "state": initial_state or {}, "parent_intent_id": parent_id, "depends_on": depends_on or [], @@ -1094,8 +1094,8 @@ def create_portfolio( json={ "name": name, "description": description, - "createdBy": self.agent_id, - "governancePolicy": governance_policy or {}, + "created_by": self.agent_id, + "governance_policy": governance_policy or {}, "metadata": metadata or {}, }, ) @@ -1133,7 +1133,8 @@ def list_portfolios( params["created_by"] = created_by response = self._client.get("/api/v1/portfolios", params=params) data = self._handle_response(response) - return [IntentPortfolio.from_dict(p) for p in data.get("portfolios", [])] + items = data if isinstance(data, list) else data.get("portfolios", []) + return [IntentPortfolio.from_dict(p) for p in items] def update_portfolio_status( self, portfolio_id: str, status: PortfolioStatus @@ -1213,8 +1214,12 @@ def get_portfolio_intents( """ response = self._client.get(f"/api/v1/portfolios/{portfolio_id}/intents") data = self._handle_response(response) - intents = [Intent.from_dict(i) for i in data.get("intents", [])] - agg = AggregateStatus.from_dict(data.get("aggregate_status", {})) + if isinstance(data, list): + intents = [Intent.from_dict(i) for i in data] + agg = AggregateStatus.from_dict({}) + else: + intents = [Intent.from_dict(i) for i in data.get("intents", [])] + agg = AggregateStatus.from_dict(data.get("aggregate_status", {})) return intents, agg def get_intent_portfolios(self, intent_id: str) -> list[IntentPortfolio]: @@ -1229,7 +1234,8 @@ def get_intent_portfolios(self, intent_id: str) -> list[IntentPortfolio]: """ response = self._client.get(f"/api/v1/intents/{intent_id}/portfolios") data = self._handle_response(response) - return [IntentPortfolio.from_dict(p) for p in data.get("portfolios", [])] + items = data if isinstance(data, list) else data.get("portfolios", []) + return [IntentPortfolio.from_dict(p) for p in items] # RFC-0005: Attachments def add_attachment( @@ -1280,7 +1286,8 @@ def get_attachments(self, intent_id: str) -> list[IntentAttachment]: """ response = self._client.get(f"/api/v1/intents/{intent_id}/attachments") data = self._handle_response(response) - return [IntentAttachment.from_dict(a) for a in data.get("attachments", [])] + items = data if isinstance(data, list) else data.get("attachments", []) + return [IntentAttachment.from_dict(a) for a in items] def delete_attachment(self, intent_id: str, attachment_id: str) -> None: """ @@ -1346,8 +1353,12 @@ def get_costs(self, intent_id: str) -> tuple[list[IntentCost], CostSummary]: """ response = self._client.get(f"/api/v1/intents/{intent_id}/costs") data = self._handle_response(response) - costs = [IntentCost.from_dict(c) for c in data.get("costs", [])] - summary = CostSummary.from_dict(data.get("summary", {})) + if isinstance(data, list): + costs = [IntentCost.from_dict(c) for c in data] + summary = CostSummary.from_dict({}) + else: + costs = [IntentCost.from_dict(c) for c in data.get("costs", [])] + summary = CostSummary.from_dict(data.get("summary", {})) return costs, summary # RFC-0010: Retry Policies @@ -1457,7 +1468,8 @@ def get_failures(self, intent_id: str) -> list[IntentFailure]: """ response = self._client.get(f"/api/v1/intents/{intent_id}/failures") data = self._handle_response(response) - return [IntentFailure.from_dict(f) for f in data.get("failures", [])] + items = data if isinstance(data, list) else data.get("failures", []) + return [IntentFailure.from_dict(f) for f in items] # RFC-0006: Subscriptions def subscribe( @@ -1515,7 +1527,8 @@ def get_subscriptions( params["portfolio_id"] = portfolio_id response = self._client.get("/api/v1/subscriptions", params=params) data = self._handle_response(response) - return [IntentSubscription.from_dict(s) for s in data.get("subscriptions", [])] + items = data if isinstance(data, list) else data.get("subscriptions", []) + return [IntentSubscription.from_dict(s) for s in items] def unsubscribe(self, subscription_id: str) -> None: """ @@ -3005,7 +3018,9 @@ def list_federated_agents( headers=headers, ) data = self._handle_response(response) - agents: list[dict[str, Any]] = data.get("agents", []) + agents: list[dict[str, Any]] = ( + data if isinstance(data, list) else data.get("agents", []) + ) return agents def federation_dispatch( @@ -3199,7 +3214,7 @@ async def create_intent( self, title: str, description: str, - constraints: Optional[list[str]] = None, + constraints: Optional[dict[str, Any]] = None, initial_state: Optional[dict[str, Any]] = None, governance_policy: Optional[dict[str, Any]] = None, ) -> Intent: @@ -3207,7 +3222,7 @@ async def create_intent( payload = { "title": title, "description": description, - "constraints": constraints or [], + "constraints": constraints or {}, "state": initial_state or {}, "created_by": self.agent_id, } @@ -3580,8 +3595,8 @@ async def create_portfolio( json={ "name": name, "description": description, - "createdBy": self.agent_id, - "governancePolicy": governance_policy or {}, + "created_by": self.agent_id, + "governance_policy": governance_policy or {}, "metadata": metadata or {}, }, ) @@ -3603,7 +3618,8 @@ async def list_portfolios( params["created_by"] = created_by response = await self._client.get("/api/v1/portfolios", params=params) data = self._handle_response(response) - return [IntentPortfolio.from_dict(p) for p in data.get("portfolios", [])] + items = data if isinstance(data, list) else data.get("portfolios", []) + return [IntentPortfolio.from_dict(p) for p in items] async def update_portfolio_status( self, portfolio_id: str, status: PortfolioStatus @@ -3651,15 +3667,20 @@ async def get_portfolio_intents( """Get all intents in a portfolio with aggregate status.""" response = await self._client.get(f"/api/v1/portfolios/{portfolio_id}/intents") data = self._handle_response(response) - intents = [Intent.from_dict(i) for i in data.get("intents", [])] - agg = AggregateStatus.from_dict(data.get("aggregate_status", {})) + if isinstance(data, list): + intents = [Intent.from_dict(i) for i in data] + agg = AggregateStatus.from_dict({}) + else: + intents = [Intent.from_dict(i) for i in data.get("intents", [])] + agg = AggregateStatus.from_dict(data.get("aggregate_status", {})) return intents, agg async def get_intent_portfolios(self, intent_id: str) -> list[IntentPortfolio]: """Get all portfolios containing an intent.""" response = await self._client.get(f"/api/v1/intents/{intent_id}/portfolios") data = self._handle_response(response) - return [IntentPortfolio.from_dict(p) for p in data.get("portfolios", [])] + items = data if isinstance(data, list) else data.get("portfolios", []) + return [IntentPortfolio.from_dict(p) for p in items] # ==================== Attachments ==================== @@ -3688,7 +3709,8 @@ async def get_attachments(self, intent_id: str) -> list[IntentAttachment]: """Get all attachments for an intent.""" response = await self._client.get(f"/api/v1/intents/{intent_id}/attachments") data = self._handle_response(response) - return [IntentAttachment.from_dict(a) for a in data.get("attachments", [])] + items = data if isinstance(data, list) else data.get("attachments", []) + return [IntentAttachment.from_dict(a) for a in items] async def delete_attachment(self, intent_id: str, attachment_id: str) -> None: """Delete an attachment.""" @@ -3727,8 +3749,12 @@ async def get_costs(self, intent_id: str) -> tuple[list[IntentCost], CostSummary """Get all costs for an intent with summary.""" response = await self._client.get(f"/api/v1/intents/{intent_id}/costs") data = self._handle_response(response) - costs = [IntentCost.from_dict(c) for c in data.get("costs", [])] - summary = CostSummary.from_dict(data.get("summary", {})) + if isinstance(data, list): + costs = [IntentCost.from_dict(c) for c in data] + summary = CostSummary.from_dict({}) + else: + costs = [IntentCost.from_dict(c) for c in data.get("costs", [])] + summary = CostSummary.from_dict(data.get("summary", {})) return costs, summary # ==================== Retry Policies ==================== @@ -3789,7 +3815,8 @@ async def get_failures(self, intent_id: str) -> list[IntentFailure]: """Get all failures for an intent.""" response = await self._client.get(f"/api/v1/intents/{intent_id}/failures") data = self._handle_response(response) - return [IntentFailure.from_dict(f) for f in data.get("failures", [])] + items = data if isinstance(data, list) else data.get("failures", []) + return [IntentFailure.from_dict(f) for f in items] # ==================== Subscriptions ==================== @@ -3832,7 +3859,8 @@ async def get_subscriptions( response = await self._client.get("/api/v1/subscriptions", params=params) data = self._handle_response(response) - return [IntentSubscription.from_dict(s) for s in data.get("subscriptions", [])] + items = data if isinstance(data, list) else data.get("subscriptions", []) + return [IntentSubscription.from_dict(s) for s in items] async def unsubscribe(self, subscription_id: str) -> None: """Unsubscribe from events.""" @@ -5073,7 +5101,9 @@ async def list_federated_agents( headers=headers, ) data = self._handle_response(response) - agents: list[dict[str, Any]] = data.get("agents", []) + agents: list[dict[str, Any]] = ( + data if isinstance(data, list) else data.get("agents", []) + ) return agents async def federation_dispatch( diff --git a/openintent/demo_agents.py b/openintent/demo_agents.py index e6bd648..8636811 100644 --- a/openintent/demo_agents.py +++ b/openintent/demo_agents.py @@ -148,7 +148,14 @@ class ResearcherAgent(DemoAgent): async def handle_intent(self, intent: Any) -> dict[str, Any]: topic = intent.description or intent.title - constraints = "\n".join(intent.constraints) if intent.constraints else "" + raw_constraints = intent.constraints or {} + if isinstance(raw_constraints, dict): + rules = raw_constraints.get("rules", []) + constraints = "\n".join(str(r) for r in rules) if rules else "" + else: + constraints = ( + "\n".join(str(c) for c in raw_constraints) if raw_constraints else "" + ) prompt = f"Research the following topic:\n\n{topic}" if constraints: diff --git a/openintent/models.py b/openintent/models.py index fc04bdd..36f8d27 100644 --- a/openintent/models.py +++ b/openintent/models.py @@ -923,14 +923,38 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> "IntentLease": + if "status" in data: + status = LeaseStatus(data["status"]) + elif data.get("released_at"): + status = LeaseStatus.RELEASED + else: + expires_str = data.get("expires_at", "") + if expires_str: + try: + expires = datetime.fromisoformat(expires_str) + status = ( + LeaseStatus.ACTIVE + if datetime.now(expires.tzinfo) < expires + else LeaseStatus.EXPIRED + ) + except (ValueError, TypeError): + status = LeaseStatus.ACTIVE + else: + status = LeaseStatus.ACTIVE + + created_at_str = data.get("created_at") or data.get("acquired_at") + created_at = ( + datetime.fromisoformat(created_at_str) if created_at_str else datetime.now() + ) + return cls( id=data["id"], intent_id=data["intent_id"], agent_id=data["agent_id"], scope=data["scope"], - status=LeaseStatus(data["status"]), + status=status, expires_at=datetime.fromisoformat(data["expires_at"]), - created_at=datetime.fromisoformat(data["created_at"]), + created_at=created_at, ) diff --git a/openintent/server/database.py b/openintent/server/database.py index f88feb6..c943552 100644 --- a/openintent/server/database.py +++ b/openintent/server/database.py @@ -1848,12 +1848,23 @@ def update_governance_policy( _database: Optional[Database] = None +_database_url: Optional[str] = None def get_database(database_url: str = "sqlite:///./openintent.db") -> Database: - """Get or create the database instance.""" - global _database + """Get or create the database instance. + + If called with a different database_url than the existing singleton, + the old instance is discarded and a new one is created. This prevents + stale connections when the server restarts on a different port and the + database path changes (e.g. openintent_server_8001.db -> 8002.db). + """ + global _database, _database_url + if _database is not None and _database_url != database_url: + _database = None + _database_url = None if _database is None: _database = Database(database_url) _database.create_tables() + _database_url = database_url return _database diff --git a/pyproject.toml b/pyproject.toml index a381d8b..95472e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openintent" -version = "0.14.0" +version = "0.14.1" description = "Python SDK and Server for the OpenIntent Coordination Protocol" readme = "README.md" license = {text = "MIT"} @@ -53,16 +53,15 @@ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "pytest-cov>=4.0.0", - "black>=23.0.0", "ruff>=0.1.0", "mypy>=1.0.0", + "pre-commit>=3.0.0", ] docs = [ "mkdocs>=1.5.0", "mkdocs-material>=9.5.0", "mkdocstrings[python]>=0.24.0", "pymdown-extensions>=10.0.0", - "black>=23.0.0", ] # LLM Provider Adapters (install only what you need) openai = [ @@ -104,10 +103,6 @@ Changelog = "https://github.com/openintent-ai/openintent/blob/main/CHANGELOG.md" where = ["."] include = ["openintent*"] -[tool.black] -line-length = 88 -target-version = ["py310", "py311", "py312"] - [tool.ruff] line-length = 88 diff --git a/tests/test_agents.py b/tests/test_agents.py index 6a170d3..baafda5 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -30,7 +30,7 @@ def test_basic_creation(self): assert spec.description == "" assert spec.assign is None assert spec.depends_on == [] - assert spec.constraints == [] + assert spec.constraints == {} assert spec.initial_state == {} def test_with_dependencies(self): diff --git a/tests/test_federation.py b/tests/test_federation.py new file mode 100644 index 0000000..b4b6553 --- /dev/null +++ b/tests/test_federation.py @@ -0,0 +1,1005 @@ +""" +Comprehensive tests for the OpenIntent Federation module (RFC-0022 & RFC-0023). + +Covers: models serialization, security (sign/verify, UCAN, SSRF, trust), +server endpoints, client methods, decorators, and integration flows. +""" + +import uuid + +import pytest + +from openintent.federation.decorators import ( + Federation, + on_budget_warning, + on_federation_callback, + on_federation_received, +) +from openintent.federation.models import ( + AgentVisibility, + CallbackEventType, + DelegationScope, + DispatchResult, + DispatchStatus, + FederatedAgent, + FederationAttestation, + FederationCallback, + FederationEnvelope, + FederationManifest, + FederationPolicy, + FederationStatus, + PeerInfo, + PeerRelationship, + ReceiveResult, + TrustPolicy, +) +from openintent.federation.security import ( + MessageSignature, + ServerIdentity, + TrustEnforcer, + UCANToken, + _canonical_bytes, + resolve_did_web, + sign_envelope, + validate_ssrf, +) +from openintent.models import EventType + + +class TestAgentVisibility: + def test_values(self): + assert AgentVisibility.PUBLIC == "public" + assert AgentVisibility.UNLISTED == "unlisted" + assert AgentVisibility.PRIVATE == "private" + + def test_from_string(self): + assert AgentVisibility("public") == AgentVisibility.PUBLIC + assert AgentVisibility("unlisted") == AgentVisibility.UNLISTED + + +class TestPeerRelationship: + def test_values(self): + assert PeerRelationship.PEER == "peer" + assert PeerRelationship.UPSTREAM == "upstream" + assert PeerRelationship.DOWNSTREAM == "downstream" + + +class TestTrustPolicy: + def test_values(self): + assert TrustPolicy.OPEN == "open" + assert TrustPolicy.ALLOWLIST == "allowlist" + assert TrustPolicy.TRUSTLESS == "trustless" + + +class TestDelegationScope: + def test_defaults(self): + scope = DelegationScope() + assert "state.patch" in scope.permissions + assert "events.log" in scope.permissions + assert scope.denied_operations == [] + assert scope.max_delegation_depth == 1 + + def test_roundtrip(self): + scope = DelegationScope( + permissions=["state.patch", "tools.invoke"], + denied_operations=["intent.delete"], + max_delegation_depth=3, + expires_at="2026-12-31T23:59:59Z", + ) + d = scope.to_dict() + restored = DelegationScope.from_dict(d) + assert restored.permissions == scope.permissions + assert restored.denied_operations == scope.denied_operations + assert restored.max_delegation_depth == 3 + assert restored.expires_at == "2026-12-31T23:59:59Z" + + def test_attenuate(self): + parent = DelegationScope( + permissions=["state.patch", "tools.invoke", "events.log"], + max_delegation_depth=3, + ) + child = DelegationScope( + permissions=["state.patch", "events.log"], + denied_operations=["tools.invoke"], + max_delegation_depth=5, + ) + result = parent.attenuate(child) + assert "state.patch" in result.permissions + assert "events.log" in result.permissions + assert "tools.invoke" not in result.permissions + assert "tools.invoke" in result.denied_operations + assert result.max_delegation_depth == 2 + + def test_attenuate_depth_narrows(self): + parent = DelegationScope(max_delegation_depth=1) + child = DelegationScope(max_delegation_depth=10) + result = parent.attenuate(child) + assert result.max_delegation_depth == 0 + + +class TestFederationPolicy: + def test_defaults(self): + policy = FederationPolicy() + assert policy.governance == {} + assert policy.budget == {} + assert policy.observability == {} + + def test_roundtrip(self): + policy = FederationPolicy( + governance={"max_delegation_depth": 2, "require_approval": True}, + budget={"max_llm_tokens": 50000, "cost_ceiling_usd": 10.0}, + observability={"report_frequency": "on_state_change"}, + ) + d = policy.to_dict() + restored = FederationPolicy.from_dict(d) + assert restored.governance["max_delegation_depth"] == 2 + assert restored.budget["cost_ceiling_usd"] == 10.0 + assert restored.observability["report_frequency"] == "on_state_change" + + def test_compose_strictest_numeric(self): + p1 = FederationPolicy( + governance={"max_delegation_depth": 3}, + budget={"max_llm_tokens": 100000}, + ) + p2 = FederationPolicy( + governance={"max_delegation_depth": 1}, + budget={"max_llm_tokens": 50000}, + ) + result = p1.compose_strictest(p2) + assert result.governance["max_delegation_depth"] == 1 + assert result.budget["max_llm_tokens"] == 50000 + + def test_compose_strictest_boolean(self): + p1 = FederationPolicy(governance={"require_approval": False}) + p2 = FederationPolicy(governance={"require_approval": True}) + result = p1.compose_strictest(p2) + assert result.governance["require_approval"] is True + + def test_compose_strictest_new_keys(self): + p1 = FederationPolicy(governance={"key_a": "value_a"}) + p2 = FederationPolicy(governance={"key_b": "value_b"}) + result = p1.compose_strictest(p2) + assert result.governance["key_a"] == "value_a" + assert result.governance["key_b"] == "value_b" + + +class TestFederationAttestation: + def test_roundtrip(self): + att = FederationAttestation( + dispatch_id="dispatch-123", + governance_compliant=True, + usage={"llm_tokens": 5000, "tool_calls": 3}, + trace_references=["trace-abc", "trace-def"], + timestamp="2026-01-01T00:00:00Z", + signature="sig123", + ) + d = att.to_dict() + restored = FederationAttestation.from_dict(d) + assert restored.dispatch_id == "dispatch-123" + assert restored.governance_compliant is True + assert restored.usage["llm_tokens"] == 5000 + assert len(restored.trace_references) == 2 + + def test_defaults(self): + att = FederationAttestation(dispatch_id="d1") + assert att.governance_compliant is True + assert att.usage == {} + assert att.signature is None + + +class TestFederationEnvelope: + def test_minimal_roundtrip(self): + env = FederationEnvelope( + dispatch_id="d-1", + source_server="https://server-a.com", + target_server="https://server-b.com", + intent_id="intent-1", + intent_title="Test Intent", + ) + d = env.to_dict() + assert d["dispatch_id"] == "d-1" + assert d["source_server"] == "https://server-a.com" + assert "delegation_scope" not in d + restored = FederationEnvelope.from_dict(d) + assert restored.intent_id == "intent-1" + assert restored.delegation_scope is None + + def test_full_roundtrip(self): + env = FederationEnvelope( + dispatch_id="d-2", + source_server="https://a.com", + target_server="https://b.com", + intent_id="i-2", + intent_title="Full Test", + intent_description="A complete test", + intent_state={"key": "value"}, + intent_constraints={"max_cost": 100}, + agent_id="agent-1", + delegation_scope=DelegationScope(permissions=["state.patch"]), + federation_policy=FederationPolicy(budget={"max_llm_tokens": 1000}), + trace_context={"trace_id": "t-1", "span_id": "s-1"}, + callback_url="https://a.com/callback", + idempotency_key="idem-1", + created_at="2026-01-01T00:00:00Z", + signature="sig-abc", + ) + d = env.to_dict() + restored = FederationEnvelope.from_dict(d) + assert restored.agent_id == "agent-1" + assert restored.delegation_scope.permissions == ["state.patch"] + assert restored.federation_policy.budget["max_llm_tokens"] == 1000 + assert restored.trace_context["trace_id"] == "t-1" + assert restored.callback_url == "https://a.com/callback" + assert restored.signature == "sig-abc" + + +class TestFederationCallback: + def test_roundtrip(self): + cb = FederationCallback( + dispatch_id="d-1", + event_type=CallbackEventType.STATE_DELTA, + state_delta={"progress": 0.5}, + attestation=FederationAttestation(dispatch_id="d-1"), + trace_id="t-1", + idempotency_key="cb-idem-1", + timestamp="2026-01-01T00:00:00Z", + ) + d = cb.to_dict() + restored = FederationCallback.from_dict(d) + assert restored.event_type == CallbackEventType.STATE_DELTA + assert restored.state_delta["progress"] == 0.5 + assert restored.attestation.dispatch_id == "d-1" + assert restored.trace_id == "t-1" + + +class TestPeerInfo: + def test_roundtrip(self): + peer = PeerInfo( + server_url="https://peer.example.com", + server_did="did:web:peer.example.com", + relationship=PeerRelationship.UPSTREAM, + trust_policy=TrustPolicy.ALLOWLIST, + public_key="pk-abc", + ) + d = peer.to_dict() + restored = PeerInfo.from_dict(d) + assert restored.server_url == "https://peer.example.com" + assert restored.relationship == PeerRelationship.UPSTREAM + + +class TestFederationManifest: + def test_roundtrip(self): + manifest = FederationManifest( + server_did="did:web:my-server.com", + server_url="https://my-server.com", + trust_policy=TrustPolicy.OPEN, + visibility_default=AgentVisibility.UNLISTED, + peers=["did:web:peer.com"], + public_key="pk-xyz", + ) + d = manifest.to_dict() + restored = FederationManifest.from_dict(d) + assert restored.server_did == "did:web:my-server.com" + assert restored.trust_policy == TrustPolicy.OPEN + assert restored.visibility_default == AgentVisibility.UNLISTED + assert "RFC-0022" in restored.supported_rfcs + + +class TestFederationStatus: + def test_roundtrip(self): + status = FederationStatus( + enabled=True, + server_did="did:web:srv.com", + trust_policy=TrustPolicy.TRUSTLESS, + peer_count=5, + active_dispatches=2, + total_dispatches=10, + total_received=8, + ) + d = status.to_dict() + restored = FederationStatus.from_dict(d) + assert restored.enabled is True + assert restored.peer_count == 5 + assert restored.trust_policy == TrustPolicy.TRUSTLESS + + +class TestDispatchResult: + def test_roundtrip(self): + result = DispatchResult( + dispatch_id="d-1", + status=DispatchStatus.ACCEPTED, + target_server="https://target.com", + message="Dispatch initiated", + remote_intent_id="remote-i-1", + ) + d = result.to_dict() + restored = DispatchResult.from_dict(d) + assert restored.status == DispatchStatus.ACCEPTED + assert restored.remote_intent_id == "remote-i-1" + + +class TestReceiveResult: + def test_roundtrip(self): + result = ReceiveResult( + dispatch_id="d-1", + accepted=True, + local_intent_id="local-i-1", + message="Accepted", + ) + d = result.to_dict() + restored = ReceiveResult.from_dict(d) + assert restored.accepted is True + assert restored.local_intent_id == "local-i-1" + + +class TestFederatedAgent: + def test_roundtrip(self): + agent = FederatedAgent( + agent_id="researcher-01", + server_url="https://server.com", + capabilities=["research", "analysis"], + visibility=AgentVisibility.PUBLIC, + server_did="did:web:server.com", + ) + d = agent.to_dict() + restored = FederatedAgent.from_dict(d) + assert restored.agent_id == "researcher-01" + assert "research" in restored.capabilities + assert restored.visibility == AgentVisibility.PUBLIC + + +class TestFederationEventTypes: + def test_event_types_exist(self): + assert EventType.FEDERATION_DISPATCHED == "federation.dispatched" + assert EventType.FEDERATION_RECEIVED == "federation.received" + assert EventType.FEDERATION_CALLBACK == "federation.callback" + assert EventType.FEDERATION_BUDGET_WARNING == "federation.budget_warning" + assert EventType.FEDERATION_COMPLETED == "federation.completed" + assert EventType.FEDERATION_FAILED == "federation.failed" + + +class TestServerIdentity: + def test_generate(self): + identity = ServerIdentity.generate("https://my-server.com") + assert identity.did == "did:web:my-server.com" + assert identity.private_key_bytes is not None + assert identity.public_key_bytes is not None + assert len(identity.public_key_b64) > 0 + + def test_did_from_url(self): + identity = ServerIdentity(server_url="https://agents.acme.com") + assert identity.did == "did:web:agents.acme.com" + + def test_did_document(self): + identity = ServerIdentity.generate("https://example.com") + doc = identity.did_document() + assert doc["id"] == "did:web:example.com" + assert len(doc["verificationMethod"]) == 1 + assert doc["verificationMethod"][0]["type"] == "Ed25519VerificationKey2020" + + def test_sign_and_verify(self): + identity = ServerIdentity.generate("https://test.com") + message = b"test message" + signature = identity.sign(message) + assert identity.verify(message, signature) + + def test_verify_wrong_message(self): + identity = ServerIdentity.generate("https://test.com") + signature = identity.sign(b"correct message") + assert not identity.verify(b"wrong message", signature) + + def test_verify_invalid_signature(self): + identity = ServerIdentity.generate("https://test.com") + assert not identity.verify(b"test", "invalid_base64!!!") + + def test_sign_no_key_raises(self): + identity = ServerIdentity(server_url="https://test.com") + with pytest.raises(ValueError, match="No private key"): + identity.sign(b"test") + + +class TestSignEnvelope: + def test_sign_and_verify(self): + identity = ServerIdentity.generate("https://signing-server.com") + envelope = { + "dispatch_id": "d-1", + "source_server": "https://signing-server.com", + "target_server": "https://target.com", + "intent_id": "i-1", + } + signature = sign_envelope(identity, envelope) + assert len(signature) > 0 + + assert identity.verify( + _canonical_bytes(envelope), + signature, + ) + + def test_signature_excludes_signature_field(self): + identity = ServerIdentity.generate("https://test.com") + envelope = {"a": 1, "b": 2} + sig1 = sign_envelope(identity, envelope) + envelope_with_sig = {**envelope, "signature": "old-sig"} + sig2 = sign_envelope(identity, envelope_with_sig) + assert sig1 == sig2 + + +class TestCanonicalBytes: + def test_deterministic(self): + d1 = {"b": 2, "a": 1} + d2 = {"a": 1, "b": 2} + assert _canonical_bytes(d1) == _canonical_bytes(d2) + + def test_excludes_signature(self): + d1 = {"a": 1} + d2 = {"a": 1, "signature": "sig"} + assert _canonical_bytes(d1) == _canonical_bytes(d2) + + +class TestMessageSignature: + def test_create(self): + identity = ServerIdentity.generate("https://test.com") + sig = MessageSignature.create( + identity=identity, + method="POST", + target_uri="https://target.com/api/v1/federation/receive", + body=b'{"dispatch_id": "d-1"}', + ) + assert sig.key_id == "did:web:test.com" + assert sig.algorithm == "ed25519" + assert len(sig.signature) > 0 + + def test_to_header(self): + sig = MessageSignature(key_id="did:web:test.com") + header = sig.to_header() + assert "did:web:test.com" in header + assert "ed25519" in header + + +class TestTrustEnforcer: + def test_open_trusts_everything(self): + enforcer = TrustEnforcer(policy=TrustPolicy.OPEN) + assert enforcer.is_trusted("https://unknown.com") + assert enforcer.is_trusted("https://anything.com", "did:web:anything.com") + + def test_allowlist_allows_listed(self): + enforcer = TrustEnforcer( + policy=TrustPolicy.ALLOWLIST, + allowed_peers=["https://peer-a.com", "did:web:peer-b.com"], + ) + assert enforcer.is_trusted("https://peer-a.com") + assert enforcer.is_trusted("https://other.com", "did:web:peer-b.com") + assert not enforcer.is_trusted("https://unknown.com") + + def test_allowlist_add_remove(self): + enforcer = TrustEnforcer(policy=TrustPolicy.ALLOWLIST) + assert not enforcer.is_trusted("https://new-peer.com") + enforcer.add_peer("https://new-peer.com") + assert enforcer.is_trusted("https://new-peer.com") + enforcer.remove_peer("https://new-peer.com") + assert not enforcer.is_trusted("https://new-peer.com") + + def test_trustless_rejects_all(self): + enforcer = TrustEnforcer(policy=TrustPolicy.TRUSTLESS) + assert not enforcer.is_trusted("https://any.com") + assert not enforcer.is_trusted("https://any.com", "did:web:any.com") + + +class TestUCANToken: + def test_create_and_encode(self): + identity = ServerIdentity.generate("https://issuer.com") + token = UCANToken( + issuer="did:web:issuer.com", + audience="did:web:audience.com", + scope=DelegationScope(permissions=["state.patch"]), + ) + encoded = token.encode(identity) + assert len(encoded.split(".")) == 3 + + def test_decode(self): + identity = ServerIdentity.generate("https://issuer.com") + original = UCANToken( + issuer="did:web:issuer.com", + audience="did:web:audience.com", + scope=DelegationScope( + permissions=["state.patch", "events.log"], + max_delegation_depth=2, + ), + ) + encoded = original.encode(identity) + decoded = UCANToken.decode(encoded) + assert decoded.issuer == "did:web:issuer.com" + assert decoded.audience == "did:web:audience.com" + assert "state.patch" in decoded.scope.permissions + + def test_decode_invalid_format(self): + with pytest.raises(ValueError, match="Invalid UCAN"): + UCANToken.decode("not.a-valid-token") + + def test_is_active(self): + import time + + token = UCANToken( + issuer="a", + audience="b", + scope=DelegationScope(), + not_before=int(time.time()) - 10, + expires_at=int(time.time()) + 3600, + ) + assert token.is_active() + assert not token.is_expired() + + def test_is_expired(self): + token = UCANToken( + issuer="a", + audience="b", + scope=DelegationScope(), + not_before=1000, + expires_at=1001, + ) + assert token.is_expired() + assert not token.is_active() + + def test_attenuate(self): + identity = ServerIdentity.generate("https://a.com") + parent = UCANToken( + issuer="did:web:a.com", + audience="did:web:b.com", + scope=DelegationScope( + permissions=["state.patch", "tools.invoke", "events.log"], + max_delegation_depth=3, + ), + ) + child_scope = DelegationScope( + permissions=["state.patch"], + max_delegation_depth=2, + ) + child = parent.attenuate("did:web:c.com", child_scope, identity) + assert child.issuer == "did:web:b.com" + assert child.audience == "did:web:c.com" + assert "state.patch" in child.scope.permissions + assert "tools.invoke" not in child.scope.permissions + assert child.scope.max_delegation_depth == 2 + assert len(child.proof_chain) == 1 + + def test_attenuate_depth_exceeded(self): + identity = ServerIdentity.generate("https://a.com") + parent = UCANToken( + issuer="did:web:a.com", + audience="did:web:b.com", + scope=DelegationScope(max_delegation_depth=0), + ) + child_scope = DelegationScope(max_delegation_depth=1) + with pytest.raises(ValueError, match="Delegation depth exceeded"): + parent.attenuate("did:web:c.com", child_scope, identity) + + +class TestResolveDIDWeb: + def test_basic(self): + url = resolve_did_web("did:web:example.com") + assert url == "https://example.com/.well-known/did.json" + + def test_with_path(self): + url = resolve_did_web("did:web:example.com:users:alice") + assert url == "https://example.com/users/alice/.well-known/did.json" + + def test_invalid_prefix(self): + with pytest.raises(ValueError, match="Not a did:web"): + resolve_did_web("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + + +class TestValidateSSRF: + def test_valid_url(self): + assert validate_ssrf("https://api.example.com/callback") + assert validate_ssrf("https://federation.partner.org/receive") + + def test_localhost_blocked(self): + assert not validate_ssrf("http://localhost:8080/callback") + assert not validate_ssrf("http://127.0.0.1/callback") + assert not validate_ssrf("http://0.0.0.0/callback") + + def test_private_networks_blocked(self): + assert not validate_ssrf("http://10.0.0.1/callback") + assert not validate_ssrf("http://192.168.1.1/callback") + assert not validate_ssrf("http://172.16.0.1/callback") + + def test_metadata_blocked(self): + assert not validate_ssrf("http://169.254.169.254/latest/meta-data") + assert not validate_ssrf("http://metadata.google.internal/computeMetadata") + + def test_internal_domains_blocked(self): + assert not validate_ssrf("http://service.internal/callback") + assert not validate_ssrf("http://host.local/callback") + + def test_invalid_scheme(self): + assert not validate_ssrf("ftp://example.com/file") + assert not validate_ssrf("file:///etc/passwd") + + def test_no_hostname(self): + assert not validate_ssrf("http:///path") + + +class TestDecoratorLifecycleHooks: + def test_on_federation_received(self): + @on_federation_received + async def handler(self, intent, context): + pass + + assert handler._openintent_handler == "federation_received" + + def test_on_federation_callback(self): + @on_federation_callback + async def handler(self, dispatch_id, attestation): + pass + + assert handler._openintent_handler == "federation_callback" + + def test_on_budget_warning(self): + @on_budget_warning + async def handler(self, dispatch_id, usage): + pass + + assert handler._openintent_handler == "budget_warning" + + +class TestFederationDecorator: + def test_decorator_configures_class(self): + @Federation( + identity="did:web:test.example.com", + visibility_default="public", + trust_policy="allowlist", + peers=["did:web:peer.com"], + server_url="https://test.example.com", + ) + class TestFederation: + pass + + assert TestFederation._federation_configured is True + assert TestFederation._federation_trust_policy_name == "allowlist" + assert TestFederation._federation_visibility_default_name == "public" + assert "did:web:peer.com" in TestFederation._federation_peer_list + + def test_decorator_sets_identity_on_init(self): + @Federation( + identity="did:web:init-test.com", + server_url="https://init-test.com", + ) + class TestFed: + pass + + instance = TestFed() + assert instance._federation_identity.did == "did:web:init-test.com" + assert instance._federation_trust_policy == TrustPolicy.ALLOWLIST + assert instance._federation_visibility_default == AgentVisibility.PUBLIC + + +class TestAgentFederationVisibility: + def test_agent_stores_federation_visibility(self): + from openintent.agents import Agent, on_assignment + + @Agent( + agent_id="test-fed-agent", + federation_visibility="public", + ) + class TestAgent: + @on_assignment + async def handle(self, intent): + return {} + + instance = TestAgent.__new__(TestAgent) + instance.__init__() + assert instance._federation_visibility == "public" + + def test_agent_default_none(self): + from openintent.agents import Agent, on_assignment + + @Agent(agent_id="test-no-fed") + class TestAgent: + @on_assignment + async def handle(self, intent): + return {} + + instance = TestAgent.__new__(TestAgent) + instance.__init__() + assert instance._federation_visibility is None + + +class TestCoordinatorFederationPolicy: + def test_coordinator_stores_federation_policy(self): + from openintent.agents import Coordinator + + @Coordinator( + coordinator_id="test-fed-coord", + federation_visibility="unlisted", + federation_policy={"budget": {"max_llm_tokens": 50000}}, + ) + class TestCoord: + pass + + instance = TestCoord.__new__(TestCoord) + instance.__init__() + assert instance._federation_visibility == "unlisted" + assert instance._federation_policy["budget"]["max_llm_tokens"] == 50000 + + +class TestServerEndpoints: + @pytest.fixture + def client(self): + from openintent.server.app import create_app + from openintent.server.config import ServerConfig + from openintent.server.federation import ( + configure_federation, + get_federation_state, + ) + + config = ServerConfig(database_url="sqlite:///./test_federation.db") + app = create_app(config) + + from fastapi.testclient import TestClient + + with TestClient(app) as tc: + configure_federation( + server_url="https://test-server.com", + server_did="did:web:test-server.com", + trust_policy=TrustPolicy.OPEN, + peers=["https://trusted-peer.com"], + ) + + state = get_federation_state() + state.register_agent( + "researcher-01", + capabilities=["research", "analysis"], + visibility=AgentVisibility.PUBLIC, + ) + state.register_agent( + "internal-bot", + capabilities=["internal"], + visibility=AgentVisibility.PRIVATE, + ) + + yield tc + + def test_federation_discovery(self, client): + response = client.get("/.well-known/openintent-federation.json") + assert response.status_code == 200 + data = response.json() + assert data["server_did"] == "did:web:test-server.com" + assert data["trust_policy"] == "open" + assert "dispatch" in data["endpoints"] + + def test_did_document(self, client): + response = client.get("/.well-known/did.json") + assert response.status_code == 200 + data = response.json() + assert data["id"] == "did:web:test-server.com" + assert len(data["verificationMethod"]) == 1 + + def test_federation_status(self, client): + response = client.get( + "/api/v1/federation/status", + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + assert data["server_did"] == "did:web:test-server.com" + assert data["trust_policy"] == "open" + + def test_federation_agents_public(self, client): + response = client.get( + "/api/v1/federation/agents", + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + agents = response.json()["agents"] + agent_ids = [a["agent_id"] for a in agents] + assert "researcher-01" in agent_ids + assert "internal-bot" not in agent_ids + + def test_federation_dispatch(self, client): + response = client.post( + "/api/v1/federation/dispatch", + json={ + "intent_id": "intent-123", + "target_server": "https://remote-server.com", + "agent_id": "remote-agent", + "delegation_scope": { + "permissions": ["state.patch"], + "max_delegation_depth": 1, + }, + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "accepted" + assert len(data["dispatch_id"]) > 0 + + def test_federation_dispatch_ssrf_blocked(self, client): + response = client.post( + "/api/v1/federation/dispatch", + json={ + "intent_id": "intent-123", + "target_server": "http://localhost:8080", + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 400 + assert "SSRF" in response.json()["detail"] + + def test_federation_dispatch_callback_ssrf_blocked(self, client): + response = client.post( + "/api/v1/federation/dispatch", + json={ + "intent_id": "intent-123", + "target_server": "https://valid-server.com", + "callback_url": "http://169.254.169.254/metadata", + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 400 + assert "SSRF" in response.json()["detail"] + + def test_federation_receive(self, client): + response = client.post( + "/api/v1/federation/receive", + json={ + "dispatch_id": str(uuid.uuid4()), + "source_server": "https://trusted-peer.com", + "intent_id": "remote-intent-1", + "intent_title": "Remote Task", + "intent_description": "Do something remotely", + "intent_state": {"key": "value"}, + "agent_id": "researcher-01", + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["accepted"] is True + assert data["local_intent_id"] is not None + + def test_federation_receive_idempotency(self, client): + dispatch_id = str(uuid.uuid4()) + idempotency_key = "idem-test-1" + + response1 = client.post( + "/api/v1/federation/receive", + json={ + "dispatch_id": dispatch_id, + "source_server": "https://trusted-peer.com", + "intent_id": "i-1", + "intent_title": "Test", + "idempotency_key": idempotency_key, + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response1.status_code == 200 + + response2 = client.post( + "/api/v1/federation/receive", + json={ + "dispatch_id": dispatch_id, + "source_server": "https://trusted-peer.com", + "intent_id": "i-1", + "intent_title": "Test", + "idempotency_key": idempotency_key, + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response2.status_code == 200 + assert "idempotent" in response2.json()["message"].lower() + + def test_federation_receive_budget_rejection(self, client): + response = client.post( + "/api/v1/federation/receive", + json={ + "dispatch_id": str(uuid.uuid4()), + "source_server": "https://trusted-peer.com", + "intent_id": "i-tight-budget", + "intent_title": "Tight Budget Task", + "federation_policy": { + "budget": {"max_llm_tokens": 0}, + "governance": {}, + "observability": {}, + }, + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["accepted"] is False + + +class TestServerEndpointsTrustEnforcement: + @pytest.fixture + def client(self): + from openintent.server.app import create_app + from openintent.server.config import ServerConfig + from openintent.server.federation import configure_federation + + config = ServerConfig(database_url="sqlite:///./test_federation_trust.db") + app = create_app(config) + + from fastapi.testclient import TestClient + + with TestClient(app) as tc: + configure_federation( + server_url="https://strict-server.com", + trust_policy=TrustPolicy.ALLOWLIST, + peers=["https://allowed-peer.com"], + ) + + yield tc + + def test_receive_from_untrusted_rejected(self, client): + response = client.post( + "/api/v1/federation/receive", + json={ + "dispatch_id": str(uuid.uuid4()), + "source_server": "https://untrusted-server.com", + "intent_id": "i-1", + "intent_title": "Test", + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 403 + assert "not trusted" in response.json()["detail"] + + def test_receive_from_trusted_accepted(self, client): + response = client.post( + "/api/v1/federation/receive", + json={ + "dispatch_id": str(uuid.uuid4()), + "source_server": "https://allowed-peer.com", + "intent_id": "i-1", + "intent_title": "Test", + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + assert response.json()["accepted"] is True + + +class TestFederationDisabledEndpoints: + @pytest.fixture + def client(self): + from openintent.server.app import create_app + from openintent.server.config import ServerConfig + from openintent.server.federation import get_federation_state + + config = ServerConfig(database_url="sqlite:///./test_federation_disabled.db") + app = create_app(config) + + from fastapi.testclient import TestClient + + with TestClient(app) as tc: + state = get_federation_state() + state.enabled = False + state.identity = None + state.manifest = None + + yield tc + + def test_discovery_404_when_disabled(self, client): + response = client.get("/.well-known/openintent-federation.json") + assert response.status_code == 404 + + def test_status_shows_disabled(self, client): + response = client.get( + "/api/v1/federation/status", + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 200 + assert response.json()["enabled"] is False + + def test_dispatch_fails_when_disabled(self, client): + response = client.post( + "/api/v1/federation/dispatch", + json={ + "intent_id": "i-1", + "target_server": "https://target.com", + }, + headers={"X-API-Key": "dev-user-key"}, + ) + assert response.status_code == 400 diff --git a/tests/test_intent_graphs.py b/tests/test_intent_graphs.py index 5f17fa8..021e6d5 100644 --- a/tests/test_intent_graphs.py +++ b/tests/test_intent_graphs.py @@ -306,7 +306,7 @@ def test_roundtrip_with_graph_fields(self): version=5, status=IntentStatus.BLOCKED, state=IntentState(data={"progress": 50}), - constraints=["budget < 1000"], + constraints={"rules": ["budget < 1000"]}, parent_intent_id="parent-xyz", depends_on=["dep-a", "dep-b", "dep-c"], ) diff --git a/tests/test_llm.py b/tests/test_llm.py index 16c9651..0c3e540 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1652,9 +1652,9 @@ async def test_think_uses_local_then_remote(self): final_response = MagicMock() final_response.choices = [MagicMock()] - final_response.choices[0].message.content = ( - "Calculator says 5, search found results." - ) + final_response.choices[ + 0 + ].message.content = "Calculator says 5, search found results." final_response.choices[0].message.tool_calls = None with patch.object(engine, "_call_llm", new_callable=AsyncMock) as mock_call: diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 298c6d4..535b5ca 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -30,6 +30,7 @@ def client(): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig( database_url=f"sqlite:///{db_path}", api_keys={"dev-key-1", "dev-user-key"}, @@ -39,6 +40,7 @@ def client(): yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) @@ -82,7 +84,6 @@ def _send_message( class TestChannelCRUD: - def test_create_channel(self, client): intent_id = _create_intent(client) ch = _create_channel(client, intent_id, name="ops") @@ -232,7 +233,6 @@ def test_create_channel_with_options(self, client): class TestMessaging: - def test_send_message(self, client): intent_id = _create_intent(client) ch = _create_channel(client, intent_id) @@ -402,7 +402,6 @@ def test_send_directed_message(self, client): class TestRequestResponseCorrelation: - def test_reply_to_message(self, client): intent_id = _create_intent(client) ch = _create_channel(client, intent_id) @@ -496,7 +495,6 @@ def test_reply_with_metadata(self, client): class TestAccessControl: - def test_channels_scoped_to_intent(self, client): intent_1 = _create_intent(client) intent_2 = _create_intent(client) diff --git a/tests/test_server.py b/tests/test_server.py index 47883f7..4d6a4b2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -554,12 +554,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client): diff --git a/tests/test_server_governance.py b/tests/test_server_governance.py index e166412..2fff473 100644 --- a/tests/test_server_governance.py +++ b/tests/test_server_governance.py @@ -30,12 +30,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client, **kwargs): @@ -228,12 +230,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client): @@ -422,12 +426,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client, **kwargs): @@ -608,12 +614,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client, **kwargs): diff --git a/tests/test_server_rfc0018_0020.py b/tests/test_server_rfc0018_0020.py index fde24b5..0ec7cf7 100644 --- a/tests/test_server_rfc0018_0020.py +++ b/tests/test_server_rfc0018_0020.py @@ -31,12 +31,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def test_register_identity(self, client): @@ -198,12 +200,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client): @@ -387,12 +391,14 @@ def client(self): db_path = f.name db_module._database = None + db_module._database_url = None config = ServerConfig(database_url=f"sqlite:///{db_path}") app = create_app(config) with TestClient(app) as c: yield c db_module._database = None + db_module._database_url = None os.unlink(db_path) def _create_intent(self, client):