From ba2e672e0250cf8fdfd7e07d02a19fa207c5668f Mon Sep 17 00:00:00 2001 From: Buck Brady <22723438+voidrot@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:44:04 -0600 Subject: [PATCH 01/23] feat(core): add migration metadata discovery --- docs/design/runtime-and-plugins.md | 28 ++ libs/core/pyproject.toml | 3 + libs/core/src/waygate_core/__init__.py | 3 +- .../src/waygate_core/database/__init__.py | 15 ++ .../src/waygate_core/database/discovery.py | 240 ++++++++++++++++++ libs/core/src/waygate_core/database/models.py | 7 + .../test_migration_metadata_discovery.py | 170 +++++++++++++ migrations/env.py | 62 ++++- 8 files changed, 516 insertions(+), 12 deletions(-) create mode 100644 libs/core/src/waygate_core/database/__init__.py create mode 100644 libs/core/src/waygate_core/database/discovery.py create mode 100644 libs/core/src/waygate_core/database/models.py create mode 100644 libs/core/tests/test_migration_metadata_discovery.py diff --git a/docs/design/runtime-and-plugins.md b/docs/design/runtime-and-plugins.md index c53173d..97fb67f 100644 --- a/docs/design/runtime-and-plugins.md +++ b/docs/design/runtime-and-plugins.md @@ -142,6 +142,34 @@ To add a plugin, follow the existing pattern: That is enough for the core runtime to discover, configure, and instantiate the plugin without app-specific wiring. +## Migration Metadata Discovery + +Alembic metadata discovery is intentionally separate from runtime bootstrap. + +- `migrations/env.py` adds workspace `src` paths and asks `waygate_core.database.discover_migration_metadata()` for `target_metadata` +- first-party workspace packages can declare migration contributors in their package `pyproject.toml` +- installed third-party packages can declare the same contract through normal Python entry points + +The explicit entry-point group is `waygate.migrations`. + +Each contributor must point to a zero-argument callable that returns either: + +- one `sqlalchemy.MetaData` object +- or an iterable of `sqlalchemy.MetaData` objects + +Example: + +- entry point group: `waygate.migrations` +- entry point value: `my_package.models:waygate_migration_metadata` + +This contract is explicit on purpose: + +- Alembic does not scan packages looking for ORM models +- Alembic does not call `bootstrap_app()` or instantiate runtime plugins +- broken contributors fail migration generation early instead of silently drifting schema state + +Workspace contributors win over installed contributors with the same entry-point name so local monorepo development can override already-installed package metadata. + ## Why This Model Exists The plugin system is not only an extensibility mechanism. It is also the main architectural boundary between product-specific workflow logic and environment-specific integrations. diff --git a/libs/core/pyproject.toml b/libs/core/pyproject.toml index eca480f..f425fa6 100644 --- a/libs/core/pyproject.toml +++ b/libs/core/pyproject.toml @@ -24,6 +24,9 @@ dependencies = [ "content-types>=0.3.0,<0.4.0", ] +[project.entry-points."waygate.migrations"] +waygate-core = "waygate_core.database:waygate_migration_metadata" + [dependency-groups] dev = ["pytest>=9.0.3", "pytest-cov>=7.1.0"] diff --git a/libs/core/src/waygate_core/__init__.py b/libs/core/src/waygate_core/__init__.py index 9505ad9..1c2b6b8 100644 --- a/libs/core/src/waygate_core/__init__.py +++ b/libs/core/src/waygate_core/__init__.py @@ -5,7 +5,8 @@ """ from .bootstrap import bootstrap_app, get_app_context +from .database import Base, discover_migration_metadata __VERSION__ = "0.1.0" # x-release-please-version -__all__ = ["bootstrap_app", "get_app_context"] +__all__ = ["Base", "bootstrap_app", "discover_migration_metadata", "get_app_context"] diff --git a/libs/core/src/waygate_core/database/__init__.py b/libs/core/src/waygate_core/database/__init__.py new file mode 100644 index 0000000..e74185b --- /dev/null +++ b/libs/core/src/waygate_core/database/__init__.py @@ -0,0 +1,15 @@ +"""Database metadata helpers used by Alembic and ORM-owning packages.""" + +from .discovery import ( + MIGRATION_ENTRYPOINT_GROUP, + discover_migration_metadata, + waygate_migration_metadata, +) +from .models import Base + +__all__ = [ + "Base", + "MIGRATION_ENTRYPOINT_GROUP", + "discover_migration_metadata", + "waygate_migration_metadata", +] diff --git a/libs/core/src/waygate_core/database/discovery.py b/libs/core/src/waygate_core/database/discovery.py new file mode 100644 index 0000000..742e546 --- /dev/null +++ b/libs/core/src/waygate_core/database/discovery.py @@ -0,0 +1,240 @@ +"""Alembic metadata discovery for first-party libraries and installed packages.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator, Sequence +from dataclasses import dataclass +from importlib import import_module +from importlib import metadata as importlib_metadata +from pathlib import Path +import tomllib + +from sqlalchemy import MetaData + +from .models import Base + +MIGRATION_ENTRYPOINT_GROUP = "waygate.migrations" + +MigrationMetadataFactory = Callable[[], MetaData | Iterable[MetaData]] + + +@dataclass(frozen=True) +class MigrationMetadataContributor: + """Resolved migration metadata contributor.""" + + name: str + source: str + factory: MigrationMetadataFactory + + +def waygate_migration_metadata() -> tuple[MetaData, ...]: + """Return the core package metadata for migration discovery.""" + + return (Base.metadata,) + + +def discover_migration_metadata(repo_root: Path | None = None) -> tuple[MetaData, ...]: + """Collect target metadata from core, workspace members, and installed packages. + + Args: + repo_root: Optional repository root used to inspect uv workspace members. + + Returns: + An ordered tuple of SQLAlchemy metadata collections for Alembic. + + Raises: + RuntimeError: If a contributor cannot be loaded or executed. + TypeError: If a contributor returns an unsupported value. + """ + + contributors: list[MigrationMetadataContributor] = [] + seen_contributors: set[str] = set() + + if repo_root is not None: + for contributor in _discover_workspace_contributors(repo_root): + if contributor.name in seen_contributors: + continue + contributors.append(contributor) + seen_contributors.add(contributor.name) + + for contributor in _discover_installed_contributors(): + if contributor.name in seen_contributors: + continue + contributors.append(contributor) + seen_contributors.add(contributor.name) + + metadata_collections: list[MetaData] = [] + seen_metadata: set[int] = set() + _append_metadata(metadata_collections, seen_metadata, Base.metadata) + + for contributor in contributors: + try: + result = contributor.factory() + except Exception as exc: # pragma: no cover - exercised via tests + raise RuntimeError( + "Migration metadata contributor " + f"'{contributor.name}' from {contributor.source} failed" + ) from exc + + for metadata in _iter_metadata(result, contributor=contributor): + _append_metadata(metadata_collections, seen_metadata, metadata) + + return tuple(metadata_collections) + + +def _discover_workspace_contributors( + repo_root: Path, +) -> tuple[MigrationMetadataContributor, ...]: + """Load migration contributors declared by local uv workspace members.""" + + root_pyproject = repo_root / "pyproject.toml" + if not root_pyproject.exists(): + return () + + with root_pyproject.open("rb") as file_handle: + root_config = tomllib.load(file_handle) + + member_patterns = ( + root_config.get("tool", {}) + .get("uv", {}) + .get("workspace", {}) + .get("members", []) + ) + + contributors: list[MigrationMetadataContributor] = [] + for member_pattern in member_patterns: + for member_path in sorted(repo_root.glob(member_pattern)): + if not member_path.is_dir(): + continue + contributors.extend(_load_workspace_member_contributors(member_path)) + + return tuple(contributors) + + +def _load_workspace_member_contributors( + member_path: Path, +) -> tuple[MigrationMetadataContributor, ...]: + """Read a workspace member's migration contributors from its pyproject.""" + + member_pyproject = member_path / "pyproject.toml" + if not member_pyproject.exists(): + return () + + with member_pyproject.open("rb") as file_handle: + member_config = tomllib.load(file_handle) + + entry_points = ( + member_config.get("project", {}) + .get("entry-points", {}) + .get(MIGRATION_ENTRYPOINT_GROUP, {}) + ) + if not isinstance(entry_points, dict): + return () + + contributors: list[MigrationMetadataContributor] = [] + for name, target in entry_points.items(): + contributors.append( + MigrationMetadataContributor( + name=name, + source=str(member_pyproject), + factory=_load_factory(target, source=str(member_pyproject), name=name), + ) + ) + + return tuple(contributors) + + +def _discover_installed_contributors() -> tuple[MigrationMetadataContributor, ...]: + """Load migration contributors from installed package entry points.""" + + contributors: list[MigrationMetadataContributor] = [] + for entry_point in _select_entry_points(MIGRATION_ENTRYPOINT_GROUP): + factory = entry_point.load() + if not callable(factory): + raise TypeError( + "Migration metadata entry point " + f"'{entry_point.name}' from {entry_point.value} must be callable" + ) + + contributors.append( + MigrationMetadataContributor( + name=entry_point.name, + source=entry_point.value, + factory=factory, + ) + ) + + return tuple(contributors) + + +def _select_entry_points(group: str) -> Sequence[importlib_metadata.EntryPoint]: + """Return entry points for a group across Python versions.""" + + entry_points = importlib_metadata.entry_points() + if hasattr(entry_points, "select"): + return tuple(entry_points.select(group=group)) + return tuple(entry_points.get(group, ())) + + +def _load_factory( + target: str, + *, + source: str, + name: str, +) -> MigrationMetadataFactory: + """Resolve a ``module:attribute`` target into a callable factory.""" + + try: + module_name, attribute_name = target.split(":", maxsplit=1) + except ValueError as exc: + raise RuntimeError( + f"Migration metadata entry point '{name}' in {source} must use module:attribute" + ) from exc + + factory = getattr(import_module(module_name), attribute_name) + if not callable(factory): + raise TypeError( + f"Migration metadata entry point '{name}' in {source} must resolve to a callable" + ) + return factory + + +def _iter_metadata( + result: MetaData | Iterable[MetaData], + *, + contributor: MigrationMetadataContributor, +) -> Iterator[MetaData]: + """Yield metadata objects from a contributor result.""" + + if isinstance(result, MetaData): + yield result + return + + if isinstance(result, Iterable) and not isinstance(result, (str, bytes)): + for metadata in result: + if not isinstance(metadata, MetaData): + raise TypeError( + "Migration metadata contributor " + f"'{contributor.name}' from {contributor.source} must return MetaData objects" + ) + yield metadata + return + + raise TypeError( + "Migration metadata contributor " + f"'{contributor.name}' from {contributor.source} returned an unsupported value" + ) + + +def _append_metadata( + metadata_collections: list[MetaData], + seen_metadata: set[int], + metadata: MetaData, +) -> None: + """Append a metadata object once while preserving order.""" + + metadata_id = id(metadata) + if metadata_id in seen_metadata: + return + seen_metadata.add(metadata_id) + metadata_collections.append(metadata) diff --git a/libs/core/src/waygate_core/database/models.py b/libs/core/src/waygate_core/database/models.py new file mode 100644 index 0000000..8ab8b0b --- /dev/null +++ b/libs/core/src/waygate_core/database/models.py @@ -0,0 +1,7 @@ +"""Canonical SQLAlchemy declarative metadata roots for WayGate.""" + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base declarative model for first-party WayGate ORM tables.""" diff --git a/libs/core/tests/test_migration_metadata_discovery.py b/libs/core/tests/test_migration_metadata_discovery.py new file mode 100644 index 0000000..eeba6ec --- /dev/null +++ b/libs/core/tests/test_migration_metadata_discovery.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from sqlalchemy import MetaData, Table, Column, Integer + +from waygate_core.database.discovery import ( + MIGRATION_ENTRYPOINT_GROUP, + MigrationMetadataContributor, + discover_migration_metadata, +) +from waygate_core.database.models import Base + + +def test_discover_migration_metadata_includes_core_base(monkeypatch) -> None: + monkeypatch.setattr( + "waygate_core.database.discovery._discover_workspace_contributors", + lambda repo_root: (), + ) + monkeypatch.setattr( + "waygate_core.database.discovery._discover_installed_contributors", + lambda: (), + ) + + metadata = discover_migration_metadata() + + assert metadata == (Base.metadata,) + + +def test_discover_migration_metadata_collects_workspace_and_installed_contributors( + monkeypatch, +) -> None: + workspace_metadata = MetaData() + installed_metadata = MetaData() + Table( + "workspace_table", workspace_metadata, Column("id", Integer, primary_key=True) + ) + Table( + "installed_table", installed_metadata, Column("id", Integer, primary_key=True) + ) + + monkeypatch.setattr( + "waygate_core.database.discovery._discover_workspace_contributors", + lambda repo_root: ( + MigrationMetadataContributor( + name="workspace-package", + source="workspace", + factory=lambda: (workspace_metadata,), + ), + ), + ) + monkeypatch.setattr( + "waygate_core.database.discovery._discover_installed_contributors", + lambda: ( + MigrationMetadataContributor( + name="third-party-plugin", + source="installed", + factory=lambda: installed_metadata, + ), + ), + ) + + metadata = discover_migration_metadata(repo_root=Path("/workspace")) + + assert metadata == (Base.metadata, workspace_metadata, installed_metadata) + + +def test_discover_migration_metadata_prefers_workspace_contributors_by_name( + monkeypatch, +) -> None: + workspace_metadata = MetaData() + installed_metadata = MetaData() + Table( + "workspace_table", workspace_metadata, Column("id", Integer, primary_key=True) + ) + Table( + "installed_table", installed_metadata, Column("id", Integer, primary_key=True) + ) + + monkeypatch.setattr( + "waygate_core.database.discovery._discover_workspace_contributors", + lambda repo_root: ( + MigrationMetadataContributor( + name="shared-package", + source="workspace", + factory=lambda: workspace_metadata, + ), + ), + ) + monkeypatch.setattr( + "waygate_core.database.discovery._discover_installed_contributors", + lambda: ( + MigrationMetadataContributor( + name="shared-package", + source="installed", + factory=lambda: installed_metadata, + ), + ), + ) + + metadata = discover_migration_metadata(repo_root=Path("/workspace")) + + assert metadata == (Base.metadata, workspace_metadata) + + +def test_discover_migration_metadata_rejects_invalid_contributor_results( + monkeypatch, +) -> None: + monkeypatch.setattr( + "waygate_core.database.discovery._discover_installed_contributors", + lambda: ( + MigrationMetadataContributor( + name="broken-package", + source="installed", + factory=lambda: object(), + ), + ), + ) + + try: + discover_migration_metadata() + except TypeError as exc: + assert "broken-package" in str(exc) + else: # pragma: no cover - defensive assertion + raise AssertionError("Expected TypeError for invalid migration metadata result") + + +def test_discover_workspace_contributors_reads_member_entry_points( + tmp_path, monkeypatch +) -> None: + repo_root = tmp_path / "repo" + member_dir = repo_root / "libs" / "sample" + package_src = member_dir / "src" + package_src.mkdir(parents=True) + + (repo_root / "pyproject.toml").write_text( + """ +[tool.uv.workspace] +members = ["libs/*"] +""".strip() + ) + (member_dir / "pyproject.toml").write_text( + f""" +[project] +name = "sample-package" + +[project.entry-points."{MIGRATION_ENTRYPOINT_GROUP}"] +sample-package = "sample_models:waygate_migration_metadata" +""".strip() + ) + (package_src / "sample_models.py").write_text( + """ +from sqlalchemy import Column, Integer, MetaData, Table + +metadata = MetaData() +Table("sample_table", metadata, Column("id", Integer, primary_key=True)) + +def waygate_migration_metadata(): + return metadata +""".strip() + ) + + monkeypatch.syspath_prepend(str(package_src)) + sys.modules.pop("sample_models", None) + + metadata = discover_migration_metadata(repo_root=repo_root) + + assert len(metadata) == 2 + assert "sample_table" in metadata[1].tables diff --git a/migrations/env.py b/migrations/env.py index 0bdcfc9..509f2db 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,14 +1,13 @@ from logging.config import fileConfig -import importlib +from os import getenv from pathlib import Path import sys +import tomllib +from alembic import context from sqlalchemy import engine_from_config from sqlalchemy import pool -from alembic import context -from os import getenv - # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -18,16 +17,57 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) -# Ensure workspace package sources are importable when Alembic runs from repo root. + +def _prepend_workspace_src_paths(repo_root: Path) -> None: + """Ensure workspace package sources are importable for Alembic. + + The monorepo uses multiple editable packages under the uv workspace. Alembic + can be executed directly from the repository root before those packages are + installed into the active environment, so we add every member's `src` + directory (or package root when it does not use a src layout) to `sys.path`. + """ + + root_pyproject = repo_root / "pyproject.toml" + if not root_pyproject.exists(): + return + + with root_pyproject.open("rb") as file_handle: + root_config = tomllib.load(file_handle) + + members = ( + root_config.get("tool", {}) + .get("uv", {}) + .get("workspace", {}) + .get("members", []) + ) + seen_paths: set[str] = set() + + for member_pattern in members: + for member_path in sorted(repo_root.glob(member_pattern)): + if not member_path.is_dir(): + continue + + import_path = member_path / "src" + resolved_path = import_path if import_path.is_dir() else member_path + resolved_str = str(resolved_path) + if resolved_str in seen_paths: + continue + seen_paths.add(resolved_str) + sys.path.insert(0, resolved_str) + + repo_root = Path(__file__).resolve().parents[1] -core_src = repo_root / "packages" / "core" / "src" -if str(core_src) not in sys.path: - sys.path.insert(0, str(core_src)) +_prepend_workspace_src_paths(repo_root) + + +def _discover_target_metadata(repo_root: Path): + from waygate_core.database import discover_migration_metadata + + return discover_migration_metadata(repo_root=repo_root) -Base = importlib.import_module("waygate_core.database.models").Base -# Tell Alembic which SQLAlchemy metadata to diff against. -target_metadata = Base.metadata +# Tell Alembic which SQLAlchemy metadata collections to diff against. +target_metadata = _discover_target_metadata(repo_root) # other values from the config, defined by the needs of env.py, # can be acquired: From 074d2cb4d37cd136b174d9eeab34c28129c75a5d Mon Sep 17 00:00:00 2001 From: Buck Brady <22723438+voidrot@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:44:41 -0600 Subject: [PATCH 02/23] feat(web): add unified web app and shared ingress --- .github/copilot-instructions.md | 124 +++- .release-please-manifest.json | 3 +- README.md | 18 +- apps/api/README.md | 6 +- apps/api/pyproject.toml | 16 +- apps/api/src/waygate_api/__init__.py | 29 +- apps/api/src/waygate_api/clients.py | 20 +- apps/api/src/waygate_api/routes/__init__.py | 1 - .../waygate_api/routes/webhooks/__init__.py | 23 - .../src/waygate_api/routes/webhooks/errors.py | 26 +- .../src/waygate_api/routes/webhooks/router.py | 168 +----- apps/api/src/waygate_api/server.py | 52 +- apps/api/tests/test_clients.py | 137 ----- .../tests/test_webhook_agent_session_route.py | 6 +- .../api/tests/test_webhook_dispatch_errors.py | 54 -- apps/api/tests/test_webhook_generic_route.py | 24 - apps/web/README.md | 20 + apps/web/pyproject.toml | 59 ++ apps/web/src/waygate_web/__init__.py | 35 ++ apps/web/src/waygate_web/auth/__init__.py | 5 + apps/web/src/waygate_web/auth/setup.py | 31 + apps/web/src/waygate_web/routes/__init__.py | 5 + apps/web/src/waygate_web/routes/pages.py | 70 +++ apps/web/src/waygate_web/server.py | 60 ++ apps/web/src/waygate_web/templates/base.html | 29 + .../src/waygate_web/templates/dashboard.html | 78 +++ .../templates/partials/runtime_summary.html | 18 + apps/web/tests/test_server.py | 23 + .../tests/test_web_startup_validation.py} | 10 +- compose.yml | 6 +- docker/Dockerfile | 8 +- docs/apps/README.md | 4 +- docs/apps/api.md | 44 -- docs/apps/web.md | 34 ++ docs/design/README.md | 2 +- docs/design/architecture.md | 38 +- docs/design/data-models-and-storage.md | 4 +- docs/plans/agent-session-webhook-spec.md | 2 +- docs/plugins/webhook-agent-session.md | 2 +- docs/plugins/webhook-generic.md | 8 +- docs/worker_communication_contract.md | 4 +- env.compose.example | 6 +- libs/core/src/waygate_core/plugin/webhook.py | 2 +- libs/webhooks/README.md | 22 + libs/webhooks/pyproject.toml | 36 ++ .../webhooks/src/waygate_webhooks/__init__.py | 20 + libs/webhooks/src/waygate_webhooks/app.py | 19 + .../webhooks/src/waygate_webhooks/dispatch.py | 45 ++ libs/webhooks/src/waygate_webhooks/errors.py | 20 + .../webhooks/src/waygate_webhooks/handlers.py | 119 ++++ libs/webhooks/src/waygate_webhooks/openapi.py | 96 ++++ libs/webhooks/tests/test_dispatch_errors.py | 27 + libs/webhooks/tests/test_openapi.py | 53 ++ plugins/webhook-agent-session/README.md | 6 +- .../plugin.py | 2 +- .../tests/test_agent_session_plugin.py | 2 +- plugins/webhook-generic/README.md | 4 +- pyproject.toml | 4 +- release-please-config.json | 15 +- scripts/compose_smoke_nats.py | 8 +- uv.lock | 536 ++++++++++++++++-- 61 files changed, 1670 insertions(+), 678 deletions(-) delete mode 100644 apps/api/src/waygate_api/routes/__init__.py delete mode 100644 apps/api/src/waygate_api/routes/webhooks/__init__.py delete mode 100644 apps/api/tests/test_clients.py delete mode 100644 apps/api/tests/test_webhook_dispatch_errors.py delete mode 100644 apps/api/tests/test_webhook_generic_route.py create mode 100644 apps/web/README.md create mode 100644 apps/web/pyproject.toml create mode 100644 apps/web/src/waygate_web/__init__.py create mode 100644 apps/web/src/waygate_web/auth/__init__.py create mode 100644 apps/web/src/waygate_web/auth/setup.py create mode 100644 apps/web/src/waygate_web/routes/__init__.py create mode 100644 apps/web/src/waygate_web/routes/pages.py create mode 100644 apps/web/src/waygate_web/server.py create mode 100644 apps/web/src/waygate_web/templates/base.html create mode 100644 apps/web/src/waygate_web/templates/dashboard.html create mode 100644 apps/web/src/waygate_web/templates/partials/runtime_summary.html create mode 100644 apps/web/tests/test_server.py rename apps/{api/tests/test_startup_validation.py => web/tests/test_web_startup_validation.py} (78%) delete mode 100644 docs/apps/api.md create mode 100644 docs/apps/web.md create mode 100644 libs/webhooks/README.md create mode 100644 libs/webhooks/pyproject.toml create mode 100644 libs/webhooks/src/waygate_webhooks/__init__.py create mode 100644 libs/webhooks/src/waygate_webhooks/app.py create mode 100644 libs/webhooks/src/waygate_webhooks/dispatch.py create mode 100644 libs/webhooks/src/waygate_webhooks/errors.py create mode 100644 libs/webhooks/src/waygate_webhooks/handlers.py create mode 100644 libs/webhooks/src/waygate_webhooks/openapi.py create mode 100644 libs/webhooks/tests/test_dispatch_errors.py create mode 100644 libs/webhooks/tests/test_openapi.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a9f0941..2ad8578 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,28 +1,103 @@ # Instructions for WayGate -WayGate is a modular platform for building Generation-Augmented Retrieval (GAR) workflows. This repository contains the core framework, libraries, plugins, and MCP server. Users can create their own modules to extend its functionality. +WayGate is a Python-first monorepo for building Generation-Augmented Retrieval workflows around a shared plugin runtime. The repository currently has three main working areas: -## Global Override Instructions for Agents +- `apps/` for long-running services such as the web app, scheduler, and workers +- `libs/` for shared runtime, worker, and workflow packages +- `plugins/` for first-party storage, communication, webhook, and provider plugins -These instructions will override any other instructions provided in the repository for GitHub Copilot. +The primary operator surface is a server-rendered FastAPI app under `apps/web`, with mountable webhook ingress extracted into `libs/webhooks`. -- Do not write code before stating assumptions. -- Do not claim correctness you haven't verified. -- Do not handle only the happy path. -- Under what conditions does this work? -- What are the edge cases? -- What are the security implications? -- What are the maintainability implications? +The repo is not a single app. Changes should respect package boundaries and the plugin-first runtime described in `docs/design/`. + +## Global Working Rules + +These rules override generic behavior for agents working in this repository. + +- State assumptions before writing code or making structural edits. +- Do not claim correctness you have not verified. +- Do not handle only the happy path; check failure modes, edge cases, and rollback behavior. +- Call out security implications when touching webhooks, storage, auth, networking, workflow dispatch, or agent-facing inputs. +- Call out maintainability implications when changing shared contracts, plugin hooks, or package boundaries. +- Prefer surgical changes that follow existing patterns in the touched package instead of introducing a new abstraction layer. +- Derive commands, naming, and conventions from the repository instead of relying on tool defaults or template boilerplate. + +## Repo Shape And Boundaries + +Use the current repository layout and names when reasoning about changes. + +- `apps/web` is the unified FastAPI host for the operator UI, AuthTuna auth flows, and mounted webhook ingress. +- `apps/scheduler` emits scheduled workflow triggers. +- `apps/draft-worker` and `apps/nats-worker` execute workflow triggers over different transports. +- `libs/core` owns bootstrap, plugin hooks, config, logging, and shared schema types. +- `libs/webhooks` owns the mountable FastAPI webhook ingress app and webhook-specific OpenAPI helpers. +- `libs/worker` contains shared worker runtime helpers. +- `libs/workflows` contains workflow logic and LangGraph-based orchestration. +- `plugins/*` contains first-party implementations of the core plugin interfaces. + +Do not describe legacy names as current behavior. When architecture questions come up, prefer the terminology used in `docs/design/architecture.md`. + +## Backend And Python Workflow + +The backend workspace uses Python 3.14+, `uv`, pytest, Ruff, and Pyright-related tooling. + +- Prefer `uv` commands for Python environment and package operations. +- Use repo tasks and package-local commands instead of inventing one-off workflows. +- Root setup and test commands should align with the repo files: + - `uv sync --all-groups --all-extras --all-packages` + - `uv run pytest` + - `uv run ruff check . --fix` + - `uv run ruff format .` +- When touching only one package, run the narrowest relevant tests first. +- When changing shared workflow, plugin, or API contracts, run broader regression coverage before concluding. +- If you could not run validation, say so explicitly and state why. + +## Web App + +The primary UI now lives in `apps/web` as a server-rendered FastAPI app. + +Current web stack: + +- FastAPI +- Jinja2 templates +- HTMX +- FastHX for server-rendered page and fragment helpers +- AuthTuna for auth routes and token-oriented auth building blocks +- Tailwind and daisyUI loaded from pinned CDNs unless the user explicitly asks for a local asset pipeline + +Web-app working rules: + +- Keep page routes, templates, and auth wiring inside `apps/web` unless a cross-app library boundary is clearly justified. +- Put mountable webhook ingress behavior in `libs/webhooks`, not in `libs/core`. +- Preserve the existing Python-first tooling and validation flow; do not introduce a Node build step for the web app unless the user asks for one. +- Keep the parent app's OpenAPI surface authoritative by merging mounted webhook schema into the parent docs. + +## Documentation, Design, And Planning + +This repository uses markdown documents under `docs/design/` to capture current design decisions and roadmap themes. Those documents are the source of truth for intended architecture and terminology. + +- Read `docs/design/` before making structural changes to runtime, workflows, storage, or plugin boundaries. +- Update the relevant design docs when a change materially alters system architecture, contracts, or terminology. +- Use `docs/plans/` for new planning documents and implementation plans. +- Treat `docs/plans/` as historical or proposed context, not the authoritative description of current behavior. +- When code and docs disagree, resolve the mismatch instead of silently following stale text. + +## Web App Documentation Expectations + +If a change materially affects `apps/web` or `libs/webhooks`, update the relevant documentation instead of leaving the operator surface implicit in backend-only docs. + +- Update repo-facing docs when the web app introduces new developer workflows, environment needs, or architectural expectations. +- Keep auth guidance specific to the current AuthTuna integration rather than generic FastAPI auth advice. ## Commit Message Convention -This repository uses [Conventional Commits](https://www.conventionalcommits.org/) to drive automated versioning and changelogs via release-please. All commits **must** follow this format: +This repository uses [Conventional Commits](https://www.conventionalcommits.org/) to drive automated versioning and changelogs via release-please. All commits must follow this format: -``` +```text (): ``` -**Types that trigger a release:** +Types that trigger a release: | Type | Release bump | When to use | | ------------------------------------------------------- | ------------ | ----------------------- | @@ -31,30 +106,29 @@ This repository uses [Conventional Commits](https://www.conventionalcommits.org/ | `perf` | patch | Performance improvement | | `feat!` / `fix!` / any `!` or `BREAKING CHANGE:` footer | major | Breaking API change | -**Types that do NOT trigger a release:** +Types that do not trigger a release: `docs`, `chore`, `refactor`, `test`, `style`, `ci`, `build` -**Scope** should be the package name or area (e.g., `core`, `api`, `local-storage`, `provider-ollama`). +Scope should be the package name or area, for example `core`, `web`, `webhooks`, `local-storage`, or `provider-ollama`. -**Examples:** +Examples: -``` +```text feat(core): add plugin config registration hook fix(local-storage): handle missing base_path gracefully -feat(api)!: remove deprecated /v1 endpoint -docs(core): update bootstrap usage example +feat(web)!: remove deprecated operator endpoint +docs(web): document local operator workflow chore(release): release waygate-core 0.2.0 ``` Agents generating commits or commit message suggestions must follow this convention. Do not use free-form commit messages. -## Planning and Design - -This repository uses markdown files under `docs/design/` to capture current design decisions and future roadmap themes. These documents are the source of truth for how the system works and where it's going. When implementing new features or making changes, refer to these design docs to ensure alignment with the overall architecture and roadmap. Update the design docs as needed when making significant changes or adding new features that impact the system's design. +## LangChain And LangGraph -This repository uses markdown files under `docs/plans/` to capture historical planning documents that informed the current design but are not themselves the source of truth for current behavior. These documents are useful for background and context, but should not be treated as defining current contracts or implementations. When in doubt, refer to the code and the `docs/design/` docs for the current state of the system. When creating a plan always write the plan to a new file under `docs/plans/` rather than editing the existing design docs, to preserve a clear record of how the design evolved over time. +When writing code that uses LangChain or when you need LangChain documentation, always use the `langchain-docs` MCP server defined in `.vscode/mcp.json` to query the official documentation. Do not hardcode API details that can be retrieved from the documentation server. -## LangChain +For workflow changes: -when writing code that uses langchain, or when needing to reference langchain documentation, always use the `langchain-docs` mcp server defined in `.vscode/mcp.json` to query the official langchain documentation. Do not link directly to the documentation or hardcode any information that can be obtained from the documentation, as it may become outdated. Always query the `langchain-docs` mcp server for the most up-to-date information on langchain usage, best practices, and API details. +- Keep LangGraph and workflow implementation concerns inside the existing workflow package boundaries unless there is a deliberate architectural reason to move them. +- Do not introduce ad hoc agent-framework patterns without checking the current design docs first. diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7e54771..4d90af7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,9 +1,10 @@ { - "apps/api": "0.1.0", + "apps/web": "0.1.0", "apps/scheduler": "0.1.0", "apps/draft-worker": "0.1.0", "apps/nats-worker": "0.1.0", "libs/core": "0.1.0", + "libs/webhooks": "0.1.0", "libs/worker": "0.1.0", "libs/workflows": "0.1.0", "plugins/local-storage": "0.1.0", diff --git a/README.md b/README.md index 7067bc6..201205b 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ WayGate is a modular platform for building **Generation-Augmented Retrieval (GAR ```text apps/ - api/ — FastAPI HTTP server; exposes webhook endpoints and the OpenAPI schema + web/ — Unified FastAPI web app for UI, auth, and mounted webhook ingress scheduler/ — Background job runner for cron-style workflows draft-worker/ — RQ worker for queued draft workflow triggers nats-worker/ — JetStream worker for durable workflow trigger execution libs/ core/ — Shared framework: plugin system, config registry, bootstrap, logging + webhooks/ — Mountable FastAPI webhook ingress app and OpenAPI helpers worker/ — Shared worker runtime helpers and JetStream consumer loop workflows/ — Shared workflow entrypoints executed by workers plugins/ @@ -39,8 +40,8 @@ Requires Python 3.14+ and [uv](https://docs.astral.sh/uv/). # Install all packages uv sync --all-packages -# Run the API server -uv run waygate-api +# Run the unified web app +uv run waygate-web # Run the scheduler uv run waygate-scheduler @@ -50,11 +51,11 @@ Copy `env.example` to `.env` and set values appropriate for your environment bef ## Docker Compose Smoke Test -Use the Compose stack when you want to exercise the generic webhook -> API -> +Use the Compose stack when you want to exercise the generic webhook -> web app -> JetStream -> nats-worker pipeline with the Ollama provider. The minimum path for that flow is `db`, `valkey`, `chromadb`, `nats`, `ollama`, -`api`, and `nats-worker`. `scheduler` is not required for webhook-driven draft +`web`, and `nats-worker`. `scheduler` is not required for webhook-driven draft runs. Use [env.compose.example](env.compose.example) as the template for your local @@ -73,10 +74,10 @@ docker compose exec ollama ollama pull qwen3.5:9b docker compose exec ollama ollama pull hermes3:8b ``` -1. Start the API and NATS worker. +1. Start the web app and NATS worker. ```bash -docker compose up -d api nats-worker +docker compose up -d web nats-worker ``` 1. Post the sample generic webhook payload. @@ -104,10 +105,11 @@ Important runtime details: | Package | Description | | ------------------------------------------------------------------ | ----------------------------------------- | | [`waygate-core`](libs/core/) | Plugin system, config registry, bootstrap | -| [`waygate-api`](apps/api/) | FastAPI HTTP server | +| [`waygate-web`](apps/web/) | Unified FastAPI web app | | [`waygate-scheduler`](apps/scheduler/) | Cron job runner | | [`waygate-draft-worker`](apps/draft-worker/) | RQ draft worker | | [`waygate-nats-worker`](apps/nats-worker/) | JetStream workflow worker | +| [`waygate-webhooks`](libs/webhooks/) | Mountable webhook ingress app | | [`waygate-worker`](libs/worker/) | Shared worker runtime helpers | | [`waygate-plugin-local-storage`](plugins/local-storage/) | Filesystem storage plugin | | [`waygate-plugin-provider-ollama`](plugins/provider-ollama/) | Ollama LLM provider plugin | diff --git a/apps/api/README.md b/apps/api/README.md index 32892df..1e3faaa 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -1,12 +1,12 @@ # waygate-api -FastAPI HTTP server for WayGate. Exposes webhook ingestion endpoints and serves the OpenAPI schema with per-plugin payload definitions merged in automatically. +Legacy FastAPI webhook ingress for WayGate. This package is retained during the migration to `apps/web`, but it now delegates webhook route registration and OpenAPI schema helpers to `waygate-webhooks`. ## Responsibilities - Receives inbound webhook requests and routes them to the registered `WebhookPlugin` for the matched route. - Persists produced raw documents and submits the workflow trigger built by the matched webhook plugin through the configured communication client plugin. -- Merges each webhook plugin's payload schema into the OpenAPI spec at startup so that `$ref` definitions resolve correctly in Swagger UI and ReDoc. +- Reuses the shared webhook OpenAPI merge helpers from `waygate-webhooks` so the legacy ingress surface stays aligned with the new unified web app. - Bootstraps the WayGate application context (config + plugins) on startup via `waygate-core`. - Instruments the application with OpenTelemetry via `opentelemetry-instrumentation-fastapi`. @@ -28,3 +28,5 @@ All `WAYGATE_*` environment variables are also read — see [`waygate-core`](../ ## OpenAPI The schema is available at `/openapi.json`. Interactive docs are at `/docs` (Swagger UI) and `/redoc`. + +The long-term operator surface is `apps/web`, but `apps/api` remains available as a migration-era ingress-only app. diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 39fa45b..6bfff38 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ "waygate-plugin-communication-nats", "waygate-plugin-communication-rq", "waygate-plugin-local-storage", + "waygate-plugin-webhook-agent-session", "waygate-plugin-webhook-generic", + "waygate-webhooks", ] [dependency-groups] @@ -30,12 +32,14 @@ requires = ["uv_build>=0.11.7,<0.12.0"] build-backend = "uv_build" [tool.uv.sources] -waygate-core = { workspace = true } -waygate-plugin-communication-http = { workspace = true } -waygate-plugin-communication-nats = { workspace = true } -waygate-plugin-communication-rq = { workspace = true } -waygate-plugin-local-storage = { workspace = true } -waygate-plugin-webhook-generic = { workspace = true } +waygate-core = { workspace = true } +waygate-plugin-communication-http = { workspace = true } +waygate-plugin-communication-nats = { workspace = true } +waygate-plugin-communication-rq = { workspace = true } +waygate-plugin-local-storage = { workspace = true } +waygate-plugin-webhook-agent-session = { workspace = true } +waygate-plugin-webhook-generic = { workspace = true } +waygate-webhooks = { workspace = true } [tool.fastapi] entrypoint = "waygate_api.server:app" diff --git a/apps/api/src/waygate_api/__init__.py b/apps/api/src/waygate_api/__init__.py index 0cbe5cd..c3fddc8 100644 --- a/apps/api/src/waygate_api/__init__.py +++ b/apps/api/src/waygate_api/__init__.py @@ -1,23 +1,18 @@ -"""WayGate API application entry points.""" +"""Legacy CLI entrypoint for the WayGate API compatibility app.""" -import os +from os import getenv import uvicorn + from waygate_core import bootstrap_app -from waygate_core.logging import get_logger +from waygate_core.plugin import CommunicationClientResolutionError from waygate_core.plugin import resolve_communication_client __VERSION__ = "0.1.0" # x-release-please-version -logger = get_logger(__name__) - def main() -> None: - """Start the WayGate API server. - - The server bootstraps the shared runtime first so startup fails fast when - the configured communication plugin is unavailable. - """ + """Preflight runtime configuration and start the legacy API server.""" app_context = bootstrap_app() resolve_communication_client( @@ -26,7 +21,15 @@ def main() -> None: allow_fallback=False, ) - host = os.getenv("HOST", "0.0.0.0") - port = int(os.getenv("PORT", "8080")) - logger.info(f"Starting WayGate API Server on {host}:{port}") + host = getenv("HOST", "0.0.0.0") + port = int(getenv("PORT", "8080")) uvicorn.run("waygate_api.server:app", host=host, port=port) + + +__all__ = [ + "CommunicationClientResolutionError", + "__VERSION__", + "bootstrap_app", + "main", + "uvicorn", +] diff --git a/apps/api/src/waygate_api/clients.py b/apps/api/src/waygate_api/clients.py index 7aa96a1..0f725c3 100644 --- a/apps/api/src/waygate_api/clients.py +++ b/apps/api/src/waygate_api/clients.py @@ -1,3 +1,5 @@ +"""Compatibility helpers for the legacy API package.""" + from waygate_core import get_app_context from waygate_core.plugin import ( CommunicationClientPlugin, @@ -23,14 +25,7 @@ def _resolve_communication_client() -> CommunicationClientPlugin: async def send_draft_message(document_paths: list[str]) -> WorkflowDispatchResult: - """Submit a draft-ready workflow trigger for the given document paths. - - Args: - document_paths: Storage-backed document paths to include in the trigger. - - Returns: - The dispatch result returned by the communication client. - """ + """Submit a draft-ready workflow trigger for the given document paths.""" if not document_paths: return WorkflowDispatchResult( @@ -48,14 +43,7 @@ async def send_draft_message(document_paths: list[str]) -> WorkflowDispatchResul async def send_workflow_message( message: WorkflowTriggerMessage, ) -> WorkflowDispatchResult: - """Submit an arbitrary workflow trigger via the configured transport. - - Args: - message: The workflow trigger message to submit. - - Returns: - The dispatch result returned by the communication client. - """ + """Submit an arbitrary workflow trigger via the configured transport.""" client = _resolve_communication_client() return await client.submit_workflow_trigger(message) diff --git a/apps/api/src/waygate_api/routes/__init__.py b/apps/api/src/waygate_api/routes/__init__.py deleted file mode 100644 index 9eb8836..0000000 --- a/apps/api/src/waygate_api/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Route package for the WayGate API app.""" diff --git a/apps/api/src/waygate_api/routes/webhooks/__init__.py b/apps/api/src/waygate_api/routes/webhooks/__init__.py deleted file mode 100644 index e84259b..0000000 --- a/apps/api/src/waygate_api/routes/webhooks/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Webhook route package for the WayGate API app.""" - -__all__ = ["webhook_router"] - - -def __getattr__(name: str): - """Lazily import the webhook router to avoid eager plugin loading. - - Args: - name: The attribute name being requested. - - Returns: - The webhook router when ``name`` is ``webhook_router``. - - Raises: - AttributeError: If the requested attribute is unknown. - """ - - if name == "webhook_router": - from .router import webhook_router - - return webhook_router - raise AttributeError(name) diff --git a/apps/api/src/waygate_api/routes/webhooks/errors.py b/apps/api/src/waygate_api/routes/webhooks/errors.py index 97259c8..e21c6e7 100644 --- a/apps/api/src/waygate_api/routes/webhooks/errors.py +++ b/apps/api/src/waygate_api/routes/webhooks/errors.py @@ -1,25 +1,5 @@ -from waygate_core.plugin import DispatchErrorKind, WorkflowDispatchResult +"""Compatibility shim for legacy API webhook dispatch error helpers.""" +from waygate_webhooks.errors import map_dispatch_failure_to_http -def map_dispatch_failure_to_http( - result: WorkflowDispatchResult, -) -> tuple[int, str]: - """Map a workflow dispatch failure to an HTTP response. - - Args: - result: The workflow dispatch result returned by the transport. - - Returns: - A ``(status_code, detail)`` tuple suitable for ``HTTPException``. - """ - - detail = result.detail or "Failed to submit workflow trigger message" - - if result.error_kind == DispatchErrorKind.VALIDATION: - return (422, detail) - if result.error_kind == DispatchErrorKind.CONFIG: - return (503, detail) - if result.error_kind == DispatchErrorKind.TRANSIENT: - return (502, detail) - - return (500, detail) +__all__ = ["map_dispatch_failure_to_http"] diff --git a/apps/api/src/waygate_api/routes/webhooks/router.py b/apps/api/src/waygate_api/routes/webhooks/router.py index 24a688e..d4ad875 100644 --- a/apps/api/src/waygate_api/routes/webhooks/router.py +++ b/apps/api/src/waygate_api/routes/webhooks/router.py @@ -1,165 +1,9 @@ -"""Dynamic webhook routing for the WayGate API app.""" +"""Compatibility shims for the legacy API webhook router module.""" -from waygate_core import get_app_context -from waygate_api.clients import send_workflow_message -from waygate_api.routes.webhooks.errors import map_dispatch_failure_to_http -from waygate_core.files import compute_content_hash, render_raw_document -from waygate_core.plugin.storage import StorageNamespace -from waygate_core.logging import get_logger -import json -from collections.abc import Callable +from waygate_webhooks.handlers import create_webhook_router +from waygate_webhooks.openapi import build_webhook_openapi_extra -from fastapi import APIRouter, HTTPException, Request +webhook_router = create_webhook_router(prefix="/webhooks") +_build_openapi_extra = build_webhook_openapi_extra -from waygate_core.plugin import ( - WebhookPlugin, - WebhookVerificationError, -) - -webhook_router = APIRouter(prefix="/webhooks", tags=["webhooks"]) - -logger = get_logger() -app_context = get_app_context() -storage = app_context.plugins.storage[app_context.config.core.storage_plugin_name] - - -def _make_handler(plugin: WebhookPlugin) -> Callable: - """Build a FastAPI route handler for a webhook plugin. - - Args: - plugin: The webhook plugin to bind to the route handler. - - Returns: - An async FastAPI route handler closure. - """ - - async def handle_webhook(request: Request): - """Handle a webhook request for the bound plugin. - - Args: - request: The incoming FastAPI request. - - Returns: - A JSON-compatible success payload. - """ - - raw_body = await request.body() - headers = dict(request.headers) - - try: - await plugin.verify_webhook_request(headers, raw_body) - payload = json.loads(raw_body.decode("utf-8")) if raw_body else {} - payload = await plugin.enrich_webhook_payload(payload, headers) - raw_documents = await plugin.handle_webhook(payload) - - if raw_documents: - logger.debug( - f"Plugin '{plugin.name}' produced {len(raw_documents)} raw documents." - ) - written_paths = [] - for doc in raw_documents: - content_hash = doc.content_hash or compute_content_hash(doc.content) - path = ( - storage.build_namespaced_path( - StorageNamespace.Raw, content_hash - ) - + ".txt" - ) - written_paths.append( - storage.write_document(path, render_raw_document(doc)) - ) - - written_paths = list(dict.fromkeys(written_paths)) - - workflow_message = plugin.build_workflow_trigger(payload, written_paths) - if workflow_message is not None: - dispatch_result = await send_workflow_message(workflow_message) - if not dispatch_result.accepted: - status_code, detail = map_dispatch_failure_to_http( - dispatch_result - ) - raise HTTPException(status_code=status_code, detail=detail) - - logger.debug( - f"Plugin '{plugin.name}' wrote {len(written_paths)} documents to storage: {written_paths}" - ) - - return { - "status": "success", - "processed": len(raw_documents), - "message": f"Webhook handled by plugin '{plugin.name}'", - } - - except WebhookVerificationError as exc: - raise HTTPException(status_code=401, detail=str(exc)) - except HTTPException: - raise - except json.JSONDecodeError as exc: - raise HTTPException( - status_code=400, detail=f"Invalid JSON payload: {exc.msg}" - ) - except NotImplementedError: - raise HTTPException( - status_code=501, - detail=f"Plugin '{plugin.name}' does not implement webhook handling", - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) - - # Give the function a unique name so FastAPI uses distinct operation IDs per plugin. - handle_webhook.__name__ = f"handle_webhook_{plugin.name.replace('-', '_')}" - return handle_webhook - - -def _build_openapi_extra(plugin: WebhookPlugin) -> dict | None: - """Build the OpenAPI request-body schema for a webhook plugin. - - Pydantic emits nested-model definitions in ``$defs`` by default. The API - server later hoists those definitions into ``components/schemas`` so the - generated OpenAPI document resolves correctly in Swagger UI and ReDoc. - - Args: - plugin: The webhook plugin whose payload schema should be exported. - - Returns: - The ``openapi_extra`` mapping for the plugin route, or ``None`` when the - plugin does not declare a payload schema. - """ - payload_schema = plugin.openapi_payload_schema - if payload_schema is None: - return None - - schema = payload_schema.model_json_schema( - ref_template="#/components/schemas/{model}" - ) - # $defs are populated into components/schemas by the server-level openapi() - # override; drop them here so they don't appear inside the requestBody. - schema.pop("$defs", None) - - return { - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": schema, - } - }, - } - } - - -# Dynamically register one route per discovered plugin so each endpoint has -# its own OpenAPI entry with a plugin-supplied schema and description. -for _plugin in app_context.plugins.webhooks.values(): - _openapi_extra = _build_openapi_extra(_plugin) - - webhook_router.add_api_route( - f"/{_plugin.name}", - _make_handler(_plugin), - methods=["POST"], - summary=_plugin.openapi_summary, - description=_plugin.description, - openapi_extra=_openapi_extra, - ) +__all__ = ["_build_openapi_extra", "webhook_router"] diff --git a/apps/api/src/waygate_api/server.py b/apps/api/src/waygate_api/server.py index c903e56..f0199c1 100644 --- a/apps/api/src/waygate_api/server.py +++ b/apps/api/src/waygate_api/server.py @@ -1,12 +1,13 @@ -"""FastAPI application assembly for the WayGate API app.""" +"""FastAPI application assembly for the legacy WayGate API app.""" + +from contextlib import asynccontextmanager -from waygate_api.routes.webhooks.router import webhook_router from fastapi import FastAPI -from fastapi.openapi.utils import get_openapi from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor -from contextlib import asynccontextmanager -from typing import Any + from waygate_core import get_app_context +from waygate_webhooks.handlers import create_webhook_router +from waygate_webhooks.openapi import build_webhook_openapi_schema @asynccontextmanager @@ -20,45 +21,16 @@ async def lifespan(app: FastAPI): app = FastAPI( title="WayGate API", - description="API for WayGate", + description="Legacy WayGate webhook ingress service.", version="0.1.0", lifespan=lifespan, ) -def custom_openapi() -> dict[str, Any]: - """Build the OpenAPI schema with webhook payload definitions merged in.""" - - if app.openapi_schema: - return app.openapi_schema - - schema = get_openapi( - title=app.title, - version=app.version, - description=app.description, - routes=app.routes, - ) - - # Merge each plugin's payload-schema definitions into components/schemas so - # that $ref values like "#/components/schemas/Foo" (produced by router.py - # via ref_template) resolve correctly in Swagger UI / ReDoc. - component_schemas: dict = schema.setdefault("components", {}).setdefault( - "schemas", {} - ) - for plugin in get_app_context().plugins.webhooks.values(): - payload_schema = plugin.openapi_payload_schema - if payload_schema is None: - continue - full = payload_schema.model_json_schema( - ref_template="#/components/schemas/{model}" - ) - # $defs holds the nested-model definitions; hoist them to the top level. - defs: dict = full.pop("$defs", {}) - component_schemas.update(defs) - component_schemas[payload_schema.__name__] = full - - app.openapi_schema = schema - return schema +def custom_openapi() -> dict[str, object]: + """Build the OpenAPI schema for the legacy API webhook surface.""" + + return build_webhook_openapi_schema(app) app.openapi = custom_openapi # type: ignore[method-assign] # ty:ignore[invalid-assignment] @@ -66,4 +38,4 @@ def custom_openapi() -> dict[str, Any]: FastAPIInstrumentor().instrument_app(app) -app.include_router(webhook_router) +app.include_router(create_webhook_router(prefix="/webhooks")) diff --git a/apps/api/tests/test_clients.py b/apps/api/tests/test_clients.py deleted file mode 100644 index 3d799c0..0000000 --- a/apps/api/tests/test_clients.py +++ /dev/null @@ -1,137 +0,0 @@ -import asyncio -from types import SimpleNamespace - -import pytest - -from waygate_api import clients -from waygate_core.plugin import ( - CommunicationClientResolutionError, - WorkflowDispatchResult, - WorkflowTriggerMessage, -) - - -class FakeCommunicationClient: - def __init__(self) -> None: - self.messages = [] - - async def submit_workflow_trigger(self, message): - self.messages.append(message) - return WorkflowDispatchResult(accepted=True, transport_message_id="msg-1") - - -def _make_app_context(preferred: str, client_map: dict[str, object]): - return SimpleNamespace( - config=SimpleNamespace( - core=SimpleNamespace(communication_plugin_name=preferred) - ), - plugins=SimpleNamespace(communication=client_map), - ) - - -def test_resolve_communication_client_uses_preferred( - monkeypatch: pytest.MonkeyPatch, -) -> None: - preferred = FakeCommunicationClient() - fallback = FakeCommunicationClient() - context = _make_app_context( - "preferred", {"preferred": preferred, "fallback": fallback} - ) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - resolved = clients._resolve_communication_client() - - assert resolved is preferred - - -def test_resolve_communication_client_raises_when_preferred_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fallback = FakeCommunicationClient() - context = _make_app_context("missing", {"fallback": fallback}) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - with pytest.raises(CommunicationClientResolutionError, match="unavailable"): - clients._resolve_communication_client() - - -def test_resolve_communication_client_raises_when_none_installed( - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = _make_app_context("preferred", {}) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - with pytest.raises(RuntimeError, match="No communication plugins"): - clients._resolve_communication_client() - - -def test_send_draft_message_submits_expected_payload( - monkeypatch: pytest.MonkeyPatch, -) -> None: - communication_client = FakeCommunicationClient() - context = _make_app_context("preferred", {"preferred": communication_client}) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - result = asyncio.run(clients.send_draft_message(["raw/a.txt", "raw/b.txt"])) - - assert result.accepted is True - assert len(communication_client.messages) == 1 - message = communication_client.messages[0] - assert message.event_type == "draft.ready" - assert message.source == "waygate-api.webhooks" - assert message.document_paths == ["raw/a.txt", "raw/b.txt"] - - -def test_send_draft_message_short_circuits_empty_document_paths( - monkeypatch: pytest.MonkeyPatch, -) -> None: - communication_client = FakeCommunicationClient() - context = _make_app_context("preferred", {"preferred": communication_client}) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - result = asyncio.run(clients.send_draft_message([])) - - assert result.accepted is True - assert result.detail == "No document paths supplied" - assert communication_client.messages == [] - - -def test_send_workflow_message_submits_custom_payload( - monkeypatch: pytest.MonkeyPatch, -) -> None: - communication_client = FakeCommunicationClient() - context = _make_app_context("preferred", {"preferred": communication_client}) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - message = WorkflowTriggerMessage( - event_type="draft.ready", - source="waygate-api.webhooks.agent-session", - document_paths=["file://raw/session-123.txt"], - idempotency_key="github-copilot-chat:session-123", - metadata={"session_id": "session-123"}, - ) - - result = asyncio.run(clients.send_workflow_message(message)) - - assert result.accepted is True - assert communication_client.messages == [message] - - -def test_send_workflow_message_submits_metadata_only_payload( - monkeypatch: pytest.MonkeyPatch, -) -> None: - communication_client = FakeCommunicationClient() - context = _make_app_context("preferred", {"preferred": communication_client}) - monkeypatch.setattr(clients, "get_app_context", lambda: context) - - message = WorkflowTriggerMessage( - event_type="cron.tick", - source="waygate-scheduler.cron.example", - document_paths=[], - metadata={"schedule": "*/5 * * * *"}, - ) - - result = asyncio.run(clients.send_workflow_message(message)) - - assert result.accepted is True - assert communication_client.messages == [message] diff --git a/apps/api/tests/test_webhook_agent_session_route.py b/apps/api/tests/test_webhook_agent_session_route.py index 0f37258..e57ea6b 100644 --- a/apps/api/tests/test_webhook_agent_session_route.py +++ b/apps/api/tests/test_webhook_agent_session_route.py @@ -17,6 +17,10 @@ def test_build_openapi_extra_includes_agent_session_payload_schema() -> None: def test_webhook_router_registers_agent_session_route() -> None: - paths = {route.path for route in webhook_router.routes} + paths = { + path + for route in webhook_router.routes + if (path := getattr(route, "path", None)) is not None + } assert "/webhooks/agent-session" in paths diff --git a/apps/api/tests/test_webhook_dispatch_errors.py b/apps/api/tests/test_webhook_dispatch_errors.py deleted file mode 100644 index 0464b0e..0000000 --- a/apps/api/tests/test_webhook_dispatch_errors.py +++ /dev/null @@ -1,54 +0,0 @@ -from waygate_api.routes.webhooks.errors import map_dispatch_failure_to_http -from waygate_core.plugin import DispatchErrorKind, WorkflowDispatchResult - - -def test_map_dispatch_failure_validation() -> None: - status_code, detail = map_dispatch_failure_to_http( - WorkflowDispatchResult( - accepted=False, - detail="invalid payload", - error_kind=DispatchErrorKind.VALIDATION, - ) - ) - - assert status_code == 422 - assert detail == "invalid payload" - - -def test_map_dispatch_failure_config() -> None: - status_code, detail = map_dispatch_failure_to_http( - WorkflowDispatchResult( - accepted=False, - detail="missing plugin config", - error_kind=DispatchErrorKind.CONFIG, - ) - ) - - assert status_code == 503 - assert detail == "missing plugin config" - - -def test_map_dispatch_failure_transient() -> None: - status_code, detail = map_dispatch_failure_to_http( - WorkflowDispatchResult( - accepted=False, - detail="upstream timeout", - error_kind=DispatchErrorKind.TRANSIENT, - ) - ) - - assert status_code == 502 - assert detail == "upstream timeout" - - -def test_map_dispatch_failure_default_permanent() -> None: - status_code, detail = map_dispatch_failure_to_http( - WorkflowDispatchResult( - accepted=False, - detail="unexpected worker response", - error_kind=DispatchErrorKind.PERMANENT, - ) - ) - - assert status_code == 500 - assert detail == "unexpected worker response" diff --git a/apps/api/tests/test_webhook_generic_route.py b/apps/api/tests/test_webhook_generic_route.py deleted file mode 100644 index 4669dcb..0000000 --- a/apps/api/tests/test_webhook_generic_route.py +++ /dev/null @@ -1,24 +0,0 @@ -from waygate_api.routes.webhooks.router import _build_openapi_extra -from waygate_plugin_webhook_generic.plugin import GenericWebhookPlugin - - -def test_build_openapi_extra_includes_generic_webhook_payload_schema() -> None: - plugin = GenericWebhookPlugin() - - extra = _build_openapi_extra(plugin) - - assert extra is not None - request_body = extra["requestBody"] - assert request_body["required"] is True - - schema = request_body["content"]["application/json"]["schema"] - assert "$defs" not in schema - assert schema["title"] == "GenericWebhookPayload" - assert ( - schema["properties"]["metadata"]["$ref"] - == "#/components/schemas/GenericWebhookPayloadMetadata" - ) - assert ( - schema["properties"]["documents"]["items"]["$ref"] - == "#/components/schemas/GenericWebhookPayloadDocument" - ) diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..6c7f455 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,20 @@ +# waygate-web + +Unified FastAPI application for WayGate. + +## Responsibilities + +- Hosts the server-rendered operator UI with Jinja templates, HTMX fragments, and daisyUI styling +- Initializes AuthTuna for browser and API-oriented auth flows +- Mounts the reusable `waygate-webhooks` ingress sub-application at `/webhooks` +- Merges webhook OpenAPI endpoints into the parent app's docs so the unified app remains the primary surface + +## Running + +```bash +uv run waygate-web +``` + +## Auth Defaults + +The app seeds local-development AuthTuna settings when auth environment variables are absent. These defaults are intended only for local development and should be overridden in deployed environments. diff --git a/apps/web/pyproject.toml b/apps/web/pyproject.toml new file mode 100644 index 0000000..511a4bf --- /dev/null +++ b/apps/web/pyproject.toml @@ -0,0 +1,59 @@ +[project] +name = "waygate-web" +version = "0.1.0" +description = "Unified WayGate web app with server-rendered UI and webhook ingress" +readme = "README.md" +authors = [ + { name = "Buck Brady", email = "22723438+voidrot@users.noreply.github.com" }, +] +requires-python = ">=3.14" +dependencies = [ + "authtuna>=0.2.3", + "fasthx[jinja]>=2.4.2", + "fastapi[standard]>=0.135.3", + "opentelemetry-instrumentation-fastapi>=0.62b0", + "pydantic>=2.12.5", + "waygate-core", + "waygate-plugin-communication-http", + "waygate-plugin-communication-nats", + "waygate-plugin-communication-rq", + "waygate-plugin-local-storage", + "waygate-plugin-webhook-agent-session", + "waygate-plugin-webhook-generic", + "waygate-webhooks", +] + +[dependency-groups] +dev = ["pytest>=9.0.3", "pytest-cov>=7.1.0"] + +[project.scripts] +waygate-web = "waygate_web:main" + +[build-system] +requires = ["uv_build>=0.11.7,<0.12.0"] +build-backend = "uv_build" + +[tool.uv.sources] +waygate-core = { workspace = true } +waygate-plugin-communication-http = { workspace = true } +waygate-plugin-communication-nats = { workspace = true } +waygate-plugin-communication-rq = { workspace = true } +waygate-plugin-local-storage = { workspace = true } +waygate-plugin-webhook-agent-session = { workspace = true } +waygate-plugin-webhook-generic = { workspace = true } +waygate-webhooks = { workspace = true } + +[tool.fastapi] +entrypoint = "waygate_web.server:app" + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +addopts = [ + "-ra", + "--strict-config", + "--strict-markers", + "--cov=waygate_web", + "--cov-report=term-missing", +] diff --git a/apps/web/src/waygate_web/__init__.py b/apps/web/src/waygate_web/__init__.py new file mode 100644 index 0000000..dcce4ce --- /dev/null +++ b/apps/web/src/waygate_web/__init__.py @@ -0,0 +1,35 @@ +"""CLI entrypoint for the unified WayGate web application.""" + +from os import getenv + +import uvicorn + +from waygate_core import bootstrap_app +from waygate_core.plugin import CommunicationClientResolutionError +from waygate_core.plugin import resolve_communication_client + +__VERSION__ = "0.1.0" # x-release-please-version + + +def main() -> None: + """Preflight runtime configuration and start the Uvicorn server.""" + + app_context = bootstrap_app() + resolve_communication_client( + app_context.plugins.communication, + app_context.config.core.communication_plugin_name, + allow_fallback=False, + ) + + host = getenv("HOST", "0.0.0.0") + port = int(getenv("PORT", "8080")) + uvicorn.run("waygate_web.server:app", host=host, port=port) + + +__all__ = [ + "CommunicationClientResolutionError", + "__VERSION__", + "bootstrap_app", + "main", + "uvicorn", +] diff --git a/apps/web/src/waygate_web/auth/__init__.py b/apps/web/src/waygate_web/auth/__init__.py new file mode 100644 index 0000000..02d150a --- /dev/null +++ b/apps/web/src/waygate_web/auth/__init__.py @@ -0,0 +1,5 @@ +"""AuthTuna integration helpers for the WayGate web app.""" + +from .setup import configure_auth + +__all__ = ["configure_auth"] diff --git a/apps/web/src/waygate_web/auth/setup.py b/apps/web/src/waygate_web/auth/setup.py new file mode 100644 index 0000000..6c60150 --- /dev/null +++ b/apps/web/src/waygate_web/auth/setup.py @@ -0,0 +1,31 @@ +"""AuthTuna configuration for the unified web application.""" + +from __future__ import annotations + +from os import getenv + +from authtuna import init_app, init_settings +from fastapi import FastAPI + +_DEV_FERNET_KEY = "2frEu1Z8ib60_6mFcq6VjA_CVxUeNbtOULgNtGx6uiE=" +_DEV_JWT_SECRET = "waygate-web-dev-secret-change-me" + + +def configure_auth(app: FastAPI) -> None: + """Initialize AuthTuna with local-safe defaults and environment overrides.""" + + base_url = getenv( + "WAYGATE_WEB_BASE_URL", getenv("API_BASE_URL", "http://localhost:8080") + ) + + init_settings( + API_BASE_URL=base_url, + APP_NAME="WayGate", + STRATEGY=getenv("WAYGATE_WEB_AUTH_STRATEGY", "AUTO"), + FERNET_KEYS=[getenv("WAYGATE_WEB_FERNET_KEY", _DEV_FERNET_KEY)], + JWT_SECRET_KEY=getenv("WAYGATE_WEB_JWT_SECRET", _DEV_JWT_SECRET), + SESSION_SECURE=getenv("WAYGATE_WEB_SESSION_SECURE", "false").lower() == "true", + UI_ENABLED=True, + dont_use_env=False, + ) + init_app(app) diff --git a/apps/web/src/waygate_web/routes/__init__.py b/apps/web/src/waygate_web/routes/__init__.py new file mode 100644 index 0000000..b905070 --- /dev/null +++ b/apps/web/src/waygate_web/routes/__init__.py @@ -0,0 +1,5 @@ +"""Route registration for the unified WayGate web app.""" + +from .pages import page_router + +__all__ = ["page_router"] diff --git a/apps/web/src/waygate_web/routes/pages.py b/apps/web/src/waygate_web/routes/pages.py new file mode 100644 index 0000000..9089c86 --- /dev/null +++ b/apps/web/src/waygate_web/routes/pages.py @@ -0,0 +1,70 @@ +"""Server-rendered page and HTMX fragment routes for the web UI.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fasthx.jinja import Jinja +from fastapi import APIRouter +from fastapi.templating import Jinja2Templates + +from waygate_core import get_app_context + +_templates = Jinja2Templates( + directory=str(Path(__file__).resolve().parents[1] / "templates") +) +_jinja = Jinja(_templates) + +page_router = APIRouter(tags=["pages"]) + + +@page_router.get("/") +@_jinja.page("dashboard.html") +async def dashboard() -> dict[str, Any]: + """Render the main operator dashboard shell.""" + + app_context = get_app_context() + plugin_counts = { + "webhooks": len(app_context.plugins.webhooks), + "communication": len(app_context.plugins.communication), + "storage": len(app_context.plugins.storage), + "llm": len(app_context.plugins.llm), + } + return { + "page_title": "WayGate Control Plane", + "mounts": [ + { + "label": "Webhook ingress", + "href": "/docs#tag/webhooks", + "description": "Mounted FastAPI sub-app with merged OpenAPI docs.", + }, + { + "label": "Auth flows", + "href": "/auth/login", + "description": "AuthTuna-provided sign-in and token surfaces.", + }, + ], + "plugin_counts": plugin_counts, + } + + +@page_router.get("/partials/runtime") +@_jinja.hx("partials/runtime_summary.html") +async def runtime_summary() -> dict[str, Any]: + """Render a small HTMX fragment summarizing the current runtime.""" + + app_context = get_app_context() + return { + "runtime_rows": [ + ("Storage plugin", app_context.config.core.storage_plugin_name), + ( + "Communication plugin", + app_context.config.core.communication_plugin_name, + ), + ( + "Webhook plugins", + ", ".join(sorted(app_context.plugins.webhooks.keys())) or "none", + ), + ] + } diff --git a/apps/web/src/waygate_web/server.py b/apps/web/src/waygate_web/server.py new file mode 100644 index 0000000..8c62590 --- /dev/null +++ b/apps/web/src/waygate_web/server.py @@ -0,0 +1,60 @@ +"""FastAPI application assembly for the unified WayGate web app.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + +from waygate_core import get_app_context +from waygate_webhooks import create_webhook_app, merge_mounted_webhook_openapi + +from .auth import configure_auth +from .routes import page_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Bootstrap shared runtime state before the app starts serving requests.""" + + get_app_context() + yield + + +app = FastAPI( + title="WayGate Web", + description="Unified WayGate web surface for UI, auth, and webhook ingress.", + version="0.1.0", + lifespan=lifespan, +) + +configure_auth(app) + +webhook_app = create_webhook_app() +app.mount("/webhooks", webhook_app) +app.include_router(page_router) + + +def custom_openapi() -> dict[str, Any]: + """Build the parent schema and merge in mounted webhook endpoints.""" + + if app.openapi_schema: + return app.openapi_schema + + schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + merge_mounted_webhook_openapi(schema, webhook_app, mount_path="/webhooks") + app.openapi_schema = schema + return schema + + +app.openapi = custom_openapi + +FastAPIInstrumentor().instrument_app(app) diff --git a/apps/web/src/waygate_web/templates/base.html b/apps/web/src/waygate_web/templates/base.html new file mode 100644 index 0000000..39c9ee1 --- /dev/null +++ b/apps/web/src/waygate_web/templates/base.html @@ -0,0 +1,29 @@ + + + + + + {{ page_title or "WayGate" }} + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/apps/web/src/waygate_web/templates/dashboard.html b/apps/web/src/waygate_web/templates/dashboard.html new file mode 100644 index 0000000..533a35c --- /dev/null +++ b/apps/web/src/waygate_web/templates/dashboard.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} {% block content %} +
+
+
+ Unified app +

