.
This commit is contained in:
91
AGENTS.md
Normal file
91
AGENTS.md
Normal file
@@ -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.
|
||||
11
config/personas.yaml
Normal file
11
config/personas.yaml
Normal file
@@ -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
|
||||
3
src/guide/app/actions/auth/__init__.py
Normal file
3
src/guide/app/actions/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from guide.app.actions.auth.login import LoginAsPersonaAction
|
||||
|
||||
__all__ = ["LoginAsPersonaAction"]
|
||||
32
src/guide/app/actions/auth/login.py
Normal file
32
src/guide/app/actions/auth/login.py
Normal file
@@ -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"})
|
||||
16
src/guide/app/actions/registry.py
Normal file
16
src/guide/app/actions/registry.py
Normal file
@@ -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"]
|
||||
11
src/guide/app/auth/__init__.py
Normal file
11
src/guide/app/auth/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
15
src/guide/app/auth/mfa.py
Normal file
15
src/guide/app/auth/mfa.py
Normal file
@@ -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."
|
||||
)
|
||||
44
src/guide/app/auth/session.py
Normal file
44
src/guide/app/auth/session.py
Normal file
@@ -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)
|
||||
26
src/guide/app/errors/__init__.py
Normal file
26
src/guide/app/errors/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
44
src/guide/app/errors/exceptions.py
Normal file
44
src/guide/app/errors/exceptions.py
Normal file
@@ -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"
|
||||
29
src/guide/app/errors/handlers.py
Normal file
29
src/guide/app/errors/handlers.py
Normal file
@@ -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",
|
||||
},
|
||||
)
|
||||
21
src/guide/app/models/domain/__init__.py
Normal file
21
src/guide/app/models/domain/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
64
src/guide/app/models/domain/models.py
Normal file
64
src/guide/app/models/domain/models.py
Normal file
@@ -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]
|
||||
4
src/guide/app/models/personas/__init__.py
Normal file
4
src/guide/app/models/personas/__init__.py
Normal file
@@ -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"]
|
||||
22
src/guide/app/models/personas/models.py
Normal file
22
src/guide/app/models/personas/models.py
Normal file
@@ -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
|
||||
32
src/guide/app/models/personas/store.py
Normal file
32
src/guide/app/models/personas/store.py
Normal file
@@ -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 "<none>"
|
||||
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())
|
||||
13
src/guide/app/models/types.py
Normal file
13
src/guide/app/models/types.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import TypeAlias
|
||||
|
||||
JSONValue: TypeAlias = (
|
||||
str
|
||||
| int
|
||||
| float
|
||||
| bool
|
||||
| None
|
||||
| dict[str, "JSONValue"]
|
||||
| list["JSONValue"]
|
||||
)
|
||||
|
||||
__all__ = ["JSONValue"]
|
||||
4
src/guide/app/raindrop/__init__.py
Normal file
4
src/guide/app/raindrop/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from guide.app.raindrop.graphql import GraphQLClient
|
||||
from guide.app.raindrop import operations, deps
|
||||
|
||||
__all__ = ["GraphQLClient", "operations", "deps"]
|
||||
13
src/guide/app/raindrop/deps.py
Normal file
13
src/guide/app/raindrop/deps.py
Normal file
@@ -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"]
|
||||
62
src/guide/app/raindrop/graphql.py
Normal file
62
src/guide/app/raindrop/graphql.py
Normal file
@@ -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
|
||||
9
src/guide/app/raindrop/operations/__init__.py
Normal file
9
src/guide/app/raindrop/operations/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
32
src/guide/app/raindrop/operations/intake.py
Normal file
32
src/guide/app/raindrop/operations/intake.py
Normal file
@@ -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 {}
|
||||
36
src/guide/app/raindrop/operations/sourcing.py
Normal file
36
src/guide/app/raindrop/operations/sourcing.py
Normal file
@@ -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 {}
|
||||
9
src/guide/app/strings/graphql/__init__.py
Normal file
9
src/guide/app/strings/graphql/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
19
src/guide/app/strings/graphql/intake.py
Normal file
19
src/guide/app/strings/graphql/intake.py
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
"""
|
||||
17
src/guide/app/strings/graphql/sourcing.py
Normal file
17
src/guide/app/strings/graphql/sourcing.py
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
"""
|
||||
9
src/guide/app/strings/labels/auth.py
Normal file
9
src/guide/app/strings/labels/auth.py
Normal file
@@ -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"
|
||||
9
src/guide/app/strings/selectors/auth.py
Normal file
9
src/guide/app/strings/selectors/auth.py
Normal file
@@ -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\"]'
|
||||
98
src/guide/app/strings/service.py
Normal file
98
src/guide/app/strings/service.py
Normal file
@@ -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"]
|
||||
3
src/guide/app/utils/__init__.py
Normal file
3
src/guide/app/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from guide.app.utils import env, ids, retry, timing
|
||||
|
||||
__all__ = ["env", "ids", "retry", "timing"]
|
||||
10
src/guide/app/utils/env.py
Normal file
10
src/guide/app/utils/env.py
Normal file
@@ -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}")
|
||||
9
src/guide/app/utils/ids.py
Normal file
9
src/guide/app/utils/ids.py
Normal file
@@ -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()}"
|
||||
28
src/guide/app/utils/retry.py
Normal file
28
src/guide/app/utils/retry.py
Normal file
@@ -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
|
||||
26
src/guide/app/utils/timing.py
Normal file
26
src/guide/app/utils/timing.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user