This commit is contained in:
2025-11-22 00:54:31 +00:00
parent d54eeeaeeb
commit bddbae0403
34 changed files with 871 additions and 0 deletions

91
AGENTS.md Normal file
View 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
View 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

View File

@@ -0,0 +1,3 @@
from guide.app.actions.auth.login import LoginAsPersonaAction
__all__ = ["LoginAsPersonaAction"]

View 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"})

View 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"]

View 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
View 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."
)

View 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)

View 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",
]

View 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"

View 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",
},
)

View 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",
]

View 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]

View 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"]

View 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

View 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())

View File

@@ -0,0 +1,13 @@
from typing import TypeAlias
JSONValue: TypeAlias = (
str
| int
| float
| bool
| None
| dict[str, "JSONValue"]
| list["JSONValue"]
)
__all__ = ["JSONValue"]

View File

@@ -0,0 +1,4 @@
from guide.app.raindrop.graphql import GraphQLClient
from guide.app.raindrop import operations, deps
__all__ = ["GraphQLClient", "operations", "deps"]

View 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"]

View 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

View 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",
]

View 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 {}

View 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 {}

View 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",
]

View 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
}
}
"""

View 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
}
}
"""

View 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"

View 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\"]'

View 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"]

View File

@@ -0,0 +1,3 @@
from guide.app.utils import env, ids, retry, timing
__all__ = ["env", "ids", "retry", "timing"]

View 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}")

View 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()}"

View 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

View 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