{{ page_title }}

+

+ Server-rendered control plane for WayGate with mounted webhook ingress, + AuthTuna auth flows, and HTMX fragments for operator views. +

+ +
+
+
+

Mounted surfaces

+
    + {% for mount in mounts %} +
  • +
    + {{ mount.label }} +

    + {{ mount.description }} +

    +
    +
  • + {% endfor %} +
+
+
+
+
+ +
+ {% for label, value in plugin_counts.items() %} +
+
{{ label }}
+
{{ value }}
+
+ {% endfor %} +
+ +
+
+
+
+

Runtime summary

+

+ HTMX fragment rendered from the server using Jinja partials. +

+
+ +
+
+
+
+
+
+{% endblock %} diff --git a/apps/web/src/waygate_web/templates/partials/runtime_summary.html b/apps/web/src/waygate_web/templates/partials/runtime_summary.html new file mode 100644 index 0000000..c319450 --- /dev/null +++ b/apps/web/src/waygate_web/templates/partials/runtime_summary.html @@ -0,0 +1,18 @@ +
+ + + + + + + + + {% for label, value in runtime_rows %} + + + + + {% endfor %} + +
ComponentSelection
{{ label }}{{ value }}
+
diff --git a/apps/web/tests/test_server.py b/apps/web/tests/test_server.py new file mode 100644 index 0000000..ea11dc8 --- /dev/null +++ b/apps/web/tests/test_server.py @@ -0,0 +1,23 @@ +from fastapi.testclient import TestClient + +from waygate_web.server import app + + +def test_root_dashboard_renders() -> None: + client = TestClient(app) + + response = client.get("/") + + assert response.status_code == 200 + assert "WayGate Control Plane" in response.text + assert "Runtime summary" in response.text + + +def test_parent_openapi_includes_mounted_webhook_paths() -> None: + client = TestClient(app) + + response = client.get("/openapi.json") + + assert response.status_code == 200 + schema = response.json() + assert "/webhooks/generic-webhook" in schema["paths"] diff --git a/apps/api/tests/test_startup_validation.py b/apps/web/tests/test_web_startup_validation.py similarity index 78% rename from apps/api/tests/test_startup_validation.py rename to apps/web/tests/test_web_startup_validation.py index 13d8b74..a2ac665 100644 --- a/apps/api/tests/test_startup_validation.py +++ b/apps/web/tests/test_web_startup_validation.py @@ -2,8 +2,8 @@ import pytest -from waygate_api import main from waygate_core.plugin import CommunicationClientResolutionError +from waygate_web import main def _make_context(plugin_name: str, plugins: dict[str, object]): @@ -19,8 +19,8 @@ def test_main_fails_fast_when_configured_plugin_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: context = _make_context("missing", {"communication-http": object()}) - monkeypatch.setattr("waygate_api.bootstrap_app", lambda: context) - monkeypatch.setattr("waygate_api.uvicorn.run", lambda *args, **kwargs: None) + monkeypatch.setattr("waygate_web.bootstrap_app", lambda: context) + monkeypatch.setattr("waygate_web.uvicorn.run", lambda *args, **kwargs: None) with pytest.raises(CommunicationClientResolutionError, match="unavailable"): main() @@ -32,12 +32,12 @@ def test_main_runs_when_configured_plugin_is_present( context = _make_context("communication-http", {"communication-http": object()}) run_calls = {"count": 0} - monkeypatch.setattr("waygate_api.bootstrap_app", lambda: context) + monkeypatch.setattr("waygate_web.bootstrap_app", lambda: context) def fake_run(*args, **kwargs): run_calls["count"] += 1 - monkeypatch.setattr("waygate_api.uvicorn.run", fake_run) + monkeypatch.setattr("waygate_web.uvicorn.run", fake_run) main() diff --git a/compose.yml b/compose.yml index c8a3c4e..a2b2781 100644 --- a/compose.yml +++ b/compose.yml @@ -1,10 +1,10 @@ services: - api: + web: build: context: . dockerfile: docker/Dockerfile args: - - APP=api + - APP=web ports: - "${API_PORT:-8080}:8080" env_file: @@ -20,7 +20,7 @@ services: - wiki_data:/data/wiki develop: watch: - - path: apps/api + - path: apps/web action: rebuild - path: libs action: rebuild diff --git a/docker/Dockerfile b/docker/Dockerfile index c51d281..5ebc945 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ # ARG must be declared before the first FROM to be available in all stages. # Override at build time: docker build --build-arg APP=scheduler ... -ARG APP=api +ARG APP=web ARG APP_UID=1000 ARG APP_GID=1000 @@ -35,11 +35,12 @@ WORKDIR /app COPY pyproject.toml uv.lock ./ # 4. Copy every workspace member's pyproject.toml (preserving directory structure). -COPY apps/api/pyproject.toml apps/api/ +COPY apps/web/pyproject.toml apps/web/ COPY apps/scheduler/pyproject.toml apps/scheduler/ COPY apps/draft-worker/pyproject.toml apps/draft-worker/ COPY apps/nats-worker/pyproject.toml apps/nats-worker/ COPY libs/core/pyproject.toml libs/core/ +COPY libs/webhooks/pyproject.toml libs/webhooks/ COPY libs/worker/pyproject.toml libs/worker/ COPY libs/workflows/pyproject.toml libs/workflows/ COPY plugins/communication-http/pyproject.toml plugins/communication-http/ @@ -47,6 +48,7 @@ COPY plugins/communication-nats/pyproject.toml plugins/communication-nats/ COPY plugins/communication-rq/pyproject.toml plugins/communication-rq/ COPY plugins/local-storage/pyproject.toml plugins/local-storage/ COPY plugins/provider-ollama/pyproject.toml plugins/provider-ollama/ +COPY plugins/webhook-agent-session/pyproject.toml plugins/webhook-agent-session/ COPY plugins/webhook-generic/pyproject.toml plugins/webhook-generic/ # 5. Install only the third-party PyPI dependencies for the target app. @@ -105,6 +107,6 @@ ENV PATH="/app/.venv/bin:$PATH" USER appuser # 12. Invoke the app's entry point script (defined in [project.scripts] in each -# app's pyproject.toml, e.g. waygate-api or waygate-scheduler). +# app's pyproject.toml, e.g. waygate-web or waygate-scheduler). # exec replaces the shell process so the application receives OS signals directly. CMD ["/bin/sh", "-c", "exec waygate-${APP}"] diff --git a/docs/apps/README.md b/docs/apps/README.md index c75b7a7..2116a77 100644 --- a/docs/apps/README.md +++ b/docs/apps/README.md @@ -4,7 +4,7 @@ This section documents the runtime apps in the WayGate monorepo. ## Apps -- [API](api.md): FastAPI ingress service for webhook handling and trigger dispatch. +- [Web](web.md): Unified FastAPI host for the operator UI, auth flows, and mounted webhook ingress. - [Draft Worker](draft-worker.md): RQ worker that consumes draft workflow triggers. - [NATS Worker](nats-worker.md): JetStream worker that consumes durable workflow triggers. - [Scheduler](scheduler.md): APScheduler-based cron runner that dispatches recurring workflow triggers. @@ -17,7 +17,7 @@ That means they all rely on the same merged `WaygateRootSettings` object, the sa Each app then layers a different responsibility on top of that shared core: -- the API turns inbound HTTP requests into raw documents and plugin-built workflow triggers +- the web app turns inbound HTTP requests into raw documents and plugin-built workflow triggers - the draft worker executes queued workflow triggers from RQ - the NATS worker executes durable workflow triggers from JetStream - the scheduler emits cron-trigger messages for installed cron plugins diff --git a/docs/apps/api.md b/docs/apps/api.md deleted file mode 100644 index 8a55a71..0000000 --- a/docs/apps/api.md +++ /dev/null @@ -1,44 +0,0 @@ -# WayGate API - -The API app is the HTTP ingress boundary for WayGate. - -It uses FastAPI to expose one webhook route per discovered webhook plugin, writes produced raw documents to storage, and submits the workflow trigger built by the matched webhook plugin through the configured communication client. - -## What It Does - -- Boots the shared WayGate app context at startup. -- Validates that the configured communication plugin exists before serving requests. -- Mounts webhook routes dynamically from installed webhook plugins. -- Persists raw documents through the configured storage plugin. -- Dispatches workflow trigger messages after webhook processing succeeds. -- Merges per-plugin OpenAPI payload schemas into the application schema. - -## Runtime Flow - -1. `waygate_api.main()` bootstraps the shared core runtime. -2. The configured communication plugin is resolved eagerly so startup fails fast when misconfigured. -3. `waygate_api.server` constructs the FastAPI app and instruments it with OpenTelemetry. -4. Webhook plugins are discovered and registered under `/webhooks/`. -5. Requests are verified, enriched, converted into raw documents, and stored. -6. The matched webhook plugin builds the downstream `WorkflowTriggerMessage`. -7. That trigger is submitted through the selected communication plugin. The default webhook behavior emits `draft.ready`. - -## Configuration - -| Variable | Default | Description | -| -------- | --------- | ------------------------------------ | -| `HOST` | `0.0.0.0` | Bind address for the Uvicorn server. | -| `PORT` | `8080` | Bind port for the Uvicorn server. | - -The API also reads all `WAYGATE_*` settings from `waygate-core`. - -## Entry Points - -- `waygate_api:main` starts the server process. -- `waygate_api.server:app` exposes the FastAPI application object. - -## Notes - -- OpenAPI payload schemas are merged at startup so plugin-specific request bodies resolve correctly in Swagger UI and ReDoc. -- Webhook handling is intentionally plugin-driven; adding a plugin adds a route. -- First-party webhook routes currently include the generic ingestion path and the dedicated agent-session path. diff --git a/docs/apps/web.md b/docs/apps/web.md new file mode 100644 index 0000000..9698fc9 --- /dev/null +++ b/docs/apps/web.md @@ -0,0 +1,34 @@ +# WayGate Web + +The web app is the primary HTTP and operator surface for WayGate. + +It serves the server-rendered UI, initializes AuthTuna, mounts the shared webhook ingress app, and publishes a single OpenAPI surface that includes the mounted webhook routes. + +## What It Does + +- Boots the shared WayGate app context. +- Validates that the configured communication plugin exists before serving requests. +- Initializes AuthTuna for browser and API-oriented auth flows. +- Includes the page routes that render the operator UI. +- Mounts the shared `waygate-webhooks` ingress app under `/webhooks`. +- Merges webhook payload schemas into the parent OpenAPI document. + +## Runtime Flow + +1. `waygate_web.main()` bootstraps the shared core runtime. +2. The configured communication plugin is resolved eagerly so startup fails fast when misconfigured. +3. `waygate_web.server` constructs the FastAPI app and instruments it with OpenTelemetry. +4. AuthTuna routes are initialized on the parent app. +5. The server-rendered page routes are included. +6. The shared webhook ingress app is mounted under `/webhooks`. +7. The parent app merges the mounted webhook OpenAPI schema into `/openapi.json`. + +## Entry Points + +- `waygate_web:main` starts the server process. +- `waygate_web.server:app` exposes the FastAPI application object. + +## Notes + +- Webhook handling is intentionally plugin-driven; adding a webhook plugin adds a route through `libs/webhooks` and makes it visible in the web app's OpenAPI output. +- Auth routes and webhook routes share the same parent FastAPI host, so the web app is the primary ingress surface for local development and deployment. diff --git a/docs/design/README.md b/docs/design/README.md index 2025e35..2c2c451 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -31,7 +31,7 @@ If you are new to the repo, start in this order: ## Scope Notes -- These docs describe the repository at its current structure: `apps/api`, `apps/scheduler`, `apps/draft-worker`, `apps/nats-worker`, `libs/core`, `libs/worker`, `libs/workflows`, and the plugins under `plugins/`. +- These docs describe the repository at its current structure: `apps/web`, `apps/scheduler`, `apps/draft-worker`, `apps/nats-worker`, `libs/core`, `libs/webhooks`, `libs/worker`, `libs/workflows`, and the plugins under `plugins/`. - `compile-supervisor-multi-agent.md` now documents the implemented sequential supervisor workflow in `libs/workflows`. Read it together with `ingestion-and-workflows.md` for the current compile contract. - Older documents that described operator UIs, MCP services, retrieval SDK packages, or static-site pipelines are treated here as deferred roadmap material unless the implementation exists in this repo. - The original compile workflow proposal is archived at `docs/plans/compile-workflow-original-plan.md`. Use it as historical background only; the current implementation contract is documented in `docs/design/ingestion-and-workflows.md`. diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 2f1037d..86263c9 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -8,28 +8,36 @@ WayGate is a Python monorepo for building Generation-Augmented Retrieval workflo The repository is organized into three layers. -- `apps`: `api`, `scheduler`, `draft-worker`, `nats-worker` - Responsibility: long-running processes that expose HTTP ingress, schedule recurring jobs, or execute workflow work over RQ or JetStream. -- `libs`: `core`, `worker`, `workflows` +- `apps`: `web`, `scheduler`, `draft-worker`, `nats-worker` + Responsibility: long-running processes that expose the operator UI, HTTP ingress, schedule recurring jobs, or execute workflow work over RQ or JetStream. +- `libs`: `core`, `webhooks`, `worker`, `workflows` Responsibility: shared runtime primitives, worker execution helpers, plugin contracts, configuration, and workflow implementation. - `plugins`: `local-storage`, `provider-ollama`, `provider-featherless-ai`, `communication-http`, `communication-nats`, `communication-rq`, `webhook-generic`, `webhook-agent-session` Responsibility: first-party implementations of the plugin interfaces defined in `waygate-core`. ## Package Boundaries -### apps/api +### apps/web -- FastAPI ingress service. -- Discovers webhook plugins and mounts one route per plugin under `/webhooks/`. +- Unified FastAPI host for the server-rendered operator UI. +- Initializes AuthTuna for browser and API-oriented auth flows. +- Mounts the reusable webhook ingress app from `libs/webhooks` under `/webhooks`. +- Merges mounted webhook OpenAPI endpoints into the parent docs so the web app is the primary API surface. + +### libs/webhooks + +- Owns the mountable FastAPI webhook ingress sub-application. +- Discovers webhook plugins and registers one route per plugin. - Persists normalized raw documents through the configured storage plugin. - Asks the matched webhook plugin to build the downstream workflow trigger after storage writes complete. - Dispatches that workflow trigger through the configured communication plugin. The default webhook behavior still emits `draft.ready`. +- Owns webhook-specific OpenAPI helpers so mounted routes can still appear in the parent app's docs. ### apps/scheduler -- Bootstraps the same app context as the API. +- Bootstraps the same app context as the web app. - Loads installed cron plugins and schedules them with APScheduler. -- Dispatches `cron.tick` messages through the same communication client contract used by the API. +- Dispatches `cron.tick` messages through the same communication client contract used by the web app. ### apps/draft-worker @@ -95,11 +103,11 @@ The current implementation does not treat a search index, vector store, static s ### Transport-agnostic workflow dispatch -The API and scheduler do not know whether work is being delivered over HTTP, JetStream, or RQ. They both send a `WorkflowTriggerMessage` and rely on communication plugins to handle the transport-specific details. +The web app and scheduler do not know whether work is being delivered over HTTP, JetStream, or RQ. They both send a `WorkflowTriggerMessage` and rely on communication plugins to handle the transport-specific details. ### Workflow logic separate from worker runtime -Compile behavior lives in `libs/workflows`, not in the API or the worker process itself. This makes the workflow reusable across transports and easier to test in isolation. +Compile behavior lives in `libs/workflows`, not in the web app or the worker process itself. This makes the workflow reusable across transports and easier to test in isolation. ## Planned Workflow Evolution @@ -119,10 +127,10 @@ The planned target design is documented in [docs/design/compile-supervisor-multi The current repo supports this main path: -1. A webhook request reaches `apps/api`. +1. A webhook request reaches the mounted webhook surface in `apps/web`. 2. The selected webhook plugin verifies, enriches, and converts the payload into `RawDocument` objects. -3. The API writes those raw documents into storage. -4. The API asks the webhook plugin to build the downstream workflow trigger for the written document URIs. +3. The webhook ingress app writes those raw documents into storage. +4. The ingress app asks the webhook plugin to build the downstream workflow trigger for the written document URIs. 5. In the default case, that trigger is still `draft.ready`; dedicated webhook plugins can attach metadata, stable idempotency keys, or skip dispatch entirely. 6. A worker consumes the trigger and runs the compile workflow from `libs/workflows`. 7. The workflow writes a published markdown document or, on repeated review failure, a human-review record. @@ -146,7 +154,7 @@ Implemented in this repo today: Not implemented in this repo today: -- a dedicated operator UI +- a dedicated browser client separate from the server-rendered FastAPI web app - a retrieval SDK or MCP server package - hybrid lexical/vector retrieval infrastructure - graph traversal over published content @@ -162,7 +170,7 @@ Several legacy docs described the right long-term direction, but with names that | Legacy term | Current repo term | | ------------ | -------------------------------------------------------- | -| receiver | `apps/api` | +| receiver | `apps/web` mounted with `libs/webhooks` | | compiler app | `libs/workflows` plus RQ and JetStream worker processes. | | live wiki | `published` storage namespace | | meta | `metadata`, `templates`, and `agents` storage namespaces | diff --git a/docs/design/data-models-and-storage.md b/docs/design/data-models-and-storage.md index c203c31..dfde600 100644 --- a/docs/design/data-models-and-storage.md +++ b/docs/design/data-models-and-storage.md @@ -45,7 +45,7 @@ The current direction is to keep one explicit model per document artifact type i ## Raw Storage Artifact -The API does not store a `RawDocument` as JSON. It renders the model into a text artifact with frontmatter and writes that artifact to storage. +The webhook ingress app does not store a `RawDocument` as JSON. It renders the model into a text artifact with frontmatter and writes that artifact to storage. The compile workflow currently depends on these parsed fields when it reloads a raw artifact: @@ -154,7 +154,7 @@ Base-relative URIs give storage plugins a stable handoff format that does not le That keeps three boundaries cleaner: -- API and scheduler can hand work to workers without assuming a host path layout +- web app and scheduler can hand work to workers without assuming a host path layout - tests can assert storage behavior without depending on absolute directories - future storage implementations can preserve the same high-level document reference shape even if the backing store changes diff --git a/docs/plans/agent-session-webhook-spec.md b/docs/plans/agent-session-webhook-spec.md index c847c6b..61f546a 100644 --- a/docs/plans/agent-session-webhook-spec.md +++ b/docs/plans/agent-session-webhook-spec.md @@ -195,7 +195,7 @@ Recommended repo-local scripts: ## Implementation Plan 1. Add this planning spec. -2. Generalize the webhook dispatch seam in `apps/api` while preserving `draft.ready` as the default compile trigger. +2. Generalize the legacy webhook dispatch seam while preserving `draft.ready` as the default compile trigger. 3. Extend the webhook plugin contract with a default trigger builder. 4. Create the `agent-session` webhook plugin package. 5. Add fixture and helper scripts. diff --git a/docs/plugins/webhook-agent-session.md b/docs/plugins/webhook-agent-session.md index 091fd6e..c3153d5 100644 --- a/docs/plugins/webhook-agent-session.md +++ b/docs/plugins/webhook-agent-session.md @@ -26,7 +26,7 @@ The repository ships helper utilities for building and posting completed session - `scripts/post-agent-session.py` - `scripts/fixtures/agent-session.completed.json` -For a local smoke test, run the API with `communication-http`, point it at `scripts/mock-worker.py`, and post the fixture payload through `scripts/post-agent-session.py`. +For a local smoke test, run the web app with `communication-http`, point it at `scripts/mock-worker.py`, and post the fixture payload through `scripts/post-agent-session.py`. ## Notes diff --git a/docs/plugins/webhook-generic.md b/docs/plugins/webhook-generic.md index 738e40d..cf86159 100644 --- a/docs/plugins/webhook-generic.md +++ b/docs/plugins/webhook-generic.md @@ -2,7 +2,7 @@ The generic webhook plugin is the reference implementation for inbound webhook ingestion in WayGate. -It validates a structured JSON payload, converts each payload document into a `RawDocument`, and exposes an OpenAPI payload schema so the API can document the request body correctly. +It validates a structured JSON payload, converts each payload document into a `RawDocument`, and exposes an OpenAPI payload schema so the mounted webhook ingress can document the request body correctly. ## What It Does @@ -10,7 +10,7 @@ It validates a structured JSON payload, converts each payload document into a `R - Validates payloads against Pydantic models. - Merges top-level and per-document topics and tags with stable first-seen ordering. - Normalizes webhook timestamps to UTC. -- Returns a list of `RawDocument` objects for the API route to store. +- Returns a list of `RawDocument` objects for the webhook ingress route to store. ## Behavior @@ -23,7 +23,7 @@ It validates a structured JSON payload, converts each payload document into a `R The plugin expects a payload with a top-level `metadata` object and a `documents` array. -The included README contains the concrete example payload shape used by the tests and the current API route. +The included README contains the concrete example payload shape used by the tests and the current webhook ingress route. ## Configuration @@ -36,4 +36,4 @@ This plugin does not currently define config fields, but it still participates i ## Notes - This plugin is a reference implementation for custom webhook adapters. -- It is the webhook plugin used by the current API route and OpenAPI schema merging logic. +- It is the webhook plugin used by the current webhook ingress route and OpenAPI schema merging logic. diff --git a/docs/worker_communication_contract.md b/docs/worker_communication_contract.md index f3d13cd..1f065f7 100644 --- a/docs/worker_communication_contract.md +++ b/docs/worker_communication_contract.md @@ -12,7 +12,7 @@ shape. ```json { "event_type": "draft.ready", - "source": "waygate-api.webhooks", + "source": "waygate-web.webhooks", "document_paths": ["file://raw/01HXYZ-source.txt"], "idempotency_key": "optional-string", "metadata": { @@ -99,5 +99,5 @@ Use the local mock endpoint for smoke testing: - `uv run python scripts/mock-worker.py` 2. Ensure defaults point to mock worker: - `WAYGATE_COMMUNICATION_HTTP__ENDPOINT=http://127.0.0.1:8090/workflows/trigger` -3. Start API or scheduler and trigger a workflow path. +3. Start the web app or scheduler and trigger a workflow path. 4. Confirm the mock worker logs received payloads and returns `202 Accepted`. diff --git a/env.compose.example b/env.compose.example index acce905..212664f 100644 --- a/env.compose.example +++ b/env.compose.example @@ -1,10 +1,14 @@ # Copy this file to .env.compose for Docker Compose based development or deployment. # Application process settings -# HOST is passed through to containers but only used by the API process. +# HOST is passed through to containers but only used by the web process. HOST = "0.0.0.0" LOG_LEVEL = "INFO" +# Web app auth defaults for local Compose development. +WAYGATE_WEB_BASE_URL = "http://127.0.0.1:8080" +WAYGATE_WEB_SESSION_SECURE = "false" + # Compose-specific host bind mount settings WIKI_DATA_DIR = "./wiki" diff --git a/libs/core/src/waygate_core/plugin/webhook.py b/libs/core/src/waygate_core/plugin/webhook.py index bebf1a0..f187122 100644 --- a/libs/core/src/waygate_core/plugin/webhook.py +++ b/libs/core/src/waygate_core/plugin/webhook.py @@ -149,6 +149,6 @@ def build_workflow_trigger( return WorkflowTriggerMessage( event_type="draft.ready", - source="waygate-api.webhooks", + source="waygate-web.webhooks", document_paths=document_paths, ) diff --git a/libs/webhooks/README.md b/libs/webhooks/README.md new file mode 100644 index 0000000..ab15eb9 --- /dev/null +++ b/libs/webhooks/README.md @@ -0,0 +1,22 @@ +# waygate-webhooks + +Reusable FastAPI webhook ingress for WayGate. + +## Responsibilities + +- Builds one webhook endpoint per discovered `WebhookPlugin` +- Verifies signatures, enriches payloads, writes raw documents, and dispatches workflow triggers +- Exposes a mountable FastAPI sub-application for `/webhooks` +- Owns webhook-specific OpenAPI request-body and schema merge helpers + +## Usage + +```python +from fastapi import FastAPI + +from waygate_webhooks import create_webhook_app, merge_mounted_webhook_openapi + +app = FastAPI() +webhook_app = create_webhook_app() +app.mount("/webhooks", webhook_app) +``` diff --git a/libs/webhooks/pyproject.toml b/libs/webhooks/pyproject.toml new file mode 100644 index 0000000..bfaf8af --- /dev/null +++ b/libs/webhooks/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "waygate-webhooks" +version = "0.1.0" +description = "Mountable FastAPI webhook ingress for WayGate" +readme = "README.md" +authors = [ + { name = "Buck Brady", email = "22723438+voidrot@users.noreply.github.com" }, +] +requires-python = ">=3.14" +dependencies = [ + "fastapi[standard]>=0.135.3", + "pydantic>=2.12.5", + "waygate-core", +] + +[dependency-groups] +dev = ["pytest>=9.0.3", "pytest-cov>=7.1.0"] + +[build-system] +requires = ["uv_build>=0.11.7,<0.12.0"] +build-backend = "uv_build" + +[tool.uv.sources] +waygate-core = { workspace = true } + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +addopts = [ + "-ra", + "--strict-config", + "--strict-markers", + "--cov=waygate_webhooks", + "--cov-report=term-missing", +] diff --git a/libs/webhooks/src/waygate_webhooks/__init__.py b/libs/webhooks/src/waygate_webhooks/__init__.py new file mode 100644 index 0000000..5b079d1 --- /dev/null +++ b/libs/webhooks/src/waygate_webhooks/__init__.py @@ -0,0 +1,20 @@ +"""Mountable FastAPI webhook ingress for WayGate.""" + +from .app import create_webhook_app +from .dispatch import send_draft_message, send_workflow_message +from .errors import map_dispatch_failure_to_http +from .handlers import create_webhook_router +from .openapi import build_webhook_openapi_extra, merge_mounted_webhook_openapi + +__VERSION__ = "0.1.0" # x-release-please-version + +__all__ = [ + "__VERSION__", + "build_webhook_openapi_extra", + "create_webhook_app", + "create_webhook_router", + "map_dispatch_failure_to_http", + "merge_mounted_webhook_openapi", + "send_draft_message", + "send_workflow_message", +] diff --git a/libs/webhooks/src/waygate_webhooks/app.py b/libs/webhooks/src/waygate_webhooks/app.py new file mode 100644 index 0000000..7424ace --- /dev/null +++ b/libs/webhooks/src/waygate_webhooks/app.py @@ -0,0 +1,19 @@ +"""Mountable FastAPI application for WayGate webhooks.""" + +from fastapi import FastAPI + +from .handlers import create_webhook_router +from .openapi import build_webhook_openapi_schema + + +def create_webhook_app() -> FastAPI: + """Create the standalone webhook ingress FastAPI sub-application.""" + + app = FastAPI( + title="WayGate Webhooks", + description="Mountable webhook ingress for WayGate plugin endpoints.", + version="0.1.0", + ) + app.include_router(create_webhook_router()) + app.openapi = lambda: build_webhook_openapi_schema(app) + return app diff --git a/libs/webhooks/src/waygate_webhooks/dispatch.py b/libs/webhooks/src/waygate_webhooks/dispatch.py new file mode 100644 index 0000000..86d8fc8 --- /dev/null +++ b/libs/webhooks/src/waygate_webhooks/dispatch.py @@ -0,0 +1,45 @@ +"""Workflow dispatch helpers for webhook-triggered events.""" + +from waygate_core import get_app_context +from waygate_core.plugin import ( + CommunicationClientPlugin, + WorkflowDispatchResult, + WorkflowTriggerMessage, + resolve_communication_client, +) + + +def resolve_configured_communication_client() -> CommunicationClientPlugin: + """Resolve the configured communication client from the shared app context.""" + + app_context = get_app_context() + return resolve_communication_client( + app_context.plugins.communication, + app_context.config.core.communication_plugin_name, + allow_fallback=False, + ) + + +async def send_draft_message(document_paths: list[str]) -> WorkflowDispatchResult: + """Submit the default draft-ready workflow trigger for stored raw documents.""" + + if not document_paths: + return WorkflowDispatchResult( + accepted=True, detail="No document paths supplied" + ) + + message = WorkflowTriggerMessage( + event_type="draft.ready", + source="waygate-web.webhooks", + document_paths=document_paths, + ) + return await send_workflow_message(message) + + +async def send_workflow_message( + message: WorkflowTriggerMessage, +) -> WorkflowDispatchResult: + """Submit an arbitrary workflow trigger through the configured transport.""" + + client = resolve_configured_communication_client() + return await client.submit_workflow_trigger(message) diff --git a/libs/webhooks/src/waygate_webhooks/errors.py b/libs/webhooks/src/waygate_webhooks/errors.py new file mode 100644 index 0000000..7d1f317 --- /dev/null +++ b/libs/webhooks/src/waygate_webhooks/errors.py @@ -0,0 +1,20 @@ +"""HTTP error mapping for workflow dispatch failures.""" + +from waygate_core.plugin import DispatchErrorKind, WorkflowDispatchResult + + +def map_dispatch_failure_to_http( + result: WorkflowDispatchResult, +) -> tuple[int, str]: + """Map a workflow dispatch failure to an HTTP status code and detail.""" + + detail = result.detail or "Failed to submit workflow trigger message" + + if result.error_kind == DispatchErrorKind.VALIDATION: + return (422, detail) + if result.error_kind == DispatchErrorKind.CONFIG: + return (503, detail) + if result.error_kind == DispatchErrorKind.TRANSIENT: + return (502, detail) + + return (500, detail) diff --git a/libs/webhooks/src/waygate_webhooks/handlers.py b/libs/webhooks/src/waygate_webhooks/handlers.py new file mode 100644 index 0000000..a5af9e3 --- /dev/null +++ b/libs/webhooks/src/waygate_webhooks/handlers.py @@ -0,0 +1,119 @@ +"""Dynamic FastAPI webhook route registration.""" + +from collections.abc import Callable +import json + +from fastapi import APIRouter, HTTPException, Request + +from waygate_core import get_app_context +from waygate_core.files import compute_content_hash, render_raw_document +from waygate_core.logging import get_logger +from waygate_core.plugin import WebhookPlugin, WebhookVerificationError +from waygate_core.plugin.storage import StorageNamespace + +from .dispatch import send_workflow_message +from .errors import map_dispatch_failure_to_http +from .openapi import build_webhook_openapi_extra + +logger = get_logger() + + +def create_webhook_router(*, prefix: str = "") -> APIRouter: + """Create a router with one POST route per discovered webhook plugin.""" + + router = APIRouter(prefix=prefix, tags=["webhooks"]) + app_context = get_app_context() + + for plugin in app_context.plugins.webhooks.values(): + router.add_api_route( + f"/{plugin.name}", + _make_handler(plugin), + methods=["POST"], + summary=plugin.openapi_summary, + description=plugin.description, + openapi_extra=build_webhook_openapi_extra(plugin), + ) + + return router + + +def _make_handler(plugin: WebhookPlugin) -> Callable: + """Build a FastAPI route handler bound to a single webhook plugin.""" + + async def handle_webhook(request: Request) -> dict[str, object]: + raw_body = await request.body() + headers = dict(request.headers) + + try: + await plugin.verify_webhook_request(headers, raw_body) + payload = json.loads(raw_body.decode("utf-8")) if raw_body else {} + payload = await plugin.enrich_webhook_payload(payload, headers) + raw_documents = await plugin.handle_webhook(payload) + + if raw_documents: + written_paths = [] + storage = _resolve_storage_plugin() + + logger.debug( + "Plugin '%s' produced %s raw documents.", + plugin.name, + len(raw_documents), + ) + + for raw_document in raw_documents: + content_hash = raw_document.content_hash or compute_content_hash( + raw_document.content + ) + path = ( + storage.build_namespaced_path( + StorageNamespace.Raw, content_hash + ) + + ".txt" + ) + written_paths.append( + storage.write_document(path, render_raw_document(raw_document)) + ) + + written_paths = list(dict.fromkeys(written_paths)) + workflow_message = plugin.build_workflow_trigger(payload, written_paths) + if workflow_message is not None: + dispatch_result = await send_workflow_message(workflow_message) + if not dispatch_result.accepted: + status_code, detail = map_dispatch_failure_to_http( + dispatch_result + ) + raise HTTPException(status_code=status_code, detail=detail) + + return { + "status": "success", + "processed": len(raw_documents), + "message": f"Webhook handled by plugin '{plugin.name}'", + } + + except WebhookVerificationError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + except HTTPException: + raise + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=400, detail=f"Invalid JSON payload: {exc.msg}" + ) + except NotImplementedError: + raise HTTPException( + status_code=501, + detail=f"Plugin '{plugin.name}' does not implement webhook handling", + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + handle_webhook.__name__ = f"handle_webhook_{plugin.name.replace('-', '_')}" + return handle_webhook + + +def _resolve_storage_plugin(): + """Resolve the configured storage plugin from the shared app context.""" + + app_context = get_app_context() + return app_context.plugins.storage[app_context.config.core.storage_plugin_name] diff --git a/libs/webhooks/src/waygate_webhooks/openapi.py b/libs/webhooks/src/waygate_webhooks/openapi.py new file mode 100644 index 0000000..c9307aa --- /dev/null +++ b/libs/webhooks/src/waygate_webhooks/openapi.py @@ -0,0 +1,96 @@ +"""OpenAPI helpers for the mountable webhook application.""" + +from typing import Any + +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from waygate_core import get_app_context +from waygate_core.plugin import WebhookPlugin + + +def build_webhook_openapi_extra(plugin: WebhookPlugin) -> dict[str, Any] | None: + """Build the OpenAPI request body schema for a webhook plugin route.""" + + payload_schema = plugin.openapi_payload_schema + if payload_schema is None: + return None + + schema = payload_schema.model_json_schema( + ref_template="#/components/schemas/{model}" + ) + schema.pop("$defs", None) + + return { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": schema, + } + }, + } + } + + +def build_webhook_openapi_schema(app: FastAPI) -> dict[str, Any]: + """Build the webhook application's OpenAPI schema with merged payload defs.""" + + if app.openapi_schema: + return app.openapi_schema + + schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + + component_schemas: dict[str, Any] = schema.setdefault("components", {}).setdefault( + "schemas", {} + ) + for plugin in get_app_context().plugins.webhooks.values(): + payload_schema = plugin.openapi_payload_schema + if payload_schema is None: + continue + full_schema = payload_schema.model_json_schema( + ref_template="#/components/schemas/{model}" + ) + nested_definitions: dict[str, Any] = full_schema.pop("$defs", {}) + component_schemas.update(nested_definitions) + component_schemas[payload_schema.__name__] = full_schema + + app.openapi_schema = schema + return schema + + +def merge_mounted_webhook_openapi( + parent_schema: dict[str, Any], + webhook_app: FastAPI, + *, + mount_path: str = "/webhooks", +) -> dict[str, Any]: + """Merge mounted webhook paths and schema components into a parent schema.""" + + webhook_schema = webhook_app.openapi() + parent_paths: dict[str, Any] = parent_schema.setdefault("paths", {}) + + for path, path_item in webhook_schema.get("paths", {}).items(): + merged_path = _join_openapi_path(mount_path, path) + parent_paths[merged_path] = path_item + + parent_components: dict[str, Any] = parent_schema.setdefault( + "components", {} + ).setdefault("schemas", {}) + parent_components.update(webhook_schema.get("components", {}).get("schemas", {})) + return parent_schema + + +def _join_openapi_path(prefix: str, path: str) -> str: + """Join an OpenAPI mount prefix and path without duplicating slashes.""" + + normalized_prefix = prefix.rstrip("/") or "/" + normalized_path = path if path.startswith("/") else f"/{path}" + if normalized_prefix == "/": + return normalized_path + return f"{normalized_prefix}{normalized_path}" diff --git a/libs/webhooks/tests/test_dispatch_errors.py b/libs/webhooks/tests/test_dispatch_errors.py new file mode 100644 index 0000000..cbe1601 --- /dev/null +++ b/libs/webhooks/tests/test_dispatch_errors.py @@ -0,0 +1,27 @@ +from waygate_core.plugin import DispatchErrorKind, WorkflowDispatchResult + +from waygate_webhooks.errors import map_dispatch_failure_to_http + + +def test_map_dispatch_failure_to_http_uses_expected_status_codes() -> None: + validation = WorkflowDispatchResult( + accepted=False, + error_kind=DispatchErrorKind.VALIDATION, + detail="bad payload", + ) + config = WorkflowDispatchResult( + accepted=False, + error_kind=DispatchErrorKind.CONFIG, + detail="transport unavailable", + ) + transient = WorkflowDispatchResult( + accepted=False, + error_kind=DispatchErrorKind.TRANSIENT, + detail="nats timeout", + ) + unknown = WorkflowDispatchResult(accepted=False, detail="unexpected") + + assert map_dispatch_failure_to_http(validation) == (422, "bad payload") + assert map_dispatch_failure_to_http(config) == (503, "transport unavailable") + assert map_dispatch_failure_to_http(transient) == (502, "nats timeout") + assert map_dispatch_failure_to_http(unknown) == (500, "unexpected") diff --git a/libs/webhooks/tests/test_openapi.py b/libs/webhooks/tests/test_openapi.py new file mode 100644 index 0000000..c45f363 --- /dev/null +++ b/libs/webhooks/tests/test_openapi.py @@ -0,0 +1,53 @@ +from waygate_plugin_webhook_agent_session.plugin import AgentSessionWebhookPlugin +from waygate_plugin_webhook_generic.plugin import GenericWebhookPlugin + +from waygate_webhooks.handlers import create_webhook_router +from waygate_webhooks.openapi import build_webhook_openapi_extra + + +def test_build_openapi_extra_includes_generic_webhook_payload_schema() -> None: + plugin = GenericWebhookPlugin() + + extra = build_webhook_openapi_extra(plugin) + + assert extra is not None + request_body = extra["requestBody"] + assert request_body["required"] is True + + schema = request_body["content"]["application/json"]["schema"] + assert "$defs" not in schema + assert schema["title"] == "GenericWebhookPayload" + assert ( + schema["properties"]["metadata"]["$ref"] + == "#/components/schemas/GenericWebhookPayloadMetadata" + ) + assert ( + schema["properties"]["documents"]["items"]["$ref"] + == "#/components/schemas/GenericWebhookPayloadDocument" + ) + + +def test_build_openapi_extra_includes_agent_session_payload_schema() -> None: + plugin = AgentSessionWebhookPlugin() + + extra = build_webhook_openapi_extra(plugin) + + assert extra is not None + schema = extra["requestBody"]["content"]["application/json"]["schema"] + assert "$defs" not in schema + assert schema["title"] == "AgentSessionWebhookPayload" + assert ( + schema["properties"]["session"]["$ref"] == "#/components/schemas/AgentSession" + ) + + +def test_webhook_router_registers_agent_session_route() -> None: + router = create_webhook_router() + + paths = { + path + for route in router.routes + if (path := getattr(route, "path", None)) is not None + } + + assert "/agent-session" in paths diff --git a/plugins/webhook-agent-session/README.md b/plugins/webhook-agent-session/README.md index ca6e869..60d5c92 100644 --- a/plugins/webhook-agent-session/README.md +++ b/plugins/webhook-agent-session/README.md @@ -79,7 +79,7 @@ The repository includes a fixture payload and helper scripts for a local end-to- /home/buck/src/voidrot/waygate/.venv/bin/python scripts/mock-worker.py ``` -1. Start the API with the HTTP communication plugin and unsigned local webhook uploads enabled: +1. Start the web app with the HTTP communication plugin and unsigned local webhook uploads enabled: ```bash HOST=127.0.0.1 \ @@ -87,7 +87,7 @@ PORT=8081 \ WAYGATE_CORE__COMMUNICATION_PLUGIN_NAME=communication-http \ WAYGATE_COMMUNICATION_HTTP__ENDPOINT=http://127.0.0.1:8090/workflows/trigger \ WAYGATE_AGENT_SESSION__ALLOW_UNSIGNED=true \ -uv run --all-packages waygate-api +uv run --all-packages waygate-web ``` 1. Post the fixture payload: @@ -99,7 +99,7 @@ uv run --all-packages waygate-api --endpoint http://127.0.0.1:8081/webhooks/agent-session ``` -The API should return a success payload and the mock worker should log a `draft.ready` trigger whose metadata includes the session id, provider, surface, capture adapter, and schema version. +The web app should return a success payload and the mock worker should log a `draft.ready` trigger whose metadata includes the session id, provider, surface, capture adapter, and schema version. ## Entry Point diff --git a/plugins/webhook-agent-session/src/waygate_plugin_webhook_agent_session/plugin.py b/plugins/webhook-agent-session/src/waygate_plugin_webhook_agent_session/plugin.py index fc34059..82890a1 100644 --- a/plugins/webhook-agent-session/src/waygate_plugin_webhook_agent_session/plugin.py +++ b/plugins/webhook-agent-session/src/waygate_plugin_webhook_agent_session/plugin.py @@ -217,7 +217,7 @@ def build_workflow_trigger( session = payload["session"] return WorkflowTriggerMessage( event_type="draft.ready", - source="waygate-api.webhooks.agent-session", + source="waygate-web.webhooks.agent-session", document_paths=document_paths, idempotency_key=f"{payload['provider']}:{session['session_id']}", metadata={ diff --git a/plugins/webhook-agent-session/tests/test_agent_session_plugin.py b/plugins/webhook-agent-session/tests/test_agent_session_plugin.py index 8421a43..b5401c9 100644 --- a/plugins/webhook-agent-session/tests/test_agent_session_plugin.py +++ b/plugins/webhook-agent-session/tests/test_agent_session_plugin.py @@ -238,7 +238,7 @@ def test_build_workflow_trigger_uses_draft_ready_with_session_metadata() -> None assert trigger == WorkflowTriggerMessage( event_type="draft.ready", - source="waygate-api.webhooks.agent-session", + source="waygate-web.webhooks.agent-session", document_paths=["file://raw/session-123.txt"], idempotency_key="github-copilot-chat:session-123", metadata={ diff --git a/plugins/webhook-generic/README.md b/plugins/webhook-generic/README.md index 9266856..cb28bdf 100644 --- a/plugins/webhook-generic/README.md +++ b/plugins/webhook-generic/README.md @@ -15,7 +15,7 @@ The plugin is discovered automatically via its entry point. No code changes are - **Payload verification** — passes through without validation by default. Override `verify_webhook_request` in a subclass to add signature checking. - **Payload enrichment** — returns the payload unchanged by default. Override `enrich_webhook_payload` to add or transform fields before document creation. - **Payload validation** — validates incoming webhook JSON against a structured payload schema before conversion. -- **Document creation** — maps each payload document to a `RawDocument` and returns the list to the API route for storage and dispatch. +- **Document creation** — maps each payload document to a `RawDocument` and returns the list to the webhook ingress route for storage and dispatch. ## Payload Contract @@ -87,7 +87,7 @@ curl -X POST http://127.0.0.1:8080/webhooks/generic-webhook \ Expected behavior: -- The API returns a success payload for the webhook request. +- The web app returns a success payload for the webhook request. - Raw source artifacts are written to the configured storage backend. - A `draft.ready` workflow trigger is published through `communication-nats` by default. - The NATS worker either publishes a compiled markdown document or writes a human-review record. diff --git a/pyproject.toml b/pyproject.toml index fc7aba1..40a5323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ members = [ "apps/*", "plugins/*", "libs/core", + "libs/webhooks", "libs/worker", "libs/workflows", ] @@ -38,8 +39,9 @@ addopts = [ "-ra", "--strict-config", "--strict-markers", - "--cov=waygate_api", "--cov=waygate_core", + "--cov=waygate_web", + "--cov=waygate_webhooks", "--cov-report=term-missing", ] filterwarnings = [ diff --git a/release-please-config.json b/release-please-config.json index 84d510c..ae0f13b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,12 +5,12 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "packages": { - "apps/api": { - "package-name": "waygate-api", + "apps/web": { + "package-name": "waygate-web", "extra-files": [ { "type": "generic", - "path": "apps/api/src/waygate_api/__init__.py" + "path": "apps/web/src/waygate_web/__init__.py" } ] }, @@ -50,6 +50,15 @@ } ] }, + "libs/webhooks": { + "package-name": "waygate-webhooks", + "extra-files": [ + { + "type": "generic", + "path": "libs/webhooks/src/waygate_webhooks/__init__.py" + } + ] + }, "libs/worker": { "package-name": "waygate-worker", "extra-files": [ diff --git a/scripts/compose_smoke_nats.py b/scripts/compose_smoke_nats.py index 8faa620..4937123 100644 --- a/scripts/compose_smoke_nats.py +++ b/scripts/compose_smoke_nats.py @@ -31,12 +31,12 @@ def parse_args() -> argparse.Namespace: "--timeout-seconds", type=float, default=900.0, - help="Maximum time to wait for API readiness and workflow output", + help="Maximum time to wait for the web app readiness and workflow output", ) parser.add_argument( "--skip-model-pull", action="store_true", - help="Skip pulling Ollama models before starting API and worker", + help="Skip pulling Ollama models before starting the web app and worker", ) parser.add_argument( "--keep-up", @@ -190,7 +190,7 @@ def dump_failure_context(env_file: Path) -> None: project_name = os.environ.get("COMPOSE_PROJECT_NAME", "waygate") for args in ( ["ps"], - ["logs", "--tail=200", "api", "nats-worker", "ollama", "nats"], + ["logs", "--tail=200", "web", "nats-worker", "ollama", "nats"], ): try: result = run_compose( @@ -234,7 +234,7 @@ def run_smoke_test(options: argparse.Namespace) -> int: ) run_compose( - ["up", "-d", "--build", "api", "nats-worker"], + ["up", "-d", "--build", "web", "nats-worker"], env_file=env_file, project_name=project_name, ) diff --git a/uv.lock b/uv.lock index 23310f5..154331e 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,6 @@ requires-python = ">=3.14" [manifest] members = [ "waygate", - "waygate-api", "waygate-core", "waygate-draft-worker", "waygate-nats-worker", @@ -18,10 +17,30 @@ members = [ "waygate-plugin-webhook-agent-session", "waygate-plugin-webhook-generic", "waygate-scheduler", + "waygate-web", + "waygate-webhooks", "waygate-worker", "waygate-workflows", ] +[[package]] +name = "aiosmtplib" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ad/240a7ce4e50713b111dff8b781a898d8d4770e5d6ad4899103f84c86005c/aiosmtplib-5.1.0.tar.gz", hash = "sha256:2504a23b2b63c9de6bc4ea719559a38996dba68f73f6af4eb97be20ee4c5e6c4", size = 66176, upload-time = "2026-01-25T01:51:11.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/82/70f2c452acd7ed18c558c8ace9a8cf4fdcc70eae9a41749b5bdc53eb6f45/aiosmtplib-5.1.0-py3-none-any.whl", hash = "sha256:368029440645b486b69db7029208a7a78c6691b90d24a5332ddba35d9109d55b", size = 27778, upload-time = "2026-01-25T01:51:10.026Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "alembic" version = "1.18.4" @@ -116,6 +135,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "authlib" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, +] + +[[package]] +name = "authtuna" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosmtplib" }, + { name = "aiosqlite" }, + { name = "authlib" }, + { name = "bcrypt" }, + { name = "cbor2" }, + { name = "cryptography" }, + { name = "dkimpy" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "pyotp" }, + { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "qrcode" }, + { name = "slowapi" }, + { name = "sqlalchemy" }, + { name = "starlette" }, + { name = "ua-parser" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/15/2d7cce2d4549fc77a850c2b77f257ac414f7244d1021b9e4181a6a32aa36/authtuna-0.2.3.tar.gz", hash = "sha256:3355d13820f424c85738d4212592e6c1b1e8ba075f883eb2f816757bd07ff901", size = 168533, upload-time = "2026-04-12T12:10:36.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/39/665a82c0084b5e4aaf34552993cd782a61134725798a037861269dd878f6/authtuna-0.2.3-py3-none-any.whl", hash = "sha256:3ce3a6a47f88bc7840aba387776e7fd75e6107f6f722f9c7be2ff01da30a2a58", size = 209148, upload-time = "2026-04-12T12:10:34.906Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "cbor2" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3533a697e5842fff7c2f64912eb251f8dcab3a8b5d88e228d6eebc3b5021/cbor2-5.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3", size = 71940, upload-time = "2026-03-22T15:56:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e2/c6ba75f3fb25dfa15ab6999cc8709c821987e9ed8e375d7f58539261bcb9/cbor2-5.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975", size = 67639, upload-time = "2026-03-22T15:56:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -125,6 +258,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -247,6 +413,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -256,6 +487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dkimpy" +version = "1.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/6f/84e91828186bbfcedd7f9385ef5e0d369632444195c20e08951b7ffe0481/dkimpy-1.1.8.tar.gz", hash = "sha256:b5f60fb47bbf5d8d762f134bcea0c388eba6b498342a682a21f1686545094b77", size = 66979, upload-time = "2024-07-04T22:16:38.321Z" } + [[package]] name = "dnspython" version = "2.8.0" @@ -265,6 +505,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -386,6 +638,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, ] +[[package]] +name = "fasthx" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/c3/d9e248cc90e2be18e6dab0274bfc3cfdab0dd6fe63ff9f5f41b8550500d7/fasthx-3.1.0.tar.gz", hash = "sha256:8826004329d2b3a557963e39ea73baf0a311c63628c411723b76d4f0dfef53ae", size = 20160, upload-time = "2025-12-31T15:48:56.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/1f/44b757b771770a2fee7592dcd23d3317776df77a3c8d666c0e1b32247702/fasthx-3.1.0-py3-none-any.whl", hash = "sha256:21f25d1352eb45db4d26049230c399ef4f3ddfebc21b73be7716b7df31488bb3", size = 19840, upload-time = "2025-12-31T15:48:54.917Z" }, +] + +[package.optional-dependencies] +jinja = [ + { name = "jinja2" }, +] + [[package]] name = "greenlet" version = "3.4.0" @@ -491,6 +760,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -538,6 +816,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -734,6 +1024,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/76/53033db34ffccd25d62c32b23b9468f7228b455da6976e1c420ae31555c4/langsmith-0.7.33-py3-none-any.whl", hash = "sha256:5b535b991d52d3b664ebb8dc6f95afcf8d0acb42e062ac45a54a6a4820139f20", size = 378981, upload-time = "2026-04-20T16:17:52.503Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "mako" version = "1.3.11" @@ -1091,6 +1395,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -1188,6 +1510,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[[package]] +name = "pyotp" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" }, +] + [[package]] name = "pyright" version = "1.1.408" @@ -1264,6 +1604,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-multipart" version = "0.0.26" @@ -1308,6 +1667,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "redis" version = "7.4.0" @@ -1482,6 +1853,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/07/9a8c6ac2440f8e532260adaa3fe4a8f7edfcac4f038f3428e71cb32e13e2/rq-2.8.0-py3-none-any.whl", hash = "sha256:49d87c8d0068b890e83052050ffd18be328339ae00c9c6d5dbf2702eb06107d2", size = 119484, upload-time = "2026-04-17T00:21:11.513Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.15.11" @@ -1538,6 +1921,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1722,6 +2117,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "ua-parser" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser-builtins" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/98/5e4b52d772a048af122a6fc5ce365c311efb9f5e79c55fd4fdd7c9f59e83/ua_parser-1.0.2.tar.gz", hash = "sha256:bab404ad42fb37f943107da2f6003ffc79724d11cc95076a7a539513371779da", size = 33239, upload-time = "2026-04-05T20:14:28.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/7c/6367995ff57aaa2d9e1055adbaec2519cf5a979780a83a93fdf8c6ec37be/ua_parser-1.0.2-py3-none-any.whl", hash = "sha256:0f8e6d0484af2a9ff804bba5a4fe696e87c028eaba98ad9a7dfae873fef7788a", size = 31219, upload-time = "2026-04-05T20:14:26.913Z" }, +] + +[[package]] +name = "ua-parser-builtins" +version = "202603" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/6f/73a4d37deefb159556d39d654b5bad67b6874d1ad0b20b96fb5a04de3949/ua_parser_builtins-202603-py3-none-any.whl", hash = "sha256:67478397a68fac1a98fd0a31c416ea7c65a719141fc151d0211316f2cd337cc9", size = 89573, upload-time = "2026-03-01T20:50:02.491Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1864,47 +2279,6 @@ dev = [ { name = "ty" }, ] -[[package]] -name = "waygate-api" -version = "0.1.0" -source = { editable = "apps/api" } -dependencies = [ - { name = "fastapi", extra = ["standard"] }, - { name = "opentelemetry-instrumentation-fastapi" }, - { name = "pydantic" }, - { name = "waygate-core" }, - { name = "waygate-plugin-communication-http" }, - { name = "waygate-plugin-communication-nats" }, - { name = "waygate-plugin-communication-rq" }, - { name = "waygate-plugin-local-storage" }, - { name = "waygate-plugin-webhook-generic" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, - { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.62b0" }, - { name = "pydantic", specifier = ">=2.12.5" }, - { name = "waygate-core", editable = "libs/core" }, - { name = "waygate-plugin-communication-http", editable = "plugins/communication-http" }, - { name = "waygate-plugin-communication-nats", editable = "plugins/communication-nats" }, - { name = "waygate-plugin-communication-rq", editable = "plugins/communication-rq" }, - { name = "waygate-plugin-local-storage", editable = "plugins/local-storage" }, - { name = "waygate-plugin-webhook-generic", editable = "plugins/webhook-generic" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, -] - [[package]] name = "waygate-core" version = "0.1.0" @@ -2157,6 +2531,84 @@ requires-dist = [ { name = "waygate-plugin-communication-rq", editable = "plugins/communication-rq" }, ] +[[package]] +name = "waygate-web" +version = "0.1.0" +source = { editable = "apps/web" } +dependencies = [ + { name = "authtuna" }, + { name = "fastapi", extra = ["standard"] }, + { name = "fasthx", extra = ["jinja"] }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "pydantic" }, + { name = "waygate-core" }, + { name = "waygate-plugin-communication-http" }, + { name = "waygate-plugin-communication-nats" }, + { name = "waygate-plugin-communication-rq" }, + { name = "waygate-plugin-local-storage" }, + { name = "waygate-plugin-webhook-agent-session" }, + { name = "waygate-plugin-webhook-generic" }, + { name = "waygate-webhooks" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "authtuna", specifier = ">=0.2.3" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, + { name = "fasthx", extras = ["jinja"], specifier = ">=2.4.2" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.62b0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "waygate-core", editable = "libs/core" }, + { name = "waygate-plugin-communication-http", editable = "plugins/communication-http" }, + { name = "waygate-plugin-communication-nats", editable = "plugins/communication-nats" }, + { name = "waygate-plugin-communication-rq", editable = "plugins/communication-rq" }, + { name = "waygate-plugin-local-storage", editable = "plugins/local-storage" }, + { name = "waygate-plugin-webhook-agent-session", editable = "plugins/webhook-agent-session" }, + { name = "waygate-plugin-webhook-generic", editable = "plugins/webhook-generic" }, + { name = "waygate-webhooks", editable = "libs/webhooks" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, +] + +[[package]] +name = "waygate-webhooks" +version = "0.1.0" +source = { editable = "libs/webhooks" } +dependencies = [ + { name = "fastapi", extra = ["standard"] }, + { name = "pydantic" }, + { name = "waygate-core" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "waygate-core", editable = "libs/core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, +] + [[package]] name = "waygate-worker" version = "0.1.0" From 81a8566f11479edb5bf268ffe1a4a184b2beebb6 Mon Sep 17 00:00:00 2001 From: Buck Brady <22723438+voidrot@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:45:09 -0600 Subject: [PATCH 03/23] chore(dev): add workspace tooling defaults --- .github/instructions/daisyui.instructions.md | 1873 ++++++++++++++++++ .vscode/settings.json | 3 + mise.toml | 2 + 3 files changed, 1878 insertions(+) create mode 100644 .github/instructions/daisyui.instructions.md diff --git a/.github/instructions/daisyui.instructions.md b/.github/instructions/daisyui.instructions.md new file mode 100644 index 0000000..e289b3c --- /dev/null +++ b/.github/instructions/daisyui.instructions.md @@ -0,0 +1,1873 @@ +--- +description: daisyUI 5 +alwaysApply: true +applyTo: "**" +downloadedFrom: https://daisyui.com/llms.txt +version: 5.5.x +--- + +# daisyUI 5 +daisyUI 5 is a CSS library for Tailwind CSS 4 +daisyUI 5 provides class names for common UI components + +- [daisyUI 5 docs](http://daisyui.com) +- [Guide: How to use this file in LLMs and code editors](https://daisyui.com/docs/editor/) +- [daisyUI 5 release notes](https://daisyui.com/docs/v5/) +- [daisyUI 4 to 5 upgrade guide](https://daisyui.com/docs/upgrade/) + +## daisyUI 5 install notes +[install guide](https://daisyui.com/docs/install/) +1. daisyUI 5 requires Tailwind CSS 4 +2. `tailwind.config.js` file is deprecated in Tailwind CSS v4. do not use `tailwind.config.js`. Tailwind CSS v4 only needs `@import "tailwindcss";` in the CSS file if it's a node dependency. +3. daisyUI 5 can be installed using `npm i -D daisyui@latest` and then adding `@plugin "daisyui";` to the CSS file +4. daisyUI is suggested to be installed as a dependency but if you really want to use it from CDN, you can use Tailwind CSS and daisyUI CDN files: +```html + + +``` +5. A CSS file with Tailwind CSS and daisyUI looks like this (if it's a node dependency) +```css +@import "tailwindcss"; +@plugin "daisyui"; +``` + +## daisyUI 5 usage rules +1. We can give styles to a HTML element by adding daisyUI class names to it. By adding a component class name, part class names (if there's any available for that component), and modifier class names (if there's any available for that component) +2. Components can be customized using Tailwind CSS utility classes if the customization is not possible using the existing daisyUI classes. For example `btn px-10` sets a custom horizontal padding to a `btn` +3. If customization of daisyUI styles using Tailwind CSS utility classes didn't work because of CSS specificity issues, you can use the `!` at the end of the Tailwind CSS utility class to override the existing styles. For example `btn bg-red-500!` sets a custom background color to a `btn` forcefully. This is a last resort solution and should be used sparingly +4. If a specific component or something similar to it doesn't exist in daisyUI, you can create your own component using Tailwind CSS utility +5. when using Tailwind CSS `flex` and `grid` for layout, it should be responsive using Tailwind CSS responsive utility prefixes. +6. Only allowed class names are existing daisyUI class names or Tailwind CSS utility classes. +7. Ideally, you won't need to write any custom CSS. Using daisyUI class names or Tailwind CSS utility classes is preferred. +8. suggested - if you need placeholder images, use https://picsum.photos/200/300 with the size you want +9. suggested - when designing , don't add a custom font unless it's necessary +10. don't add `bg-base-100 text-base-content` to body unless it's necessary +11. For design decisions, use Refactoring UI book best practices + +daisyUI 5 class names are one of the following categories. These type names are only for reference and are not used in the actual code +- `component`: the required component class +- `part`: a child part of a component +- `style`: sets a specific style to component or part +- `behavior`: changes the behavior of component or part +- `color`: sets a specific color to component or part +- `size`: sets a specific size to component or part +- `placement`: sets a specific placement to component or part +- `direction`: sets a specific direction to component or part +- `modifier`: modifies the component or part in a specific way +- `variant`: prefixes for utility classes that conditionally apply styles. syntax is `variant:utility-class` + +## Config +daisyUI 5 config docs: https://daisyui.com/docs/config/ +daisyUI without config: +```css +@plugin "daisyui"; +``` +daisyUI config with `light` theme only: +```css +@plugin "daisyui" { + themes: light --default; +} +``` +daisyUI with all the default configs: +```css +@plugin "daisyui" { + themes: light --default, dark --prefersdark; + root: ":root"; + include: ; + exclude: ; + prefix: ; + logs: true; +} +``` +An example config: +In below config, all the built-in themes are enabled while bumblebee is the default theme and synthwave is the prefersdark theme (default dark mode) +All the other themes are enabled and can be used by adding `data-theme="THEME_NAME"` to the `` element +root scrollbar gutter is excluded. `daisy-` prefix is used for all daisyUI classes and console.log is disabled +```css +@plugin "daisyui" { + themes: light, dark, cupcake, bumblebee --default, emerald, corporate, synthwave --prefersdark, retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, fantasy, wireframe, black, luxury, dracula, cmyk, autumn, business, acid, lemonade, night, coffee, winter, dim, nord, sunset, caramellatte, abyss, silk; + root: ":root"; + include: ; + exclude: rootscrollgutter, checkbox; + prefix: daisy-; + logs: false; +} +``` +## daisyUI 5 colors + +### daisyUI color names +- `primary`: Primary brand color, The main color of your brand +- `primary-content`: Foreground content color to use on primary color +- `secondary`: Secondary brand color, The optional, secondary color of your brand +- `secondary-content`: Foreground content color to use on secondary color +- `accent`: Accent brand color, The optional, accent color of your brand +- `accent-content`: Foreground content color to use on accent color +- `neutral`: Neutral dark color, For not-saturated parts of UI +- `neutral-content`: Foreground content color to use on neutral color +- `base-100`:-100 Base surface color of page, used for blank backgrounds +- `base-200`:-200 Base color, darker shade, to create elevations +- `base-300`:-300 Base color, even more darker shade, to create elevations +- `base-content`: Foreground content color to use on base color +- `info`: Info color, For informative/helpful messages +- `info-content`: Foreground content color to use on info color +- `success`: Success color, For success/safe messages +- `success-content`: Foreground content color to use on success color +- `warning`: Warning color, For warning/caution messages +- `warning-content`: Foreground content color to use on warning color +- `error`: Error color, For error/danger/destructive messages +- `error-content`: Foreground content color to use on error color + +### daisyUI color rules +1. daisyUI adds semantic color names to Tailwind CSS colors +2. daisyUI color names can be used in utility classes, like other Tailwind CSS color names. for example, `bg-primary` will use the primary color for the background +3. daisyUI color names include variables as value so they can change based the theme +4. There's no need to use `dark:` for daisyUI color names +5. Ideally only daisyUI color names should be used for colors so the colors can change automatically based on the theme +6. If a Tailwind CSS color name (like `red-500`) is used, it will be same red color on all themes +7. If a daisyUI color name (like `primary`) is used, it will change color based on the theme +8. Using Tailwind CSS color names for text colors should be avoided because Tailwind CSS color `text-gray-800` on `bg-base-100` would be unreadable on a dark theme - because on dark theme, `bg-base-100` is a dark color +9. `*-content` colors should have a good contrast compared to their associated colors +10. suggestion - when designing a page use `base-*` colors for majority of the page. use `primary` color for important elements + +### daisyUI custom theme with custom colors +A CSS file with Tailwind CSS, daisyUI and a custom daisyUI theme looks like this: +```css +@import "tailwindcss"; +@plugin "daisyui"; +@plugin "daisyui/theme" { + name: "mytheme"; + default: true; /* set as default */ + prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ + color-scheme: light; /* color of browser-provided UI */ + + --color-base-100: oklch(98% 0.02 240); + --color-base-200: oklch(95% 0.03 240); + --color-base-300: oklch(92% 0.04 240); + --color-base-content: oklch(20% 0.05 240); + --color-primary: oklch(55% 0.3 240); + --color-primary-content: oklch(98% 0.01 240); + --color-secondary: oklch(70% 0.25 200); + --color-secondary-content: oklch(98% 0.01 200); + --color-accent: oklch(65% 0.25 160); + --color-accent-content: oklch(98% 0.01 160); + --color-neutral: oklch(50% 0.05 240); + --color-neutral-content: oklch(98% 0.01 240); + --color-info: oklch(70% 0.2 220); + --color-info-content: oklch(98% 0.01 220); + --color-success: oklch(65% 0.25 140); + --color-success-content: oklch(98% 0.01 140); + --color-warning: oklch(80% 0.25 80); + --color-warning-content: oklch(20% 0.05 80); + --color-error: oklch(65% 0.3 30); + --color-error-content: oklch(98% 0.01 30); + + --radius-selector: 1rem; /* border radius of selectors (checkbox, toggle, badge) */ + --radius-field: 0.25rem; /* border radius of fields (button, input, select, tab) */ + --radius-box: 0.5rem; /* border radius of boxes (card, modal, alert) */ + /* preferred values for --radius-* : 0rem, 0.25rem, 0.5rem, 1rem, 2rem */ + + --size-selector: 0.25rem; /* base size of selectors (checkbox, toggle, badge). Value must be 0.25rem unless we intentionally want bigger selectors. In so it can be 0.28125 or 0.3125. If we intentionally want smaller selectors, it can be 0.21875 or 0.1875 */ + --size-field: 0.25rem; /* base size of fields (button, input, select, tab). Value must be 0.25rem unless we intentionally want bigger fields. In so it can be 0.28125 or 0.3125. If we intentionally want smaller fields, it can be 0.21875 or 0.1875 */ + + --border: 1px; /* border size. Value must be 1px unless we intentionally want thicker borders. In so it can be 1.5px or 2px. If we intentionally want thinner borders, it can be 0.5px */ + + --depth: 1; /* only 0 or 1 – Adds a shadow and subtle 3D depth effect to components */ + --noise: 0; /* only 0 or 1 - Adds a subtle noise (grain) effect to components */ +} +``` +#### Rules +- All CSS variables above are required +- Colors can be OKLCH or hex or other formats +- If you're generating a custom theme, do not include the comments from the example above. Just provide the code. + +People can use https://daisyui.com/theme-generator/ visual tool to create their own theme. + +## daisyUI 5 components + +### accordion +Accordion is used for showing and hiding content but only one item can stay open at a time + +[accordion docs](https://daisyui.com/components/accordion/) + +#### Class names +- component: `collapse` +- part: `collapse-title`, `collapse-content` +- modifier: `collapse-arrow`, `collapse-plus`, `collapse-open`, `collapse-close` + +#### Syntax +```html +
{CONTENT}
+``` +where content is: +```html + +
{title}
+
{CONTENT}
+``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names +- Accordion uses radio inputs. All radio inputs with the same name work together and only one of them can be open at a time +- If you have more than one set of accordion items on a page, use different names for the radio inputs on each set +- Replace {name} with a unique name for the accordion group +- replace `{checked}` with `checked="checked"` if you want the accordion to be open by default + +### alert +Alert informs users about important events + +[alert docs](https://daisyui.com/components/alert/) + +#### Class names +- component: `alert` +- style: `alert-outline`, `alert-dash`, `alert-soft` +- color: `alert-info`, `alert-success`, `alert-warning`, `alert-error` +- direction: `alert-vertical`, `alert-horizontal` + +#### Syntax +```html + +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/direction class names +- Add `sm:alert-horizontal` for responsive layouts + +### avatar +Avatars are used to show a thumbnail + +[avatar docs](https://daisyui.com/components/avatar/) + +#### Class names +- component: `avatar`, `avatar-group` +- modifier: `avatar-online`, `avatar-offline`, `avatar-placeholder` + +#### Syntax +```html +
+
+ +
+
+``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names +- Use `avatar-group` for containing multiple avatars +- You can set custom sizes using `w-*` and `h-*` +- You can use mask classes such as `mask-squircle`, `mask-hexagon`, `mask-triangle` + +### badge +Badges are used to inform the user of the status of specific data + +[badge docs](https://daisyui.com/components/badge/) + +#### Class names +- component: `badge` +- style: `badge-outline`, `badge-dash`, `badge-soft`, `badge-ghost` +- color: `badge-neutral`, `badge-primary`, `badge-secondary`, `badge-accent`, `badge-info`, `badge-success`, `badge-warning`, `badge-error` +- size: `badge-xs`, `badge-sm`, `badge-md`, `badge-lg`, `badge-xl` + +#### Syntax +```html +Badge +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names +- Can be used inside text or buttons +- To create an empty badge, just remove the text between the span tags + +### breadcrumbs +Breadcrumbs helps users to navigate + +[breadcrumbs docs](https://daisyui.com/components/breadcrumbs/) + +#### Class names +- component: `breadcrumbs` + +#### Syntax +```html + +``` + +#### Rules +- breadcrumbs only has one main class name +- Can contain icons inside the links +- If you set `max-width` or the list gets larger than the container it will scroll + +### button +Buttons allow the user to take actions + +[button docs](https://daisyui.com/components/button/) + +#### Class names +- component: `btn` +- color: `btn-neutral`, `btn-primary`, `btn-secondary`, `btn-accent`, `btn-info`, `btn-success`, `btn-warning`, `btn-error` +- style: `btn-outline`, `btn-dash`, `btn-soft`, `btn-ghost`, `btn-link` +- behavior: `btn-active`, `btn-disabled` +- size: `btn-xs`, `btn-sm`, `btn-md`, `btn-lg`, `btn-xl` +- modifier: `btn-wide`, `btn-block`, `btn-square`, `btn-circle` + +#### Syntax +```html + +``` +#### Rules +- {MODIFIER} is optional and can have one of each color/style/behavior/size/modifier class names +- btn can be used on any html tags such as ` +``` + +#### Rules +- {MODIFIER} is optional and can have one of the size class names +- To make a button active, add `dock-active` class to the button +- add `` is required for responsivness of the dock in iOS + +### drawer +Drawer is a grid layout that can show/hide a sidebar on the left or right side of the page + +[drawer docs](https://daisyui.com/components/drawer/) + +#### Class names +- component: `drawer` +- part: `drawer-toggle`, `drawer-content`, `drawer-side`, `drawer-overlay` +- placement: `drawer-end` +- modifier: `drawer-open` +- variant: `is-drawer-open:`, `is-drawer-close:` + +#### Syntax +```html +
+ +
{CONTENT}
+
{SIDEBAR}
+
+``` +where {CONTENT} can be navbar, site content, footer, etc +and {SIDEBAR} can be a menu like: +```html +
+``` +To open/close the drawer, use a label that points to the `drawer-toggle` input: +```html + +``` +Example: This sidebar is always visible on large screen, can be toggled on small screen: +```html +
+ +
+ + +
+
+ + +
+
+``` + +Example: This sidebar is always visible. When it's close we only see iocns, when it's open we see icons and text +```html +
+ +
+ +
+
+ +
+ + + +
+ +
+
+
+
+``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/placement class names +- `id` is required for the `drawer-toggle` input. change `my-drawer` to a unique id according to your needs +- `lg:drawer-open` can be used to make sidebar visible on larger screens +- `drawer-toggle` is a hidden checkbox. Use label with "for" attribute to toggle state +- if you want to open the drawer when a button is clicked, use `` where `my-drawer` is the id of the `drawer-toggle` input +- when using drawer, every page content must be inside `drawer-content` element. for example navbar, footer, etc should not be outside of `drawer` + +### dropdown +Dropdown can open a menu or any other element when the button is clicked + +[dropdown docs](https://daisyui.com/components/dropdown/) + +#### Class names +- component: `dropdown` +- part: `dropdown-content` +- placement: `dropdown-start`, `dropdown-center`, `dropdown-end`, `dropdown-top`, `dropdown-bottom`, `dropdown-left`, `dropdown-right` +- modifier: `dropdown-hover`, `dropdown-open`, `dropdown-close` + +#### Syntax +Using details and summary +```html + +``` + +Using popover API +```html + + +``` + +Using CSS focus +```html + +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/placement class names +- replace `{id}` and `{anchor}` with a unique name +- For CSS focus dropdowns, use `tabindex="0"` and `role="button"` on the button +- The content can be any HTML element (not just `
    `) + +### fab +FAB (Floating Action Button) stays in the bottom corner of screen. It includes a focusable and accessible element with button role. Clicking or focusing it shows additional buttons (known as Speed Dial buttons) in a vertical arrangement or a flower shape (quarter circle) + +[fab docs](https://daisyui.com/components/fab/) + +#### Class names +- component: `fab` +- part: `fab-close`, `fab-main-action` +- modifier: `fab-flower` + +#### Syntax +A single FAB in the corder of screen +```html +
    + +
    +``` +A FAB that opens a 3 other buttons in the corner of page vertically +```html +
    +
    {IconOriginal}
    + + + +
    +``` +A FAB that opens a 3 other buttons in the corner of page vertically and they have label text +```html +
    +
    {IconOriginal}
    +
    {Label1}
    +
    {Label2}
    +
    {Label3}
    +
    +``` +FAB with rectangle buttons. These are not circular buttons so they can have more content. +```html +
    +
    {IconOriginal}
    + + + +
    +``` +FAB with close button. When FAB is open, the original button is replaced with a close button +```html +
    +
    {IconOriginal}
    +
    Close
    +
    {Label1}
    +
    {Label2}
    +
    {Label3}
    +
    +``` +FAB with Main Action button. When FAB is open, the original button is replaced with a main action button +```html +
    +
    {IconOriginal}
    +
    + {LabelMainAction} +
    +
    {Label1}
    +
    {Label2}
    +
    {Label3}
    +
    +``` +FAB Flower. It opens the buttons in a flower shape (quarter circle) arrangement instead of vertical +```html +
    +
    {IconOriginal}
    + + + + +
    +``` +FAB Flower with tooltips. There's no space for a text label in a quarter circle, so tooltips are used to indicate the button's function +```html +
    +
    {IconOriginal}
    + +
    + +
    +
    + +
    +
    + +
    +
    +``` +#### Rules +- {Icon*} should be replaced with the appropriate icon for each button. SVG icons are recommended +- {IconOriginal} is the icon that we see before opening the FAB +- {IconMainAction} is the icon we see after opening the FAB +- {Icon1}, {Icon2}, {Icon3} are the icons for the additional buttons +- {Label*} is the label text for each button + +### fieldset +Fieldset is a container for grouping related form elements. It includes fieldset-legend as a title and label as a description + +[fieldset docs](https://daisyui.com/components/fieldset/) + +#### Class names +- Component: `fieldset`, `label` +- Parts: `fieldset-legend` + +#### Syntax +```html +
    + {title} + {CONTENT} +

    {description}

    +
    +``` + +#### Rules +- You can use any element as a direct child of fieldset to add form elements + +### file-input +File Input is a an input field for uploading files + +[file-input docs](https://daisyui.com/components/file-input/) + +#### Class Names: +- Component: `file-input` +- Style: `file-input-ghost` +- Color: `file-input-neutral`, `file-input-primary`, `file-input-secondary`, `file-input-accent`, `file-input-info`, `file-input-success`, `file-input-warning`, `file-input-error` +- Size: `file-input-xs`, `file-input-sm`, `file-input-md`, `file-input-lg`, `file-input-xl` + +#### Syntax +```html + +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names + +### filter +Filter is a group of radio buttons. Choosing one of the options will hide the others and shows a reset button next to the chosen option + +[filter docs](https://daisyui.com/components/filter/) + +#### Class names +- component: `filter` +- part: `filter-reset` + +#### Syntax +Using HTML form +```html +
    + + + +
    +``` +Without HTML form +```html +
    + + + +
    +``` + +#### Rules +- replace `{NAME}` with proper value, according to the context of the filter +- Each set of radio inputs must have unique `name` attributes to avoid conflicts +- Use `
    ` tag when possible and only use `
    ` if you can't use a HTML form for some reason +- Use `filter-reset` class for the reset button + +### footer +Footer can contain logo, copyright notice, and links to other pages + +[footer docs](https://daisyui.com/components/footer/) + +#### Class names +- component: `footer` +- part: `footer-title` +- placement: `footer-center` +- direction: `footer-horizontal`, `footer-vertical` + +#### Syntax +```html +
    {CONTENT}
    +``` +where content can contain several `