diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e7ceadf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# Repository Guidelines (Agent-Facing) + +## What This Project Is +- Guided demo platform built on FastAPI + Playwright to showcase browser automations and GraphQL-backed data flows. +- Entry point: `src/guide/app/main.py`; CLI runner: `python -m guide` (respects `HOST`/`PORT`). +- Personas and host targets are data-driven via YAML, not hardcoded. +- App state wiring happens in `create_app()`: loads settings, builds `PersonaStore`, `ActionRegistry`, and `BrowserClient`, then stashes them on `app.state` for dependency injection. + +## Code & Module Layout +- Source root: `src/guide/app/` + - `actions/` — FastAPI-triggered demo actions; keep them thin, declarative, and side-effect scoped. + - `auth/` — pluggable MFA/auth helpers; avoid coupling to specific IdPs. + - `browser/` — `BrowserClient` and Playwright wrappers; centralize navigation, timeouts, and tracing. + - `core/` — settings, logging, dependency wiring; `AppSettings` drives runtime config and reads YAML/env merges. + - `errors/` — `GuideError` hierarchy; routes standardize error responses. + - `raindrop/` — GraphQL client + operations; isolate schemas/operations from business logic. + - `strings/` — selectors, labels, copy, GraphQL strings; no inline literals in actions. + - `models/` — domain/persona models; prefer pydantic v2 models with explicit types. + - `utils/` — shared helpers; keep <300 LoC and avoid circular deps. +- Config files: `config/hosts.yaml`, `config/personas.yaml` describe targets/personas; never embed secrets. ENV overrides: `RAINDROP_DEMO_BROWSER_HOSTS_JSON` and `RAINDROP_DEMO_PERSONAS_JSON` accept JSON arrays of objects matching the YAML shape. + +## Development Workflow +- Install deps: `pip install -e .` +- Type check: `basedpyright src` +- Sanity compile: `python -m compileall src/guide` +- Run API (dev): `python -m guide` +- Preferred loop: edit → `basedpyright` → `compileall` → manual smoke via CLI or hitting local `/docs`. +- API surface (2025-11-22): + - `GET /healthz` – liveness. + - `GET /actions` – list action metadata. + - `POST /actions/{id}/execute` – run an action; yields `ActionEnvelope` with correlation_id. + - `GET /config/browser-hosts` – current default + host map. + +## Coding Conventions +- Python 3.12+, Pydantic v2, pydantic-settings. +- Use PEP 604 unions (`str | None`), built-in generics (`list[str]`, `dict[str, JSONValue]`). +- Ban `Any` and `# type: ignore`; add type guards instead. +- Actions/constants: use `ClassVar[str]` for IDs/descriptions. +- Keep modules small (<300 LoC); split by domain (e.g., `actions/intake/basic.py`). +- Strings/queries belong in `strings/`; selectors should be reusable and labeled. +- `strings.service` enforces domain names; missing keys raise immediately, so keep selectors/texts in sync with UI. + +## Error Handling & Logging +- Raise `GuideError` subclasses; let routers translate to consistent HTTP responses. +- Log via `core/logging` (structured, levelled). Include persona/action ids and host targets in logs for traceability. +- For browser flows, prefer Playwright traces over ad-hoc prints; disable tracing only intentionally. +- `BrowserClient.open_page` resolves host by ID → CDP attach (reuses existing Raindrop page) or launches headless chromium/firefox/webkit; always close context/browser on exit. +- CDP mode requires `host` + `port` in `hosts.yaml`; errors surface as `BrowserConnectionError` with host_id detail. + +## Browser Automation Notes +- Route all CDP calls through `BrowserClient`; configure timeouts/retries there, not per action. +- Keep selectors in `strings/`; avoid brittle text selectors—prefer data-testid / aria labels when available. +- When mocking Playwright in tests, isolate side effects and avoid real network/CDP hits. +- Current MFA flow uses `DummyMfaCodeProvider` which raises `NotImplementedError`; real runs need a provider implementation or override before executing login/ensure_persona. + +## GraphQL & Data Layer +- `raindrop/graphql.py` houses the client; operations live in `raindrop/operations`. Keep queries/mutations versioned and colocated with types in `strings/`. +- Validate responses with pydantic models; surface schema mismatches as `GuideError`. +- Never bake URLs/tokens—pull from `AppSettings` and YAML/env overrides. +- HTTP transport via `httpx.AsyncClient` (10s timeout); GraphQL errors bubble as `GraphQLTransportError` or `GraphQLOperationError` with returned `errors` payload in `details`. + +## Configuration & Secrets +- Runtime configuration flows: env vars → `config/*.yaml` → `AppSettings` defaults. +- Do not commit credentials; use env overrides for tokens/hosts. +- MFA providers are pluggable; keep provider-specific wiring behind interfaces. +- Default env prefix: `RAINDROP_DEMO_` (see `AppSettings.model_config`). + +## Testing Expectations +- Minimum: `basedpyright src` and `python -m compileall src/guide` before publishing. +- Add unit tests under `tests/` alongside code; mock Playwright/GraphQL; avoid real external calls. +- Prefer deterministic fixtures; document any required env vars in test module docstrings. +- Action pipeline happy-path: request → resolve persona + browser host → `BrowserClient.open_page` → optional `ensure_persona` → action.run → `ActionEnvelope` with correlation_id from `ActionContext`. + +## Git & PR Hygiene +- Commits: scoped and descriptive (e.g., `feat: add auth login action`, `chore: tighten typing`). +- PRs should state changes, commands run, and any config additions (`hosts.yaml`, `personas.yaml`). +- Link related issues; include screenshots/log snippets when UI or API behavior changes. + +## Performance & Footprint +- Keep browser sessions short-lived; close contexts to avoid leaking handles. +- Cache expensive GraphQL lookups when safe; prefer per-request timeouts over global sleeps. +- Avoid widening dependencies; stick to project pins unless justified. +- Close Playwright contexts/browser handles promptly (client already wraps in contextmanager; keep action code lean to avoid dangling pages). + +## Quick Start Checklist (new agents) +- [ ] Create/verify `config/hosts.yaml` and `config/personas.yaml` match your target env. +- [ ] Run `pip install -e .` +- [ ] Run `basedpyright src` and `python -m compileall src/guide`. +- [ ] Start API: `python -m guide` and open `/docs` to smoke basic actions. +- [ ] Review `strings/` for selectors relevant to your feature before writing new actions. +- [ ] If executing auth flows, supply a working `MfaCodeProvider` (current dummy raises) or stub the login call in demos/tests. diff --git a/config/personas.yaml b/config/personas.yaml new file mode 100644 index 0000000..e67ab1d --- /dev/null +++ b/config/personas.yaml @@ -0,0 +1,11 @@ +personas: + buyer: + role: buyer + email: buyer.demo@example.com + login_method: mfa_email + browser_host_id: demo-cdp + supplier: + role: supplier + email: supplier.demo@example.com + login_method: mfa_email + browser_host_id: headless-local diff --git a/src/guide/app/actions/auth/__init__.py b/src/guide/app/actions/auth/__init__.py new file mode 100644 index 0000000..535df89 --- /dev/null +++ b/src/guide/app/actions/auth/__init__.py @@ -0,0 +1,3 @@ +from guide.app.actions.auth.login import LoginAsPersonaAction + +__all__ = ["LoginAsPersonaAction"] diff --git a/src/guide/app/actions/auth/login.py b/src/guide/app/actions/auth/login.py new file mode 100644 index 0000000..9ed4ba0 --- /dev/null +++ b/src/guide/app/actions/auth/login.py @@ -0,0 +1,32 @@ +from playwright.async_api import Page + +from typing import ClassVar, override + +from guide.app.actions.base import DemoAction +from guide.app.auth import DummyMfaCodeProvider, ensure_persona +from guide.app import errors +from guide.app.models.domain import ActionContext, ActionResult +from guide.app.models.personas import PersonaStore + + +class LoginAsPersonaAction(DemoAction): + id: ClassVar[str] = "auth.login_as_persona" + description: ClassVar[str] = "Log in as the specified persona using MFA." + category: ClassVar[str] = "auth" + + _personas: PersonaStore + _mfa_provider: DummyMfaCodeProvider + _login_url: str + + def __init__(self, personas: PersonaStore, login_url: str) -> None: + self._personas = personas + self._mfa_provider = DummyMfaCodeProvider() + self._login_url = login_url + + @override + async def run(self, page: Page, context: ActionContext) -> ActionResult: + if context.persona_id is None: + raise errors.PersonaError("persona_id is required for login action") + persona = self._personas.get(context.persona_id) + await ensure_persona(page, persona, self._mfa_provider, login_url=self._login_url) + return ActionResult(details={"persona_id": persona.id, "status": "logged_in"}) diff --git a/src/guide/app/actions/registry.py b/src/guide/app/actions/registry.py new file mode 100644 index 0000000..6509472 --- /dev/null +++ b/src/guide/app/actions/registry.py @@ -0,0 +1,16 @@ +from guide.app.actions.auth.login import LoginAsPersonaAction +from guide.app.actions.base import ActionRegistry, DemoAction +from guide.app.actions.intake.basic import FillIntakeBasicAction +from guide.app.actions.sourcing.add_suppliers import AddThreeSuppliersAction +from guide.app.models.personas import PersonaStore + + +def default_registry(persona_store: PersonaStore, login_url: str) -> ActionRegistry: + actions: list[DemoAction] = [ + LoginAsPersonaAction(persona_store, login_url), + FillIntakeBasicAction(), + AddThreeSuppliersAction(), + ] + return ActionRegistry(actions) + +__all__ = ["default_registry", "ActionRegistry", "DemoAction"] diff --git a/src/guide/app/auth/__init__.py b/src/guide/app/auth/__init__.py new file mode 100644 index 0000000..5c21e63 --- /dev/null +++ b/src/guide/app/auth/__init__.py @@ -0,0 +1,11 @@ +from guide.app.auth.mfa import DummyMfaCodeProvider, MfaCodeProvider +from guide.app.auth.session import detect_current_persona, ensure_persona, login_with_mfa, logout + +__all__ = [ + "DummyMfaCodeProvider", + "MfaCodeProvider", + "detect_current_persona", + "ensure_persona", + "login_with_mfa", + "logout", +] diff --git a/src/guide/app/auth/mfa.py b/src/guide/app/auth/mfa.py new file mode 100644 index 0000000..1004bfc --- /dev/null +++ b/src/guide/app/auth/mfa.py @@ -0,0 +1,15 @@ +from typing import Protocol + + +class MfaCodeProvider(Protocol): + def get_code(self, email: str, hint: str | None = None) -> str: ... + + +class DummyMfaCodeProvider: + """Placeholder provider until email integration is added.""" + + def get_code(self, email: str, hint: str | None = None) -> str: + del email, hint + raise NotImplementedError( + "MFA code provider is not implemented. Plug in a real provider." + ) diff --git a/src/guide/app/auth/session.py b/src/guide/app/auth/session.py new file mode 100644 index 0000000..e7b3d61 --- /dev/null +++ b/src/guide/app/auth/session.py @@ -0,0 +1,44 @@ +from playwright.async_api import Page + +from guide.app.auth.mfa import MfaCodeProvider +from guide.app.models.personas.models import DemoPersona +from guide.app.strings.service import strings + + +async def detect_current_persona(page: Page) -> str | None: + """Return the email/identifier of the currently signed-in user, if visible.""" + current_selector = strings.selector("AUTH", "CURRENT_USER_DISPLAY") + element = page.locator(current_selector) + if await element.count() == 0: + return None + text = await element.first.text_content() + if text is None: + return None + prefix = strings.label("AUTH", "CURRENT_USER_DISPLAY_PREFIX") + if prefix and text.startswith(prefix): + return text.removeprefix(prefix).strip() + return text.strip() + + +async def login_with_mfa(page: Page, email: str, mfa_provider: MfaCodeProvider, login_url: str | None = None) -> None: + if login_url: + _response = await page.goto(login_url) + del _response + await page.fill(strings.selector("AUTH", "EMAIL_INPUT"), email) + await page.click(strings.selector("AUTH", "SEND_CODE_BUTTON")) + code = mfa_provider.get_code(email) + await page.fill(strings.selector("AUTH", "CODE_INPUT"), code) + await page.click(strings.selector("AUTH", "SUBMIT_BUTTON")) + + +async def logout(page: Page) -> None: + logout_selector = strings.selector("AUTH", "LOGOUT_BUTTON") + await page.click(logout_selector) + + +async def ensure_persona(page: Page, persona: DemoPersona, mfa_provider: MfaCodeProvider, login_url: str | None = None) -> None: + current = await detect_current_persona(page) + if current and current.lower() == persona.email.lower(): + return + await logout(page) + await login_with_mfa(page, persona.email, mfa_provider, login_url=login_url) diff --git a/src/guide/app/errors/__init__.py b/src/guide/app/errors/__init__.py new file mode 100644 index 0000000..75288ee --- /dev/null +++ b/src/guide/app/errors/__init__.py @@ -0,0 +1,26 @@ +from guide.app.errors.exceptions import ( + ActionExecutionError, + AuthError, + BrowserConnectionError, + ConfigError, + GraphQLOperationError, + GraphQLTransportError, + GuideError, + MfaError, + PersonaError, +) +from guide.app.errors.handlers import guide_error_handler, unhandled_error_handler + +__all__ = [ + "GuideError", + "ConfigError", + "BrowserConnectionError", + "PersonaError", + "AuthError", + "MfaError", + "ActionExecutionError", + "GraphQLTransportError", + "GraphQLOperationError", + "guide_error_handler", + "unhandled_error_handler", +] diff --git a/src/guide/app/errors/exceptions.py b/src/guide/app/errors/exceptions.py new file mode 100644 index 0000000..a24b4bd --- /dev/null +++ b/src/guide/app/errors/exceptions.py @@ -0,0 +1,44 @@ +from guide.app.models.types import JSONValue + + +class GuideError(Exception): + code: str = "UNKNOWN_ERROR" + message: str + details: dict[str, JSONValue] + + def __init__(self, message: str, *, details: dict[str, JSONValue] | None = None): + super().__init__(message) + self.message = message + self.details = details or {} + + +class ConfigError(GuideError): + code: str = "CONFIG_ERROR" + + +class BrowserConnectionError(GuideError): + code: str = "BROWSER_CONNECT_FAILED" + + +class PersonaError(GuideError): + code: str = "PERSONA_ERROR" + + +class AuthError(GuideError): + code: str = "AUTH_ERROR" + + +class MfaError(GuideError): + code: str = "AUTH_MFA_FAILED" + + +class ActionExecutionError(GuideError): + code: str = "ACTION_EXECUTION_FAILED" + + +class GraphQLTransportError(GuideError): + code: str = "GRAPHQL_TRANSPORT_ERROR" + + +class GraphQLOperationError(GuideError): + code: str = "GRAPHQL_OPERATION_ERROR" diff --git a/src/guide/app/errors/handlers.py b/src/guide/app/errors/handlers.py new file mode 100644 index 0000000..fd61b84 --- /dev/null +++ b/src/guide/app/errors/handlers.py @@ -0,0 +1,29 @@ +from fastapi import Request +from fastapi.responses import JSONResponse + +from guide.app.errors.exceptions import GuideError + + +def guide_error_handler(request: Request, exc: GuideError) -> JSONResponse: + _ = request # explicitly ignore + return JSONResponse( + status_code=400, + content={ + "status": "error", + "error_code": exc.code, + "message": exc.message, + "details": exc.details or {}, + }, + ) + + +def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse: + _ = (request, exc) # explicitly ignore + return JSONResponse( + status_code=500, + content={ + "status": "error", + "error_code": "UNHANDLED_EXCEPTION", + "message": "An unexpected error occurred", + }, + ) diff --git a/src/guide/app/models/domain/__init__.py b/src/guide/app/models/domain/__init__.py new file mode 100644 index 0000000..e7afcce --- /dev/null +++ b/src/guide/app/models/domain/__init__.py @@ -0,0 +1,21 @@ +from guide.app.models.domain.models import ( + ActionContext, + ActionEnvelope, + ActionMetadata, + ActionRequest, + ActionResponse, + ActionResult, + ActionStatus, + BrowserHostsResponse, +) + +__all__ = [ + "ActionContext", + "ActionEnvelope", + "ActionMetadata", + "ActionRequest", + "ActionResponse", + "ActionResult", + "ActionStatus", + "BrowserHostsResponse", +] diff --git a/src/guide/app/models/domain/models.py b/src/guide/app/models/domain/models.py new file mode 100644 index 0000000..f96acb8 --- /dev/null +++ b/src/guide/app/models/domain/models.py @@ -0,0 +1,64 @@ +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Field + +from guide.app.core.config import BrowserHostConfig +from guide.app.models.types import JSONValue +from guide.app import utils + + +class ActionRequest(BaseModel): + persona_id: str | None = None + browser_host_id: str | None = None + params: dict[str, JSONValue] = Field(default_factory=dict) + + +class ActionContext(BaseModel): + action_id: str + persona_id: str | None = None + browser_host_id: str + params: dict[str, JSONValue] = Field(default_factory=dict) + correlation_id: str = Field(default_factory=utils.ids.new_correlation_id) + + +class ActionResult(BaseModel): + status: Literal["ok", "error"] = "ok" + details: dict[str, JSONValue] = Field(default_factory=dict) + error: str | None = None + + +class ActionMetadata(BaseModel): + id: str + description: str + category: str + + +class ActionResponse(BaseModel): + status: Literal["ok", "error"] + action_id: str + browser_host_id: str + persona_id: str | None = None + correlation_id: str | None = None + details: dict[str, JSONValue] | None = None + error: str | None = None + + +class ActionStatus(str, Enum): + SUCCESS = "success" + ERROR = "error" + + +class ActionEnvelope(BaseModel): + status: ActionStatus + action_id: str + correlation_id: str + result: dict[str, JSONValue] | None = None + error_code: str | None = None + message: str | None = None + details: dict[str, JSONValue] | None = None + + +class BrowserHostsResponse(BaseModel): + default_browser_host_id: str + browser_hosts: dict[str, BrowserHostConfig] diff --git a/src/guide/app/models/personas/__init__.py b/src/guide/app/models/personas/__init__.py new file mode 100644 index 0000000..dec0f62 --- /dev/null +++ b/src/guide/app/models/personas/__init__.py @@ -0,0 +1,4 @@ +from guide.app.models.personas.models import DemoPersona, LoginMethod, PersonaRole +from guide.app.models.personas.store import PersonaStore + +__all__ = ["DemoPersona", "LoginMethod", "PersonaRole", "PersonaStore"] diff --git a/src/guide/app/models/personas/models.py b/src/guide/app/models/personas/models.py new file mode 100644 index 0000000..5a7e0ec --- /dev/null +++ b/src/guide/app/models/personas/models.py @@ -0,0 +1,22 @@ +from enum import Enum + +from pydantic import BaseModel + + +class PersonaRole(str, Enum): + BUYER = "buyer" + SUPPLIER = "supplier" + APPROVER = "approver" + AP_CLERK = "ap_clerk" + + +class LoginMethod(str, Enum): + MFA_EMAIL = "mfa_email" + + +class DemoPersona(BaseModel): + id: str + role: PersonaRole + email: str + login_method: LoginMethod = LoginMethod.MFA_EMAIL + browser_host_id: str | None = None diff --git a/src/guide/app/models/personas/store.py b/src/guide/app/models/personas/store.py new file mode 100644 index 0000000..765a750 --- /dev/null +++ b/src/guide/app/models/personas/store.py @@ -0,0 +1,32 @@ +from guide.app.core.config import AppSettings, PersonaConfig +from guide.app import errors +from guide.app.models.personas.models import DemoPersona, LoginMethod, PersonaRole + + +class PersonaStore: + """In-memory persona registry loaded from AppSettings.""" + + def __init__(self, settings: AppSettings) -> None: + self._personas: dict[str, DemoPersona] = self._load(settings.personas) + + def _load(self, configurations: dict[str, PersonaConfig]) -> dict[str, DemoPersona]: + personas: dict[str, DemoPersona] = { + persona.id: DemoPersona( + id=persona.id, + role=PersonaRole(persona.role), + email=persona.email, + login_method=LoginMethod(persona.login_method), + browser_host_id=persona.browser_host_id, + ) + for persona in configurations.values() + } + return personas + + def get(self, persona_id: str) -> DemoPersona: + if persona_id not in self._personas: + known = ", ".join(self._personas.keys()) or "" + raise errors.PersonaError(f"Unknown persona '{persona_id}'. Known: {known}") + return self._personas[persona_id] + + def list(self) -> list[DemoPersona]: + return list(self._personas.values()) diff --git a/src/guide/app/models/types.py b/src/guide/app/models/types.py new file mode 100644 index 0000000..39131c1 --- /dev/null +++ b/src/guide/app/models/types.py @@ -0,0 +1,13 @@ +from typing import TypeAlias + +JSONValue: TypeAlias = ( + str + | int + | float + | bool + | None + | dict[str, "JSONValue"] + | list["JSONValue"] +) + +__all__ = ["JSONValue"] diff --git a/src/guide/app/raindrop/__init__.py b/src/guide/app/raindrop/__init__.py new file mode 100644 index 0000000..f005f2c --- /dev/null +++ b/src/guide/app/raindrop/__init__.py @@ -0,0 +1,4 @@ +from guide.app.raindrop.graphql import GraphQLClient +from guide.app.raindrop import operations, deps + +__all__ = ["GraphQLClient", "operations", "deps"] diff --git a/src/guide/app/raindrop/deps.py b/src/guide/app/raindrop/deps.py new file mode 100644 index 0000000..890fb5d --- /dev/null +++ b/src/guide/app/raindrop/deps.py @@ -0,0 +1,13 @@ +from typing import Annotated + +from fastapi import Depends + +from guide.app.core.config import AppSettings +from guide.app.raindrop.graphql import GraphQLClient + + +def graphql_client(settings: Annotated[AppSettings, Depends()]) -> GraphQLClient: + return GraphQLClient(settings) + + +__all__ = ["graphql_client"] diff --git a/src/guide/app/raindrop/graphql.py b/src/guide/app/raindrop/graphql.py new file mode 100644 index 0000000..037d748 --- /dev/null +++ b/src/guide/app/raindrop/graphql.py @@ -0,0 +1,62 @@ +import httpx + +from collections.abc import Mapping +from typing import cast + +from guide.app import errors +from guide.app.core.config import AppSettings +from guide.app.models.personas.models import DemoPersona +from guide.app.models.types import JSONValue + + +class GraphQLClient: + def __init__(self, settings: AppSettings) -> None: + self._settings: AppSettings = settings + + async def execute( + self, + *, + query: str, + variables: Mapping[str, JSONValue] | None, + persona: DemoPersona | None, + operation_name: str | None = None, + ) -> dict[str, JSONValue]: + url = self._settings.raindrop_graphql_url + headers = self._build_headers(persona) + async with httpx.AsyncClient(timeout=10.0) as client: + try: + resp = await client.post( + url, + json={ + "query": query, + "variables": variables or {}, + "operationName": operation_name, + }, + headers=headers, + ) + except httpx.HTTPError as exc: + raise errors.GraphQLTransportError( + f"Transport error calling GraphQL: {exc}" + ) from exc + + if resp.status_code >= 400: + raise errors.GraphQLTransportError( + "GraphQL HTTP error", + details={"status_code": resp.status_code, "body": resp.text}, + ) + + data = cast(dict[str, object], resp.json()) + errors_list = data.get("errors") + if errors_list: + details: dict[str, JSONValue] = {"errors": cast(JSONValue, errors_list)} + raise errors.GraphQLOperationError("GraphQL operation failed", details=details) + payload = data.get("data", {}) + if isinstance(payload, dict): + return cast(dict[str, JSONValue], payload) + return {} + + def _build_headers(self, persona: DemoPersona | None) -> dict[str, str]: + headers: dict[str, str] = {"Content-Type": "application/json"} + # TODO: attach persona/service auth tokens when available in config + _ = persona + return headers diff --git a/src/guide/app/raindrop/operations/__init__.py b/src/guide/app/raindrop/operations/__init__.py new file mode 100644 index 0000000..a6b74b1 --- /dev/null +++ b/src/guide/app/raindrop/operations/__init__.py @@ -0,0 +1,9 @@ +from guide.app.raindrop.operations.intake import create_intake_request, get_intake_request +from guide.app.raindrop.operations.sourcing import add_supplier, list_suppliers + +__all__ = [ + "create_intake_request", + "get_intake_request", + "add_supplier", + "list_suppliers", +] diff --git a/src/guide/app/raindrop/operations/intake.py b/src/guide/app/raindrop/operations/intake.py new file mode 100644 index 0000000..dff35d1 --- /dev/null +++ b/src/guide/app/raindrop/operations/intake.py @@ -0,0 +1,32 @@ +from guide.app.models.personas.models import DemoPersona +from guide.app.models.types import JSONValue +from guide.app.raindrop.graphql import GraphQLClient +from guide.app.strings import graphql as gql_strings + + +async def get_intake_request( + client: GraphQLClient, persona: DemoPersona, request_id: str +) -> dict[str, JSONValue]: + variables: dict[str, JSONValue] = {"id": request_id} + data = await client.execute( + query=gql_strings.GET_INTAKE_REQUEST, + variables=variables, + persona=persona, + operation_name="GetIntakeRequest", + ) + result = data.get("intakeRequest") + return result if isinstance(result, dict) else {} + + +async def create_intake_request( + client: GraphQLClient, persona: DemoPersona, payload: dict[str, JSONValue] +) -> dict[str, JSONValue]: + variables: dict[str, JSONValue] = {"input": payload} + data = await client.execute( + query=gql_strings.CREATE_INTAKE_REQUEST, + variables=variables, + persona=persona, + operation_name="CreateIntakeRequest", + ) + result = data.get("createIntakeRequest") + return result if isinstance(result, dict) else {} diff --git a/src/guide/app/raindrop/operations/sourcing.py b/src/guide/app/raindrop/operations/sourcing.py new file mode 100644 index 0000000..e05df00 --- /dev/null +++ b/src/guide/app/raindrop/operations/sourcing.py @@ -0,0 +1,36 @@ +from guide.app.models.personas.models import DemoPersona +from guide.app.models.types import JSONValue +from guide.app.raindrop.graphql import GraphQLClient +from guide.app.strings import graphql as gql_strings + + +async def list_suppliers(client: GraphQLClient, persona: DemoPersona, limit: int = 10) -> list[dict[str, JSONValue]]: + variables: dict[str, JSONValue] = {"limit": limit} + data = await client.execute( + query=gql_strings.LIST_SUPPLIERS, + variables=variables, + persona=persona, + operation_name="ListSuppliers", + ) + suppliers = data.get("suppliers") + if not isinstance(suppliers, list): + return [] + filtered: list[dict[str, JSONValue]] = [] + for item in suppliers: + if isinstance(item, dict): + filtered.append(item) + return filtered + + +async def add_supplier( + client: GraphQLClient, persona: DemoPersona, supplier: dict[str, JSONValue] +) -> dict[str, JSONValue]: + variables: dict[str, JSONValue] = {"input": supplier} + data = await client.execute( + query=gql_strings.ADD_SUPPLIER, + variables=variables, + persona=persona, + operation_name="AddSupplier", + ) + result = data.get("addSupplier") + return result if isinstance(result, dict) else {} diff --git a/src/guide/app/strings/graphql/__init__.py b/src/guide/app/strings/graphql/__init__.py new file mode 100644 index 0000000..a9a4085 --- /dev/null +++ b/src/guide/app/strings/graphql/__init__.py @@ -0,0 +1,9 @@ +from guide.app.strings.graphql.intake import CREATE_INTAKE_REQUEST, GET_INTAKE_REQUEST +from guide.app.strings.graphql.sourcing import ADD_SUPPLIER, LIST_SUPPLIERS + +__all__ = [ + "GET_INTAKE_REQUEST", + "CREATE_INTAKE_REQUEST", + "LIST_SUPPLIERS", + "ADD_SUPPLIER", +] diff --git a/src/guide/app/strings/graphql/intake.py b/src/guide/app/strings/graphql/intake.py new file mode 100644 index 0000000..5e7a521 --- /dev/null +++ b/src/guide/app/strings/graphql/intake.py @@ -0,0 +1,19 @@ +GET_INTAKE_REQUEST = """ +query GetIntakeRequest($id: ID!) { + intakeRequest(id: $id) { + id + title + status + } +} +""" + +CREATE_INTAKE_REQUEST = """ +mutation CreateIntakeRequest($input: IntakeRequestInput!) { + createIntakeRequest(input: $input) { + id + title + status + } +} +""" diff --git a/src/guide/app/strings/graphql/sourcing.py b/src/guide/app/strings/graphql/sourcing.py new file mode 100644 index 0000000..e1271c7 --- /dev/null +++ b/src/guide/app/strings/graphql/sourcing.py @@ -0,0 +1,17 @@ +LIST_SUPPLIERS = """ +query ListSuppliers($limit: Int!) { + suppliers(limit: $limit) { + id + name + } +} +""" + +ADD_SUPPLIER = """ +mutation AddSupplier($input: SupplierInput!) { + addSupplier(input: $input) { + id + name + } +} +""" diff --git a/src/guide/app/strings/labels/auth.py b/src/guide/app/strings/labels/auth.py new file mode 100644 index 0000000..5d0f176 --- /dev/null +++ b/src/guide/app/strings/labels/auth.py @@ -0,0 +1,9 @@ +from typing import ClassVar + + +class AuthLabels: + LOGIN_EMAIL_LABEL: ClassVar[str] = "Email" + LOGIN_SEND_CODE_BUTTON: ClassVar[str] = "Send code" + LOGIN_VERIFY_CODE_LABEL: ClassVar[str] = "Verification code" + LOGOUT_LABEL: ClassVar[str] = "Sign out" + CURRENT_USER_DISPLAY_PREFIX: ClassVar[str] = "Signed in as" diff --git a/src/guide/app/strings/selectors/auth.py b/src/guide/app/strings/selectors/auth.py new file mode 100644 index 0000000..42c9c39 --- /dev/null +++ b/src/guide/app/strings/selectors/auth.py @@ -0,0 +1,9 @@ +from typing import ClassVar + +class AuthSelectors: + EMAIL_INPUT: ClassVar[str] = '[data-test="auth-email-input"]' + SEND_CODE_BUTTON: ClassVar[str] = '[data-test="auth-send-code"]' + CODE_INPUT: ClassVar[str] = '[data-test="auth-code-input"]' + SUBMIT_BUTTON: ClassVar[str] = '[data-test="auth-submit"]' + LOGOUT_BUTTON: ClassVar[str] = '[data-test="auth-logout"]' + CURRENT_USER_DISPLAY: ClassVar[str] = '[data-test=\"user-display\"]' diff --git a/src/guide/app/strings/service.py b/src/guide/app/strings/service.py new file mode 100644 index 0000000..7d0f58b --- /dev/null +++ b/src/guide/app/strings/service.py @@ -0,0 +1,98 @@ +from typing import TypeAlias, cast + +from guide.app.strings.graphql import ( + ADD_SUPPLIER, + CREATE_INTAKE_REQUEST, + GET_INTAKE_REQUEST, + LIST_SUPPLIERS, +) +from guide.app.strings.demo_texts import DemoTexts, EventTexts, IntakeTexts, SupplierTexts +from guide.app.strings.labels import AuthLabels, IntakeLabels, Labels, SourcingLabels +from guide.app.strings.selectors import AuthSelectors, IntakeSelectors, NavigationSelectors, Selectors, SourcingSelectors + +SelectorNamespace: TypeAlias = type[IntakeSelectors] | type[SourcingSelectors] | type[NavigationSelectors] | type[AuthSelectors] +LabelNamespace: TypeAlias = type[IntakeLabels] | type[SourcingLabels] | type[AuthLabels] +TextNamespace: TypeAlias = type[IntakeTexts] | type[SupplierTexts] | type[EventTexts] +TextValue: TypeAlias = str | list[str] + +_SELECTORS: dict[str, SelectorNamespace] = { + "INTAKE": Selectors.INTAKE, + "SOURCING": Selectors.SOURCING, + "NAVIGATION": Selectors.NAVIGATION, + "AUTH": Selectors.AUTH, +} + +_LABELS: dict[str, LabelNamespace] = { + "INTAKE": Labels.INTAKE, + "SOURCING": Labels.SOURCING, + "AUTH": Labels.AUTH, +} + +_TEXTS: dict[str, TextNamespace] = { + "INTAKE": DemoTexts.INTAKE, + "SUPPLIERS": DemoTexts.SUPPLIERS, + "EVENTS": DemoTexts.EVENTS, +} + +_GQL = { + "GET_INTAKE_REQUEST": GET_INTAKE_REQUEST, + "CREATE_INTAKE_REQUEST": CREATE_INTAKE_REQUEST, + "LIST_SUPPLIERS": LIST_SUPPLIERS, + "ADD_SUPPLIER": ADD_SUPPLIER, +} + + +class Strings: + """Unified accessor for selectors, labels, texts, and GraphQL queries.""" + + @staticmethod + def selector(domain: str, name: str) -> str: + ns = _SELECTORS.get(domain.upper()) + if ns is None: + raise KeyError(f"Unknown selector domain '{domain}'") + value = cast(str | None, getattr(ns, name, None)) + return _as_str(value, f"selector {domain}.{name}") + + @staticmethod + def label(domain: str, name: str) -> str: + ns = _LABELS.get(domain.upper()) + if ns is None: + raise KeyError(f"Unknown label domain '{domain}'") + value = cast(str | None, getattr(ns, name, None)) + return _as_str(value, f"label {domain}.{name}") + + @staticmethod + def text(domain: str, name: str) -> TextValue: + ns = _TEXTS.get(domain.upper()) + if ns is None: + raise KeyError(f"Unknown text domain '{domain}'") + value: object | None = getattr(ns, name, None) + if value is None: + raise KeyError(f"Unknown text {domain}.{name}") + if isinstance(value, str): + return value + if isinstance(value, list): + value_list: list[object] = cast(list[object], value) + str_values: list[str] = [] + for item in value_list: + if not isinstance(item, str): + raise TypeError(f"text {domain}.{name} must be a string or list of strings") + str_values.append(item) + return str_values + raise TypeError(f"text {domain}.{name} must be a string or list of strings") + + @staticmethod + def gql(name: str) -> str: + value = _GQL.get(name) + return _as_str(value, f"graphql string {name}") + + +def _as_str(value: object, label: str) -> str: + if not isinstance(value, str): # pragma: no cover + raise TypeError(f"{label} must be a string") + return value + + +strings = Strings() + +__all__ = ["Strings", "strings"] diff --git a/src/guide/app/utils/__init__.py b/src/guide/app/utils/__init__.py new file mode 100644 index 0000000..c751767 --- /dev/null +++ b/src/guide/app/utils/__init__.py @@ -0,0 +1,3 @@ +from guide.app.utils import env, ids, retry, timing + +__all__ = ["env", "ids", "retry", "timing"] diff --git a/src/guide/app/utils/env.py b/src/guide/app/utils/env.py new file mode 100644 index 0000000..9dd51cc --- /dev/null +++ b/src/guide/app/utils/env.py @@ -0,0 +1,10 @@ +import os + +from guide.app.errors import ConfigError + + +def require_env(name: str) -> str: + if value := os.getenv(name): + return value + else: + raise ConfigError(f"Missing required environment variable {name}") diff --git a/src/guide/app/utils/ids.py b/src/guide/app/utils/ids.py new file mode 100644 index 0000000..e2a7387 --- /dev/null +++ b/src/guide/app/utils/ids.py @@ -0,0 +1,9 @@ +import uuid + + +def new_correlation_id() -> str: + return str(uuid.uuid4()) + + +def new_job_id(prefix: str = "job") -> str: + return f"{prefix}-{uuid.uuid4()}" diff --git a/src/guide/app/utils/retry.py b/src/guide/app/utils/retry.py new file mode 100644 index 0000000..aba8504 --- /dev/null +++ b/src/guide/app/utils/retry.py @@ -0,0 +1,28 @@ +import time +from collections.abc import Callable +from typing import TypeVar + +T = TypeVar("T") + + +def retry( + fn: Callable[[], T], + *, + retries: int = 3, + delay_seconds: float = 0.5, + backoff_factor: float = 2.0, + on_error: Callable[[Exception, int], None] | None = None, +) -> T: + attempt = 0 + current_delay = delay_seconds + while True: + try: + return fn() + except Exception as exc: # noqa: PERF203 + attempt += 1 + if attempt > retries: + raise + if on_error: + on_error(exc, attempt) + time.sleep(current_delay) + current_delay *= backoff_factor diff --git a/src/guide/app/utils/timing.py b/src/guide/app/utils/timing.py new file mode 100644 index 0000000..e1e8f03 --- /dev/null +++ b/src/guide/app/utils/timing.py @@ -0,0 +1,26 @@ +import time +from collections.abc import Callable +from functools import wraps +from typing import TypeVar, Protocol + +T = TypeVar("T") + + +class SupportsInfo(Protocol): + def info(self, msg: str, *args: object) -> None: ... + + +def timed(operation: str, logger: SupportsInfo) -> Callable[[Callable[..., T]], Callable[..., T]]: + def decorator(fn: Callable[..., T]) -> Callable[..., T]: + @wraps(fn) + def wrapper(*args: object, **kwargs: object) -> T: + start = time.perf_counter() + try: + return fn(*args, **kwargs) + finally: + duration_ms = (time.perf_counter() - start) * 1000.0 + logger.info("operation=%s duration_ms=%.2f", operation, duration_ms) + + return wrapper + + return decorator