diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py index 11ef9b9..0b95a93 100644 --- a/alembic/versions/0001_initial_schema.py +++ b/alembic/versions/0001_initial_schema.py @@ -5,6 +5,7 @@ Revises: Create Date: 2026-07-03 00:00:00.000000 """ + from __future__ import annotations from collections.abc import Sequence @@ -58,7 +59,9 @@ def upgrade() -> None: sa.Column("ts", sa.DateTime(), index=True), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_intel_competitor_ts", "intel_snapshots", ["competitor_id", sa.text("ts DESC")]) + op.create_index( + "ix_intel_competitor_ts", "intel_snapshots", ["competitor_id", sa.text("ts DESC")] + ) op.create_table( "costing_entries", sa.Column("id", sa.Integer(), nullable=False), diff --git a/api.py b/api.py index d2efcb1..35f61a5 100644 --- a/api.py +++ b/api.py @@ -81,7 +81,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Startup: validate deps. Shutdown: cleanup clients.""" logger.info("pry_startup", version="3.0.0") if not settings.api_key: - logger.warning("pry_api_key_unset", message="PRY_API_KEY is not set; all protected endpoints are publicly accessible") + logger.warning( + "pry_api_key_unset", + message="PRY_API_KEY is not set; all protected endpoints are publicly accessible", + ) get_pipeline() # Initialize pipeline yield logger.info("pry_shutdown") diff --git a/apify_schema.py b/apify_schema.py index 0d6cc84..5f72f39 100644 --- a/apify_schema.py +++ b/apify_schema.py @@ -231,7 +231,9 @@ class ObjectField(FieldSpec): class ApifySchemaBuilder: """Fluent builder for Apify actor input/output schemas.""" - def __init__(self, title: str = "Actor Input", description: str = "", schema_version: int = 1) -> None: + def __init__( + self, title: str = "Actor Input", description: str = "", schema_version: int = 1 + ) -> None: self.title = title self.description = description self.schema_version = schema_version @@ -284,7 +286,13 @@ class ApifySchemaBuilder: maximum: int | None = None, ) -> ApifySchemaBuilder: field = IntegerField( - name, title, description=description, default=default, nullable=nullable, minimum=minimum, maximum=maximum + name, + title, + description=description, + default=default, + nullable=nullable, + minimum=minimum, + maximum=maximum, ) return self.add_field(field, required=required) @@ -300,7 +308,13 @@ class ApifySchemaBuilder: maximum: float | None = None, ) -> ApifySchemaBuilder: field = NumberField( - name, title, description=description, default=default, nullable=nullable, minimum=minimum, maximum=maximum + name, + title, + description=description, + default=default, + nullable=nullable, + minimum=minimum, + maximum=maximum, ) return self.add_field(field, required=required) @@ -313,7 +327,9 @@ class ApifySchemaBuilder: default: bool | None = None, nullable: bool = False, ) -> ApifySchemaBuilder: - field = BooleanField(name, title, description=description, default=default, nullable=nullable) + field = BooleanField( + name, title, description=description, default=default, nullable=nullable + ) return self.add_field(field, required=required) def add_enum( @@ -326,7 +342,9 @@ class ApifySchemaBuilder: default: str | None = None, nullable: bool = False, ) -> ApifySchemaBuilder: - field = EnumField(name, title, enum, description=description, default=default, nullable=nullable) + field = EnumField( + name, title, enum, description=description, default=default, nullable=nullable + ) return self.add_field(field, required=required) def add_array( @@ -365,7 +383,13 @@ class ApifySchemaBuilder: nested_required: list[str] | None = None, ) -> ApifySchemaBuilder: field = ObjectField( - name, title, properties, description=description, default=default, nullable=nullable, required=nested_required + name, + title, + properties, + description=description, + default=default, + nullable=nullable, + required=nested_required, ) return self.add_field(field, required=required) diff --git a/cli.py b/cli.py index c51e59b..2ec522c 100755 --- a/cli.py +++ b/cli.py @@ -196,6 +196,7 @@ def cmd_screenshot(url, output=None, timeout=30): def cmd_migrate(): """Run pending database migrations (alembic upgrade head).""" from db import run_migrations + run_migrations() print(f"{GREEN}✓{NC} Migrations up to date") diff --git a/tests/test_api.py b/tests/test_api.py index 859c24a..54cb3b4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -47,11 +47,17 @@ async def test_api_key_required_when_set(client: TestClient, monkeypatch) -> Non """Protected endpoints require a valid Bearer PRY_API_KEY.""" monkeypatch.setattr("api.settings.api_key", "secret-pry-key") # Disable x402 payment gating so we can test auth in isolation. - with patch("x402_middleware.X402Middleware.__call__", lambda self, scope, receive, send: self.app(scope, receive, send)): + with patch( + "x402_middleware.X402Middleware.__call__", + lambda self, scope, receive, send: self.app(scope, receive, send), + ): resp = client.post("/v1/scrape", json={"url": "https://example.com"}) assert resp.status_code == 401 - with patch("x402_middleware.X402Middleware.__call__", lambda self, scope, receive, send: self.app(scope, receive, send)): + with patch( + "x402_middleware.X402Middleware.__call__", + lambda self, scope, receive, send: self.app(scope, receive, send), + ): resp = client.post( "/v1/scrape", json={"url": "https://example.com"}, @@ -59,7 +65,10 @@ async def test_api_key_required_when_set(client: TestClient, monkeypatch) -> Non ) assert resp.status_code == 401 - with patch("x402_middleware.X402Middleware.__call__", lambda self, scope, receive, send: self.app(scope, receive, send)): + with patch( + "x402_middleware.X402Middleware.__call__", + lambda self, scope, receive, send: self.app(scope, receive, send), + ): resp = client.post( "/v1/scrape", json={"url": "https://example.com"}, @@ -67,4 +76,3 @@ async def test_api_key_required_when_set(client: TestClient, monkeypatch) -> Non ) # auth passes; endpoint returns 402 because we did not mock scraper, which is fine assert resp.status_code in (200, 402) - diff --git a/tests/test_api_scraping.py b/tests/test_api_scraping.py index 29eccc8..1d15549 100644 --- a/tests/test_api_scraping.py +++ b/tests/test_api_scraping.py @@ -11,6 +11,7 @@ TestClient. External I/O (HTTP requests, browser automation) is mocked at the deterministic. """ +from collections.abc import Iterator from unittest.mock import AsyncMock, patch import pytest @@ -20,14 +21,17 @@ from api import app @pytest.fixture(autouse=True) -def bypass_x402_middleware() -> None: +def bypass_x402_middleware() -> Iterator[None]: """Disable x402 payment gating so scraping endpoints can be tested.""" - with patch("x402_middleware.X402Middleware.__call__", lambda self, scope, receive, send: self.app(scope, receive, send)): + with patch( + "x402_middleware.X402Middleware.__call__", + lambda self, scope, receive, send: self.app(scope, receive, send), + ): yield @pytest.fixture(autouse=True) -def clear_response_cache() -> None: +def clear_response_cache() -> Iterator[None]: """Clear the shared response cache so tests don't see each other's data.""" from deps import cache as response_cache @@ -71,7 +75,10 @@ async def test_v1_scrape_success(client: TestClient, mock_scrape_result: dict) - async def test_v1_scrape_with_json_schema(client: TestClient, mock_scrape_result: dict) -> None: schema = {"title": "page title"} extracted = {"title": "Example"} - with patch("routers.scraping.scraper") as mock_scraper, patch("routers.scraping.extractor") as mock_extractor: + with ( + patch("routers.scraping.scraper") as mock_scraper, + patch("routers.scraping.extractor") as mock_extractor, + ): mock_scraper.scrape = AsyncMock(return_value=mock_scrape_result) mock_extractor.extract = AsyncMock(return_value=extracted) resp = client.post("/v1/scrape", json={"url": "https://example.com", "jsonSchema": schema}) @@ -94,7 +101,11 @@ async def test_v1_detect_block_success(client: TestClient) -> None: ) ) mock_get_client.return_value = mock_client - resp = client.post("/v1/detect-block", content="\"https://example.com\"", headers={"content-type": "application/json"}) + resp = client.post( + "/v1/detect-block", + content='"https://example.com"', + headers={"content-type": "application/json"}, + ) assert resp.status_code == 200 data = resp.json() @@ -121,7 +132,9 @@ async def test_v1_crawl_success(client: TestClient, mock_scrape_result: dict) -> pages = [mock_scrape_result] with patch("routers.scraping.scraper") as mock_scraper: mock_scraper.crawl = AsyncMock(return_value=pages) - resp = client.post("/v1/crawl", json={"url": "https://example.com", "maxPages": 2, "maxDepth": 1}) + resp = client.post( + "/v1/crawl", json={"url": "https://example.com", "maxPages": 2, "maxDepth": 1} + ) assert resp.status_code == 200 data = resp.json() diff --git a/tests/test_api_templates.py b/tests/test_api_templates.py index 17a571a..6b23efa 100644 --- a/tests/test_api_templates.py +++ b/tests/test_api_templates.py @@ -5,6 +5,7 @@ # Licensed under MIT. """End-to-end tests for the templates router endpoints.""" +from collections.abc import Iterator from unittest.mock import AsyncMock, patch import pytest @@ -14,9 +15,12 @@ from api import app @pytest.fixture(autouse=True) -def bypass_x402_middleware() -> None: +def bypass_x402_middleware() -> Iterator[None]: """Disable x402 payment gating for template execution tests.""" - with patch("x402_middleware.X402Middleware.__call__", lambda self, scope, receive, send: self.app(scope, receive, send)): + with patch( + "x402_middleware.X402Middleware.__call__", + lambda self, scope, receive, send: self.app(scope, receive, send), + ): yield @@ -65,7 +69,10 @@ async def test_get_template_not_found(client: TestClient) -> None: async def test_execute_template(client: TestClient) -> None: result = {"success": True, "data": {"title": "Widget"}} with patch("routers.templates.execute_template", new=AsyncMock(return_value=result)): - resp = client.post("/v1/templates/execute", json={"template_id": "amazon_product", "url": "https://example.com"}) + resp = client.post( + "/v1/templates/execute", + json={"template_id": "amazon_product", "url": "https://example.com"}, + ) assert resp.status_code == 200 data = resp.json() diff --git a/tests/test_apify_schema.py b/tests/test_apify_schema.py index 56fe1b5..80a0848 100644 --- a/tests/test_apify_schema.py +++ b/tests/test_apify_schema.py @@ -63,10 +63,17 @@ def test_array_and_object_fields() -> None: def test_nullable_field() -> None: - schema = ApifySchemaBuilder().add_string("optional", "Optional", nullable=True).build_input_schema() + schema = ( + ApifySchemaBuilder().add_string("optional", "Optional", nullable=True).build_input_schema() + ) assert schema["properties"]["optional"]["type"] == ["string", "null"] def test_prefab() -> None: - schema = ApifySchemaBuilder().set_prefab("START_URLS").add_string("url", "URL", required=True).build_input_schema() + schema = ( + ApifySchemaBuilder() + .set_prefab("START_URLS") + .add_string("url", "URL", required=True) + .build_input_schema() + ) assert schema["prefab"] == "START_URLS"