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) <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-08-11 00:12:09 +02:00
co-authored by Claude Opus 4.6
parent 7b13912202
commit ce6e7711af
17 changed files with 230 additions and 948 deletions
+16 -1
View File
@@ -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():
-7
View File
@@ -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)
-77
View File
@@ -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,
}
+7 -56
View File
@@ -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")
-19
View File
@@ -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)
+14 -71
View File
@@ -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]
-91
View File
@@ -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}
+28 -81
View File
@@ -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)
+2 -54
View File
@@ -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
-5
View File
@@ -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))
+2 -4
View File
@@ -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]
-3
View File
@@ -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:
+82 -264
View File
@@ -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
-39
View File
@@ -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
Generated
+1 -173
View File
@@ -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" },
]
+25 -3
View File
@@ -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']}"}
+53
View File
@@ -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