From ce6e7711aff74c7312fbbe394444c92179557c6c Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Tue, 11 Aug 2026 00:12:09 +0200 Subject: [PATCH] Strip API to install-counter service, add CLI install ping API: replace the marketplace (submit/approve/reject/moderation) with a minimal counter table (id + downloads). Single upsert endpoint auto-creates rows on first ping, X-Source: site exclusion preserved. Inline migration copies existing counts and drops legacy tables. Removes slowapi, pyyaml, manifest validation, and all moderation routes. CLI: gcontext add now pings the API after a successful registry install so the download counter reflects real usage. Fire-and-forget with 3s timeout, gated to skip when GCONTEXT_REGISTRY is overridden (tests, private registries). Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/db.py | 17 +- api/app/main.py | 7 - api/app/manifest.py | 77 -------- api/app/models.py | 63 +------ api/app/ratelimit.py | 19 -- api/app/routes_admin.py | 85 ++------- api/app/routes_moderation.py | 91 --------- api/app/routes_public.py | 109 +++-------- api/app/schemas.py | 56 +----- api/app/settings.py | 5 - api/pyproject.toml | 6 +- api/tests/conftest.py | 3 - api/tests/test_api.py | 346 +++++++++-------------------------- api/tests/test_ratelimit.py | 39 ---- api/uv.lock | 174 +----------------- gcontext/registry.py | 28 ++- tests/test_add.py | 53 ++++++ 17 files changed, 230 insertions(+), 948 deletions(-) delete mode 100644 api/app/manifest.py delete mode 100644 api/app/ratelimit.py delete mode 100644 api/app/routes_moderation.py delete mode 100644 api/tests/test_ratelimit.py diff --git a/api/app/db.py b/api/app/db.py index eb19e88..cddd7eb 100644 --- a/api/app/db.py +++ b/api/app/db.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import Session, sessionmaker from . import settings @@ -17,6 +17,21 @@ def engine(): def init_db() -> None: Base.metadata.create_all(engine()) + _migrate_legacy(engine()) + + +def _migrate_legacy(eng) -> None: + """One-time migration: copy download counts from the legacy tables and drop them.""" + if not inspect(eng).has_table("templates"): + return + with eng.begin() as conn: + conn.execute(text( + "INSERT INTO workflows (id, downloads) " + "SELECT id, SUM(downloads) FROM templates GROUP BY id " + "ON CONFLICT (id) DO NOTHING" + )) + conn.execute(text("DROP TABLE IF EXISTS template_files")) + conn.execute(text("DROP TABLE IF EXISTS templates")) def get_session(): diff --git a/api/app/main.py b/api/app/main.py index 6a07225..f51fc5a 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -2,13 +2,9 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from slowapi import _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded from .db import init_db -from .ratelimit import limiter from .routes_admin import router as admin_router -from .routes_moderation import router as moderation_router from .routes_public import router as public_router @@ -19,8 +15,6 @@ async def lifespan(app: FastAPI): app = FastAPI(title="gcontext workflows API", lifespan=lifespan) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware( CORSMiddleware, allow_origins=[ @@ -33,7 +27,6 @@ app.add_middleware( allow_headers=["*"], ) app.include_router(public_router) -app.include_router(moderation_router) app.include_router(admin_router) diff --git a/api/app/manifest.py b/api/app/manifest.py deleted file mode 100644 index f629395..0000000 --- a/api/app/manifest.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Parse and validate a submitted bundle. - -The manifest is the YAML frontmatter of the bundle's index.md. The client's -words are never trusted: id, name, description, and tags are read from the -frontmatter here, exactly as `gcontext add` reads them on install. -""" - -import re - -import yaml - -from . import settings - -ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") -_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) - - -class BundleError(ValueError): - pass - - -def validate_files(files: list[dict]) -> None: - if not files: - raise BundleError("bundle has no files") - if len(files) > settings.MAX_FILES: - raise BundleError(f"bundle exceeds {settings.MAX_FILES} files") - total = 0 - seen: set[str] = set() - for f in files: - path, content = f["path"], f["content"] - if not path or path.startswith("/") or ".." in path.split("/") or "\\" in path: - raise BundleError(f"invalid path: {path!r}") - if path in seen: - raise BundleError(f"duplicate path: {path!r}") - seen.add(path) - size = len(content.encode("utf-8")) - if size > settings.MAX_FILE_BYTES: - raise BundleError(f"file too large: {path!r}") - total += size - if total > settings.MAX_BUNDLE_BYTES: - raise BundleError("bundle too large") - - -def parse_manifest(files: list[dict]) -> dict: - index = next((f for f in files if f["path"] == "index.md"), None) - if index is None: - raise BundleError("bundle has no index.md") - match = _FRONTMATTER_RE.match(index["content"]) - if match is None: - raise BundleError("index.md has no YAML frontmatter") - try: - data = yaml.safe_load(match.group(1)) - except yaml.YAMLError as exc: - raise BundleError(f"frontmatter does not parse: {exc}") from exc - if not isinstance(data, dict): - raise BundleError("frontmatter is not a mapping") - - workflow_id = data.get("id") - name = data.get("name") - description = data.get("description") - tags = data.get("tags", []) - - if not isinstance(workflow_id, str) or not ID_RE.match(workflow_id): - raise BundleError("frontmatter id is missing or not a url-safe slug") - if not isinstance(name, str) or not name.strip(): - raise BundleError("frontmatter name is missing") - if not isinstance(description, str) or not description.strip(): - raise BundleError("frontmatter description is missing") - if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags): - raise BundleError("frontmatter tags must be a list of strings") - - return { - "id": workflow_id, - "name": name.strip(), - "description": description.strip(), - "tags": tags, - } diff --git a/api/app/models.py b/api/app/models.py index 7072fbd..7b4d24a 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -1,64 +1,15 @@ -from datetime import datetime - -from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, func -from sqlalchemy.dialects.postgresql import ARRAY -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - -PENDING = "pending" -APPROVED = "approved" -REJECTED = "rejected" +from sqlalchemy import Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass -class Template(Base): - __tablename__ = "templates" +class Workflow(Base): + __tablename__ = "workflows" - pk: Mapped[int] = mapped_column(Integer, primary_key=True) - id: Mapped[str] = mapped_column(Text, nullable=False) - name: Mapped[str] = mapped_column(Text, nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False) - tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) - status: Mapped[str] = mapped_column(Text, nullable=False, default=PENDING) - submitted_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + id: Mapped[str] = mapped_column(Text, primary_key=True) + downloads: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" ) - reviewed_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - downloads: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - votes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - - files: Mapped[list["TemplateFile"]] = relationship( - back_populates="template", cascade="all, delete-orphan", order_by="TemplateFile.path" - ) - - -# One approved and at most one pending entry per workflow id. -Index( - "uq_templates_id_approved", - Template.id, - unique=True, - postgresql_where=Template.status == APPROVED, -) -Index( - "uq_templates_id_pending", - Template.id, - unique=True, - postgresql_where=Template.status == PENDING, -) - - -class TemplateFile(Base): - __tablename__ = "template_files" - - pk: Mapped[int] = mapped_column(Integer, primary_key=True) - template_pk: Mapped[int] = mapped_column( - ForeignKey("templates.pk", ondelete="CASCADE"), nullable=False, index=True - ) - path: Mapped[str] = mapped_column(Text, nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - - template: Mapped[Template] = relationship(back_populates="files") diff --git a/api/app/ratelimit.py b/api/app/ratelimit.py deleted file mode 100644 index 5950a9a..0000000 --- a/api/app/ratelimit.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Shared rate limiter. Lives in its own module so routes and main can both -import it without a circular import.""" - -from fastapi import Request -from slowapi import Limiter - - -def client_ip(request: Request) -> str: - # The API runs behind exactly one trusted reverse proxy (Coolify's - # Traefik), which appends the real client IP as the last entry of - # X-Forwarded-For. Earlier entries are client-supplied and spoofable, - # so key on the last one. Fall back to the direct peer for local runs. - forwarded = request.headers.get("x-forwarded-for", "") - if forwarded: - return forwarded.rsplit(",", 1)[-1].strip() - return request.client.host if request.client else "unknown" - - -limiter = Limiter(key_func=client_ip) diff --git a/api/app/routes_admin.py b/api/app/routes_admin.py index b34ae27..60cb014 100644 --- a/api/app/routes_admin.py +++ b/api/app/routes_admin.py @@ -1,83 +1,26 @@ -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm import Session +from . import settings from .db import get_session -from .models import APPROVED, REJECTED, Template -from .routes_moderation import require_admin -from .schemas import AdminUpdateIn, AdminWorkflowOut +from .models import Workflow +from .schemas import WorkflowOut + + +def require_admin(authorization: str = Header(default="")): + if authorization != f"Bearer {settings.admin_token()}": + raise HTTPException(status_code=401, detail="invalid admin token") + router = APIRouter( prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)] ) -def _to_out(t: Template) -> AdminWorkflowOut: - return AdminWorkflowOut( - id=t.id, - name=t.name, - description=t.description, - tags=t.tags, - status=t.status, - submitted_at=t.submitted_at, - reviewed_at=t.reviewed_at, - file_count=len(t.files), - downloads=t.downloads, - ) - - -@router.get("/workflows", response_model=list[AdminWorkflowOut]) +@router.get("/workflows", response_model=list[WorkflowOut]) def list_all(session: Session = Depends(get_session)): rows = session.scalars( - select(Template).options(selectinload(Template.files)).order_by(Template.id) + select(Workflow).order_by(Workflow.downloads.desc()) ).all() - return [_to_out(t) for t in rows] - - -@router.patch("/workflows/{workflow_id}", response_model=AdminWorkflowOut) -def update_metadata( - workflow_id: str, body: AdminUpdateIn, session: Session = Depends(get_session) -): - template = session.scalars( - select(Template) - .where(Template.id == workflow_id) - .options(selectinload(Template.files)) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="workflow not found") - if body.name is not None: - template.name = body.name - if body.description is not None: - template.description = body.description - if body.tags is not None: - template.tags = body.tags - session.commit() - session.refresh(template) - return _to_out(template) - - -@router.post("/workflows/{workflow_id}/publish") -def publish_workflow(workflow_id: str, session: Session = Depends(get_session)): - template = session.scalars( - select(Template).where(Template.id == workflow_id, Template.status == REJECTED) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="no rejected workflow with this id") - template.status = APPROVED - template.reviewed_at = datetime.now(timezone.utc) - session.commit() - return {"id": workflow_id, "status": APPROVED} - - -@router.delete("/workflows/{workflow_id}") -def delete_workflow(workflow_id: str, session: Session = Depends(get_session)): - template = session.scalars( - select(Template).where(Template.id == workflow_id) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="workflow not found") - session.delete(template) - session.commit() - return {"deleted": workflow_id} + return [WorkflowOut(id=w.id, downloads=w.downloads) for w in rows] diff --git a/api/app/routes_moderation.py b/api/app/routes_moderation.py deleted file mode 100644 index 26594ca..0000000 --- a/api/app/routes_moderation.py +++ /dev/null @@ -1,91 +0,0 @@ -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, Header, HTTPException -from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload - -from . import settings -from .db import get_session -from .models import APPROVED, PENDING, REJECTED, Template -from .schemas import FileIn, ManifestOut, TemplateOut - -router = APIRouter(prefix="/api/moderation/workflows", tags=["moderation"]) - - -def require_admin(authorization: str = Header(default="")): - if authorization != f"Bearer {settings.admin_token()}": - raise HTTPException(status_code=401, detail="invalid admin token") - - -def _pending(session: Session, workflow_id: str) -> Template: - template = session.scalars( - select(Template) - .where(Template.id == workflow_id, Template.status == PENDING) - .options(selectinload(Template.files)) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="no pending workflow with this id") - return template - - -@router.get("", response_model=list[ManifestOut], dependencies=[Depends(require_admin)]) -def list_pending(session: Session = Depends(get_session)): - rows = session.scalars( - select(Template).where(Template.status == PENDING).order_by(Template.submitted_at) - ).all() - return [ - ManifestOut(id=t.id, name=t.name, description=t.description, tags=t.tags) - for t in rows - ] - - -@router.get( - "/{workflow_id}", response_model=TemplateOut, dependencies=[Depends(require_admin)] -) -def get_pending(workflow_id: str, session: Session = Depends(get_session)): - template = _pending(session, workflow_id) - return TemplateOut( - id=template.id, - name=template.name, - description=template.description, - tags=template.tags, - files=[FileIn(path=f.path, content=f.content) for f in template.files], - ) - - -@router.post("/{workflow_id}/approve", dependencies=[Depends(require_admin)]) -def approve(workflow_id: str, session: Session = Depends(get_session)): - template = _pending(session, workflow_id) - # Approving a replacement supersedes the previously approved entry. - old = session.scalars( - select(Template).where(Template.id == workflow_id, Template.status == APPROVED) - ).first() - if old is not None: - session.delete(old) - session.flush() - template.status = APPROVED - template.reviewed_at = datetime.now(timezone.utc) - session.commit() - return {"id": workflow_id, "status": APPROVED} - - -@router.post("/{workflow_id}/reject", dependencies=[Depends(require_admin)]) -def reject(workflow_id: str, session: Session = Depends(get_session)): - template = _pending(session, workflow_id) - template.status = REJECTED - template.reviewed_at = datetime.now(timezone.utc) - session.commit() - return {"id": workflow_id, "status": REJECTED} - - -@router.post("/{workflow_id}/unpublish", dependencies=[Depends(require_admin)]) -def unpublish(workflow_id: str, session: Session = Depends(get_session)): - template = session.scalars( - select(Template).where(Template.id == workflow_id, Template.status == APPROVED) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="no approved workflow with this id") - template.status = REJECTED - template.reviewed_at = datetime.now(timezone.utc) - session.commit() - return {"id": workflow_id, "status": REJECTED} diff --git a/api/app/routes_public.py b/api/app/routes_public.py index 538de6a..e078b88 100644 --- a/api/app/routes_public.py +++ b/api/app/routes_public.py @@ -1,95 +1,42 @@ +import re + from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Session from .db import get_session -from .manifest import BundleError, parse_manifest, validate_files -from .ratelimit import limiter -from .models import APPROVED, PENDING, Template, TemplateFile -from .schemas import FileIn, ManifestOut, StatusOut, SubmitIn, SubmitOut, TemplateOut +from .models import Workflow +from .schemas import WorkflowOut router = APIRouter(prefix="/api/workflows", tags=["workflows"]) - -@router.get("", response_model=list[ManifestOut]) -def list_workflows(session: Session = Depends(get_session)): - rows = session.scalars( - select(Template).where(Template.status == APPROVED).order_by(Template.id) - ).all() - return [ - ManifestOut(id=t.id, name=t.name, description=t.description, tags=t.tags) - for t in rows - ] +SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") -@router.get("/{workflow_id}", response_model=TemplateOut) +@router.get("/{workflow_id}", response_model=WorkflowOut) def get_workflow( workflow_id: str, request: Request, session: Session = Depends(get_session) ): - template = session.scalars( - select(Template) - .where(Template.id == workflow_id, Template.status == APPROVED) - .options(selectinload(Template.files)) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="workflow not found") - # The landing's own page renders send X-Source: site and do not count. - if request.headers.get("x-source") != "site": - template.downloads += 1 - session.commit() - return TemplateOut( - id=template.id, - name=template.name, - description=template.description, - tags=template.tags, - files=[FileIn(path=f.path, content=f.content) for f in template.files], + if not SLUG_RE.match(workflow_id): + raise HTTPException(status_code=404, detail="invalid workflow id") + + if request.headers.get("x-source") == "site": + row = session.scalars( + select(Workflow).where(Workflow.id == workflow_id) + ).first() + return WorkflowOut(id=workflow_id, downloads=row.downloads if row else 0) + + stmt = ( + insert(Workflow) + .values(id=workflow_id, downloads=1) + .on_conflict_do_update( + index_elements=[Workflow.id], + set_={"downloads": Workflow.downloads + 1}, + ) + .returning(Workflow.downloads) ) - - -@router.get("/{workflow_id}/status", response_model=StatusOut) -def workflow_status(workflow_id: str, session: Session = Depends(get_session)): - template = session.scalars( - select(Template) - .where(Template.id == workflow_id) - .order_by(Template.submitted_at.desc()) - ).first() - if template is None: - raise HTTPException(status_code=404, detail="workflow not found") - return StatusOut( - id=template.id, - status=template.status, - submitted_at=template.submitted_at, - reviewed_at=template.reviewed_at, - ) - - -@router.post("", response_model=SubmitOut, status_code=201) -@limiter.limit("5/hour") -def submit_workflow(request: Request, body: SubmitIn, session: Session = Depends(get_session)): - files = [f.model_dump() for f in body.files] - try: - validate_files(files) - manifest = parse_manifest(files) - except BundleError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - - # A new submission replaces an existing pending entry for the same id. - # An approved entry stays live until the replacement is approved. - existing_pending = session.scalars( - select(Template).where(Template.id == manifest["id"], Template.status == PENDING) - ).first() - if existing_pending is not None: - session.delete(existing_pending) - session.flush() - - template = Template( - id=manifest["id"], - name=manifest["name"], - description=manifest["description"], - tags=manifest["tags"], - status=PENDING, - files=[TemplateFile(path=f["path"], content=f["content"]) for f in files], - ) - session.add(template) + result = session.execute(stmt) session.commit() - return SubmitOut(status=PENDING, **manifest) + count = result.scalar_one() + return WorkflowOut(id=workflow_id, downloads=count) diff --git a/api/app/schemas.py b/api/app/schemas.py index fe26c81..d971e51 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -1,58 +1,6 @@ -from datetime import datetime - -from pydantic import BaseModel, model_validator +from pydantic import BaseModel -class FileIn(BaseModel): - path: str - content: str - - -class SubmitIn(BaseModel): - files: list[FileIn] - - -class ManifestOut(BaseModel): +class WorkflowOut(BaseModel): id: str - name: str - description: str - tags: list[str] - - -class SubmitOut(ManifestOut): - status: str - - -class TemplateOut(ManifestOut): - files: list[FileIn] - - -class AdminWorkflowOut(BaseModel): - id: str - name: str - description: str - tags: list[str] - status: str - submitted_at: datetime - reviewed_at: datetime | None - file_count: int downloads: int - - -class StatusOut(BaseModel): - id: str - status: str - submitted_at: datetime - reviewed_at: datetime | None - - -class AdminUpdateIn(BaseModel): - name: str | None = None - description: str | None = None - tags: list[str] | None = None - - @model_validator(mode="after") - def at_least_one_field(self): - if self.name is None and self.description is None and self.tags is None: - raise ValueError("at least one field must be provided") - return self diff --git a/api/app/settings.py b/api/app/settings.py index 186b903..3564be5 100644 --- a/api/app/settings.py +++ b/api/app/settings.py @@ -7,8 +7,3 @@ def database_url() -> str: def admin_token() -> str: return os.environ["ADMIN_TOKEN"] - - -MAX_FILE_BYTES = int(os.environ.get("MAX_FILE_BYTES", 1_000_000)) -MAX_BUNDLE_BYTES = int(os.environ.get("MAX_BUNDLE_BYTES", 5_000_000)) -MAX_FILES = int(os.environ.get("MAX_FILES", 200)) diff --git a/api/pyproject.toml b/api/pyproject.toml index f6a72f8..eb2e230 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,15 +1,13 @@ [project] name = "gcontext-workflows-api" -version = "0.1.1" -description = "Marketplace backend for gcontext workflow templates: Postgres-backed store with submit-for-review publishing." +version = "0.2.0" +description = "Install-counter service for gcontext workflow templates." requires-python = ">=3.11" dependencies = [ "fastapi>=0.115", "uvicorn>=0.30", "sqlalchemy>=2.0", "psycopg[binary]>=3.2", - "pyyaml>=6.0", - "slowapi>=0.1.9", ] [dependency-groups] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index ac882fc..1225e53 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -39,7 +39,6 @@ def postgres(): f"postgresql+psycopg://postgres:test@127.0.0.1:{PORT}/workflows" ) os.environ["ADMIN_TOKEN"] = "test-admin-token" - # The port answers before postgres accepts connections; retry the first connect. from app.db import init_db for _ in range(30): @@ -61,9 +60,7 @@ def client(postgres): from app.db import engine from app.main import app from app.models import Base - from app.ratelimit import limiter - limiter.reset() Base.metadata.drop_all(engine()) Base.metadata.create_all(engine()) with TestClient(app) as test_client: diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 413c349..b41fdb6 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,296 +1,114 @@ -INDEX_MD = """--- -id: demo-flow -name: Demo Flow -description: > - A demo workflow used by the tests. -tags: [demo, testing] ---- - -# demo-flow - -Body text. -""" - - -def bundle(index_content=INDEX_MD, extra=None): - files = [ - {"path": "index.md", "content": index_content}, - {"path": "steps/index.md", "content": "one line per step"}, - {"path": "steps/1-do.md", "content": "do the thing"}, - {"path": "commands/setup.md", "content": "the install interview"}, - {"path": "runs/example/index.md", "content": "example run"}, - ] - if extra: - files += extra - return {"files": files} - - -def submit(client, **kwargs): - return client.post("/api/workflows", json=bundle(**kwargs)) - - -def test_submit_lands_pending_and_invisible(client, admin): - resp = submit(client) - assert resp.status_code == 201 - body = resp.json() - assert body["id"] == "demo-flow" - assert body["name"] == "Demo Flow" - assert body["description"] == "A demo workflow used by the tests." - assert body["tags"] == ["demo", "testing"] - assert body["status"] == "pending" - - assert client.get("/api/workflows").json() == [] - assert client.get("/api/workflows/demo-flow").status_code == 404 - - pending = client.get("/api/moderation/workflows", headers=admin).json() - assert [p["id"] for p in pending] == ["demo-flow"] - - -def test_approve_makes_public_with_full_bundle(client, admin): - submit(client) - resp = client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) +def test_first_get_creates_row_with_count_1(client): + resp = client.get("/api/workflows/demo-flow") assert resp.status_code == 200 - - directory = client.get("/api/workflows").json() - assert [d["id"] for d in directory] == ["demo-flow"] - assert directory[0]["tags"] == ["demo", "testing"] - - full = client.get("/api/workflows/demo-flow").json() - paths = {f["path"] for f in full["files"]} - assert paths == { - "index.md", - "steps/index.md", - "steps/1-do.md", - "commands/setup.md", - "runs/example/index.md", - } - index = next(f for f in full["files"] if f["path"] == "index.md") - assert index["content"] == INDEX_MD + assert resp.json() == {"id": "demo-flow", "downloads": 1} -def test_reject_hides(client, admin): - submit(client) - resp = client.post("/api/moderation/workflows/demo-flow/reject", headers=admin) - assert resp.status_code == 200 - assert client.get("/api/workflows").json() == [] - assert client.get("/api/moderation/workflows", headers=admin).json() == [] - - -def test_rejected_id_does_not_block_resubmission(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/reject", headers=admin) - assert submit(client).status_code == 201 - - -def test_new_pending_replaces_old_pending(client, admin): - submit(client) - updated = INDEX_MD.replace("Demo Flow", "Demo Flow v2") - resp = submit(client, index_content=updated) - assert resp.status_code == 201 - pending = client.get("/api/moderation/workflows", headers=admin).json() - assert len(pending) == 1 - assert pending[0]["name"] == "Demo Flow v2" - - -def test_approving_replacement_swaps_content(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - - updated = INDEX_MD.replace("Demo Flow", "Demo Flow v2") - submit(client, index_content=updated) - # Old version stays live while the replacement is pending. - assert client.get("/api/workflows/demo-flow").json()["name"] == "Demo Flow" - - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - assert client.get("/api/workflows/demo-flow").json()["name"] == "Demo Flow v2" - assert len(client.get("/api/workflows").json()) == 1 - - -def test_moderation_requires_token(client): - assert client.get("/api/moderation/workflows").status_code == 401 - bad = {"Authorization": "Bearer wrong"} - assert client.get("/api/moderation/workflows", headers=bad).status_code == 401 - assert ( - client.post("/api/moderation/workflows/x/approve", headers=bad).status_code - == 401 - ) - - -def test_moderation_view_of_pending_bundle(client, admin): - submit(client) - full = client.get("/api/moderation/workflows/demo-flow", headers=admin).json() - assert len(full["files"]) == 5 - - -def test_invalid_bundles_rejected(client): - no_index = {"files": [{"path": "steps/1-do.md", "content": "x"}]} - assert client.post("/api/workflows", json=no_index).status_code == 422 - - no_frontmatter = submit(client, index_content="# no frontmatter here") - assert no_frontmatter.status_code == 422 - - missing_id = submit( - client, index_content="---\nname: X\ndescription: Y\n---\nbody" - ) - assert missing_id.status_code == 422 - - bad_slug = submit( - client, - index_content="---\nid: Bad Slug!\nname: X\ndescription: Y\n---\nbody", - ) - assert bad_slug.status_code == 422 - - -def test_admin_list_all_statuses(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - alt = INDEX_MD.replace("demo-flow", "alt-flow").replace("Demo Flow", "Alt Flow") - client.post("/api/workflows", json=bundle(index_content=alt)) - client.post("/api/moderation/workflows/alt-flow/reject", headers=admin) - - resp = client.get("/api/admin/workflows", headers=admin) - assert resp.status_code == 200 - items = resp.json() - by_id = {w["id"]: w for w in items} - assert by_id["demo-flow"]["status"] == "approved" - assert by_id["demo-flow"]["file_count"] == 5 - assert by_id["alt-flow"]["status"] == "rejected" - - -def test_download_counter_increments_on_fetch(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - - client.get("/api/workflows/demo-flow") - client.get("/api/workflows/demo-flow") +def test_repeated_gets_increment(client, admin): + for _ in range(3): + client.get("/api/workflows/demo-flow") listed = client.get("/api/admin/workflows", headers=admin).json() - assert listed[0]["downloads"] == 2 + assert len(listed) == 1 + assert listed[0]["id"] == "demo-flow" + assert listed[0]["downloads"] == 3 -def test_download_counter_skips_site_fetches(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - +def test_site_header_does_not_increment(client, admin): client.get("/api/workflows/demo-flow", headers={"X-Source": "site"}) listed = client.get("/api/admin/workflows", headers=admin).json() - assert listed[0]["downloads"] == 0 + assert listed == [] - client.get("/api/workflows/demo-flow") + +def test_site_header_missing_id_returns_zero(client): + resp = client.get("/api/workflows/unknown-flow", headers={"X-Source": "site"}) + assert resp.status_code == 200 + assert resp.json() == {"id": "unknown-flow", "downloads": 0} + + +def test_cli_header_increments(client, admin): + resp = client.get("/api/workflows/demo-flow", headers={"X-Source": "cli"}) + assert resp.status_code == 200 + assert resp.json()["downloads"] == 1 listed = client.get("/api/admin/workflows", headers=admin).json() assert listed[0]["downloads"] == 1 -def test_download_counter_ignores_missing_and_pending(client, admin): - submit(client) - # Pending: the fetch 404s and must not create a count once approved. - client.get("/api/workflows/demo-flow") - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - listed = client.get("/api/admin/workflows", headers=admin).json() - assert listed[0]["downloads"] == 0 - - def test_admin_list_requires_token(client): assert client.get("/api/admin/workflows").status_code == 401 bad = {"Authorization": "Bearer wrong"} assert client.get("/api/admin/workflows", headers=bad).status_code == 401 -def test_admin_update_metadata(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - - resp = client.patch( - "/api/admin/workflows/demo-flow", - json={"name": "Updated Name", "tags": ["new"]}, - headers=admin, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "Updated Name" - assert resp.json()["tags"] == ["new"] - - public = client.get("/api/workflows/demo-flow").json() - assert public["name"] == "Updated Name" - assert public["tags"] == ["new"] +def test_admin_list_returns_all_ids(client, admin): + client.get("/api/workflows/alpha") + client.get("/api/workflows/beta") + client.get("/api/workflows/beta") + listed = client.get("/api/admin/workflows", headers=admin).json() + by_id = {w["id"]: w["downloads"] for w in listed} + assert by_id == {"alpha": 1, "beta": 2} -def test_admin_update_partial(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - - resp = client.patch( - "/api/admin/workflows/demo-flow", - json={"name": "New Name"}, - headers=admin, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "New Name" - assert resp.json()["description"] == "A demo workflow used by the tests." - assert resp.json()["tags"] == ["demo", "testing"] +def test_invalid_slug_rejected(client): + assert client.get("/api/workflows/Bad Slug!").status_code == 404 + assert client.get("/api/workflows/../escape").status_code == 404 + assert client.get("/api/workflows/-starts-dash").status_code == 404 -def test_admin_update_not_found(client, admin): - resp = client.patch( - "/api/admin/workflows/nonexistent", - json={"name": "X"}, - headers=admin, - ) - assert resp.status_code == 404 +def test_health(client): + assert client.get("/health").json() == {"status": "ok"} -def test_admin_delete(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) +def test_migration_from_legacy_tables(postgres): + """Simulate the one-time migration from the old marketplace schema.""" + from sqlalchemy import text - resp = client.delete("/api/admin/workflows/demo-flow", headers=admin) - assert resp.status_code == 200 - assert resp.json()["deleted"] == "demo-flow" + from app.db import engine, init_db + from app.models import Base - assert client.get("/api/workflows/demo-flow").status_code == 404 - assert client.get("/api/admin/workflows", headers=admin).json() == [] + eng = engine() + Base.metadata.drop_all(eng) + with eng.begin() as conn: + conn.execute(text( + "CREATE TABLE templates (" + " pk SERIAL PRIMARY KEY," + " id TEXT NOT NULL," + " name TEXT NOT NULL," + " description TEXT NOT NULL," + " status TEXT NOT NULL," + " downloads INTEGER NOT NULL DEFAULT 0" + ")" + )) + conn.execute(text( + "CREATE TABLE template_files (" + " pk SERIAL PRIMARY KEY," + " template_pk INTEGER REFERENCES templates(pk)," + " path TEXT NOT NULL," + " content TEXT NOT NULL" + ")" + )) + conn.execute(text( + "INSERT INTO templates (id, name, description, status, downloads) VALUES " + "('support-ops', 'Support Ops', 'desc', 'approved', 7)," + "('browser-recipes', 'Browser Recipes', 'desc', 'approved', 3)," + "('coolify-ops', 'Coolify Ops', 'desc', 'rejected', 2)" + )) -def test_admin_delete_rejected(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/reject", headers=admin) + init_db() - resp = client.delete("/api/admin/workflows/demo-flow", headers=admin) - assert resp.status_code == 200 - assert client.get("/api/admin/workflows", headers=admin).json() == [] + with eng.begin() as conn: + rows = conn.execute(text("SELECT id, downloads FROM workflows ORDER BY id")).fetchall() + by_id = {r[0]: r[1] for r in rows} + assert by_id == {"browser-recipes": 3, "coolify-ops": 2, "support-ops": 7} + assert not conn.execute(text( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'templates'" + )).fetchone() + assert not conn.execute(text( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'template_files'" + )).fetchone() -def test_admin_delete_not_found(client, admin): - resp = client.delete("/api/admin/workflows/nonexistent", headers=admin) - assert resp.status_code == 404 - - -def test_admin_publish_rejected(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/reject", headers=admin) - - resp = client.post("/api/admin/workflows/demo-flow/publish", headers=admin) - assert resp.status_code == 200 - assert resp.json()["status"] == "approved" - - public = client.get("/api/workflows/demo-flow") - assert public.status_code == 200 - assert public.json()["name"] == "Demo Flow" - - -def test_admin_publish_not_rejected_404(client, admin): - submit(client) - client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) - resp = client.post("/api/admin/workflows/demo-flow/publish", headers=admin) - assert resp.status_code == 404 - - -def test_admin_delete_requires_token(client): - assert client.delete("/api/admin/workflows/x").status_code == 401 - - -def test_path_traversal_rejected(client): - evil = bundle(extra=[{"path": "../outside.md", "content": "x"}]) - assert client.post("/api/workflows", json=evil).status_code == 422 - absolute = bundle(extra=[{"path": "/etc/passwd", "content": "x"}]) - assert client.post("/api/workflows", json=absolute).status_code == 422 - duplicate = bundle(extra=[{"path": "index.md", "content": "x"}]) - assert client.post("/api/workflows", json=duplicate).status_code == 422 + init_db() + with eng.begin() as conn: + rows = conn.execute(text("SELECT id, downloads FROM workflows ORDER BY id")).fetchall() + assert len(rows) == 3 diff --git a/api/tests/test_ratelimit.py b/api/tests/test_ratelimit.py deleted file mode 100644 index f7e61a3..0000000 --- a/api/tests/test_ratelimit.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The submit endpoint is rate limited per client IP.""" - -from app.ratelimit import limiter - -INDEX_MD = """--- -id: ratelimit-test -name: Rate Limit Test -description: > - A workflow used by the rate limit test. -tags: [test] ---- - -# ratelimit-test - -Body text. -""" - - -def _bundle(): - return { - "files": [ - {"path": "index.md", "content": INDEX_MD}, - {"path": "steps/index.md", "content": "one line per step"}, - {"path": "steps/1-do.md", "content": "do the thing"}, - {"path": "commands/setup.md", "content": "the install interview"}, - {"path": "runs/example/index.md", "content": "example run"}, - ] - } - - -def test_submit_rate_limited(client): - limiter.reset() - for i in range(5): - resp = client.post("/api/workflows", json=_bundle()) - assert resp.status_code in (201, 422), ( - f"request {i + 1} returned {resp.status_code}" - ) - resp = client.post("/api/workflows", json=_bundle()) - assert resp.status_code == 429 diff --git a/api/uv.lock b/api/uv.lock index 8ecb524..738f491 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -63,18 +63,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[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 = "fastapi" version = "0.141.1" @@ -93,13 +81,11 @@ wheels = [ [[package]] name = "gcontext-workflows-api" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "psycopg", extra = ["binary"] }, - { name = "pyyaml" }, - { name = "slowapi" }, { name = "sqlalchemy" }, { name = "uvicorn" }, ] @@ -114,8 +100,6 @@ dev = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", specifier = ">=0.30" }, ] @@ -258,20 +242,6 @@ 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 = "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 = "packaging" version = "26.3" @@ -501,73 +471,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { 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 = "slowapi" -version = "0.1.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "limits" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/52/24527cf25a8b508926aff53350b0136561dfe86c7125f61526653666e1b2/slowapi-0.1.10.tar.gz", hash = "sha256:d320d5bc04d9f171a77fb16700faf3036d85b00f420f22924c8a225f95bd14f9", size = 13841, upload-time = "2026-06-13T11:59:31.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/8b/1d359f38706b4097d9a943bf8bd22599f537de4cbaff1e622d3e3936e164/slowapi-0.1.10-py3-none-any.whl", hash = "sha256:3acb61561dc9d687e3d3669362ff6a439de9ba44e2fed3a9c165da26b4b83e28", size = 14921, upload-time = "2026-06-13T11:59:30.485Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.51" @@ -671,78 +574,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77 wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, ] - -[[package]] -name = "wrapt" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe", size = 81427, upload-time = "2026-07-28T06:04:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d", size = 82360, upload-time = "2026-07-28T06:04:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8", size = 161700, upload-time = "2026-07-28T06:04:22.723Z" }, - { url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c", size = 162922, upload-time = "2026-07-28T06:04:24.177Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731", size = 156125, upload-time = "2026-07-28T06:04:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb", size = 162039, upload-time = "2026-07-28T06:04:27.154Z" }, - { url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6", size = 155110, upload-time = "2026-07-28T06:04:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd", size = 161089, upload-time = "2026-07-28T06:04:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14", size = 78030, upload-time = "2026-07-28T06:04:31.241Z" }, - { url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84", size = 80944, upload-time = "2026-07-28T06:04:32.655Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98", size = 80074, upload-time = "2026-07-28T06:04:33.811Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, - { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, - { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, - { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, - { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, - { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, - { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, - { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, - { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, - { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, - { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, - { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, - { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, - { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, - { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, - { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, - { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, - { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, - { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, - { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, - { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, - { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, - { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, - { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, - { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, - { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, - { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, - { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, -] diff --git a/gcontext/registry.py b/gcontext/registry.py index e5144e5..ccbb39b 100644 --- a/gcontext/registry.py +++ b/gcontext/registry.py @@ -12,6 +12,7 @@ import json import os import tarfile import urllib.error +import urllib.parse import urllib.request from pathlib import Path, PurePosixPath @@ -23,6 +24,7 @@ class RegistryError(Exception): DEFAULT_REGISTRY = "bleak-ai/workflows@main" +DEFAULT_API = "https://api.gcontext.ai" MANIFEST_NAME = ".template.yaml" @@ -194,11 +196,26 @@ def read_manifest(module_dir: Path) -> dict | None: return None +def _ping_download(workflow_id: str) -> None: + """Notify the API of a CLI install so the download counter stays accurate.""" + try: + req = urllib.request.Request( + f"{os.environ.get('GCONTEXT_API', DEFAULT_API)}/api/workflows/" + f"{urllib.parse.quote(workflow_id, safe='')}", + headers={"X-Source": "cli", "User-Agent": "gcontext-cli"}, + ) + with urllib.request.urlopen(req, timeout=3): + pass + except Exception: + pass + + def install_workflow(project_dir: Path, source: str) -> dict: - if "://" in source or source.startswith("github.com/"): - files, ref = fetch_workflow_by_url(source) - else: + is_registry_install = not ("://" in source or source.startswith("github.com/")) + if is_registry_install: files, ref = fetch_workflow_by_id(source) + else: + files, ref = fetch_workflow_by_url(source) try: meta = validate_bundle(files) @@ -219,6 +236,11 @@ def install_workflow(project_dir: Path, source: str) -> dict: write_manifest(module_dir, meta["id"], ref, files) + if is_registry_install and ( + os.environ.get("GCONTEXT_API") or registry_spec() == DEFAULT_REGISTRY + ): + _ping_download(meta["id"]) + return {"id": meta["id"], "name": meta["name"], "count": len(files), "path": f"modules/{meta['id']}"} diff --git a/tests/test_add.py b/tests/test_add.py index ae0144e..1e88c02 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -209,3 +209,56 @@ def test_add_tarball_path_traversal_in_archive(registry, agent): result = run_cli("add", "demo-flow", cwd=agent) assert result.returncode == 1 assert "unsafe file path" in result.stderr + + +# --- Install-ping tests --- + + +@pytest.fixture +def ping_server(monkeypatch): + """Local HTTP server that records requests. Yields its received list.""" + received = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + received.append({"path": self.path, "headers": dict(self.headers)}) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"id":"demo-flow","name":"Demo","description":"x","tags":[],"files":[]}') + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}" + monkeypatch.setenv("GCONTEXT_API", url) + yield received + server.shutdown() + + +def test_add_pings_download_counter(registry, agent, ping_server): + registry[0] = _build_tarball(_registry_files()) + result = run_cli("add", "demo-flow", cwd=agent) + assert result.returncode == 0, result.stderr + assert len(ping_server) == 1 + assert ping_server[0]["path"] == "/api/workflows/demo-flow" + assert ping_server[0]["headers"].get("X-Source") == "cli" + + +def test_add_succeeds_when_ping_endpoint_down(registry, agent, monkeypatch): + monkeypatch.setenv("GCONTEXT_API", "http://127.0.0.1:1") + registry[0] = _build_tarball(_registry_files()) + result = run_cli("add", "demo-flow", cwd=agent) + assert result.returncode == 0, result.stderr + assert (agent / "modules" / "demo-flow" / "index.md").exists() + + +def test_add_url_install_does_not_ping(registry, agent, ping_server): + registry[0] = _build_tarball(BUNDLE_FILES, prefix="repo-main") + local_url = os.environ["GCONTEXT_REGISTRY"] + result = run_cli("add", local_url, cwd=agent) + assert result.returncode == 0, result.stderr + assert len(ping_server) == 0