mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
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:
co-authored by
Claude Opus 4.6
parent
7b13912202
commit
ce6e7711af
+16
-1
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user