diff --git a/api/Dockerfile b/api/Dockerfile index 82c6ad4c..89b8b926 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.11-slim AS base ARG ENV_VARS +ARG POETRY_GROUPS="" ENV ENV_VARS=${ENV_VARS} \ POETRY_VERSION=1.8.2 \ @@ -13,7 +14,7 @@ COPY poetry.lock pyproject.toml entrypoint.sh /code/ RUN pip install "poetry==$POETRY_VERSION" -RUN poetry install +RUN poetry install ${POETRY_GROUPS:+--with $POETRY_GROUPS} COPY . /code diff --git a/api/alembic/versions/h1i2j3k4l5m6_add_lab_notebook_tables.py b/api/alembic/versions/h1i2j3k4l5m6_add_lab_notebook_tables.py new file mode 100644 index 00000000..e6b15f8d --- /dev/null +++ b/api/alembic/versions/h1i2j3k4l5m6_add_lab_notebook_tables.py @@ -0,0 +1,105 @@ +"""add lab notebook tables (tech lab) + +Revision ID: h1i2j3k4l5m6 +Revises: g7h8i9j0k1l2 +Create Date: 2026-05-07 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision = "h1i2j3k4l5m6" +down_revision = "g7h8i9j0k1l2" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "lab_folders", + sa.Column("id", sa.Integer, autoincrement=True, primary_key=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("parent_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("visibility", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["parent_id"], + ["lab_folders.id"], + ondelete="CASCADE", + use_alter=True, + ), + ) + op.create_index("ix_lab_folders_user_id", "lab_folders", ["user_id"]) + op.create_index("ix_lab_folders_parent_id", "lab_folders", ["parent_id"]) + op.create_index("ix_lab_folders_visibility", "lab_folders", ["visibility"]) + + op.create_table( + "lab_notebooks", + sa.Column("id", sa.Integer, autoincrement=True, primary_key=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("folder_id", sa.Integer(), nullable=True), + sa.Column("visibility", sa.String(length=16), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("source", sa.Text(), nullable=False, server_default=""), + sa.Column("cell_outputs", JSONB, nullable=False, server_default="{}"), + sa.Column("last_executed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["folder_id"], ["lab_folders.id"], ondelete="SET NULL" + ), + ) + op.create_index("ix_lab_notebooks_user_id", "lab_notebooks", ["user_id"]) + op.create_index("ix_lab_notebooks_folder_id", "lab_notebooks", ["folder_id"]) + op.create_index("ix_lab_notebooks_visibility", "lab_notebooks", ["visibility"]) + + op.create_table( + "lab_executions", + sa.Column("id", sa.Integer, autoincrement=True, primary_key=True), + sa.Column("notebook_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("cell_id", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("outputs", JSONB, nullable=False, server_default="[]"), + sa.Column("execution_count", sa.Integer(), nullable=True), + sa.Column("celery_task_id", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["notebook_id"], ["lab_notebooks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + ) + op.create_index( + "ix_lab_executions_notebook_id_started_at", + "lab_executions", + ["notebook_id", sa.text("started_at DESC")], + ) + op.create_index("ix_lab_executions_status", "lab_executions", ["status"]) + + +def downgrade(): + op.drop_index("ix_lab_executions_status", table_name="lab_executions") + op.drop_index( + "ix_lab_executions_notebook_id_started_at", table_name="lab_executions" + ) + op.drop_table("lab_executions") + op.drop_index("ix_lab_notebooks_visibility", table_name="lab_notebooks") + op.drop_index("ix_lab_notebooks_folder_id", table_name="lab_notebooks") + op.drop_index("ix_lab_notebooks_user_id", table_name="lab_notebooks") + op.drop_table("lab_notebooks") + op.drop_index("ix_lab_folders_visibility", table_name="lab_folders") + op.drop_index("ix_lab_folders_parent_id", table_name="lab_folders") + op.drop_index("ix_lab_folders_user_id", table_name="lab_folders") + op.drop_table("lab_folders") diff --git a/api/alembic/versions/i2j3k4l5m6n7_add_lab_notebook_pinned.py b/api/alembic/versions/i2j3k4l5m6n7_add_lab_notebook_pinned.py new file mode 100644 index 00000000..cf5dfc5e --- /dev/null +++ b/api/alembic/versions/i2j3k4l5m6n7_add_lab_notebook_pinned.py @@ -0,0 +1,32 @@ +"""add is_pinned to lab_notebooks + +Revision ID: i2j3k4l5m6n7 +Revises: h1i2j3k4l5m6 +Create Date: 2026-05-12 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "i2j3k4l5m6n7" +down_revision = "h1i2j3k4l5m6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "lab_notebooks", + sa.Column( + "is_pinned", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade(): + op.drop_column("lab_notebooks", "is_pinned") diff --git a/api/alembic/versions/j3k4l5m6n7o8_per_user_notebook_pins.py b/api/alembic/versions/j3k4l5m6n7o8_per_user_notebook_pins.py new file mode 100644 index 00000000..a773ee02 --- /dev/null +++ b/api/alembic/versions/j3k4l5m6n7o8_per_user_notebook_pins.py @@ -0,0 +1,52 @@ +"""per-user notebook pins (drop lab_notebooks.is_pinned, add lab_notebook_pins) + +Revision ID: j3k4l5m6n7o8 +Revises: i2j3k4l5m6n7 +Create Date: 2026-05-12 00:00:01.000000 + +Pinning is a per-user bookmark, not a property of the notebook itself — +two viewers of the same global notebook can pin it independently. Moves +the previous boolean on ``lab_notebooks.is_pinned`` to a join table. + +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "j3k4l5m6n7o8" +down_revision = "i2j3k4l5m6n7" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column("lab_notebooks", "is_pinned") + op.create_table( + "lab_notebook_pins", + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("notebook_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("user_id", "notebook_id"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["notebook_id"], ["lab_notebooks.id"], ondelete="CASCADE" + ), + ) + op.create_index( + "ix_lab_notebook_pins_user_id", "lab_notebook_pins", ["user_id"] + ) + + +def downgrade(): + op.drop_index("ix_lab_notebook_pins_user_id", table_name="lab_notebook_pins") + op.drop_table("lab_notebook_pins") + op.add_column( + "lab_notebooks", + sa.Column( + "is_pinned", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) diff --git a/api/app/auth/auth.py b/api/app/auth/auth.py index d1a75c69..1c2c9f82 100644 --- a/api/app/auth/auth.py +++ b/api/app/auth/auth.py @@ -121,6 +121,11 @@ class TokenData(BaseModel): "reactor:update": "Update Tech Lab reactor scripts.", "reactor:delete": "Delete Tech Lab reactor scripts.", "reactor:run": "Run a Tech Lab reactor script test execution.", + "lab:create": "Create Tech Lab notebooks and folders.", + "lab:read": "Read Tech Lab notebooks, folders, and executions.", + "lab:update": "Update Tech Lab notebooks and folders.", + "lab:delete": "Delete Tech Lab notebooks and folders.", + "lab:run": "Execute cells in a Tech Lab notebook.", "notifications:read": "Read notifications.", "notifications:update": "Update notifications.", "user_settings:read": "Read user settings.", diff --git a/api/app/cli/__main__.py b/api/app/cli/__main__.py index 8bfa6156..22fe1923 100644 --- a/api/app/cli/__main__.py +++ b/api/app/cli/__main__.py @@ -1,11 +1,17 @@ +import json +from datetime import datetime, timezone +from pathlib import Path from typing import Optional import typer + from app.database import SessionLocal +from app.models import lab as lab_models from app.repositories import organisations as organisations_repository from app.repositories import users as user_repository from app.schemas import organisations as organisation_schemas from app.schemas import user as user_schemas +from app.services.tech_lab.lab import nbformat_io from app.worker import tasks app = typer.Typer() @@ -78,5 +84,90 @@ def load_taxonomies(user_id: Optional[int] = None): tasks.load_taxonomies.delay() +@app.command() +def seed_lab_library( + owner_email: str = typer.Option( + ..., + "--owner-email", + help="Email of the user that will own the seeded notebooks", + ), + directory: Path = typer.Option( + Path("/code/lab_library"), + "--directory", + help="Directory containing .ipynb files to seed", + ), +): + """Upsert notebooks from .ipynb files into the Library section. + + Each file becomes a notebook with visibility=library, named after the file + stem. Re-running the command updates the source of existing library + notebooks (matched by name) without touching personal forks. + """ + if not directory.exists() or not directory.is_dir(): + typer.echo(f"Directory not found: {directory}", err=True) + raise typer.Exit(code=1) + + db = SessionLocal() + owner = user_repository.get_user_by_email(db, email=owner_email) + if owner is None: + typer.echo(f"User not found: {owner_email}", err=True) + raise typer.Exit(code=1) + + files = sorted(directory.glob("*.ipynb")) + if not files: + typer.echo(f"No .ipynb files in {directory}") + return + + created = updated = 0 + for path in files: + try: + blob = json.loads(path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + typer.echo(f"Skipping {path.name}: invalid JSON ({e})", err=True) + continue + if not isinstance(blob, dict) or "cells" not in blob: + typer.echo(f"Skipping {path.name}: not a valid .ipynb", err=True) + continue + source, fallback_name = nbformat_io.from_nbformat(blob) + # Prefer the file stem so re-running with renamed metadata still + # matches the same library entry. + name = path.stem + description = ((blob.get("metadata") or {}).get("misp_workbench") or {}).get( + "description" + ) or fallback_name + + existing = ( + db.query(lab_models.LabNotebook) + .filter( + lab_models.LabNotebook.visibility == "library", + lab_models.LabNotebook.name == name, + ) + .first() + ) + now = datetime.now(timezone.utc) + if existing is None: + row = lab_models.LabNotebook( + user_id=owner.id, + folder_id=None, + visibility="library", + name=name, + description=description, + source=source, + cell_outputs={}, + created_at=now, + ) + db.add(row) + created += 1 + else: + existing.source = source + existing.description = description + existing.cell_outputs = {} + existing.last_executed_at = None + existing.updated_at = now + updated += 1 + db.commit() + typer.echo(f"Library seeded: {created} created, {updated} updated.") + + if __name__ == "__main__": app() diff --git a/api/app/main.py b/api/app/main.py index 9de3e13c..d7085a81 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -11,6 +11,7 @@ feeds, galaxies, hunts, + lab, mcp, modules, object_templates, @@ -153,6 +154,9 @@ # Tech Lab — Reactor Scripts app.include_router(reactor.router, tags=["Tech Lab / Reactor"]) +# Tech Lab — Notebooks +app.include_router(lab.router, tags=["Tech Lab / Notebooks"]) + # MCP config endpoint (must be registered before the /mcp mount) app.include_router(mcp.router, tags=["MCP"]) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 26191fcb..7e434c70 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.hunt import Hunt # noqa from app.models.module import ModuleSettings # noqa from app.models.organisation import Organisation # noqa +from app.models.lab import LabExecution, LabFolder, LabNotebook # noqa from app.models.reactor import ReactorRun, ReactorScript # noqa from app.models.role import Role # noqa from app.models.server import Server # noqa diff --git a/api/app/models/lab.py b/api/app/models/lab.py new file mode 100644 index 00000000..ba22dcec --- /dev/null +++ b/api/app/models/lab.py @@ -0,0 +1,90 @@ +from app.database import Base +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB + + +class LabFolder(Base): + __tablename__ = "lab_folders" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + parent_id = Column( + Integer, + ForeignKey("lab_folders.id", ondelete="CASCADE"), + nullable=True, + ) + name = Column(String(255), nullable=False) + visibility = Column(String(16), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + updated_at = Column(DateTime(timezone=True), nullable=True) + + +class LabNotebook(Base): + __tablename__ = "lab_notebooks" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + folder_id = Column( + Integer, + ForeignKey("lab_folders.id", ondelete="SET NULL"), + nullable=True, + ) + visibility = Column(String(16), nullable=False) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + source = Column(Text, nullable=False, default="", server_default="") + cell_outputs = Column(JSONB, nullable=False, default=dict, server_default="{}") + last_executed_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False) + updated_at = Column(DateTime(timezone=True), nullable=True) + + +class LabNotebookPin(Base): + """Per-user notebook pin. Composite PK on ``(user_id, notebook_id)``. + + Pinning is a viewer preference, not a property of the notebook itself — + two users can pin the same global notebook independently, and unpinning + only affects the current user's view. + """ + + __tablename__ = "lab_notebook_pins" + + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ) + notebook_id = Column( + Integer, + ForeignKey("lab_notebooks.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at = Column(DateTime(timezone=True), nullable=False) + + +class LabExecution(Base): + __tablename__ = "lab_executions" + + id = Column(Integer, primary_key=True, index=True) + notebook_id = Column( + Integer, + ForeignKey("lab_notebooks.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + cell_id = Column(String(64), nullable=False) + status = Column(String(32), nullable=False, default="queued") + started_at = Column(DateTime(timezone=True), nullable=True) + finished_at = Column(DateTime(timezone=True), nullable=True) + error = Column(Text, nullable=True) + outputs = Column(JSONB, nullable=False, default=list, server_default="[]") + execution_count = Column(Integer, nullable=True) + celery_task_id = Column(String(128), nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False) diff --git a/api/app/repositories/lab.py b/api/app/repositories/lab.py new file mode 100644 index 00000000..12c40345 --- /dev/null +++ b/api/app/repositories/lab.py @@ -0,0 +1,560 @@ +"""CRUD + visibility for Tech Lab notebooks and folders. + +All read methods take ``current_user_id`` and apply visibility filtering: +``row.visibility == 'global' OR row.user_id == current_user_id``. +All write/delete methods enforce ownership: ``row.user_id == current_user_id``. +""" + +import logging +import re +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models import lab as lab_models +from app.schemas import lab as lab_schemas + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Folders +# ────────────────────────────────────────────────────────────────────────── + + +def _user_can_see(row, current_user_id: int) -> bool: + if row.visibility in ("global", "library"): + return True + return row.user_id == current_user_id + + +def _ensure_writable(row) -> None: + """Reject mutations on library rows. Library content is the seed-managed + catalogue — users must fork to a personal copy to make changes.""" + if row.visibility == "library": + raise PermissionError("library notebooks are read-only; fork to make changes") + + +def get_folder_by_id( + db: Session, folder_id: int, current_user_id: int +) -> Optional[lab_models.LabFolder]: + folder = ( + db.query(lab_models.LabFolder) + .filter(lab_models.LabFolder.id == folder_id) + .first() + ) + if folder is None or not _user_can_see(folder, current_user_id): + return None + return folder + + +def create_folder( + db: Session, + payload: lab_schemas.LabFolderCreate, + current_user_id: int, + via_api: bool = True, +) -> lab_models.LabFolder: + if via_api and payload.visibility == "library": + raise PermissionError( + "library folders are seeded via CLI and cannot be created via API" + ) + if payload.parent_id is not None: + parent = get_folder_by_id(db, payload.parent_id, current_user_id) + if parent is None: + raise ValueError("parent folder not found") + if parent.visibility != payload.visibility: + raise ValueError("parent folder visibility mismatch") + folder = lab_models.LabFolder( + user_id=current_user_id, + parent_id=payload.parent_id, + name=payload.name, + visibility=payload.visibility, + created_at=datetime.now(timezone.utc), + ) + db.add(folder) + db.commit() + db.refresh(folder) + return folder + + +def update_folder( + db: Session, + folder_id: int, + payload: lab_schemas.LabFolderUpdate, + current_user_id: int, +) -> Optional[lab_models.LabFolder]: + folder = ( + db.query(lab_models.LabFolder) + .filter(lab_models.LabFolder.id == folder_id) + .first() + ) + if folder is None or not _user_can_see(folder, current_user_id): + return None + _ensure_writable(folder) + if folder.user_id != current_user_id: + raise PermissionError("only the owner can update this folder") + + data = payload.model_dump(exclude_unset=True) + if "parent_id" in data: + new_parent_id = data["parent_id"] + if new_parent_id is not None: + parent = get_folder_by_id(db, new_parent_id, current_user_id) + if parent is None: + raise ValueError("parent folder not found") + if parent.visibility != folder.visibility: + raise ValueError("parent folder visibility mismatch") + if _would_create_cycle(db, folder.id, new_parent_id): + raise ValueError("move would create a folder cycle") + folder.parent_id = new_parent_id + if "name" in data and data["name"] is not None: + folder.name = data["name"] + folder.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(folder) + return folder + + +def delete_folder(db: Session, folder_id: int, current_user_id: int) -> Optional[dict]: + folder = ( + db.query(lab_models.LabFolder) + .filter(lab_models.LabFolder.id == folder_id) + .first() + ) + if folder is None or not _user_can_see(folder, current_user_id): + return None + _ensure_writable(folder) + if folder.user_id != current_user_id: + raise PermissionError("only the owner can delete this folder") + db.delete(folder) + db.commit() + return {"status": "success"} + + +def _would_create_cycle(db: Session, folder_id: int, new_parent_id: int) -> bool: + """Return True if making folder_id a child of new_parent_id would form a cycle.""" + cursor = new_parent_id + seen: set[int] = set() + while cursor is not None: + if cursor in seen: + return True + seen.add(cursor) + if cursor == folder_id: + return True + parent = ( + db.query(lab_models.LabFolder.parent_id) + .filter(lab_models.LabFolder.id == cursor) + .first() + ) + cursor = parent[0] if parent else None + return False + + +# ────────────────────────────────────────────────────────────────────────── +# Notebooks +# ────────────────────────────────────────────────────────────────────────── + + +def get_notebook_by_id( + db: Session, notebook_id: int, current_user_id: int +) -> Optional[lab_models.LabNotebook]: + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + return nb + + +def create_notebook( + db: Session, + payload: lab_schemas.LabNotebookCreate, + current_user_id: int, + via_api: bool = True, +) -> lab_models.LabNotebook: + if via_api and payload.visibility == "library": + raise PermissionError( + "library notebooks are seeded via CLI and cannot be created via API" + ) + if payload.folder_id is not None: + folder = get_folder_by_id(db, payload.folder_id, current_user_id) + if folder is None: + raise ValueError("folder not found") + if folder.visibility != payload.visibility: + raise ValueError("folder visibility mismatch") + nb = lab_models.LabNotebook( + user_id=current_user_id, + folder_id=payload.folder_id, + visibility=payload.visibility, + name=payload.name, + description=payload.description, + source=payload.source or "", + cell_outputs={}, + created_at=datetime.now(timezone.utc), + ) + db.add(nb) + db.commit() + db.refresh(nb) + return nb + + +def update_notebook( + db: Session, + notebook_id: int, + payload: lab_schemas.LabNotebookUpdate, + current_user_id: int, +) -> Optional[lab_models.LabNotebook]: + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + _ensure_writable(nb) + if nb.user_id != current_user_id: + raise PermissionError("only the owner can update this notebook") + + data = payload.model_dump(exclude_unset=True) + if "folder_id" in data: + new_folder_id = data["folder_id"] + if new_folder_id is not None: + folder = get_folder_by_id(db, new_folder_id, current_user_id) + if folder is None: + raise ValueError("folder not found") + if folder.visibility != nb.visibility: + raise ValueError("folder visibility mismatch") + nb.folder_id = new_folder_id + for key in ("name", "description", "source"): + if key in data and data[key] is not None: + setattr(nb, key, data[key]) + nb.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(nb) + return nb + + +def delete_notebook( + db: Session, notebook_id: int, current_user_id: int +) -> Optional[dict]: + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + _ensure_writable(nb) + if nb.user_id != current_user_id: + raise PermissionError("only the owner can delete this notebook") + db.delete(nb) + db.commit() + return {"status": "success"} + + +def clear_outputs( + db: Session, notebook_id: int, current_user_id: int +) -> Optional[lab_models.LabNotebook]: + """Reset ``cell_outputs`` to ``{}``. Owner-only. + + Last-executed-at is also cleared so the UI doesn't keep showing a stale + timestamp. ``LabExecution`` rows survive — they're audit history. + """ + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + _ensure_writable(nb) + if nb.user_id != current_user_id: + raise PermissionError("only the owner can clear outputs") + nb.cell_outputs = {} + nb.last_executed_at = None + nb.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(nb) + return nb + + +def fork_notebook( + db: Session, + notebook_id: int, + current_user_id: int, + target_visibility: str = "personal", +) -> Optional[lab_models.LabNotebook]: + """Duplicate a visible notebook into a new one owned by the current user. + + ``target_visibility`` selects where the copy lands: + + - ``"personal"`` (default) — the classic "fork to a private copy" flow, + used when readers want to mutate / execute a library or shared notebook. + - ``"global"`` — "publish to global" flow, used by an owner to share their + personal notebook with the rest of the workbench. Library notebooks + cannot be published this way (use the CLI seeder instead). + + Cell ids inside ``source`` are regenerated so the original and the copy + can be open simultaneously without execution conflicts; ``cell_outputs`` + is rewritten to use the new ids. + """ + if target_visibility not in ("personal", "global"): + raise ValueError("target_visibility must be 'personal' or 'global'") + + src = get_notebook_by_id(db, notebook_id, current_user_id) + if src is None: + return None + + if target_visibility == "global" and src.visibility == "library": + raise ValueError( + "library notebooks cannot be published to global; seed via CLI" + ) + + new_source, id_map = _regenerate_cell_ids(src.source or "") + new_outputs: dict[str, list[dict]] = {} + for old_id, new_id in id_map.items(): + if old_id in (src.cell_outputs or {}): + new_outputs[new_id] = src.cell_outputs[old_id] + + # Personal forks keep the "(fork)" suffix so the tree disambiguates them + # from the source. Global publishes keep the original name — they're the + # canonical shared copy. + name = src.name if target_visibility == "global" else f"{src.name} (fork)" + + fork = lab_models.LabNotebook( + user_id=current_user_id, + folder_id=None, + visibility=target_visibility, + name=name, + description=src.description, + source=new_source, + cell_outputs=new_outputs, + created_at=datetime.now(timezone.utc), + ) + db.add(fork) + db.commit() + db.refresh(fork) + return fork + + +_CELL_DELIMITER_RE = re.compile( + r"^(#\s*%%)(?:\s*\[id=([0-9a-fA-F-]+)\])?(.*)$", + re.MULTILINE, +) + + +def _regenerate_cell_ids(source: str) -> tuple[str, dict[str, str]]: + """Replace every ``[id=]`` in cell delimiters with a fresh uuid. + + Returns the rewritten source plus a map from old id → new id for any + delimiter that had one. Delimiters without an id get a new id assigned. + """ + id_map: dict[str, str] = {} + + def _sub(match: re.Match) -> str: + prefix, old_id, suffix = match.group(1), match.group(2), match.group(3) + new_id = str(uuid.uuid4()) + if old_id: + id_map[old_id] = new_id + return f"{prefix} [id={new_id}]{suffix}" + + rewritten = _CELL_DELIMITER_RE.sub(_sub, source) + return rewritten, id_map + + +# ────────────────────────────────────────────────────────────────────────── +# Executions +# ────────────────────────────────────────────────────────────────────────── + + +def create_execution( + db: Session, + notebook_id: int, + cell_id: str, + current_user_id: int, +) -> lab_models.LabExecution: + """Create a queued execution row. + + Caller is expected to have already verified the user can run the notebook + (read-visibility check). Allowed for any reader: a global notebook's + reader can run cells under their own kernel keyed by + ``(running_user_id, notebook_id)``. + """ + row = lab_models.LabExecution( + notebook_id=notebook_id, + user_id=current_user_id, + cell_id=cell_id, + status="queued", + outputs=[], + created_at=datetime.now(timezone.utc), + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def get_execution( + db: Session, execution_id: int, current_user_id: int +) -> Optional[lab_models.LabExecution]: + row = ( + db.query(lab_models.LabExecution) + .filter(lab_models.LabExecution.id == execution_id) + .first() + ) + if row is None: + return None + # Anyone who can see the notebook can poll its executions; private + # notebook executions remain invisible. + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == row.notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + return row + + +def write_cell_source( + db: Session, + notebook_id: int, + cell_id: str, + source: str, + current_user_id: int, +) -> Optional[lab_models.LabNotebook]: + """Replace the source of a single cell inside a notebook's blob. + + Used by ``POST /cells/execute`` to capture the editor-current source + before queueing the run, so the executor can find the up-to-date slice. + Owner-only — non-owners can't mutate a global notebook's source. They + can still execute (their cell's source travels in the request body and + is found by re-parsing the stored blob, which is OK because they ran + Fork-to-personal first if they wanted a private copy to mutate). + + Returns the updated notebook, or ``None`` if not found / not permitted. + For non-owner runs, a no-op success: returns the notebook unchanged. + """ + from app.services.tech_lab.lab.cell_parser import ( + parse_cells, + serialize_cells, + ) + + nb = get_notebook_by_id(db, notebook_id, current_user_id) + if nb is None: + return None + if nb.visibility == "library": + return nb # library is read-only; cell source travels via the request + if nb.user_id != current_user_id: + return nb # non-owner: don't mutate; return as-is + + cells = parse_cells(nb.source or "") + found = False + for c in cells: + if c.cell_id == cell_id: + c.source = source if source.endswith("\n") else source + "\n" + found = True + break + if not found: + return nb + nb.source = serialize_cells(cells) + nb.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(nb) + return nb + + +# ────────────────────────────────────────────────────────────────────────── +# Tree +# ────────────────────────────────────────────────────────────────────────── + + +def get_tree(db: Session, current_user_id: int) -> lab_schemas.LabTree: + folders = ( + db.query(lab_models.LabFolder) + .filter( + or_( + lab_models.LabFolder.visibility.in_(("global", "library")), + lab_models.LabFolder.user_id == current_user_id, + ) + ) + .order_by(lab_models.LabFolder.name.asc()) + .all() + ) + notebooks = ( + db.query(lab_models.LabNotebook) + .filter( + or_( + lab_models.LabNotebook.visibility.in_(("global", "library")), + lab_models.LabNotebook.user_id == current_user_id, + ) + ) + .order_by(lab_models.LabNotebook.name.asc()) + .all() + ) + pinned_ids = [ + nb_id + for (nb_id,) in db.query(lab_models.LabNotebookPin.notebook_id) + .filter(lab_models.LabNotebookPin.user_id == current_user_id) + .all() + ] + return lab_schemas.LabTree( + folders=[lab_schemas.LabFolder.model_validate(f) for f in folders], + notebooks=[lab_schemas.LabNotebookSummary.model_validate(n) for n in notebooks], + pinned_notebook_ids=pinned_ids, + ) + + +def pin_notebook( + db: Session, notebook_id: int, current_user_id: int +) -> Optional[dict]: + """Create a pin for the current user. Idempotent. Returns None if the + user cannot see the notebook (caller maps to 404).""" + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + existing = ( + db.query(lab_models.LabNotebookPin) + .filter( + lab_models.LabNotebookPin.user_id == current_user_id, + lab_models.LabNotebookPin.notebook_id == notebook_id, + ) + .first() + ) + if existing is None: + db.add( + lab_models.LabNotebookPin( + user_id=current_user_id, + notebook_id=notebook_id, + created_at=datetime.now(timezone.utc), + ) + ) + db.commit() + return {"status": "pinned"} + + +def unpin_notebook( + db: Session, notebook_id: int, current_user_id: int +) -> Optional[dict]: + """Remove the pin for the current user. Idempotent.""" + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == notebook_id) + .first() + ) + if nb is None or not _user_can_see(nb, current_user_id): + return None + db.query(lab_models.LabNotebookPin).filter( + lab_models.LabNotebookPin.user_id == current_user_id, + lab_models.LabNotebookPin.notebook_id == notebook_id, + ).delete() + db.commit() + return {"status": "unpinned"} diff --git a/api/app/routers/diagnostics.py b/api/app/routers/diagnostics.py index dea53c98..83abfd51 100644 --- a/api/app/routers/diagnostics.py +++ b/api/app/routers/diagnostics.py @@ -380,3 +380,66 @@ def get_modules_diagnostics( "url": url, "error": str(e), } + + +@router.get("/diagnostics/lab") +def get_lab_diagnostics( + user: user_schemas.User = Security(get_current_active_user, scopes=["tasks:read"]), +): + """Lab-worker connectivity and running jupyter kernels. + + Worker presence is derived from Celery inspect — we look for a worker + subscribed to the ``lab_kernel`` queue. The kernel registry lives inside + the lab-worker process, so we round-trip a short task to fetch a snapshot. + """ + from app.worker.tasks import celery_app, lab_kernel_list + + worker_info = None + try: + inspect = celery_app.control.inspect(timeout=2) + active_queues = inspect.active_queues() or {} + stats = inspect.stats() or {} + for worker_name, queues in active_queues.items(): + if any(q.get("name") == "lab_kernel" for q in queues): + s = stats.get(worker_name) or {} + worker_info = { + "name": worker_name, + "pool_implementation": (s.get("pool") or {}).get( + "implementation" + ), + "concurrency": (s.get("pool") or {}).get("max-concurrency"), + "uptime_seconds": s.get("uptime"), + "pid": (s.get("pool") or {}).get("processes") or s.get("pid"), + } + break + except Exception as e: + logger.warning("Failed to inspect celery for lab-worker: %s", e) + + if worker_info is None: + return { + "connected": False, + "worker": None, + "kernel_count": 0, + "kernels": [], + "idle_seconds_threshold": None, + "error": "lab-worker not found on lab_kernel queue", + } + + try: + snapshot = lab_kernel_list.apply_async(queue="lab_kernel").get(timeout=5) + except Exception as e: + logger.error("Failed to snapshot lab kernels: %s", e) + return { + "connected": True, + "worker": worker_info, + "kernel_count": 0, + "kernels": [], + "idle_seconds_threshold": None, + "error": f"snapshot task failed: {e}", + } + + return { + "connected": True, + "worker": worker_info, + **snapshot, + } diff --git a/api/app/routers/lab.py b/api/app/routers/lab.py new file mode 100644 index 00000000..54d04876 --- /dev/null +++ b/api/app/routers/lab.py @@ -0,0 +1,493 @@ +"""Tech Lab — Notebooks router. + +Mounted under ``/tech-lab`` so notebooks (this file) sit alongside reactor +scripts under the same Tech Lab umbrella. Visibility (personal/global) and +ownership are enforced inside the repository; this layer only translates +exceptions into HTTP responses. +""" + +import json +from typing import Literal + +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Security, + UploadFile, + status, +) +from sqlalchemy.orm import Session + +from app.auth.security import get_current_active_user +from app.db.session import get_db +from app.repositories import lab as lab_repository +from app.schemas import lab as lab_schemas +from app.schemas import user as user_schemas +from app.services.tech_lab.lab import nbformat_io + +router = APIRouter() + + +# ────────────────────────────────────────────────────────────────────────── +# Tree +# ────────────────────────────────────────────────────────────────────────── + + +@router.get("/tech-lab/tree", response_model=lab_schemas.LabTree) +async def get_tree( + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:read"]), +): + return lab_repository.get_tree(db, current_user_id=user.id) + + +# ────────────────────────────────────────────────────────────────────────── +# Folders +# ────────────────────────────────────────────────────────────────────────── + + +@router.post( + "/tech-lab/folders", + response_model=lab_schemas.LabFolder, + status_code=status.HTTP_201_CREATED, +) +async def create_folder( + payload: lab_schemas.LabFolderCreate, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:create"]), +): + try: + return lab_repository.create_folder(db, payload, current_user_id=user.id) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.patch( + "/tech-lab/folders/{folder_id}", + response_model=lab_schemas.LabFolder, +) +async def update_folder( + folder_id: int, + payload: lab_schemas.LabFolderUpdate, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:update"]), +): + try: + folder = lab_repository.update_folder( + db, folder_id, payload, current_user_id=user.id + ) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + if folder is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" + ) + return folder + + +@router.delete( + "/tech-lab/folders/{folder_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_folder( + folder_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:delete"]), +): + try: + result = lab_repository.delete_folder(db, folder_id, current_user_id=user.id) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" + ) + + +# ────────────────────────────────────────────────────────────────────────── +# Notebooks +# ────────────────────────────────────────────────────────────────────────── + + +@router.post( + "/tech-lab/notebooks", + response_model=lab_schemas.LabNotebook, + status_code=status.HTTP_201_CREATED, +) +async def create_notebook( + payload: lab_schemas.LabNotebookCreate, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:create"]), +): + try: + return lab_repository.create_notebook(db, payload, current_user_id=user.id) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.get( + "/tech-lab/notebooks/{notebook_id}", + response_model=lab_schemas.LabNotebook, +) +async def get_notebook( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:read"]), +): + nb = lab_repository.get_notebook_by_id(db, notebook_id, current_user_id=user.id) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return nb + + +@router.patch( + "/tech-lab/notebooks/{notebook_id}", + response_model=lab_schemas.LabNotebook, +) +async def update_notebook( + notebook_id: int, + payload: lab_schemas.LabNotebookUpdate, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:update"]), +): + try: + nb = lab_repository.update_notebook( + db, notebook_id, payload, current_user_id=user.id + ) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return nb + + +@router.delete( + "/tech-lab/notebooks/{notebook_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_notebook( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:delete"]), +): + try: + result = lab_repository.delete_notebook( + db, notebook_id, current_user_id=user.id + ) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/clear_outputs", + response_model=lab_schemas.LabNotebook, +) +async def clear_outputs( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:update"]), +): + try: + nb = lab_repository.clear_outputs(db, notebook_id, current_user_id=user.id) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return nb + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/pin", + status_code=status.HTTP_200_OK, +) +async def pin_notebook( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:read"]), +): + result = lab_repository.pin_notebook(db, notebook_id, current_user_id=user.id) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return result + + +@router.delete( + "/tech-lab/notebooks/{notebook_id}/pin", + status_code=status.HTTP_200_OK, +) +async def unpin_notebook( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:read"]), +): + result = lab_repository.unpin_notebook(db, notebook_id, current_user_id=user.id) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return result + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/fork", + response_model=lab_schemas.LabNotebook, + status_code=status.HTTP_201_CREATED, +) +async def fork_notebook( + notebook_id: int, + visibility: Literal["personal", "global"] = "personal", + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:create"]), +): + try: + nb = lab_repository.fork_notebook( + db, notebook_id, current_user_id=user.id, target_visibility=visibility + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return nb + + +# ────────────────────────────────────────────────────────────────────────── +# Execution +# ────────────────────────────────────────────────────────────────────────── + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/cells/execute", + response_model=lab_schemas.LabExecution, + status_code=status.HTTP_201_CREATED, +) +async def execute_cell( + notebook_id: int, + payload: lab_schemas.LabExecuteRequest, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:run"]), +): + nb = lab_repository.get_notebook_by_id(db, notebook_id, current_user_id=user.id) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + if nb.visibility == "library": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="library notebooks cannot be executed; fork to a personal copy", + ) + # Owner-only: capture the editor-current source onto the blob so the + # executor finds the same slice. Non-owners run against the stored + # source as-is (see write_cell_source contract). + lab_repository.write_cell_source( + db, notebook_id, payload.cell_id, payload.source, current_user_id=user.id + ) + row = lab_repository.create_execution( + db, notebook_id, payload.cell_id, current_user_id=user.id + ) + + from app.worker.tasks import lab_execute_cell as _task + + async_result = _task.apply_async( + args=[row.id, payload.timeout_seconds], queue="lab_kernel" + ) + row.celery_task_id = getattr(async_result, "id", None) + db.commit() + db.refresh(row) + return row + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/cells/execute_all", + response_model=list[lab_schemas.LabExecution], + status_code=status.HTTP_201_CREATED, +) +async def execute_all_cells( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:run"]), +): + nb = lab_repository.get_notebook_by_id(db, notebook_id, current_user_id=user.id) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + if nb.visibility == "library": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="library notebooks cannot be executed; fork to a personal copy", + ) + + from app.services.tech_lab.lab.cell_parser import parse_cells + from app.worker.tasks import lab_execute_cell as _task + + rows: list = [] + for cell in parse_cells(nb.source or ""): + if cell.type != "code": + continue + row = lab_repository.create_execution( + db, notebook_id, cell.cell_id, current_user_id=user.id + ) + async_result = _task.apply_async(args=[row.id], queue="lab_kernel") + row.celery_task_id = getattr(async_result, "id", None) + db.commit() + db.refresh(row) + rows.append(row) + return rows + + +@router.get( + "/tech-lab/notebooks/{notebook_id}/executions/{execution_id}", + response_model=lab_schemas.LabExecution, +) +async def get_execution( + notebook_id: int, + execution_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:read"]), +): + row = lab_repository.get_execution(db, execution_id, current_user_id=user.id) + if row is None or row.notebook_id != notebook_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Execution not found" + ) + return row + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/kernel/interrupt", + status_code=status.HTTP_202_ACCEPTED, +) +async def interrupt_kernel( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:run"]), +): + nb = lab_repository.get_notebook_by_id(db, notebook_id, current_user_id=user.id) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + if nb.visibility == "library": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="library notebooks have no running kernel", + ) + from app.worker.tasks import lab_kernel_interrupt as _task + + _task.apply_async(args=[user.id, notebook_id], queue="lab_kernel") + return {"status": "queued"} + + +@router.post( + "/tech-lab/notebooks/{notebook_id}/kernel/shutdown", + status_code=status.HTTP_202_ACCEPTED, +) +async def shutdown_kernel( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:run"]), +): + nb = lab_repository.get_notebook_by_id(db, notebook_id, current_user_id=user.id) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + if nb.visibility == "library": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="library notebooks have no running kernel", + ) + from app.worker.tasks import lab_kernel_shutdown as _task + + _task.apply_async(args=[user.id, notebook_id], queue="lab_kernel") + return {"status": "queued"} + + +# ────────────────────────────────────────────────────────────────────────── +# Import / export +# ────────────────────────────────────────────────────────────────────────── + + +@router.get("/tech-lab/notebooks/{notebook_id}/export") +async def export_notebook( + notebook_id: int, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:read"]), +): + """Return the notebook as nbformat 4.5 JSON. + + Frontend wraps the response body in a ``Blob`` and saves it as + ``.ipynb``. Outputs from the most recent run are included so the + downloaded file opens with results visible in JupyterLab. + """ + nb = lab_repository.get_notebook_by_id(db, notebook_id, current_user_id=user.id) + if nb is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found" + ) + return nbformat_io.to_nbformat(nb) + + +@router.post( + "/tech-lab/notebooks/import", + response_model=lab_schemas.LabNotebook, + status_code=status.HTTP_201_CREATED, +) +async def import_notebook( + file: UploadFile = File(...), + folder_id: int | None = None, + db: Session = Depends(get_db), + user: user_schemas.User = Security(get_current_active_user, scopes=["lab:create"]), +): + """Import an .ipynb. Always creates a *personal* notebook owned by the + current user — analysts can promote to global later by re-creating in a + Global folder if they want to share.""" + raw = await file.read() + try: + blob = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid .ipynb (not valid JSON): {e}", + ) + if not isinstance(blob, dict) or "cells" not in blob: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid .ipynb (missing cells)", + ) + source, name = nbformat_io.from_nbformat(blob) + payload = lab_schemas.LabNotebookCreate( + name=name, + visibility="personal", + folder_id=folder_id, + source=source, + ) + try: + return lab_repository.create_notebook(db, payload, current_user_id=user.id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/api/app/schemas/lab.py b/api/app/schemas/lab.py new file mode 100644 index 00000000..733d4a5d --- /dev/null +++ b/api/app/schemas/lab.py @@ -0,0 +1,127 @@ +from datetime import datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +LabVisibility = Literal["personal", "global", "library"] +LabExecutionStatus = Literal["queued", "running", "success", "error", "interrupted"] + + +# ────────────────────────────────────────────────────────────────────────── +# Folders +# ────────────────────────────────────────────────────────────────────────── + + +class LabFolderBase(BaseModel): + name: str + visibility: LabVisibility + parent_id: Optional[int] = None + + +class LabFolderCreate(LabFolderBase): + pass + + +class LabFolderUpdate(BaseModel): + name: Optional[str] = None + parent_id: Optional[int] = None + + +class LabFolder(LabFolderBase): + id: int + user_id: int + created_at: datetime + updated_at: Optional[datetime] = None + model_config = ConfigDict(from_attributes=True) + + +# ────────────────────────────────────────────────────────────────────────── +# Notebooks +# ────────────────────────────────────────────────────────────────────────── + + +class LabNotebookBase(BaseModel): + name: str + description: Optional[str] = None + visibility: LabVisibility + folder_id: Optional[int] = None + + +class LabNotebookCreate(LabNotebookBase): + source: str = "" + + +class LabNotebookUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + folder_id: Optional[int] = None + source: Optional[str] = None + + +class LabNotebookSummary(LabNotebookBase): + """Tree-view payload — omits source/cell_outputs to keep the tree fetch small.""" + + id: int + user_id: int + last_executed_at: Optional[datetime] = None + created_at: datetime + updated_at: Optional[datetime] = None + model_config = ConfigDict(from_attributes=True) + + +class LabNotebook(LabNotebookBase): + id: int + user_id: int + source: str = "" + cell_outputs: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) + last_executed_at: Optional[datetime] = None + created_at: datetime + updated_at: Optional[datetime] = None + model_config = ConfigDict(from_attributes=True) + + +# ────────────────────────────────────────────────────────────────────────── +# Tree +# ────────────────────────────────────────────────────────────────────────── + + +class LabTree(BaseModel): + """Single response that returns Personal + Global + Library trees so the + left panel renders in one round trip. Library notebooks are read-only + prebuilt content (seeded via CLI); users fork them to a personal copy + before running.""" + + folders: list[LabFolder] + notebooks: list[LabNotebookSummary] + pinned_notebook_ids: list[int] = Field(default_factory=list) + + +# ────────────────────────────────────────────────────────────────────────── +# Execution +# ────────────────────────────────────────────────────────────────────────── + + +class LabExecuteRequest(BaseModel): + cell_id: str + source: str + timeout_seconds: int = 60 + + +class LabExecution(BaseModel): + id: int + notebook_id: int + user_id: int + cell_id: str + status: LabExecutionStatus + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + outputs: list[dict[str, Any]] = Field(default_factory=list) + execution_count: Optional[int] = None + celery_task_id: Optional[str] = None + created_at: datetime + model_config = ConfigDict(from_attributes=True) + + +class LabQueryParams(BaseModel): + filter: Optional[str] = None diff --git a/api/app/services/tech_lab/lab/__init__.py b/api/app/services/tech_lab/lab/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/app/services/tech_lab/lab/cell_parser.py b/api/app/services/tech_lab/lab/cell_parser.py new file mode 100644 index 00000000..1348bf2a --- /dev/null +++ b/api/app/services/tech_lab/lab/cell_parser.py @@ -0,0 +1,83 @@ +"""Parse the notebook ``source`` blob into cells, and inverse. + +The on-disk format is a single text document with ``# %% [id=] code|markdown`` +delimiter lines (Jupytext "percent format", augmented with stable cell ids). +The frontend Monaco editor edits this directly; the server parses it on demand +(for ``execute_all`` and import/export). Permissive: missing ``[id=...]`` gets +a fresh uuid; missing type defaults to ``code``; trailing whitespace is preserved. +""" + +import re +import uuid +from dataclasses import dataclass +from typing import Literal + + +CellType = Literal["code", "markdown"] + +_DELIMITER_RE = re.compile( + r"^#\s*%%(?:\s*\[id=([0-9a-fA-F-]+)\])?(?:\s+(code|markdown))?\s*(.*)$" +) + + +@dataclass +class ParsedCell: + cell_id: str + type: CellType + source: str + """The cell body, **without** the delimiter line. Trailing newline preserved.""" + + +def parse_cells(source: str) -> list[ParsedCell]: + """Split ``source`` into cells on ``# %% ...`` delimiter lines. + + Content before the first delimiter is treated as an implicit code cell + (with a fresh id) so notebooks that don't start with ``# %%`` still + behave sensibly. + """ + if source is None: + return [] + lines = source.splitlines(keepends=True) + cells: list[ParsedCell] = [] + cur_id: str | None = None + cur_type: CellType = "code" + cur_body: list[str] = [] + + def _flush() -> None: + if cur_id is None and not cur_body: + return + cells.append( + ParsedCell( + cell_id=cur_id or str(uuid.uuid4()), + type=cur_type, + source="".join(cur_body), + ) + ) + + for line in lines: + stripped = line.rstrip("\r\n") + m = _DELIMITER_RE.match(stripped) + if m is not None: + _flush() + cur_id = m.group(1) or str(uuid.uuid4()) + cur_type = m.group(2) or "code" # type: ignore[assignment] + cur_body = [] + continue + cur_body.append(line) + _flush() + return cells + + +def serialize_cells(cells: list[ParsedCell]) -> str: + """Inverse of ``parse_cells`` — emit canonical delimiter lines. + + Always writes the explicit ``[id=...] `` header so subsequent parses + are stable. + """ + out: list[str] = [] + for c in cells: + out.append(f"# %% [id={c.cell_id}] {c.type}\n") + out.append(c.source) + if c.source and not c.source.endswith("\n"): + out.append("\n") + return "".join(out) diff --git a/api/app/services/tech_lab/lab/executor.py b/api/app/services/tech_lab/lab/executor.py new file mode 100644 index 00000000..855c02e5 --- /dev/null +++ b/api/app/services/tech_lab/lab/executor.py @@ -0,0 +1,210 @@ +"""Drive a single cell execution end-to-end. + +Loads a ``LabExecution`` row, sends its source to the appropriate kernel, +drains IOPub messages until ``status == idle`` (or ``timeout_seconds`` +elapses), persists outputs onto the row, and — only when the running user +is the notebook owner — writes them back into ``notebook.cell_outputs`` so +they survive a reload. +""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime, timezone +from typing import Any, Optional + +from app.models import lab as lab_models +from app.services.tech_lab.lab import kernel_manager as km +from app.services.tech_lab.lab.cell_parser import parse_cells +from sqlalchemy.orm import Session + + +logger = logging.getLogger(__name__) + + +def execute_cell( + db: Session, + execution_id: int, + *, + timeout_seconds: int = 60, + registry: Optional[km.LabKernelRegistry] = None, +) -> None: + """Run one queued execution to completion (success / error / interrupted). + + Idempotent on terminal status: if the row is already finished, no-op + (covers Celery retries). + """ + row = ( + db.query(lab_models.LabExecution) + .filter(lab_models.LabExecution.id == execution_id) + .first() + ) + if row is None: + logger.warning("lab_execute_cell: execution id=%s not found", execution_id) + return + if row.status not in ("queued", "running"): + logger.info( + "lab_execute_cell: execution id=%s already terminal (%s); skipping", + execution_id, + row.status, + ) + return + + nb = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == row.notebook_id) + .first() + ) + if nb is None: + row.status = "error" + row.error = "notebook not found" + row.finished_at = datetime.now(timezone.utc) + db.commit() + return + + cell_source = _find_cell_source(nb.source or "", row.cell_id) + if cell_source is None: + row.status = "error" + row.error = f"cell {row.cell_id} not present in notebook source" + row.finished_at = datetime.now(timezone.utc) + db.commit() + return + + row.status = "running" + row.started_at = datetime.now(timezone.utc) + db.commit() + + reg = registry or km.get_default_registry() + key = (row.user_id, row.notebook_id) + timeout = max(1, int(timeout_seconds)) + + outputs: list[dict[str, Any]] = [] + execution_count: Optional[int] = None + error_text: Optional[str] = None + + try: + entry = reg.get_or_start(key) + with entry.lock: + client = entry.client + msg_id = client.execute( # type: ignore[attr-defined] + cell_source, silent=False, store_history=True + ) + outputs, execution_count, error_text, status, timed_out = _drain_iopub( + client, msg_id, timeout=timeout + ) + if timed_out: + # Send SIGINT so the runaway cell raises KeyboardInterrupt and + # stops emitting IOPub. If we don't, the kernel keeps running + # and the *next* execution drains its leftover messages. + reg.interrupt(key) + grace_outputs, _, _, _, _ = _drain_iopub( + client, msg_id, timeout=5 + ) + outputs.extend(grace_outputs) + reg.touch(key) + except Exception as e: # noqa: BLE001 + status = "error" + error_text = f"{type(e).__name__}: {e}" + logger.exception("lab_execute_cell id=%s crashed", execution_id) + + row.status = status + row.error = error_text + row.outputs = outputs + row.execution_count = execution_count + row.finished_at = datetime.now(timezone.utc) + + if row.user_id == nb.user_id: + cell_outputs = dict(nb.cell_outputs or {}) + cell_outputs[row.cell_id] = outputs + nb.cell_outputs = cell_outputs + nb.last_executed_at = row.finished_at + db.commit() + + +def _find_cell_source(notebook_source: str, cell_id: str) -> Optional[str]: + for cell in parse_cells(notebook_source): + if cell.cell_id == cell_id: + return cell.source + return None + + +def _drain_iopub( + client, + parent_msg_id: str, + *, + timeout: int, +) -> tuple[list[dict[str, Any]], Optional[int], Optional[str], str, bool]: + """Collect IOPub messages whose parent is ``parent_msg_id`` until idle. + + Returns ``(outputs, execution_count, error_text, status, timed_out)``. + ``timed_out`` is True when the deadline elapsed before an ``idle`` status + message was received — the caller is then expected to send an interrupt + and drain leftover messages. + """ + outputs: list[dict[str, Any]] = [] + execution_count: Optional[int] = None + error_text: Optional[str] = None + status = "success" + timed_out = False + deadline = time.monotonic() + timeout + + while True: + remaining = max(0.05, deadline - time.monotonic()) + try: + msg = client.get_iopub_msg(timeout=remaining) + except Exception: # noqa: BLE001 + status = "interrupted" + error_text = f"cell exceeded {timeout}s" + timed_out = True + break + parent = msg.get("parent_header") or {} + if parent.get("msg_id") != parent_msg_id: + continue + msg_type = msg.get("msg_type") + content = msg.get("content") or {} + if msg_type == "status": + if content.get("execution_state") == "idle": + break + continue + if msg_type == "stream": + outputs.append( + { + "output_type": "stream", + "name": content.get("name", "stdout"), + "text": content.get("text", ""), + } + ) + elif msg_type == "display_data": + outputs.append( + { + "output_type": "display_data", + "data": content.get("data", {}), + "metadata": content.get("metadata", {}), + } + ) + elif msg_type == "execute_result": + execution_count = content.get("execution_count") + outputs.append( + { + "output_type": "execute_result", + "execution_count": execution_count, + "data": content.get("data", {}), + "metadata": content.get("metadata", {}), + } + ) + elif msg_type == "error": + status = "error" + error_text = f"{content.get('ename')}: {content.get('evalue')}" + outputs.append( + { + "output_type": "error", + "ename": content.get("ename", ""), + "evalue": content.get("evalue", ""), + "traceback": content.get("traceback", []), + } + ) + elif msg_type == "execute_input": + execution_count = content.get("execution_count") + + return outputs, execution_count, error_text, status, timed_out diff --git a/api/app/services/tech_lab/lab/kernel_manager.py b/api/app/services/tech_lab/lab/kernel_manager.py new file mode 100644 index 00000000..c56693ff --- /dev/null +++ b/api/app/services/tech_lab/lab/kernel_manager.py @@ -0,0 +1,240 @@ +"""Process-local registry of live ipykernels, keyed by ``(user_id, notebook_id)``. + +The ``lab-worker`` Celery container runs this with ``--pool=threads`` so all +kernels live in one Python process and any thread can drive any kernel — that's +the whole reason for picking a threadpool over prefork. A registry-wide +``RLock`` serialises map mutations; per-key ``Lock`` instances let two +unrelated notebooks execute cells at the same time without contention. + +Idle eviction runs lazily on every ``get_or_start`` call: any kernel whose last +activity is older than ``LAB_KERNEL_IDLE_SECONDS`` is shut down. We don't +spawn a background thread for this — the worker process is already a Celery +worker thread and we want the eviction policy to be deterministic for tests. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import tempfile +import threading +import time +from dataclasses import dataclass, field +from typing import Optional, Tuple + + +logger = logging.getLogger(__name__) + +KernelKey = Tuple[int, int] # (user_id, notebook_id) + +# Tunable via env so ops can stretch / shrink the idle window without redeploy. +LAB_KERNEL_IDLE_SECONDS = int(os.environ.get("LAB_KERNEL_IDLE_SECONDS", "1800")) + + +@dataclass +class _Entry: + manager: "object" # jupyter_client.KernelManager (typed loosely; optional dep) + client: "object" # jupyter_client.BlockingKernelClient + cwd: str + last_active: float + lock: threading.Lock = field(default_factory=threading.Lock) + started: bool = False + + +class LabKernelRegistry: + """Singleton holding the (user_id, notebook_id) → kernel map. + + Tests construct their own registry to avoid touching global state; prod + code uses ``get_default_registry()``. + """ + + def __init__(self, *, idle_seconds: int = LAB_KERNEL_IDLE_SECONDS): + self._idle_seconds = idle_seconds + self._lock = threading.RLock() + self._kernels: dict[KernelKey, _Entry] = {} + + # ── lookup / lifecycle ───────────────────────────────────────────────── + + def get_or_start(self, key: KernelKey) -> _Entry: + """Return the entry for ``key``, starting a new kernel if missing. + + Caller is expected to hold ``entry.lock`` while sending an + ``execute_request`` / draining IOPub, so two cell executions on the + same kernel never interleave. + """ + self._evict_idle() + with self._lock: + entry = self._kernels.get(key) + if entry is not None: + entry.last_active = time.monotonic() + return entry + entry = self._spawn(key) + self._kernels[key] = entry + return entry + + def shutdown(self, key: KernelKey) -> bool: + with self._lock: + entry = self._kernels.pop(key, None) + if entry is None: + return False + self._teardown_entry(entry) + return True + + def interrupt(self, key: KernelKey) -> bool: + with self._lock: + entry = self._kernels.get(key) + if entry is None: + return False + try: + entry.manager.interrupt_kernel() # type: ignore[attr-defined] + except Exception: # noqa: BLE001 + logger.exception("interrupt failed for %s", key) + return False + return True + + def shutdown_all(self) -> None: + with self._lock: + entries = list(self._kernels.values()) + self._kernels.clear() + for e in entries: + self._teardown_entry(e) + + def is_running(self, key: KernelKey) -> bool: + with self._lock: + return key in self._kernels + + def touch(self, key: KernelKey) -> None: + """Mark a kernel as recently used (called by executor after each cell).""" + with self._lock: + entry = self._kernels.get(key) + if entry is not None: + entry.last_active = time.monotonic() + + def snapshot(self) -> dict: + """Read-only summary of the current registry, safe to ship over the wire.""" + now = time.monotonic() + with self._lock: + kernels = [ + { + "user_id": user_id, + "notebook_id": notebook_id, + "cwd": entry.cwd, + "idle_seconds": int(now - entry.last_active), + "started": entry.started, + } + for (user_id, notebook_id), entry in self._kernels.items() + ] + kernels.sort(key=lambda k: k["idle_seconds"]) + return { + "idle_seconds_threshold": self._idle_seconds, + "kernel_count": len(kernels), + "kernels": kernels, + } + + # ── internals ────────────────────────────────────────────────────────── + + def _evict_idle(self) -> None: + now = time.monotonic() + cutoff = now - self._idle_seconds + with self._lock: + stale = [ + (k, v) for k, v in self._kernels.items() if v.last_active < cutoff + ] + for k, _ in stale: + self._kernels.pop(k, None) + # Tear down outside the lock — manager.shutdown_kernel() can block on subprocess wait. + for k, entry in stale: + logger.info("evicting idle lab kernel %s", k) + self._teardown_entry(entry) + + def _spawn(self, key: KernelKey) -> _Entry: + # Local import: jupyter_client is only available inside the lab-worker + # image. Importing at module top would break the api container. + from jupyter_client import KernelManager # type: ignore + + cwd = tempfile.mkdtemp(prefix=f"lab-{key[0]}-{key[1]}-") + km = KernelManager(kernel_name="python3") + km.start_kernel(cwd=cwd) + client = km.client() + client.start_channels() + client.wait_for_ready(timeout=30) + + entry = _Entry( + manager=km, + client=client, + cwd=cwd, + last_active=time.monotonic(), + started=True, + ) + + # Run the startup snippet so `mwlab` is bound before any user cell. + from app.services.tech_lab.lab.startup import render_startup + + startup_code = render_startup(user_id=key[0], notebook_id=key[1]) + msg_id = client.execute(startup_code, silent=True, store_history=False) + # Drain shell reply for startup; surface errors loudly so a silent + # import failure (e.g. mwctipy not on sys.path) doesn't yield a zombie + # kernel whose user cells then complain about an unbound ``mwlab``. + try: + reply = client.get_shell_msg(timeout=30) + except Exception: # noqa: BLE001 + self._teardown_entry(entry) + raise RuntimeError( + f"lab kernel startup did not reply within 30s (msg={msg_id})" + ) + content = (reply or {}).get("content") or {} + if content.get("status") != "ok": + ename = content.get("ename", "StartupError") + evalue = content.get("evalue", "(no message)") + self._teardown_entry(entry) + raise RuntimeError(f"lab kernel startup failed: {ename}: {evalue}") + # Drain the small handful of IOPub status messages emitted during the + # silent startup so the next user cell's poll doesn't pick them up. + import time as _time + + deadline = _time.monotonic() + 5 + while True: + try: + msg = client.get_iopub_msg( + timeout=max(0.05, deadline - _time.monotonic()) + ) + except Exception: # noqa: BLE001 + break + parent = msg.get("parent_header") or {} + if parent.get("msg_id") != msg_id: + continue + if ( + msg.get("msg_type") == "status" + and (msg.get("content") or {}).get("execution_state") == "idle" + ): + break + return entry + + def _teardown_entry(self, entry: _Entry) -> None: + try: + entry.client.stop_channels() # type: ignore[attr-defined] + except Exception: # noqa: BLE001 + logger.exception("client.stop_channels failed") + try: + entry.manager.shutdown_kernel(now=True) # type: ignore[attr-defined] + except Exception: # noqa: BLE001 + logger.exception("manager.shutdown_kernel failed") + if entry.cwd: + shutil.rmtree(entry.cwd, ignore_errors=True) + + +# ── module-level default registry (used by Celery tasks) ────────────────── + + +_default_registry: Optional[LabKernelRegistry] = None +_default_registry_lock = threading.Lock() + + +def get_default_registry() -> LabKernelRegistry: + global _default_registry + if _default_registry is None: + with _default_registry_lock: + if _default_registry is None: + _default_registry = LabKernelRegistry() + return _default_registry diff --git a/api/app/services/tech_lab/lab/nbformat_io.py b/api/app/services/tech_lab/lab/nbformat_io.py new file mode 100644 index 00000000..35f987e3 --- /dev/null +++ b/api/app/services/tech_lab/lab/nbformat_io.py @@ -0,0 +1,97 @@ +"""Convert between our delimiter-source notebooks and ``nbformat`` JSON. + +We store notebooks as a single text blob with ``# %% [id=...] code|markdown`` +delimiters, but JupyterLab and downstream tooling speak nbformat. The +conversion is lossless for cell sources and ids; cell outputs are preserved +in export so a downloaded notebook can be opened with stored results, but +dropped on import (they will regenerate on the next run). +""" + +from __future__ import annotations + +import uuid as _uuid +from typing import Any + +from app.models import lab as lab_models +from app.services.tech_lab.lab.cell_parser import ( + ParsedCell, + parse_cells, + serialize_cells, +) + + +def to_nbformat(nb: lab_models.LabNotebook) -> dict: + """Build an nbformat 4.5 JSON-serializable dict from a notebook row.""" + cell_outputs = nb.cell_outputs or {} + cells: list[dict[str, Any]] = [] + for c in parse_cells(nb.source or ""): + base = { + "cell_type": c.type, + "id": c.cell_id, + "metadata": {}, + "source": _split_for_nbformat(c.source), + } + if c.type == "code": + base["outputs"] = cell_outputs.get(c.cell_id, []) + base["execution_count"] = None + cells.append(base) + + return { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "name": "python3", + "display_name": "Python 3", + "language": "python", + }, + "language_info": {"name": "python"}, + "misp_workbench": { + "notebook_id": nb.id, + "name": nb.name, + "visibility": nb.visibility, + }, + }, + "cells": cells, + } + + +def from_nbformat(blob: dict) -> tuple[str, str]: + """Convert an nbformat dict to our delimiter-source format. + + Returns ``(source, name)``. Cell ids are preserved when present and + regenerated otherwise. Cell outputs are intentionally dropped — they'll + re-render on the next execution against a real kernel. + """ + parsed: list[ParsedCell] = [] + for c in blob.get("cells") or []: + cell_type = c.get("cell_type") + if cell_type not in ("code", "markdown"): + continue + source = c.get("source") or "" + if isinstance(source, list): + source = "".join(source) + parsed.append( + ParsedCell( + cell_id=str(c.get("id") or _uuid.uuid4()), + type=cell_type, + source=source, + ) + ) + name = ( + ((blob.get("metadata") or {}).get("misp_workbench") or {}).get("name") + or "imported notebook" + ) + return serialize_cells(parsed), name + + +def _split_for_nbformat(text: str) -> list[str]: + """nbformat expects ``source`` as a list of lines (each ending with ``\\n``). + + Splitting keeps diffs friendly when the file is opened in Jupyter and + re-saved. + """ + if not text: + return [] + parts = text.splitlines(keepends=True) + return parts diff --git a/api/app/services/tech_lab/lab/startup.py b/api/app/services/tech_lab/lab/startup.py new file mode 100644 index 00000000..459ce40f --- /dev/null +++ b/api/app/services/tech_lab/lab/startup.py @@ -0,0 +1,33 @@ +"""Source executed as a kernel's first cell to bind the ``mwlab`` instance. + +Interpolated by ``kernel_manager`` with the running user's id and the active +notebook id, so every SDK call carried over IOPub is attributable in the +audit log via ``actor_type=lab_notebook``, ``actor_credential_id=notebook_id``. + +The ``sys.path`` injection is necessary because the kernel subprocess runs +with cwd set to a per-notebook tempdir; ``app/`` and ``mwctipy/`` live at +``/code/`` (the container's WORKDIR) and aren't installed as wheels, so +without the path tweak ``import mwctipy`` would fail. +""" + +import os + +STARTUP_TEMPLATE = """\ +import sys as _sys +_code_root = {code_root!r} +if _code_root and _code_root not in _sys.path: + _sys.path.insert(0, _code_root) +del _sys, _code_root + +from mwctipy import MwLab, render +mwlab = MwLab(user_id={user_id}, notebook_id={notebook_id}) +""" + + +def render_startup(user_id: int, notebook_id: int) -> str: + code_root = os.environ.get("LAB_CODE_ROOT", "/code") + return STARTUP_TEMPLATE.format( + code_root=code_root, + user_id=int(user_id), + notebook_id=int(notebook_id), + ) diff --git a/api/app/tests/api/test_lab.py b/api/app/tests/api/test_lab.py new file mode 100644 index 00000000..fc70aeaf --- /dev/null +++ b/api/app/tests/api/test_lab.py @@ -0,0 +1,1217 @@ +"""API tests for the Tech Lab — Notebooks router (CRUD + visibility + fork + execute).""" + +from unittest.mock import patch + +import pytest +from app.auth import auth +from app.models import lab as lab_models # noqa: F401 (used by other test modules via fixtures) +from app.models import user as user_models +from app.repositories import lab as lab_repository +from app.schemas import lab as lab_schemas +from app.tests.api_tester import ApiTester +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + + +SAMPLE_SOURCE = ( + "# %% [id=cell-1] code\n" + "print('hello')\n" + "# %% [id=cell-2] markdown\n" + "## Notes\n" +) + + +class TestLabRouter(ApiTester): + # ── Folders: CRUD ──────────────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:create"]]) + def test_create_personal_folder( + self, client: TestClient, auth_token: auth.Token + ): + response = client.post( + "/tech-lab/folders", + json={"name": "research", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + data = response.json() + assert data["name"] == "research" + assert data["visibility"] == "personal" + assert data["parent_id"] is None + + @pytest.mark.parametrize("scopes", [["lab:create"]]) + def test_create_nested_folder_visibility_mismatch_400( + self, client: TestClient, auth_token: auth.Token + ): + parent = client.post( + "/tech-lab/folders", + json={"name": "g-parent", "visibility": "global"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.post( + "/tech-lab/folders", + json={ + "name": "p-child", + "visibility": "personal", + "parent_id": parent["id"], + }, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize("scopes", [[]]) + def test_create_folder_unauthorized( + self, client: TestClient, auth_token: auth.Token + ): + response = client.post( + "/tech-lab/folders", + json={"name": "x", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:update"]]) + def test_update_folder_rename( + self, client: TestClient, auth_token: auth.Token + ): + created = client.post( + "/tech-lab/folders", + json={"name": "old", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.patch( + f"/tech-lab/folders/{created['id']}", + json={"name": "new"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "new" + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:delete"]]) + def test_delete_folder(self, client: TestClient, auth_token: auth.Token): + created = client.post( + "/tech-lab/folders", + json={"name": "trash", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.delete( + f"/tech-lab/folders/{created['id']}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # ── Notebooks: CRUD ────────────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:create"]]) + def test_create_personal_notebook( + self, client: TestClient, auth_token: auth.Token + ): + response = client.post( + "/tech-lab/notebooks", + json={ + "name": "my-notebook", + "visibility": "personal", + "source": SAMPLE_SOURCE, + }, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + data = response.json() + assert data["name"] == "my-notebook" + assert data["visibility"] == "personal" + assert data["source"] == SAMPLE_SOURCE + assert data["cell_outputs"] == {} + + @pytest.mark.parametrize("scopes", [["lab:create"]]) + def test_create_notebook_in_folder_visibility_mismatch_400( + self, client: TestClient, auth_token: auth.Token + ): + folder = client.post( + "/tech-lab/folders", + json={"name": "g-folder", "visibility": "global"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.post( + "/tech-lab/notebooks", + json={ + "name": "mismatch", + "visibility": "personal", + "folder_id": folder["id"], + }, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_get_notebook_returns_full_payload( + self, client: TestClient, auth_token: auth.Token + ): + created = client.post( + "/tech-lab/notebooks", + json={ + "name": "g-test", + "visibility": "personal", + "source": SAMPLE_SOURCE, + }, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.get( + f"/tech-lab/notebooks/{created['id']}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["source"] == SAMPLE_SOURCE + + @pytest.mark.parametrize( + "scopes", [["lab:create", "lab:update", "lab:read"]] + ) + def test_update_notebook_source_autosave( + self, client: TestClient, auth_token: auth.Token + ): + created = client.post( + "/tech-lab/notebooks", + json={"name": "u", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + new_source = "# %% [id=z] code\nprint(1)\n" + response = client.patch( + f"/tech-lab/notebooks/{created['id']}", + json={"source": new_source}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["source"] == new_source + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:delete"]]) + def test_delete_notebook( + self, client: TestClient, auth_token: auth.Token + ): + created = client.post( + "/tech-lab/notebooks", + json={"name": "del", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.delete( + f"/tech-lab/notebooks/{created['id']}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # ── Tree ───────────────────────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:read", "lab:create"]]) + def test_tree_returns_personal_and_global( + self, client: TestClient, auth_token: auth.Token + ): + client.post( + "/tech-lab/folders", + json={"name": "p-only", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + client.post( + "/tech-lab/notebooks", + json={"name": "g-nb", "visibility": "global"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + response = client.get( + "/tech-lab/tree", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + names = [f["name"] for f in data["folders"]] + [ + n["name"] for n in data["notebooks"] + ] + assert "p-only" in names + assert "g-nb" in names + + # ── Visibility: cross-user ─────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_personal_notebook_invisible_to_other_user( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + # Notebook owned by user_1 (not the api_tester_user holding the token). + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-private", + visibility="personal", + source="", + ), + current_user_id=user_1.id, + ) + response = client.get( + f"/tech-lab/notebooks/{nb.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_global_notebook_visible_to_other_user( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-shared", + visibility="global", + source=SAMPLE_SOURCE, + ), + current_user_id=user_1.id, + ) + response = client.get( + f"/tech-lab/notebooks/{nb.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["source"] == SAMPLE_SOURCE + + @pytest.mark.parametrize("scopes", [["lab:update"]]) + def test_global_notebook_not_editable_by_non_owner( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-shared-2", + visibility="global", + source="", + ), + current_user_id=user_1.id, + ) + response = client.patch( + f"/tech-lab/notebooks/{nb.id}", + json={"source": "tampered"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.parametrize("scopes", [["lab:delete"]]) + def test_global_notebook_not_deletable_by_non_owner( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-shared-3", + visibility="global", + source="", + ), + current_user_id=user_1.id, + ) + response = client.delete( + f"/tech-lab/notebooks/{nb.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # ── Fork ──────────────────────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_fork_global_notebook_creates_personal_copy( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + original_source = ( + "# %% [id=aaaa1111-aaaa-aaaa-aaaa-aaaaaaaaaaaa] code\n" + "x = 1\n" + ) + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="forkable", + visibility="global", + source=original_source, + ), + current_user_id=user_1.id, + ) + response = client.post( + f"/tech-lab/notebooks/{nb.id}/fork", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + data = response.json() + assert data["user_id"] == api_tester_user.id + assert data["visibility"] == "personal" + assert data["folder_id"] is None + assert data["name"] == "forkable (fork)" + # Cell ids must be regenerated. + assert "aaaa1111-aaaa-aaaa-aaaa-aaaaaaaaaaaa" not in data["source"] + assert "[id=" in data["source"] + + @pytest.mark.parametrize("scopes", [["lab:create"]]) + def test_publish_personal_to_global_creates_global_copy( + self, + client: TestClient, + db: Session, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + original_source = ( + "# %% [id=aaaa2222-aaaa-aaaa-aaaa-aaaaaaaaaaaa] code\n" + "y = 2\n" + ) + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="shareable", + visibility="personal", + source=original_source, + ), + current_user_id=api_tester_user.id, + ) + response = client.post( + f"/tech-lab/notebooks/{nb.id}/fork?visibility=global", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + data = response.json() + assert data["user_id"] == api_tester_user.id + assert data["visibility"] == "global" + # Global publish keeps the original name (no "(fork)" suffix). + assert data["name"] == "shareable" + assert data["folder_id"] is None + assert "aaaa2222-aaaa-aaaa-aaaa-aaaaaaaaaaaa" not in data["source"] + assert "[id=" in data["source"] + # Personal original is untouched. + db.refresh(nb) + assert nb.visibility == "personal" + + # ── Pin / unpin ───────────────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_pin_notebook_appears_in_tree( + self, client: TestClient, auth_token: auth.Token + ): + nb = client.post( + "/tech-lab/notebooks", + json={"name": "pin-me", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.post( + f"/tech-lab/notebooks/{nb['id']}/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == {"status": "pinned"} + + tree = client.get( + "/tech-lab/tree", + headers={"Authorization": "Bearer " + auth_token}, + ).json() + assert nb["id"] in tree["pinned_notebook_ids"] + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_pin_notebook_idempotent( + self, + client: TestClient, + db: Session, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + nb = client.post( + "/tech-lab/notebooks", + json={"name": "pin-twice", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + for _ in range(2): + response = client.post( + f"/tech-lab/notebooks/{nb['id']}/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + # Exactly one pin row in the DB — no duplicates. + count = ( + db.query(lab_models.LabNotebookPin) + .filter( + lab_models.LabNotebookPin.user_id == api_tester_user.id, + lab_models.LabNotebookPin.notebook_id == nb["id"], + ) + .count() + ) + assert count == 1 + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_pin_notebook_not_found( + self, client: TestClient, auth_token: auth.Token + ): + response = client.post( + "/tech-lab/notebooks/999999/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_pin_personal_notebook_of_other_user_404( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-private", visibility="personal", source="" + ), + current_user_id=user_1.id, + ) + response = client.post( + f"/tech-lab/notebooks/{nb.id}/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_unpin_notebook_removes_from_tree( + self, client: TestClient, auth_token: auth.Token + ): + nb = client.post( + "/tech-lab/notebooks", + json={"name": "unpin-me", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + client.post( + f"/tech-lab/notebooks/{nb['id']}/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + response = client.delete( + f"/tech-lab/notebooks/{nb['id']}/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == {"status": "unpinned"} + + tree = client.get( + "/tech-lab/tree", + headers={"Authorization": "Bearer " + auth_token}, + ).json() + assert nb["id"] not in tree["pinned_notebook_ids"] + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_unpin_notebook_idempotent( + self, client: TestClient, auth_token: auth.Token + ): + nb = client.post( + "/tech-lab/notebooks", + json={"name": "never-pinned", "visibility": "personal"}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + # Two unpins on a never-pinned notebook still succeed. + for _ in range(2): + response = client.delete( + f"/tech-lab/notebooks/{nb['id']}/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_unpin_notebook_not_found( + self, client: TestClient, auth_token: auth.Token + ): + response = client.delete( + "/tech-lab/notebooks/999999/pin", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize("scopes", [["lab:create"]]) + def test_publish_library_to_global_rejected( + self, + client: TestClient, + db: Session, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="lib-thing", + visibility="library", + source="# %% code\nprint('hi')\n", + ), + current_user_id=api_tester_user.id, + via_api=False, + ) + response = client.post( + f"/tech-lab/notebooks/{nb.id}/fork?visibility=global", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestLabExecution(ApiTester): + """Execute-flow tests with the celery task stubbed to a synchronous runner.""" + + @pytest.mark.parametrize( + "scopes", [["lab:create", "lab:run", "lab:read"]] + ) + def test_execute_cell_owner_writes_outputs_to_notebook( + self, client: TestClient, auth_token: auth.Token, db: Session + ): + # Create a notebook with a single code cell. + cell_id = "cccc1111-cccc-cccc-cccc-cccccccccccc" + source = f"# %% [id={cell_id}] code\nprint(40 + 2)\n" + nb = client.post( + "/tech-lab/notebooks", + json={"name": "exec-test", "visibility": "personal", "source": source}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + + # Stub the Celery task: write a synthetic mimebundle synchronously. + def _fake_apply_async(args=None, queue=None, **_kwargs): + execution_id = args[0] + row = ( + db.query(lab_models.LabExecution) + .filter(lab_models.LabExecution.id == execution_id) + .first() + ) + from datetime import datetime, timezone + + row.status = "success" + row.outputs = [ + {"output_type": "stream", "name": "stdout", "text": "42\n"} + ] + row.execution_count = 1 + row.started_at = datetime.now(timezone.utc) + row.finished_at = datetime.now(timezone.utc) + # Owner-side persist into notebook.cell_outputs. + notebook = ( + db.query(lab_models.LabNotebook) + .filter(lab_models.LabNotebook.id == row.notebook_id) + .first() + ) + outputs_map = dict(notebook.cell_outputs or {}) + outputs_map[row.cell_id] = row.outputs + notebook.cell_outputs = outputs_map + db.commit() + + class _R: + id = "fake-task-id" + + return _R() + + with patch( + "app.worker.tasks.lab_execute_cell.apply_async", + side_effect=_fake_apply_async, + ): + response = client.post( + f"/tech-lab/notebooks/{nb['id']}/cells/execute", + json={"cell_id": cell_id, "source": "print(40 + 2)"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + ex = response.json() + assert ex["status"] == "success" + assert ex["outputs"][0]["text"] == "42\n" + + # Owner-side outputs should be persisted onto the notebook. + nb_full = client.get( + f"/tech-lab/notebooks/{nb['id']}", + headers={"Authorization": "Bearer " + auth_token}, + ).json() + assert nb_full["cell_outputs"][cell_id][0]["text"] == "42\n" + + @pytest.mark.parametrize("scopes", [["lab:run"]]) + def test_execute_cell_non_owner_does_not_pollute_notebook_outputs( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + # user_1 owns a global notebook; api_tester_user runs a cell. + cell_id = "dddd2222-dddd-dddd-dddd-dddddddddddd" + source = f"# %% [id={cell_id}] code\nprint(1)\n" + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="shared", visibility="global", source=source + ), + current_user_id=user_1.id, + ) + + def _fake_apply_async(args=None, queue=None, **_kwargs): + execution_id = args[0] + row = ( + db.query(lab_models.LabExecution) + .filter(lab_models.LabExecution.id == execution_id) + .first() + ) + from datetime import datetime, timezone + + row.status = "success" + row.outputs = [ + {"output_type": "stream", "name": "stdout", "text": "non-owner\n"} + ] + row.started_at = datetime.now(timezone.utc) + row.finished_at = datetime.now(timezone.utc) + # NOTE: deliberately do NOT update notebook.cell_outputs — that's + # the contract for non-owner runs. + db.commit() + + class _R: + id = "fake-task-id" + + return _R() + + with patch( + "app.worker.tasks.lab_execute_cell.apply_async", + side_effect=_fake_apply_async, + ): + response = client.post( + f"/tech-lab/notebooks/{nb.id}/cells/execute", + json={"cell_id": cell_id, "source": "print(1)"}, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + + # Notebook owner's snapshot should be untouched. + db.refresh(nb) + assert cell_id not in (nb.cell_outputs or {}) + + @pytest.mark.parametrize( + "scopes", [["lab:create", "lab:run", "lab:read"]] + ) + def test_execute_all_creates_one_execution_per_code_cell( + self, client: TestClient, auth_token: auth.Token, db: Session + ): + source = ( + "# %% [id=eeee1111-eeee-eeee-eeee-eeeeeeeeeeee] code\n" + "print('a')\n" + "# %% [id=eeee2222-eeee-eeee-eeee-eeeeeeeeeeee] markdown\n" + "## skip me\n" + "# %% [id=eeee3333-eeee-eeee-eeee-eeeeeeeeeeee] code\n" + "print('b')\n" + ) + nb = client.post( + "/tech-lab/notebooks", + json={"name": "exec-all", "visibility": "personal", "source": source}, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + + with patch( + "app.worker.tasks.lab_execute_cell.apply_async" + ) as mock_apply: + mock_apply.return_value.id = "fake-task-id" + response = client.post( + f"/tech-lab/notebooks/{nb['id']}/cells/execute_all", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + rows = response.json() + assert len(rows) == 2 # markdown cell skipped + cell_ids = {r["cell_id"] for r in rows} + assert "eeee1111-eeee-eeee-eeee-eeeeeeeeeeee" in cell_ids + assert "eeee3333-eeee-eeee-eeee-eeeeeeeeeeee" in cell_ids + + # ── get_execution ──────────────────────────────────────────────────────── + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_get_execution_returns_row( + self, + client: TestClient, + db: Session, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + cell_id = "ffff1111-ffff-ffff-ffff-ffffffffffff" + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="get-exec", + visibility="personal", + source=f"# %% [id={cell_id}] code\nprint(1)\n", + ), + current_user_id=api_tester_user.id, + ) + ex = lab_repository.create_execution( + db, nb.id, cell_id, current_user_id=api_tester_user.id + ) + response = client.get( + f"/tech-lab/notebooks/{nb.id}/executions/{ex.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK, response.text + data = response.json() + assert data["id"] == ex.id + assert data["notebook_id"] == nb.id + assert data["cell_id"] == cell_id + assert data["status"] == "queued" + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_get_execution_mismatched_notebook_id_404( + self, + client: TestClient, + db: Session, + api_tester_user: user_models.User, + auth_token: auth.Token, + ): + """The router rejects when ``execution.notebook_id`` doesn't match the + path's ``notebook_id`` — prevents id-substitution from leaking another + notebook's execution.""" + cell_id = "ffff2222-ffff-ffff-ffff-ffffffffffff" + nb_a = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="nb-a", + visibility="personal", + source=f"# %% [id={cell_id}] code\nprint(1)\n", + ), + current_user_id=api_tester_user.id, + ) + nb_b = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="nb-b", visibility="personal", source="" + ), + current_user_id=api_tester_user.id, + ) + ex = lab_repository.create_execution( + db, nb_a.id, cell_id, current_user_id=api_tester_user.id + ) + response = client.get( + f"/tech-lab/notebooks/{nb_b.id}/executions/{ex.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_get_execution_personal_notebook_invisible_to_other_user_404( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + cell_id = "ffff3333-ffff-ffff-ffff-ffffffffffff" + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-private-exec", + visibility="personal", + source=f"# %% [id={cell_id}] code\nprint(1)\n", + ), + current_user_id=user_1.id, + ) + ex = lab_repository.create_execution( + db, nb.id, cell_id, current_user_id=user_1.id + ) + response = client.get( + f"/tech-lab/notebooks/{nb.id}/executions/{ex.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_get_execution_on_global_notebook_visible_to_reader( + self, + client: TestClient, + db: Session, + user_1: user_models.User, + auth_token: auth.Token, + ): + cell_id = "ffff4444-ffff-ffff-ffff-ffffffffffff" + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="user_1-shared-exec", + visibility="global", + source=f"# %% [id={cell_id}] code\nprint(1)\n", + ), + current_user_id=user_1.id, + ) + # Execution belongs to user_1, on a global notebook — reader should + # be able to fetch it. + ex = lab_repository.create_execution( + db, nb.id, cell_id, current_user_id=user_1.id + ) + response = client.get( + f"/tech-lab/notebooks/{nb.id}/executions/{ex.id}", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json()["id"] == ex.id + + @pytest.mark.parametrize("scopes", [["lab:read"]]) + def test_get_execution_not_found( + self, client: TestClient, auth_token: auth.Token + ): + response = client.get( + "/tech-lab/notebooks/1/executions/999999", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_executor_timeout_interrupts_kernel( + self, db: Session, api_tester_user: user_models.User + ): + """When ``_drain_iopub`` reports ``timed_out=True`` the executor must + interrupt the kernel and drain leftover IOPub before returning, so + the next cell on the same kernel doesn't pick up stale messages.""" + import threading + import time + + from app.services.tech_lab.lab import executor as lab_executor + from app.services.tech_lab.lab.kernel_manager import ( + LabKernelRegistry, + _Entry, + ) + + cell_id = "eeee4444-eeee-eeee-eeee-eeeeeeeeeeee" + source = f"# %% [id={cell_id}] code\nwhile True: pass\n" + nb = lab_repository.create_notebook( + db, + lab_schemas.LabNotebookCreate( + name="timeout-test", visibility="personal", source=source + ), + current_user_id=api_tester_user.id, + ) + row = lab_repository.create_execution( + db, nb.id, cell_id, current_user_id=api_tester_user.id + ) + + class _Manager: + def __init__(self): + self.interrupt_called = False + self.shutdown_called = False + + def shutdown_kernel(self, now=False): + self.shutdown_called = True + + def interrupt_kernel(self): + self.interrupt_called = True + client.interrupted = True + + class _Client: + def __init__(self): + self.interrupted = False + + def execute(self, src, silent=False, store_history=True): + return "msg-1" + + def stop_channels(self): + pass + + def get_iopub_msg(self, timeout=None): + # Pre-interrupt: simulate the cell never replying, so the + # drain deadline elapses and timed_out=True flows back. + if not self.interrupted: + raise TimeoutError("no message") + # Post-interrupt: emit one stream output, then idle, so the + # grace drain terminates promptly. + if not getattr(self, "_drained_grace", False): + self._drained_grace = True + return { + "parent_header": {"msg_id": "msg-1"}, + "msg_type": "stream", + "content": {"name": "stdout", "text": "post-interrupt\n"}, + } + return { + "parent_header": {"msg_id": "msg-1"}, + "msg_type": "status", + "content": {"execution_state": "idle"}, + } + + manager = _Manager() + client = _Client() + + reg = LabKernelRegistry(idle_seconds=10_000) + reg._kernels[(api_tester_user.id, nb.id)] = _Entry( + manager=manager, + client=client, + cwd="", + last_active=time.monotonic(), + lock=threading.Lock(), + started=True, + ) + + lab_executor.execute_cell( + db, row.id, timeout_seconds=1, registry=reg + ) + + db.refresh(row) + assert row.status == "interrupted" + assert manager.interrupt_called + assert any( + o.get("output_type") == "stream" and "post-interrupt" in o.get("text", "") + for o in (row.outputs or []) + ) + + +class TestLabRepositoryUnit: + """Pure-Python unit tests for helpers that don't need the API.""" + + def test_regenerate_cell_ids_replaces_uuids(self): + from app.repositories.lab import _regenerate_cell_ids + + src = ( + "# %% [id=11111111-1111-1111-1111-111111111111] code\n" + "print(1)\n" + "# %% [id=22222222-2222-2222-2222-222222222222] markdown\n" + "## hi\n" + ) + new_src, id_map = _regenerate_cell_ids(src) + assert "11111111-1111-1111-1111-111111111111" not in new_src + assert "22222222-2222-2222-2222-222222222222" not in new_src + assert len(id_map) == 2 + for old_id, new_id in id_map.items(): + assert old_id != new_id + assert new_id in new_src + + def test_regenerate_cell_ids_assigns_id_when_missing(self): + from app.repositories.lab import _regenerate_cell_ids + + src = "# %% code\nprint(1)\n" + new_src, _id_map = _regenerate_cell_ids(src) + assert "[id=" in new_src + + +class TestCellParser: + def test_parse_round_trip(self): + from app.services.tech_lab.lab.cell_parser import ( + parse_cells, + serialize_cells, + ) + + src = ( + "# %% [id=11111111-1111-1111-1111-111111111111] code\n" + "x = 1\n" + "y = 2\n" + "# %% [id=22222222-2222-2222-2222-222222222222] markdown\n" + "## Notes\n" + "Some text.\n" + ) + cells = parse_cells(src) + assert len(cells) == 2 + assert cells[0].cell_id == "11111111-1111-1111-1111-111111111111" + assert cells[0].type == "code" + assert "x = 1" in cells[0].source + assert cells[1].type == "markdown" + assert "Notes" in cells[1].source + assert serialize_cells(cells) == src + + def test_parse_assigns_id_when_missing(self): + from app.services.tech_lab.lab.cell_parser import parse_cells + + cells = parse_cells("# %% code\nprint(1)\n") + assert len(cells) == 1 + assert cells[0].cell_id # uuid generated + assert cells[0].type == "code" + + def test_parse_implicit_first_cell_before_delimiter(self): + from app.services.tech_lab.lab.cell_parser import parse_cells + + cells = parse_cells( + "x = 1\n# %% [id=aaa] code\nprint(x)\n" + ) + assert len(cells) == 2 + assert "x = 1" in cells[0].source + assert cells[1].cell_id == "aaa" + + +class TestNbformatIO: + """Round-trip tests for export/import.""" + + def test_to_nbformat_basic_shape(self): + from app.services.tech_lab.lab.nbformat_io import to_nbformat + + class FakeNb: + id = 7 + name = "x" + visibility = "personal" + source = ( + "# %% [id=aaaa] code\n" + "print(1)\n" + "# %% [id=bbbb] markdown\n" + "## Notes\n" + ) + cell_outputs = { + "aaaa": [ + {"output_type": "stream", "name": "stdout", "text": "1\n"} + ] + } + + blob = to_nbformat(FakeNb()) + assert blob["nbformat"] == 4 + assert len(blob["cells"]) == 2 + assert blob["cells"][0]["cell_type"] == "code" + assert blob["cells"][0]["id"] == "aaaa" + assert blob["cells"][0]["outputs"][0]["text"] == "1\n" + assert blob["cells"][1]["cell_type"] == "markdown" + assert blob["cells"][1]["id"] == "bbbb" + + def test_from_nbformat_drops_outputs_and_preserves_ids(self): + from app.services.tech_lab.lab.nbformat_io import from_nbformat + + blob = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {"misp_workbench": {"name": "exported nb"}}, + "cells": [ + { + "cell_type": "code", + "id": "id-a", + "source": "print(1)\n", + "outputs": [ + {"output_type": "stream", "name": "stdout", "text": "1\n"} + ], + "execution_count": 5, + }, + { + "cell_type": "markdown", + "id": "id-b", + "source": ["## Hi\n"], + }, + ], + } + source, name = from_nbformat(blob) + assert "[id=id-a] code" in source + assert "print(1)" in source + assert "[id=id-b] markdown" in source + assert "## Hi" in source + # Outputs are dropped. + assert "stdout" not in source + assert name == "exported nb" + + +class TestExportImport(ApiTester): + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_export_returns_nbformat( + self, client: TestClient, auth_token: auth.Token + ): + cell_id = "abcd1234-abcd-abcd-abcd-abcdabcdabcd" + nb = client.post( + "/tech-lab/notebooks", + json={ + "name": "export-me", + "visibility": "personal", + "source": f"# %% [id={cell_id}] code\nprint(2 + 2)\n", + }, + headers={"Authorization": "Bearer " + auth_token}, + ).json() + response = client.get( + f"/tech-lab/notebooks/{nb['id']}/export", + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["nbformat"] == 4 + assert body["cells"][0]["id"] == cell_id + assert body["metadata"]["misp_workbench"]["name"] == "export-me" + + @pytest.mark.parametrize("scopes", [["lab:create", "lab:read"]]) + def test_import_creates_personal_notebook( + self, + client: TestClient, + auth_token: auth.Token, + api_tester_user: user_models.User, + ): + import io + import json as _json + + blob = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {"misp_workbench": {"name": "imported"}}, + "cells": [ + { + "cell_type": "code", + "id": "id-x", + "source": "print('hi')\n", + "outputs": [], + "execution_count": None, + } + ], + } + files = { + "file": ( + "imported.ipynb", + io.BytesIO(_json.dumps(blob).encode("utf-8")), + "application/json", + ) + } + response = client.post( + "/tech-lab/notebooks/import", + files=files, + headers={"Authorization": "Bearer " + auth_token}, + ) + assert response.status_code == status.HTTP_201_CREATED, response.text + data = response.json() + assert data["user_id"] == api_tester_user.id + assert data["visibility"] == "personal" + assert "[id=id-x] code" in data["source"] + + +class _FakeManager: + def __init__(self): + self.shutdown_called = False + self.interrupt_called = False + + def shutdown_kernel(self, now=False): + self.shutdown_called = True + + def interrupt_kernel(self): + self.interrupt_called = True + + +class _FakeClient: + def __init__(self): + self.channels_stopped = False + + def stop_channels(self): + self.channels_stopped = True + + +class TestKernelManagerUnit: + def test_idle_eviction_pops_and_tears_down(self): + """Eviction must shut down the kernel manager and remove the per-kernel + tempdir — earlier versions only popped the dict entry, leaking the + subprocess and its working directory.""" + import os + import tempfile + import time + + from app.services.tech_lab.lab.kernel_manager import ( + LabKernelRegistry, + _Entry, + ) + + reg = LabKernelRegistry(idle_seconds=0) # immediate eviction + manager = _FakeManager() + client = _FakeClient() + cwd = tempfile.mkdtemp(prefix="lab-test-") + reg._kernels[(1, 1)] = _Entry( + manager=manager, + client=client, + cwd=cwd, + last_active=time.monotonic() - 10, + ) + reg._evict_idle() + assert (1, 1) not in reg._kernels + assert manager.shutdown_called + assert client.channels_stopped + assert not os.path.exists(cwd) diff --git a/api/app/tests/api_tester.py b/api/app/tests/api_tester.py index badaafdd..8acec96d 100644 --- a/api/app/tests/api_tester.py +++ b/api/app/tests/api_tester.py @@ -15,6 +15,7 @@ from app.models import sharing_groups as sharing_groups_models from app.models import tag as tag_models from app.models import notification as notification_models +from app.models import lab as lab_models from app.models import reactor as reactor_models from app.models import taxonomy as taxonomy_models from app.models import user as user_models @@ -88,6 +89,11 @@ def teardown_db(self, db: Session): ) db.query(reactor_models.ReactorRun).delete(synchronize_session=False) db.query(reactor_models.ReactorScript).delete(synchronize_session=False) + # Lab notebooks: clear executions → notebooks → folders. Folders self-FK + # is CASCADE so a single delete is enough. + db.query(lab_models.LabExecution).delete(synchronize_session=False) + db.query(lab_models.LabNotebook).delete(synchronize_session=False) + db.query(lab_models.LabFolder).delete(synchronize_session=False) db.query(notification_models.Notification).delete(synchronize_session=False) db.query(api_key_models.ApiKey).delete(synchronize_session=False) db.query(audit_log_models.AuditLog).delete(synchronize_session=False) diff --git a/api/app/worker/tasks.py b/api/app/worker/tasks.py index 40c9f2ca..3bacad73 100644 --- a/api/app/worker/tasks.py +++ b/api/app/worker/tasks.py @@ -26,6 +26,8 @@ from app.repositories import taxonomies as taxonomies_repository from app.schemas import attribute as attribute_schemas from app.services.tech_lab.reactor import runner as reactor_runner +from app.services.tech_lab.lab import executor as lab_executor +from app.services.tech_lab.lab import kernel_manager as lab_kernel_manager from celery import Celery from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -1032,3 +1034,36 @@ def run_reactor_script(run_id: int): reactor_runner.run_script(db, run_id) logger.info("run_reactor_script run_id=%s finished", run_id) return True + + +# ────────────────────────────────────────────────────────────────────────── +# Tech Lab — Notebooks +# ────────────────────────────────────────────────────────────────────────── + + +@celery_app.task(queue="lab_kernel", time_limit=600, soft_time_limit=540) +def lab_execute_cell(execution_id: int, timeout_seconds: int = 60): + """Run one queued cell execution. Lives on the lab-worker container.""" + logger.info("lab_execute_cell execution_id=%s started", execution_id) + with Session(engine) as db: + lab_executor.execute_cell(db, execution_id, timeout_seconds=timeout_seconds) + logger.info("lab_execute_cell execution_id=%s finished", execution_id) + return True + + +@celery_app.task(queue="lab_kernel") +def lab_kernel_interrupt(user_id: int, notebook_id: int): + lab_kernel_manager.get_default_registry().interrupt((user_id, notebook_id)) + return True + + +@celery_app.task(queue="lab_kernel") +def lab_kernel_shutdown(user_id: int, notebook_id: int): + lab_kernel_manager.get_default_registry().shutdown((user_id, notebook_id)) + return True + + +@celery_app.task(queue="lab_kernel") +def lab_kernel_list(): + """Snapshot of running kernels for the diagnostics endpoint.""" + return lab_kernel_manager.get_default_registry().snapshot() diff --git a/api/lab_library/altair_plots_quickstart.ipynb b/api/lab_library/altair_plots_quickstart.ipynb new file mode 100644 index 00000000..fc6b4840 --- /dev/null +++ b/api/lab_library/altair_plots_quickstart.ipynb @@ -0,0 +1,130 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c2d3e4f5-0001-0000-0000-000000000001", + "metadata": {}, + "source": [ + "# Altair plots quickstart\n", + "\n", + "Shows how to draw charts inside a notebook using [Altair](https://altair-viz.github.io/),\n", + "a declarative plotting library that pairs well with `pandas` DataFrames.\n", + "\n", + "We pull a sample of attributes and events via `mwlab` and render two charts:\n", + "a breakdown of attribute types and an event-volume timeline.\n", + "\n", + "Fork this notebook to a personal copy before running." + ] + }, + { + "cell_type": "markdown", + "id": "c2d3e4f5-0002-0000-0000-000000000002", + "metadata": {}, + "source": [ + "## 1. Imports\n", + "\n", + "`altair` and `pandas` ship in the lab-worker image." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2d3e4f5-0003-0000-0000-000000000003", + "metadata": {}, + "outputs": [], + "source": [ + "import altair as alt\n", + "import pandas as pd\n", + "\n", + "# The notebook UI sanitises script tags out of cell HTML for safety, so\n", + "# Altair's default vega-embed renderer (which needs JavaScript) won't draw.\n", + "# Switch to static SVG via vl-convert — supported by CellOutput.vue.\n", + "alt.renderers.enable(\"svg\")" + ] + }, + { + "cell_type": "markdown", + "id": "c2d3e4f5-0004-0000-0000-000000000004", + "metadata": {}, + "source": [ + "## 2. Attribute-type breakdown\n", + "\n", + "Pull a batch of attributes and chart them by `type`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2d3e4f5-0005-0000-0000-000000000005", + "metadata": {}, + "outputs": [], + "source": [ + "attrs = mwlab.search_attributes(size=200)\n", + "df = pd.DataFrame(attrs)\n", + "\n", + "type_counts = (\n", + " df.groupby(\"type\").size().reset_index(name=\"count\").sort_values(\"count\", ascending=False)\n", + ")\n", + "\n", + "alt.Chart(type_counts).mark_bar().encode(\n", + " x=alt.X(\"count:Q\", title=\"attributes\"),\n", + " y=alt.Y(\"type:N\", sort=\"-x\", title=\"attribute type\"),\n", + " tooltip=[\"type\", \"count\"],\n", + ").properties(width=500, height=300, title=\"Attributes by type\")" + ] + }, + { + "cell_type": "markdown", + "id": "c2d3e4f5-0006-0000-0000-000000000006", + "metadata": {}, + "source": [ + "## 3. Event volume over time\n", + "\n", + "Group events by their `date` field and plot a daily bar chart." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2d3e4f5-0007-0000-0000-000000000007", + "metadata": {}, + "outputs": [], + "source": [ + "events = mwlab.search_events(size=200)\n", + "events_df = pd.DataFrame(events)\n", + "events_df[\"@timestamp\"] = pd.to_datetime(events_df[\"@timestamp\"], errors=\"coerce\")\n", + "\n", + "daily = (\n", + " events_df.dropna(subset=[\"@timestamp\"])\n", + " .assign(day=lambda d: d[\"@timestamp\"].dt.floor(\"D\"))\n", + " .groupby(\"day\")\n", + " .size()\n", + " .reset_index(name=\"count\")\n", + ")\n", + "\n", + "alt.Chart(daily).mark_bar().encode(\n", + " x=alt.X(\"day:T\", title=\"day\"),\n", + " y=alt.Y(\"count:Q\", title=\"events\"),\n", + " tooltip=[\"day\", \"count\"],\n", + ").properties(width=600, height=250, title=\"Events per day\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "misp_workbench": { + "description": "Plot attribute-type breakdowns and event-volume timelines with Altair.", + "name": "altair_plots_quickstart", + "visibility": "library" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/api/lab_library/mmdb_lookup_quickstart.ipynb b/api/lab_library/mmdb_lookup_quickstart.ipynb new file mode 100644 index 00000000..71e3f63f --- /dev/null +++ b/api/lab_library/mmdb_lookup_quickstart.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b1c2d3e4-0001-0000-0000-000000000001", + "metadata": {}, + "source": [ + "# mmdb_lookup quickstart\n", + "\n", + "Shows how to call an enrichment module from a notebook. We use\n", + "`mmdb_lookup` (MaxMind GeoIP) to resolve an IP to a country/ASN.\n", + "\n", + "Fork this notebook to a personal copy before running." + ] + }, + { + "cell_type": "markdown", + "id": "b1c2d3e4-0002-0000-0000-000000000002", + "metadata": {}, + "source": [ + "## 1. List enabled modules\n", + "\n", + "Sanity-check that `mmdb_lookup` is enabled in this workbench." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b1c2d3e4-0003-0000-0000-000000000003", + "metadata": {}, + "outputs": [], + "source": [ + "[m[\"name\"] for m in mwlab.modules() if m[\"name\"] == \"mmdb_lookup\"]" + ] + }, + { + "cell_type": "markdown", + "id": "b1c2d3e4-0004-0000-0000-000000000004", + "metadata": {}, + "source": [ + "## 2. Look up a single IP" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b1c2d3e4-0005-0000-0000-000000000005", + "metadata": {}, + "outputs": [], + "source": [ + "results = mwlab.enrich(value=\"8.8.8.8\", type=\"ip-dst\", module=\"mmdb_lookup\")\n", + "print(results)" + ] + }, + { + "cell_type": "markdown", + "id": "b1c2d3e4-0006-0000-0000-000000000006", + "metadata": {}, + "source": [ + "## 3. Geolocate every IP from a search\n", + "\n", + "Pull a small batch of IP attributes and run them through the same module." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b1c2d3e4-0007-0000-0000-000000000007", + "metadata": {}, + "outputs": [], + "source": [ + "ips = mwlab.search_attributes(type=\"ip-dst\", size=5)\n", + "for a in ips:\n", + " value = a.get(\"value\")\n", + " print(f\"\\n── {value} ───────────────\")\n", + " results = mwlab.enrich(value=value, type=\"ip-dst\", module=\"mmdb_lookup\")\n", + " print(results)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "misp_workbench": { + "description": "Geo-locate an IP using the mmdb_lookup enrichment module.", + "name": "mmdb_lookup_quickstart", + "visibility": "library" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/api/mwctipy/__init__.py b/api/mwctipy/__init__.py new file mode 100644 index 00000000..57d03c41 --- /dev/null +++ b/api/mwctipy/__init__.py @@ -0,0 +1,11 @@ +"""mwctipy — analyst SDK for misp-workbench notebooks. + +Imported into every Tech Lab notebook kernel at startup. The bound instance +``mwlab = MwLab(user_id=..., notebook_id=...)`` carries the running user's +identity for audit purposes. +""" + +from mwctipy.client import MwLab # noqa: F401 +from mwctipy import render # noqa: F401 + +__all__ = ["MwLab", "render"] diff --git a/api/mwctipy/client.py b/api/mwctipy/client.py new file mode 100644 index 00000000..d9cc7b81 --- /dev/null +++ b/api/mwctipy/client.py @@ -0,0 +1,216 @@ +"""The ``MwLab`` instance that user notebooks call into. + +Every method opens its own short-lived ``Session(engine)``. The kernel is +long-lived across cells, so holding a single session would accumulate +transaction state, hit Postgres idle timeouts, and miss runtime config +changes. This is the key behavioural difference from ``ReactorContext``, +which takes a Session in its constructor for a single short-lived run. +""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from typing import Any, Iterator, Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +@contextmanager +def _session() -> Iterator[Session]: + # Local imports keep this module importable in environments that don't + # have the full ``app`` stack on sys.path (rare, but useful for unit tests). + from app.database import SQLALCHEMY_DATABASE_URL + from sqlalchemy import create_engine + + engine = create_engine(SQLALCHEMY_DATABASE_URL) + session = Session(engine) + try: + yield session + finally: + session.close() + + +class MwLab: + """Read-only analyst SDK bound to a single ``(user_id, notebook_id)``.""" + + def __init__(self, *, user_id: int, notebook_id: int): + self.user_id = int(user_id) + self.notebook_id = int(notebook_id) + + def __repr__(self) -> str: # pragma: no cover - cosmetic + return f"" + + # ── single-record reads ──────────────────────────────────────────────── + + def get_event(self, event_uuid: str) -> Optional[dict]: + from app.repositories import events as events_repository + + result = events_repository.get_event_from_opensearch(UUID(event_uuid)) + if result is None: + return None + return _to_dict(result) + + def get_attribute(self, attribute_uuid: str) -> Optional[dict]: + from app.repositories import attributes as attributes_repository + + result = attributes_repository.get_attribute_from_opensearch( + UUID(attribute_uuid) + ) + if result is None: + return None + return _to_dict(result) + + def get_object(self, object_uuid: str) -> Optional[dict]: + from app.repositories import objects as objects_repository + + result = objects_repository.get_object_from_opensearch(UUID(object_uuid)) + if result is None: + return None + return _to_dict(result) + + # ── search ───────────────────────────────────────────────────────────── + + def search_events( + self, + query: Optional[str] = None, + tags: Optional[list[str]] = None, + size: int = 50, + ) -> list[dict]: + """Search events via OpenSearch. + + ``query`` is a free-text fragment matched against event ``info``; + ``tags`` is an AND-list of tag names. Returns a list of dicts + (not pydantic models) so analysts can stuff them into pandas. + """ + from app.services.opensearch import get_opensearch_client + + must: list[dict] = [] + if query: + must.append({"match": {"info": query}}) + if tags: + # tags.name is analyzed text — use match_phrase so hyphens and + # colons in tag names (e.g. misp-galaxy:threat-actor="…") match + # the same way the events repo does it. + for t in tags: + must.append({"match_phrase": {"tags.name": t}}) + body = { + "size": size, + "query": {"bool": {"must": must}} if must else {"match_all": {}}, + } + client = get_opensearch_client() + resp = client.search(index="misp-events", body=body) + return [hit.get("_source", {}) for hit in resp.get("hits", {}).get("hits", [])] + + def search_attributes( + self, + value: Optional[str] = None, + type: Optional[str] = None, + size: int = 50, + ) -> list[dict]: + from app.services.opensearch import get_opensearch_client + + must: list[dict] = [] + if value: + must.append({"match": {"value": value}}) + if type: + # type is analyzed text — "ip-dst" gets split on the hyphen, so a + # plain term query never matches. Use the .keyword sub-field for + # exact match, mirroring app.repositories.attributes. + must.append({"term": {"type.keyword": type}}) + body = { + "size": size, + "query": {"bool": {"must": must}} if must else {"match_all": {}}, + } + client = get_opensearch_client() + resp = client.search(index="misp-attributes", body=body) + return [hit.get("_source", {}) for hit in resp.get("hits", {}).get("hits", [])] + + # ── modules / enrichment ─────────────────────────────────────────────── + + def modules(self, enabled_only: bool = True) -> list[dict]: + from app.repositories import modules as modules_repository + + with _session() as db: + modules = modules_repository.get_modules( + db, enabled=True if enabled_only else None + ) + return [_to_dict(m) for m in modules] + + def enrich( + self, + value: str, + type: str, + module: str, + config: Optional[dict] = None, + ) -> dict: + """Run a MISP expansion module against one indicator. + + Audited under ``actor_type=lab_notebook``, + ``actor_credential_id=notebook_id`` so admins can trace any + third-party API call back to the notebook that triggered it. + """ + from app.repositories import modules as modules_repository + from app.schemas import module as module_schemas + from app.services import audit + + query = module_schemas.ModuleQuery( + module=module, + attribute={"type": type, "value": value, "uuid": ""}, + config=config, + ) + with _session() as db: + try: + result = modules_repository.query_module(db, query) + except Exception as e: + audit.record( + db, + action="lab.enrich.error", + resource_type="module", + actor_user_id=self.user_id, + actor_type="lab_notebook", + actor_credential_id=self.notebook_id, + metadata={ + "module": module, + "type": type, + "value": value, + "error": str(e), + }, + ) + db.commit() + raise + audit.record( + db, + action="lab.enrich", + resource_type="module", + actor_user_id=self.user_id, + actor_type="lab_notebook", + actor_credential_id=self.notebook_id, + metadata={"module": module, "type": type, "value": value}, + ) + db.commit() + return result + + # ── convenience ──────────────────────────────────────────────────────── + + def dataframe(self, rows: list[dict]): + """Return a ``pandas.DataFrame`` from a list of dicts. + + Pandas is available in the lab-worker image; the import lives inside + the call so importing ``mwctipy`` outside that container doesn't + require it. + """ + import pandas as pd + + return pd.DataFrame(rows) + + +def _to_dict(obj: Any) -> dict: + if hasattr(obj, "model_dump"): + return obj.model_dump(mode="json") + if isinstance(obj, dict): + return obj + return dict(obj) diff --git a/api/mwctipy/render.py b/api/mwctipy/render.py new file mode 100644 index 00000000..15174bb5 --- /dev/null +++ b/api/mwctipy/render.py @@ -0,0 +1,45 @@ +"""Tiny HTML renderers for analyst-style summaries. + +Returned as raw HTML strings; analysts wrap them in ``IPython.display.HTML`` +when they want them rendered, or feed them straight into Markdown cells. +Real visualisation belongs in matplotlib / altair, which analysts can +``import`` themselves. +""" + +from __future__ import annotations + +import html +from typing import Iterable + + +def timeline(events: Iterable[dict]) -> str: + """One-line-per-event ordered list (most recent first by ``date`` if present).""" + rows = list(events) + rows.sort(key=lambda e: e.get("date") or "", reverse=True) + items = [] + for ev in rows: + info = html.escape(str(ev.get("info", "(no info)"))) + date = html.escape(str(ev.get("date", ""))) + items.append(f"
  • {date} — {info}
  • ") + return f"
      {''.join(items)}
    " + + +def tag_cloud(items: Iterable[dict]) -> str: + """Sum tag counts across rows; render as a ```` cloud sized by frequency.""" + counts: dict[str, int] = {} + for row in items: + for t in row.get("tags") or []: + name = t.get("name") if isinstance(t, dict) else t + if name: + counts[name] = counts.get(name, 0) + 1 + if not counts: + return "(no tags)" + max_n = max(counts.values()) + spans = [] + for name, n in sorted(counts.items(), key=lambda kv: -kv[1]): + size = 0.8 + 1.2 * (n / max_n) + safe = html.escape(name) + spans.append( + f"{safe} ({n})" + ) + return f"
    {''.join(spans)}
    " diff --git a/api/poetry.lock b/api/poetry.lock index bd5a1417..2e6e453f 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiofiles" @@ -6,7 +6,6 @@ version = "0.8.0" description = "File support for asyncio." optional = false python-versions = ">=3.6,<4.0" -groups = ["main"] files = [ {file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"}, {file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"}, @@ -18,7 +17,6 @@ version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, @@ -30,7 +28,6 @@ version = "3.13.4" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "aiohttp-3.13.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722"}, {file = "aiohttp-3.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845"}, @@ -165,7 +162,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli (>=1.2)", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi (>=1.2)"] [[package]] name = "aiosignal" @@ -173,7 +170,6 @@ version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -189,7 +185,6 @@ version = "1.18.4" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"}, {file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"}, @@ -204,13 +199,36 @@ typing-extensions = ">=4.12" [package.extras] tz = ["tzdata"] +[[package]] +name = "altair" +version = "5.5.0" +description = "Vega-Altair: A declarative statistical visualization library for Python." +optional = false +python-versions = ">=3.9" +files = [ + {file = "altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c"}, + {file = "altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d"}, +] + +[package.dependencies] +jinja2 = "*" +jsonschema = ">=3.0" +narwhals = ">=1.14.2" +packaging = "*" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.14\""} + +[package.extras] +all = ["altair-tiles (>=0.3.0)", "anywidget (>=0.9.0)", "numpy", "pandas (>=1.1.3)", "pyarrow (>=11)", "vega-datasets (>=0.9.0)", "vegafusion[embed] (>=1.6.6)", "vl-convert-python (>=1.7.0)"] +dev = ["duckdb (>=1.0)", "geopandas", "hatch (>=1.13.0)", "ipython[kernel]", "mistune", "mypy", "pandas (>=1.1.3)", "pandas-stubs", "polars (>=0.20.3)", "pyarrow-stubs", "pytest", "pytest-cov", "pytest-xdist[psutil] (>=3.5,<4.0)", "ruff (>=0.6.0)", "types-jsonschema", "types-setuptools"] +doc = ["docutils", "jinja2", "myst-parser", "numpydoc", "pillow (>=9,<10)", "pydata-sphinx-theme (>=0.14.1)", "scipy", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinxext-altair"] +save = ["vl-convert-python (>=1.7.0)"] + [[package]] name = "amqp" version = "5.3.1" description = "Low-level AMQP client for Python (fork of amqplib)." optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2"}, {file = "amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432"}, @@ -225,7 +243,6 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -237,7 +254,6 @@ version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, @@ -249,7 +265,18 @@ idna = ">=2.8" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] +trio = ["trio (>=0.31.0)", "trio (>=0.32.0)"] + +[[package]] +name = "appnope" +version = "0.1.4" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = ">=3.6" +files = [ + {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, + {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, +] [[package]] name = "asgiref" @@ -257,7 +284,6 @@ version = "3.11.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"}, {file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"}, @@ -269,14 +295,27 @@ typing_extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] +[[package]] +name = "asttokens" +version = "3.0.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"}, + {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"}, +] + +[package.extras] +astroid = ["astroid (>=2,<5)"] +test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] + [[package]] name = "async-timeout" version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_full_version <= \"3.11.2\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -288,7 +327,6 @@ version = "25.4.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, @@ -300,7 +338,6 @@ version = "1.6.11" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3"}, {file = "authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f"}, @@ -315,7 +352,6 @@ version = "2.3.3" description = "Removes unused imports and unused variables" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53"}, {file = "autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504"}, @@ -331,7 +367,6 @@ version = "2.3.2" description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128"}, {file = "autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758"}, @@ -347,7 +382,6 @@ version = "4.3.0" description = "Modern password hashing for your software and your servers" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, @@ -412,7 +446,6 @@ version = "4.2.4" description = "Python multiprocessing fork with improvements and bugfixes" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5"}, {file = "billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f"}, @@ -424,7 +457,6 @@ version = "26.3.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2"}, {file = "black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b"}, @@ -469,7 +501,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] +uvloop = ["uvloop (>=0.15.2)", "winloop (>=0.5.0)"] [[package]] name = "boto3" @@ -477,7 +509,6 @@ version = "1.42.69" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "boto3-1.42.69-py3-none-any.whl", hash = "sha256:6823a4b59aa578c7d98124280a9b6d83cea04bdb02525cbaa79370e5b6f7f631"}, {file = "boto3-1.42.69.tar.gz", hash = "sha256:e59846f4ff467b23bae4751948298db554dbdda0d72b09028d2cacbeff27e1ad"}, @@ -497,7 +528,6 @@ version = "1.42.69" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "botocore-1.42.69-py3-none-any.whl", hash = "sha256:ef0e3d860a5d7bffc0ccb4911781c4c27d538557ed9a616ba1926c762d72e5f6"}, {file = "botocore-1.42.69.tar.gz", hash = "sha256:0934f2d90403c5c8c2cba83e754a39d77edcad5885d04a79363edff3e814f55e"}, @@ -517,7 +547,6 @@ version = "5.6.2" description = "Distributed Task Queue." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5"}, {file = "celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b"}, @@ -539,32 +568,32 @@ vine = ">=5.1.0,<6.0" arangodb = ["pyArango (>=2.0.2)"] auth = ["cryptography (==46.0.3)"] azureblockblob = ["azure-identity (>=1.19.0)", "azure-storage-blob (>=12.15.0)"] -brotli = ["brotli (>=1.0.0) ; platform_python_implementation == \"CPython\"", "brotlipy (>=0.7.0) ; platform_python_implementation == \"PyPy\""] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] cassandra = ["cassandra-driver (>=3.25.0,<4)"] consul = ["python-consul2 (==0.1.5)"] cosmosdbsql = ["pydocumentdb (==2.3.5)"] -couchbase = ["couchbase (>=3.0.0) ; platform_python_implementation != \"PyPy\" and (platform_system != \"Windows\" or python_version < \"3.10\")"] +couchbase = ["couchbase (>=3.0.0)"] couchdb = ["pycouchdb (==1.16.0)"] django = ["Django (>=2.2.28)"] dynamodb = ["boto3 (>=1.26.143)"] elasticsearch = ["elastic-transport (<=9.1.0)", "elasticsearch (<=9.1.2)"] -eventlet = ["eventlet (>=0.32.0) ; python_version < \"3.10\""] +eventlet = ["eventlet (>=0.32.0)"] gcs = ["google-cloud-firestore (==2.22.0)", "google-cloud-storage (>=2.10.0)", "grpcio (==1.75.1)"] gevent = ["gevent (>=1.5.0)"] -librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] -memcache = ["pylibmc (==1.6.3) ; platform_system != \"Windows\""] +librabbitmq = ["librabbitmq (>=2.0.0)"] +memcache = ["pylibmc (==1.6.3)"] mongodb = ["kombu[mongodb]"] msgpack = ["kombu[msgpack]"] -pydantic = ["pydantic (>=2.12.0a1) ; python_version >= \"3.14\"", "pydantic (>=2.4) ; python_version < \"3.14\""] +pydantic = ["pydantic (>=2.12.0a1)", "pydantic (>=2.4)"] pymemcache = ["python-memcached (>=1.61)"] -pyro = ["pyro4 (==4.82) ; python_version < \"3.11\""] +pyro = ["pyro4 (==4.82)"] pytest = ["pytest-celery[all] (>=1.2.0,<1.3.0)"] redis = ["kombu[redis]"] s3 = ["boto3 (>=1.26.143)"] slmq = ["softlayer_messaging (>=1.0.3)"] -solar = ["ephem (==4.2) ; platform_python_implementation != \"PyPy\""] +solar = ["ephem (==4.2)"] sqlalchemy = ["kombu[sqlalchemy]"] -sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.5.0)", "pycurl (>=7.43.0.5,<7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version < \"3.9\"", "pycurl (>=7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "urllib3 (>=1.26.16)"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.5.0)", "pycurl (>=7.43.0.5,<7.45.4)", "pycurl (>=7.45.4)", "urllib3 (>=1.26.16)"] tblib = ["tblib (==3.2.2)"] yaml = ["kombu[yaml]"] zookeeper = ["kazoo (>=1.3.1)"] @@ -576,7 +605,6 @@ version = "2.3.3" description = "A Celery Beat Scheduler using Redis for persistent storage" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "celery_redbeat-2.3.3-py2.py3-none-any.whl", hash = "sha256:0d6da101c46c0147b88f3ae2bfaed42548545934263a31d325a8b0e6585c3fb1"}, {file = "celery_redbeat-2.3.3.tar.gz", hash = "sha256:f52664b490b2923a787d9f348f291243601430bf8ec383b6e79870a1731ee92a"}, @@ -594,7 +622,6 @@ version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, @@ -606,8 +633,6 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -704,7 +729,6 @@ version = "3.5.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, @@ -716,7 +740,6 @@ version = "3.4.6" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, @@ -855,7 +878,6 @@ version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] files = [ {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, @@ -870,7 +892,6 @@ version = "0.3.1" description = "Enables git-like *did-you-mean* feature in click" optional = false python-versions = ">=3.6.2" -groups = ["main"] files = [ {file = "click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c"}, {file = "click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463"}, @@ -885,7 +906,6 @@ version = "1.1.1.2" description = "An extension module for click to enable registering CLI commands via setuptools entry-points." optional = false python-versions = "*" -groups = ["main"] files = [ {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, @@ -903,7 +923,6 @@ version = "0.3.0" description = "REPL plugin for Click" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, @@ -922,12 +941,24 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} + +[[package]] +name = "comm" +version = "0.2.3" +description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." +optional = false +python-versions = ">=3.8" +files = [ + {file = "comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417"}, + {file = "comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971"}, +] + +[package.extras] +test = ["pytest"] [[package]] name = "coverage" @@ -935,7 +966,6 @@ version = "7.13.5" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, @@ -1049,7 +1079,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "cryptography" @@ -1057,7 +1087,6 @@ version = "46.0.7" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] files = [ {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, @@ -1111,8 +1140,8 @@ files = [ ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} -typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] @@ -1130,7 +1159,6 @@ version = "4.10.0" description = "Intuitive, easy CLIs based on type hints." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "cyclopts-4.10.0-py3-none-any.whl", hash = "sha256:50f333382a60df8d40ec14aa2e627316b361c4f478598ada1f4169d959bf9ea7"}, {file = "cyclopts-4.10.0.tar.gz", hash = "sha256:0ae04a53274e200ef3477c8b54de63b019bc6cd0162d75c718bf40c9c3fb5268"}, @@ -1149,7 +1177,7 @@ debug = ["ipdb (>=0.13.9)", "line-profiler (>=3.5.1)"] dev = ["coverage[toml] (>=5.1)", "mkdocs (>=1.4.0)", "pre-commit (>=2.16.0)", "pydantic (>=2.11.2,<3.0.0)", "pytest (>=8.2.0)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)", "pyyaml (>=6.0.1)", "syrupy (>=4.0.0)", "toml (>=0.10.2,<1.0.0)", "trio (>=0.10.0)"] docs = ["gitpython (>=3.1.31)", "myst-parser[linkify] (>=3.0.1,<5.0.0)", "sphinx (>=7.4.7,<8.2.0)", "sphinx-autodoc-typehints (>=1.25.2,<4.0.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-rtd-dark-mode (>=1.3.0,<2.0.0)", "sphinx-rtd-theme (>=3.0.0,<4.0.0)"] mkdocs = ["markdown (>=3.3)", "mkdocs (>=1.4.0)", "pymdown-extensions (>=10.0)"] -toml = ["tomli (>=2.0.0) ; python_version < \"3.11\""] +toml = ["tomli (>=2.0.0)"] trio = ["trio (>=0.10.0)"] yaml = ["pyyaml (>=6.0.1)"] @@ -1159,7 +1187,6 @@ version = "1.8.20" description = "An implementation of the Debug Adapter Protocol for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64"}, {file = "debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642"}, @@ -1193,13 +1220,23 @@ files = [ {file = "debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33"}, ] +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] + [[package]] name = "deprecated" version = "1.3.1" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -groups = ["main"] files = [ {file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"}, {file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"}, @@ -1209,7 +1246,7 @@ files = [ wrapt = ">=1.10,<3" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools", "tox"] [[package]] name = "distlib" @@ -1217,7 +1254,6 @@ version = "0.4.0" description = "Distribution utilities" optional = false python-versions = "*" -groups = ["dev"] files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, @@ -1229,7 +1265,6 @@ version = "2.8.0" description = "DNS toolkit" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, @@ -1242,7 +1277,7 @@ doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] doq = ["aioquic (>=1.2.0)"] idna = ["idna (>=3.10)"] trio = ["trio (>=0.30)"] -wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] +wmi = ["wmi (>=1.5.1)"] [[package]] name = "docstring-parser" @@ -1250,14 +1285,13 @@ version = "0.17.0" description = "Parse Python docstrings in reST, Google and Numpydoc format" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708"}, {file = "docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912"}, ] [package.extras] -dev = ["pre-commit (>=2.16.0) ; python_version >= \"3.9\"", "pydoctor (>=25.4.0)", "pytest"] +dev = ["pre-commit (>=2.16.0)", "pydoctor (>=25.4.0)", "pytest"] docs = ["pydoctor (>=25.4.0)"] test = ["pytest"] @@ -1267,7 +1301,6 @@ version = "0.22.4" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de"}, {file = "docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968"}, @@ -1279,7 +1312,6 @@ version = "2.3.0" description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, @@ -1295,7 +1327,6 @@ version = "0.5" description = "Bringing the elegance of C# EventHandler to Python" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "Events-0.5-py3-none-any.whl", hash = "sha256:a7286af378ba3e46640ac9825156c93bdba7502174dd696090fdfcd4d80a1abd"}, ] @@ -1306,12 +1337,10 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] -markers = {dev = "python_version == \"3.10\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -1319,13 +1348,26 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "executing" +version = "2.2.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +files = [ + {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, + {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + [[package]] name = "fastapi" version = "0.115.14" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, @@ -1346,7 +1388,6 @@ version = "0.12.34" description = "FastAPI pagination" optional = false python-versions = "<4.0,>=3.8" -groups = ["main"] files = [ {file = "fastapi_pagination-0.12.34-py3-none-any.whl", hash = "sha256:089d1078aae1784395b4dbd923d0c8246641ddcc291c5ec6d92a30edb92ecbdd"}, {file = "fastapi_pagination-0.12.34.tar.gz", hash = "sha256:05ee8c0bc572072160f7f30900bfd87869e1880c87bc5797922fec2e49e65f11"}, @@ -1379,7 +1420,6 @@ version = "2.12.5" description = "The fast, Pythonic way to build MCP servers and clients." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "fastmcp-2.12.5-py3-none-any.whl", hash = "sha256:b1e542f9b83dbae7cecfdc9c73b062f77074785abda9f2306799116121344133"}, {file = "fastmcp-2.12.5.tar.gz", hash = "sha256:2dfd02e255705a4afe43d26caddbc864563036e233dbc6870f389ee523b39a6a"}, @@ -1408,7 +1448,6 @@ version = "3.25.2" description = "A platform independent file lock." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, @@ -1420,7 +1459,6 @@ version = "7.3.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, @@ -1437,7 +1475,6 @@ version = "2.0.1" description = "Celery Flower" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2"}, {file = "flower-2.0.1.tar.gz", hash = "sha256:5ab717b979530770c16afb48b50d2a98d23c3e9fe39851dcf6bc4d01845a02a0"}, @@ -1456,7 +1493,6 @@ version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, @@ -1596,8 +1632,6 @@ version = "3.3.2" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" files = [ {file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"}, {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"}, @@ -1664,7 +1698,6 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1676,7 +1709,6 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1698,7 +1730,6 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1711,7 +1742,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -1723,7 +1754,6 @@ version = "0.4.3" description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, @@ -1735,7 +1765,6 @@ version = "4.15.0" description = "Python humanize utilities" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769"}, {file = "humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10"}, @@ -1750,7 +1779,6 @@ version = "2.6.18" description = "File identification library for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737"}, {file = "identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd"}, @@ -1765,7 +1793,6 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1780,19 +1807,88 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "ipykernel" +version = "6.31.0" +description = "IPython Kernel for Jupyter" +optional = false +python-versions = ">=3.9" +files = [ + {file = "ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af"}, + {file = "ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6"}, +] + +[package.dependencies] +appnope = {version = ">=0.1.2", markers = "platform_system == \"Darwin\""} +comm = ">=0.1.1" +debugpy = ">=1.6.5" +ipython = ">=7.23.1" +jupyter-client = ">=8.0.0" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +matplotlib-inline = ">=0.1" +nest-asyncio = ">=1.4" +packaging = ">=22" +psutil = ">=5.7" +pyzmq = ">=25" +tornado = ">=6.2" +traitlets = ">=5.4.0" + +[package.extras] +cov = ["coverage[toml]", "matplotlib", "pytest-cov", "trio"] +docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] +pyqt5 = ["pyqt5"] +pyside6 = ["pyside6"] +test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0,<9)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "ipython" +version = "8.39.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f"}, + {file = "ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt_toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack_data = "*" +traitlets = ">=5.13.0" +typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing_extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + [[package]] name = "isodate" version = "0.7.2" description = "An ISO 8601 date/time/duration parser and formatter" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, @@ -1804,7 +1900,6 @@ version = "6.0.1" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.9.0" -groups = ["dev"] files = [ {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, @@ -1814,13 +1909,30 @@ files = [ colors = ["colorama"] plugins = ["setuptools"] +[[package]] +name = "jedi" +version = "0.20.0" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.10" +files = [ + {file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"}, + {file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"}, +] + +[package.dependencies] +parso = ">=0.8.6,<0.9.0" + +[package.extras] +dev = ["Django", "attrs", "colorama", "docopt", "flake8 (==7.1.2)", "pytest (<9.0.0)", "types-setuptools (==80.9.0.20250529)", "typing-extensions", "zuban (==0.7.0)"] +docs = ["Jinja2 (==3.1.6)", "MarkupSafe (==3.0.3)", "Pygments (==2.20.0)", "Sphinx (==9.1.0)", "alabaster (==1.0.0)", "babel (==2.18.0)", "certifi (==2026.4.22)", "charset-normalizer (==3.4.7)", "docutils (==0.22.4)", "idna (==3.13)", "imagesize (==2.0.0)", "iniconfig (==2.3.0)", "packaging (==26.2)", "pluggy (==1.6.0)", "pytest (==9.0.3)", "requests (==2.33.1)", "roman-numerals (==4.1.0)", "snowballstemmer (==3.0.1)", "sphinx-rtd-theme (==3.1.0)", "sphinxcontrib-applehelp (==2.0.0)", "sphinxcontrib-devhelp (==2.0.0)", "sphinxcontrib-htmlhelp (==2.1.0)", "sphinxcontrib-jquery (==4.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==2.0.0)", "sphinxcontrib-serializinghtml (==2.0.0)", "urllib3 (==2.6.3)"] + [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1838,7 +1950,6 @@ version = "1.1.0" description = "JSON Matching Expressions" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, @@ -1850,7 +1961,6 @@ version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, @@ -1872,7 +1982,6 @@ version = "0.3.4" description = "JSONSchema Spec with object-oriented paths" optional = false python-versions = "<4.0.0,>=3.8.0" -groups = ["main"] files = [ {file = "jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8"}, {file = "jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001"}, @@ -1890,7 +1999,6 @@ version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, @@ -1899,13 +2007,54 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "jupyter-client" +version = "8.8.0" +description = "Jupyter protocol implementation and client libraries" +optional = false +python-versions = ">=3.10" +files = [ + {file = "jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a"}, + {file = "jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e"}, +] + +[package.dependencies] +jupyter-core = ">=5.1" +python-dateutil = ">=2.8.2" +pyzmq = ">=25.0" +tornado = ">=6.4.1" +traitlets = ">=5.3" + +[package.extras] +docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +orjson = ["orjson"] +test = ["anyio", "coverage", "ipykernel (>=6.14)", "msgpack", "mypy", "paramiko", "pre-commit", "pytest", "pytest-cov", "pytest-jupyter[client] (>=0.6.2)", "pytest-timeout"] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +description = "Jupyter core package. A base package on which Jupyter projects rely." +optional = false +python-versions = ">=3.10" +files = [ + {file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"}, + {file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"}, +] + +[package.dependencies] +platformdirs = ">=2.5" +traitlets = ">=5.3" + +[package.extras] +docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-spelling", "traitlets"] +test = ["ipykernel", "pre-commit", "pytest (<9)", "pytest-cov", "pytest-timeout"] + [[package]] name = "kombu" version = "5.6.2" description = "Messaging library for Python." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93"}, {file = "kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55"}, @@ -1923,7 +2072,7 @@ azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6. confluentkafka = ["confluent-kafka (>=2.2.0)"] consul = ["python-consul2 (==0.1.5)"] gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.75.1)", "protobuf (==6.32.1)"] -librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] +librabbitmq = ["librabbitmq (>=2.0.0)"] mongodb = ["pymongo (==4.15.3)"] msgpack = ["msgpack (==1.1.2)"] pyro = ["pyro4 (==4.82)"] @@ -1931,7 +2080,7 @@ qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] slmq = ["softlayer_messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] -sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16)"] +sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=2.8.0)"] @@ -1941,7 +2090,6 @@ version = "1.12.0" description = "A fast and thorough lazy object proxy." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519"}, {file = "lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6"}, @@ -1995,7 +2143,6 @@ version = "1.3.12" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"}, {file = "mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a"}, @@ -2015,7 +2162,6 @@ version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, @@ -2039,7 +2185,6 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -2132,13 +2277,29 @@ files = [ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.9" +files = [ + {file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"}, + {file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"}, +] + +[package.dependencies] +traitlets = "*" + +[package.extras] +test = ["flake8", "matplotlib", "nbdime", "nbval", "notebook", "pytest"] + [[package]] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" -groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -2150,7 +2311,6 @@ version = "1.12.4" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789"}, {file = "mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5"}, @@ -2180,7 +2340,6 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -2192,7 +2351,6 @@ version = "10.8.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, @@ -2204,7 +2362,6 @@ version = "6.7.1" description = "multidict implementation" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, @@ -2363,31 +2520,209 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "narwhals" +version = "2.21.0" +description = "Extremely lightweight compatibility layer between dataframe libraries" +optional = false +python-versions = ">=3.9" +files = [ + {file = "narwhals-2.21.0-py3-none-any.whl", hash = "sha256:1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be"}, + {file = "narwhals-2.21.0.tar.gz", hash = "sha256:7c6e7f50528e62b7a967dd864d7e117d2955d38d4f730653ce46a9861358e2dc"}, +] + +[package.extras] +cudf = ["cudf-cu12 (>=24.10.0)"] +dask = ["dask[dataframe] (>=2024.8)"] +duckdb = ["duckdb (>=1.1)"] +ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] +modin = ["modin"] +pandas = ["pandas (>=1.1.3)"] +polars = ["polars (>=0.20.4)"] +pyarrow = ["pyarrow (>=13.0.0)"] +pyspark = ["pyspark (>=3.5.0)"] +pyspark-connect = ["pyspark[connect] (>=3.5.0)"] +sql = ["duckdb (>=1.1)", "sqlparse"] +sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + [[package]] name = "nodeenv" version = "1.10.0" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] files = [ {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, ] +[[package]] +name = "numpy" +version = "2.2.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + +[[package]] +name = "numpy" +version = "2.4.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +files = [ + {file = "numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db"}, + {file = "numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0"}, + {file = "numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015"}, + {file = "numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40"}, + {file = "numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d"}, + {file = "numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502"}, + {file = "numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd"}, + {file = "numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5"}, + {file = "numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e"}, + {file = "numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e"}, + {file = "numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8"}, + {file = "numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121"}, + {file = "numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e"}, + {file = "numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44"}, + {file = "numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d"}, + {file = "numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827"}, + {file = "numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a"}, + {file = "numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c"}, + {file = "numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103"}, + {file = "numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83"}, + {file = "numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed"}, + {file = "numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959"}, + {file = "numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed"}, + {file = "numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf"}, + {file = "numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d"}, + {file = "numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5"}, + {file = "numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7"}, + {file = "numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93"}, + {file = "numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e"}, + {file = "numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40"}, + {file = "numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e"}, + {file = "numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392"}, + {file = "numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008"}, + {file = "numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8"}, + {file = "numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b"}, + {file = "numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a"}, + {file = "numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d"}, + {file = "numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252"}, + {file = "numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f"}, + {file = "numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc"}, + {file = "numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74"}, + {file = "numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb"}, + {file = "numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e"}, + {file = "numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113"}, + {file = "numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d"}, + {file = "numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d"}, + {file = "numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f"}, + {file = "numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0"}, + {file = "numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150"}, + {file = "numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871"}, + {file = "numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e"}, + {file = "numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119"}, + {file = "numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0"}, +] + [[package]] name = "openapi-core" version = "0.22.0" description = "client-side and server-side support for the OpenAPI Specification v3" optional = false python-versions = "<4.0.0,>=3.9.0" -groups = ["main"] files = [ {file = "openapi_core-0.22.0-py3-none-any.whl", hash = "sha256:8fb7c325f2db4ef6c60584b1870f90eeb3183aa47e30643715c5003b7677a149"}, {file = "openapi_core-0.22.0.tar.gz", hash = "sha256:b30490dfa74e3aac2276105525590135212352f5dd7e5acf8f62f6a89ed6f2d0"}, @@ -2418,7 +2753,6 @@ version = "0.5.1" description = "Pydantic OpenAPI schema implementation" optional = false python-versions = "<4.0,>=3.8" -groups = ["main"] files = [ {file = "openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146"}, {file = "openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d"}, @@ -2433,7 +2767,6 @@ version = "0.6.3" description = "OpenAPI schema validation for Python" optional = false python-versions = "<4.0.0,>=3.8.0" -groups = ["main"] files = [ {file = "openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3"}, {file = "openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee"}, @@ -2450,7 +2783,6 @@ version = "0.7.2" description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" optional = false python-versions = "<4.0.0,>=3.8.0" -groups = ["main"] files = [ {file = "openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60"}, {file = "openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734"}, @@ -2468,7 +2800,6 @@ version = "2.8.0" description = "Python client for OpenSearch" optional = false python-versions = "<4,>=3.8" -groups = ["main"] files = [ {file = "opensearch_py-2.8.0-py3-none-any.whl", hash = "sha256:52c60fdb5d4dcf6cce3ee746c13b194529b0161e0f41268b98ab8f1624abe2fa"}, {file = "opensearch_py-2.8.0.tar.gz", hash = "sha256:6598df0bc7a003294edd0ba88a331e0793acbb8c910c43edf398791e3b2eccda"}, @@ -2493,19 +2824,131 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] +[[package]] +name = "pandas" +version = "2.3.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, + {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, + {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, + {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, + {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, + {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, + {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, + {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "parso" +version = "0.8.7" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"}, + {file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "types-setuptools (==67.2.0.1)", "zuban (==0.5.1)"] +testing = ["docopt", "pytest"] + [[package]] name = "pathable" version = "0.4.4" description = "Object-oriented paths" optional = false python-versions = "<4.0.0,>=3.7.0" -groups = ["main"] files = [ {file = "pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2"}, {file = "pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2"}, @@ -2517,7 +2960,6 @@ version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, @@ -2529,13 +2971,26 @@ optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + [[package]] name = "platformdirs" version = "4.9.4" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, @@ -2547,7 +3002,6 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -2563,7 +3017,6 @@ version = "4.5.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, @@ -2582,7 +3035,6 @@ version = "0.24.1" description = "Python client for the Prometheus monitoring system." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055"}, {file = "prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9"}, @@ -2599,7 +3051,6 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, @@ -2614,7 +3065,6 @@ version = "0.4.1" description = "Accelerated property cache" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, @@ -2740,13 +3190,46 @@ files = [ {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, ] +[[package]] +name = "psutil" +version = "7.2.2" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +files = [ + {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel", "wmi"] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32", "setuptools", "wheel", "wmi"] + [[package]] name = "psycopg2-binary" version = "2.9.11" description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c"}, {file = "psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2"}, @@ -2817,13 +3300,37 @@ files = [ {file = "psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02"}, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pycodestyle" version = "2.14.0" description = "Python style guide checker" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, @@ -2835,8 +3342,6 @@ version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, @@ -2848,7 +3353,6 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -2863,7 +3367,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +timezone = ["tzdata"] [[package]] name = "pydantic-core" @@ -2871,7 +3375,6 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -3005,7 +3508,6 @@ version = "2.13.1" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, @@ -3029,7 +3531,6 @@ version = "3.4.0" description = "passive checker of Python programs" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, @@ -3041,7 +3542,6 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -3056,7 +3556,6 @@ version = "5.1.2" description = "Call stack profiler for Python. Shows you why your code is slow!" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"}, {file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"}, @@ -3138,7 +3637,6 @@ version = "2.12.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, @@ -3159,7 +3657,6 @@ version = "2.5.33.1" description = "Python API for MISP." optional = false python-versions = "<4.0,>=3.10" -groups = ["main"] files = [ {file = "pymisp-2.5.33.1-py3-none-any.whl", hash = "sha256:7d7034bb5b05235e0836c15af9a8116d56a65247e0037a796216942a1d3eb977"}, {file = "pymisp-2.5.33.1.tar.gz", hash = "sha256:b5cd9aac342596fbe2696b7c3ee02a2a221574557c0334451b0d4e21a4c5928f"}, @@ -3172,8 +3669,8 @@ requests = ">=2.32.5" [package.extras] brotli = ["urllib3[broti] (>=2.6.3)"] -docs = ["docutils (>=0.22.4) ; python_version >= \"3.12\"", "myst-parser (>=5.0.0) ; python_version >= \"3.12\"", "sphinx (>=9.1.0) ; python_version >= \"3.12\"", "sphinx-autodoc-typehints (>=3.9.5) ; python_version >= \"3.12\""] -email = ["RTFDE (>=0.1.2.2) ; python_version <= \"3.10\"", "extract_msg (>=0.55.0)", "oletools (>=0.60.2)"] +docs = ["docutils (>=0.22.4)", "myst-parser (>=5.0.0)", "sphinx (>=9.1.0)", "sphinx-autodoc-typehints (>=3.9.5)"] +email = ["RTFDE (>=0.1.2.2)", "extract_msg (>=0.55.0)", "oletools (>=0.60.2)"] fileobjects = ["lief (>=0.17.4)", "pure-magic-rs (>=0.3.2)", "pydeep2 (>=0.5.1)"] openioc = ["beautifulsoup4 (>=4.13.5,<4.14)"] pdfexport = ["reportlab (>=4.4.10)"] @@ -3186,7 +3683,6 @@ version = "1.11.0" description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"}, {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"}, @@ -3198,7 +3694,6 @@ version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, @@ -3222,7 +3717,6 @@ version = "6.3.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, @@ -3242,7 +3736,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -3257,7 +3750,6 @@ version = "1.1.3" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e"}, {file = "python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5"}, @@ -3277,7 +3769,6 @@ version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, @@ -3292,7 +3783,6 @@ version = "0.0.27" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645"}, {file = "python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602"}, @@ -3304,7 +3794,6 @@ version = "0.4.1" description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, @@ -3359,7 +3848,6 @@ version = "2026.1.post1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, @@ -3371,7 +3859,6 @@ version = "3.21.2" description = "A tool to automatically upgrade syntax for newer versions." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pyupgrade-3.21.2-py2.py3-none-any.whl", hash = "sha256:2ac7b95cbd176475041e4dfe8ef81298bd4654a244f957167bd68af37d52be9f"}, {file = "pyupgrade-3.21.2.tar.gz", hash = "sha256:1a361bea39deda78d1460f65d9dd548d3a36ff8171d2482298539b9dc11c9c06"}, @@ -3386,8 +3873,6 @@ version = "311" description = "Python for Window Extensions" optional = false python-versions = "*" -groups = ["main"] -markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -3417,7 +3902,6 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -3494,13 +3978,116 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386"}, + {file = "pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda"}, + {file = "pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f"}, + {file = "pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32"}, + {file = "pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394"}, + {file = "pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f"}, + {file = "pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97"}, + {file = "pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07"}, + {file = "pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496"}, + {file = "pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd"}, + {file = "pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf"}, + {file = "pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f"}, + {file = "pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5"}, + {file = "pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6"}, + {file = "pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e"}, + {file = "pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7"}, + {file = "pyzmq-27.1.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831"}, + {file = "pyzmq-27.1.0-cp38-cp38-win32.whl", hash = "sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312"}, + {file = "pyzmq-27.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266"}, + {file = "pyzmq-27.1.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f"}, + {file = "pyzmq-27.1.0-cp39-cp39-win32.whl", hash = "sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78"}, + {file = "pyzmq-27.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db"}, + {file = "pyzmq-27.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9"}, + {file = "pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + [[package]] name = "redis" version = "4.6.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "redis-4.6.0-py3-none-any.whl", hash = "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c"}, {file = "redis-4.6.0.tar.gz", hash = "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d"}, @@ -3519,7 +4106,6 @@ version = "0.36.2" description = "JSON Referencing + Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, @@ -3536,7 +4122,6 @@ version = "2.33.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["main", "dev"] files = [ {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, @@ -3559,7 +4144,6 @@ version = "0.1.4" description = "A pure python RFC3339 validator" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] files = [ {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, @@ -3574,7 +4158,6 @@ version = "14.3.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["main"] files = [ {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, @@ -3593,7 +4176,6 @@ version = "1.3.2" description = "A beautiful reStructuredText renderer for rich" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a"}, {file = "rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4"}, @@ -3612,7 +4194,6 @@ version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, @@ -3737,7 +4318,6 @@ version = "0.16.0" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, @@ -3755,7 +4335,6 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -3767,7 +4346,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -3779,7 +4357,6 @@ version = "2.0.48" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89"}, {file = "sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0"}, @@ -3881,7 +4458,6 @@ version = "3.0.3" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431"}, {file = "sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971"}, @@ -3896,13 +4472,31 @@ examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "starlette" version = "0.46.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, @@ -3920,7 +4514,6 @@ version = "9.1.4" description = "Retry code until it succeeds" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, @@ -3936,7 +4529,6 @@ version = "6.2.0" description = "A wrapper around the stdlib `tokenize` which roundtrips." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44"}, {file = "tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6"}, @@ -3948,7 +4540,6 @@ version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, @@ -3998,7 +4589,6 @@ files = [ {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] -markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "tornado" @@ -4006,7 +4596,6 @@ version = "6.5.5" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, @@ -4020,13 +4609,27 @@ files = [ {file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"}, ] +[[package]] +name = "traitlets" +version = "5.15.0" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.9" +files = [ + {file = "traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40"}, + {file = "traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "mypy (>=1.7.0,<1.19)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + [[package]] name = "typer" version = "0.15.3" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd"}, {file = "typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c"}, @@ -4044,12 +4647,10 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version == \"3.10\""} [[package]] name = "typing-inspection" @@ -4057,7 +4658,6 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -4072,7 +4672,6 @@ version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main"] files = [ {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, @@ -4084,7 +4683,6 @@ version = "5.3.1" description = "tzinfo object for the local timezone" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, @@ -4102,17 +4700,16 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" -groups = ["main", "dev"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "uvicorn" @@ -4120,7 +4717,6 @@ version = "0.29.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, @@ -4132,7 +4728,7 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "vine" @@ -4140,7 +4736,6 @@ version = "5.1.0" description = "Python promises." optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc"}, {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, @@ -4152,7 +4747,6 @@ version = "21.2.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, @@ -4165,13 +4759,27 @@ platformdirs = ">=3.9.1,<5" python-discovery = ">=1" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} +[[package]] +name = "vl-convert-python" +version = "1.9.0.post1" +description = "Convert Vega-Lite chart specifications to SVG, PNG, or Vega" +optional = false +python-versions = ">=3.7" +files = [ + {file = "vl_convert_python-1.9.0.post1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:43e9515f65bbcd317d1ef328787fd7bf0344c2fde9292eb7a0e64d5d3d29fccb"}, + {file = "vl_convert_python-1.9.0.post1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b0e7a3245f32addec7e7abeb1badf72b1513ed71ba1dba7aca853901217b3f4e"}, + {file = "vl_convert_python-1.9.0.post1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6ecfe4b7e2ea9e8c30fd6d6eaea3ef85475be1ad249407d9796dce4ecdb5b32"}, + {file = "vl_convert_python-1.9.0.post1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c1558fa0055e88c465bd3d71760cde9fa2c94a95f776a0ef9178252fd820b1f"}, + {file = "vl_convert_python-1.9.0.post1-cp37-abi3-win_amd64.whl", hash = "sha256:7e263269ac0d304640ca842b44dfe430ed863accd9edecff42e279bfc48ce940"}, + {file = "vl_convert_python-1.9.0.post1.tar.gz", hash = "sha256:a5b06b3128037519001166f5341ec7831e19fbd7f3a5f78f73d557ac2d5859ef"}, +] + [[package]] name = "wcwidth" version = "0.6.0" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad"}, {file = "wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159"}, @@ -4183,7 +4791,6 @@ version = "3.1.6" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, @@ -4201,7 +4808,6 @@ version = "2.1.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c"}, {file = "wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f"}, @@ -4304,7 +4910,6 @@ version = "1.23.0" description = "Yet another URL library" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107"}, {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d"}, @@ -4442,6 +5047,6 @@ multidict = ">=4.0" propcache = ">=0.2.1" [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.10" -content-hash = "5aee23263b87e5446d32fbb5dcdfd82af2734bd6649d9f1e63d2d51a8ae7eee9" +content-hash = "e9f47eecc8b424bdf969704e87fd2998e34c6074d576736db4d2d35f5f4a06d6" diff --git a/api/pyproject.toml b/api/pyproject.toml index 62a52f46..4173896e 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -35,6 +35,16 @@ celery-redbeat = "^2.3.3" fastmcp = "^2.0" pyinstrument = "^5.0" +[tool.poetry.group.lab] +optional = true + +[tool.poetry.group.lab.dependencies] +ipykernel = "^6.29" +jupyter-client = "^8.6" +pandas = "^2.2" +altair = "^5.5" +vl-convert-python = "^1.7" + [tool.poetry.dev-dependencies] pytest = "^9.0.3" pytest-cov = "^6.2.1" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 44732f3b..a2e4dd58 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -76,6 +76,20 @@ services: extra_hosts: - "host.docker.internal:host-gateway" + lab-worker: + ports: + - "5681:5681" + command: + [ + "sh", + "-c", + "poetry run python -m debugpy --listen 0.0.0.0:5681 -m celery -A app.worker.tasks worker --loglevel=debug --hostname=lab1@%h --queues=lab_kernel --pool=threads --concurrency=4", + ] + volumes: + - ./api:/code + extra_hosts: + - "host.docker.internal:host-gateway" + beat: ports: diff --git a/docker-compose.yml b/docker-compose.yml index 9959dd16..f825c0fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -210,6 +210,31 @@ services: networks: - backend + lab-worker: + build: + context: ./api + args: + POETRY_GROUPS: lab + # Dedicated worker for Tech Lab notebook kernels. Threadpool — not + # prefork — because all kernels share one Python process; any thread + # must be able to drive any kernel via the in-memory KernelManager + # registry. Prefork would shard the registry across N subprocesses. + command: poetry run celery -A app.worker.tasks worker --loglevel=info --hostname lab@%h --queues=lab_kernel --pool=threads --concurrency=8 -E + restart: unless-stopped + environment: + <<: *default-env + depends_on: + - api + - redis + - postgres + mem_limit: 512m + pids_limit: 256 + volumes: + - garage-creds:/var/lib/misp-workbench/secrets + - oauth-creds:/var/lib/misp-workbench/oauth-creds + networks: + - backend + beat: build: context: ./api diff --git a/docs/features/api/openapi.json b/docs/features/api/openapi.json index 9608f301..a5398c6c 100644 --- a/docs/features/api/openapi.json +++ b/docs/features/api/openapi.json @@ -8159,6 +8159,33 @@ ] } }, + "/diagnostics/lab": { + "get": { + "tags": [ + "Diagnostics" + ], + "summary": "Get Lab Diagnostics", + "description": "Lab-worker connectivity and running jupyter kernels.\n\nWorker presence is derived from Celery inspect \u2014 we look for a worker\nsubscribed to the ``lab_kernel`` queue. The kernel registry lives inside\nthe lab-worker process, so we round-trip a short task to fetch a snapshot.", + "operationId": "get_lab_diagnostics_diagnostics_lab_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "tasks:read" + ] + } + ] + } + }, "/hunts/": { "get": { "tags": [ @@ -10053,20 +10080,21 @@ } } }, - "/mcp/config": { + "/tech-lab/tree": { "get": { "tags": [ - "MCP" + "Tech Lab / Notebooks" ], - "summary": "Get Mcp Config", - "description": "Return a ready-to-use MCP client configuration for this server.\n\nRequires the mcp:config scope. Generates a dedicated MCP token scoped to\nmcp:* only, valid for the configured refresh token lifetime. Save the\nresponse as .mcp.json to use it directly with the MCP client.", - "operationId": "get_mcp_config_mcp_config_get", + "summary": "Get Tree", + "operationId": "get_tree_tech_lab_tree_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/LabTree" + } } } } @@ -10074,183 +10102,1134 @@ "security": [ { "OAuth2PasswordBearer": [ - "mcp:config" + "lab:read" ] } ] } - } - }, - "components": { - "schemas": { - "AnalysisLevel": { - "type": "integer", - "enum": [ - 0, - 1, - 2 + }, + "/tech-lab/folders": { + "post": { + "tags": [ + "Tech Lab / Notebooks" ], - "title": "AnalysisLevel", - "description": "Enum for the Event analysis level" - }, - "ApiKey": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "user_id": { - "type": "integer", - "title": "User Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "summary": "Create Folder", + "operationId": "create_folder_tech_lab_folders_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabFolderCreate" } - ], - "title": "Comment" - }, - "scopes": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Scopes" + } }, - "expires_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabFolder" + } } - ], - "title": "Expires At" + } }, - "last_used_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } - ], - "title": "Last Used At" - }, - "disabled": { - "type": "boolean", - "title": "Disabled" - }, - "admin_disabled": { - "type": "boolean", - "title": "Admin Disabled" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" + } } }, - "type": "object", - "required": [ - "id", - "user_id", - "name", - "scopes", - "disabled", - "admin_disabled", - "created_at" + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:create" + ] + } + ] + } + }, + "/tech-lab/folders/{folder_id}": { + "patch": { + "tags": [ + "Tech Lab / Notebooks" ], - "title": "ApiKey" - }, - "ApiKeyCreate": { - "properties": { - "name": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Name" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Comment" - }, - "scopes": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Scopes" - }, - "expires_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" + "summary": "Update Folder", + "operationId": "update_folder_tech_lab_folders__folder_id__patch", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:update" + ] + } + ], + "parameters": [ + { + "name": "folder_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Folder Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabFolderUpdate" } - ], - "title": "Expires At" + } } }, - "type": "object", - "required": [ - "name" - ], - "title": "ApiKeyCreate" - }, - "ApiKeyCreated": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "user_id": { - "type": "integer", - "title": "User Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabFolder" + } } - ], - "title": "Comment" - }, - "scopes": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Scopes" + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Delete Folder", + "operationId": "delete_folder_tech_lab_folders__folder_id__delete", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:delete" + ] + } + ], + "parameters": [ + { + "name": "folder_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Folder Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Create Notebook", + "operationId": "create_notebook_tech_lab_notebooks_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebookCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebook" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:create" + ] + } + ] + } + }, + "/tech-lab/notebooks/{notebook_id}": { + "get": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Get Notebook", + "operationId": "get_notebook_tech_lab_notebooks__notebook_id__get", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:read" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebook" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Update Notebook", + "operationId": "update_notebook_tech_lab_notebooks__notebook_id__patch", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:update" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebookUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebook" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Delete Notebook", + "operationId": "delete_notebook_tech_lab_notebooks__notebook_id__delete", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:delete" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/clear_outputs": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Clear Outputs", + "operationId": "clear_outputs_tech_lab_notebooks__notebook_id__clear_outputs_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:update" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebook" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/pin": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Pin Notebook", + "operationId": "pin_notebook_tech_lab_notebooks__notebook_id__pin_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:read" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Unpin Notebook", + "operationId": "unpin_notebook_tech_lab_notebooks__notebook_id__pin_delete", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:read" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/fork": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Fork Notebook", + "operationId": "fork_notebook_tech_lab_notebooks__notebook_id__fork_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:create" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + }, + { + "name": "visibility", + "in": "query", + "required": false, + "schema": { + "enum": [ + "personal", + "global" + ], + "type": "string", + "default": "personal", + "title": "Visibility" + } + } + ], + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebook" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/cells/execute": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Execute Cell", + "operationId": "execute_cell_tech_lab_notebooks__notebook_id__cells_execute_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:run" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabExecuteRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabExecution" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/cells/execute_all": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Execute All Cells", + "operationId": "execute_all_cells_tech_lab_notebooks__notebook_id__cells_execute_all_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:run" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabExecution" + }, + "title": "Response Execute All Cells Tech Lab Notebooks Notebook Id Cells Execute All Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/executions/{execution_id}": { + "get": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Get Execution", + "operationId": "get_execution_tech_lab_notebooks__notebook_id__executions__execution_id__get", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:read" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + }, + { + "name": "execution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Execution Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabExecution" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/kernel/interrupt": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Interrupt Kernel", + "operationId": "interrupt_kernel_tech_lab_notebooks__notebook_id__kernel_interrupt_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:run" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/kernel/shutdown": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Shutdown Kernel", + "operationId": "shutdown_kernel_tech_lab_notebooks__notebook_id__kernel_shutdown_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:run" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/{notebook_id}/export": { + "get": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Export Notebook", + "description": "Return the notebook as nbformat 4.5 JSON.\n\nFrontend wraps the response body in a ``Blob`` and saves it as\n``.ipynb``. Outputs from the most recent run are included so the\ndownloaded file opens with results visible in JupyterLab.", + "operationId": "export_notebook_tech_lab_notebooks__notebook_id__export_get", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:read" + ] + } + ], + "parameters": [ + { + "name": "notebook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Notebook Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tech-lab/notebooks/import": { + "post": { + "tags": [ + "Tech Lab / Notebooks" + ], + "summary": "Import Notebook", + "description": "Import an .ipynb. Always creates a *personal* notebook owned by the\ncurrent user \u2014 analysts can promote to global later by re-creating in a\nGlobal folder if they want to share.", + "operationId": "import_notebook_tech_lab_notebooks_import_post", + "security": [ + { + "OAuth2PasswordBearer": [ + "lab:create" + ] + } + ], + "parameters": [ + { + "name": "folder_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Folder Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_import_notebook_tech_lab_notebooks_import_post" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabNotebook" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/mcp/config": { + "get": { + "tags": [ + "MCP" + ], + "summary": "Get Mcp Config", + "description": "Return a ready-to-use MCP client configuration for this server.\n\nRequires the mcp:config scope. Generates a dedicated MCP token scoped to\nmcp:* only, valid for the configured refresh token lifetime. Save the\nresponse as .mcp.json to use it directly with the MCP client.", + "operationId": "get_mcp_config_mcp_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "mcp:config" + ] + } + ] + } + } + }, + "components": { + "schemas": { + "AnalysisLevel": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "title": "AnalysisLevel", + "description": "Enum for the Event analysis level" + }, + "ApiKey": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes" + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "disabled": { + "type": "boolean", + "title": "Disabled" + }, + "admin_disabled": { + "type": "boolean", + "title": "Admin Disabled" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "scopes", + "disabled", + "admin_disabled", + "created_at" + ], + "title": "ApiKey" + }, + "ApiKeyCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Name" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes" + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "ApiKeyCreate" + }, + "ApiKeyCreated": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes" }, "expires_at": { "anyOf": [ @@ -10964,6 +11943,20 @@ ], "title": "AuditLog" }, + "Body_import_notebook_tech_lab_notebooks_import_post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_import_notebook_tech_lab_notebooks_import_post" + }, "Body_login_for_access_token_auth_token_post": { "properties": { "grant_type": { @@ -11717,20 +12710,263 @@ "type": "null" } ], - "title": "Date" + "title": "Date" + }, + "info": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Info" + }, + "published": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Published" + }, + "analysis": { + "anyOf": [ + { + "$ref": "#/components/schemas/AnalysisLevel" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "distribution": { + "anyOf": [ + { + "$ref": "#/components/schemas/DistributionLevel" + }, + { + "type": "null" + } + ] + }, + "sharing_group_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sharing Group Id" + }, + "proposal_email_lock": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Proposal Email Lock" + }, + "locked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Locked" + }, + "threat_level": { + "anyOf": [ + { + "$ref": "#/components/schemas/ThreatLevel" + }, + { + "type": "null" + } + ] + }, + "publish_timestamp": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Publish Timestamp" + }, + "sighting_timestamp": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sighting Timestamp" + }, + "disable_correlation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Disable Correlation" + }, + "extends_uuid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Extends Uuid" + }, + "protected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Protected" + } + }, + "type": "object", + "title": "EventUpdate" + }, + "Feed": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "provider": { + "type": "string", + "title": "Provider" + }, + "url": { + "type": "string", + "title": "Url" + }, + "rules": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Rules" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled", + "default": false + }, + "distribution": { + "anyOf": [ + { + "$ref": "#/components/schemas/DistributionLevel" + }, + { + "type": "null" + } + ] + }, + "sharing_group_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sharing Group Id" + }, + "tag_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Tag Id" + }, + "default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Default", + "default": false }, - "info": { + "source_format": { + "type": "string", + "title": "Source Format" + }, + "fixed_event": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "null" } ], - "title": "Info" + "title": "Fixed Event", + "default": false }, - "published": { + "delta_merge": { "anyOf": [ { "type": "boolean" @@ -11739,51 +12975,62 @@ "type": "null" } ], - "title": "Published" + "title": "Delta Merge", + "default": false }, - "analysis": { + "event_uuid": { "anyOf": [ { - "$ref": "#/components/schemas/AnalysisLevel" + "type": "string", + "format": "uuid" }, { "type": "null" } - ] + ], + "title": "Event Uuid" }, - "timestamp": { + "publish": { "anyOf": [ { - "type": "integer" + "type": "boolean" }, { "type": "null" } ], - "title": "Timestamp" + "title": "Publish", + "default": false }, - "distribution": { + "override_ids": { "anyOf": [ { - "$ref": "#/components/schemas/DistributionLevel" + "type": "boolean" }, { "type": "null" } - ] + ], + "title": "Override Ids", + "default": false }, - "sharing_group_id": { + "settings": { "anyOf": [ { - "type": "integer" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Sharing Group Id" + "title": "Settings" }, - "proposal_email_lock": { + "input_source": { + "type": "string", + "title": "Input Source" + }, + "delete_local_file": { "anyOf": [ { "type": "boolean" @@ -11792,9 +13039,10 @@ "type": "null" } ], - "title": "Proposal Email Lock" + "title": "Delete Local File", + "default": false }, - "locked": { + "lookup_visible": { "anyOf": [ { "type": "boolean" @@ -11803,30 +13051,46 @@ "type": "null" } ], - "title": "Locked" + "title": "Lookup Visible", + "default": false }, - "threat_level": { + "headers": { "anyOf": [ { - "$ref": "#/components/schemas/ThreatLevel" + "additionalProperties": true, + "type": "object" }, { "type": "null" } - ] + ], + "title": "Headers" }, - "publish_timestamp": { + "caching_enabled": { "anyOf": [ { - "type": "integer" + "type": "boolean" }, { "type": "null" } ], - "title": "Publish Timestamp" + "title": "Caching Enabled", + "default": false }, - "sighting_timestamp": { + "force_to_ids": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Force To Ids", + "default": false + }, + "orgc_id": { "anyOf": [ { "type": "integer" @@ -11835,47 +13099,58 @@ "type": "null" } ], - "title": "Sighting Timestamp" + "title": "Orgc Id" }, - "disable_correlation": { + "tag_collection_id": { "anyOf": [ { - "type": "boolean" + "type": "integer" }, { "type": "null" } ], - "title": "Disable Correlation" + "title": "Tag Collection Id" }, - "extends_uuid": { + "cached_elements": { "anyOf": [ { - "type": "string", - "format": "uuid" + "type": "integer" }, { "type": "null" } ], - "title": "Extends Uuid" + "title": "Cached Elements" }, - "protected": { + "coverage_by_other_feeds": { "anyOf": [ { - "type": "boolean" + "type": "number" }, { "type": "null" } ], - "title": "Protected" + "title": "Coverage By Other Feeds" + }, + "id": { + "type": "integer", + "title": "Id" } }, "type": "object", - "title": "EventUpdate" + "required": [ + "name", + "provider", + "url", + "source_format", + "input_source", + "id" + ], + "title": "Feed" }, - "Feed": { + "FeedCreate": { "properties": { "name": { "type": "string", @@ -12140,10 +13415,6 @@ } ], "title": "Coverage By Other Feeds" - }, - "id": { - "type": "integer", - "title": "Id" } }, "type": "object", @@ -12152,98 +13423,202 @@ "provider", "url", "source_format", - "input_source", - "id" + "input_source" ], - "title": "Feed" + "title": "FeedCreate" }, - "FeedCreate": { + "FeedUpdate": { "properties": { "name": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Name" }, "provider": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Provider" }, "url": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Url" }, - "rules": { + "rules": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Rules" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "distribution": { + "anyOf": [ + { + "$ref": "#/components/schemas/DistributionLevel" + }, + { + "type": "null" + } + ] + }, + "sharing_group_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sharing Group Id" + }, + "tag_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Tag Id" + }, + "default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Default" + }, + "source_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Format" + }, + "fixed_event": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Fixed Event" + }, + "delta_merge": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "boolean" }, { "type": "null" } ], - "title": "Rules" + "title": "Delta Merge" }, - "enabled": { + "event_uuid": { "anyOf": [ { - "type": "boolean" + "type": "string", + "format": "uuid" }, { "type": "null" } ], - "title": "Enabled", - "default": false + "title": "Event Uuid" }, - "distribution": { + "publish": { "anyOf": [ { - "$ref": "#/components/schemas/DistributionLevel" + "type": "boolean" }, { "type": "null" } - ] + ], + "title": "Publish" }, - "sharing_group_id": { + "override_ids": { "anyOf": [ { - "type": "integer" + "type": "boolean" }, { "type": "null" } ], - "title": "Sharing Group Id" + "title": "Override Ids" }, - "tag_id": { + "settings": { "anyOf": [ { - "type": "integer" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Tag Id" + "title": "Settings" }, - "default": { + "input_source": { "anyOf": [ { - "type": "boolean" + "type": "string" }, { "type": "null" } ], - "title": "Default", - "default": false - }, - "source_format": { - "type": "string", - "title": "Source Format" + "title": "Input Source" }, - "fixed_event": { + "delete_local_file": { "anyOf": [ { "type": "boolean" @@ -12252,10 +13627,9 @@ "type": "null" } ], - "title": "Fixed Event", - "default": false + "title": "Delete Local File" }, - "delta_merge": { + "lookup_visible": { "anyOf": [ { "type": "boolean" @@ -12264,22 +13638,21 @@ "type": "null" } ], - "title": "Delta Merge", - "default": false + "title": "Lookup Visible" }, - "event_uuid": { + "headers": { "anyOf": [ { - "type": "string", - "format": "uuid" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Event Uuid" + "title": "Headers" }, - "publish": { + "caching_enabled": { "anyOf": [ { "type": "boolean" @@ -12288,10 +13661,9 @@ "type": "null" } ], - "title": "Publish", - "default": false + "title": "Caching Enabled" }, - "override_ids": { + "force_to_ids": { "anyOf": [ { "type": "boolean" @@ -12300,50 +13672,103 @@ "type": "null" } ], - "title": "Override Ids", - "default": false + "title": "Force To Ids" }, - "settings": { + "orgc_id": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "integer" }, { "type": "null" } ], - "title": "Settings" + "title": "Orgc Id" }, - "input_source": { - "type": "string", - "title": "Input Source" + "tag_collection_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Tag Collection Id" }, - "delete_local_file": { + "cached_elements": { "anyOf": [ { - "type": "boolean" + "type": "integer" }, { "type": "null" } ], - "title": "Delete Local File", - "default": false + "title": "Cached Elements" }, - "lookup_visible": { + "coverage_by_other_feeds": { "anyOf": [ { - "type": "boolean" + "type": "number" }, { "type": "null" } ], - "title": "Lookup Visible", - "default": false + "title": "Coverage By Other Feeds" + } + }, + "type": "object", + "title": "FeedUpdate" + }, + "Galaxy": { + "properties": { + "uuid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Uuid" }, - "headers": { + "name": { + "type": "string", + "title": "Name" + }, + "type": { + "type": "string", + "title": "Type" + }, + "description": { + "type": "string", + "title": "Description" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "icon": { + "type": "string", + "title": "Icon" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "local_only": { + "type": "boolean", + "title": "Local Only" + }, + "kill_chain_order": { "anyOf": [ { "additionalProperties": true, @@ -12353,33 +13778,124 @@ "type": "null" } ], - "title": "Headers" + "title": "Kill Chain Order", + "default": {} }, - "caching_enabled": { + "default": { + "type": "boolean", + "title": "Default" + }, + "org_id": { + "type": "integer", + "title": "Org Id" + }, + "orgc_id": { + "type": "integer", + "title": "Orgc Id" + }, + "created": { + "type": "string", + "format": "date-time", + "title": "Created" + }, + "modified": { + "type": "string", + "format": "date-time", + "title": "Modified" + }, + "distribution": { + "$ref": "#/components/schemas/DistributionLevel" + }, + "id": { + "type": "integer", + "title": "Id" + }, + "clusters": { + "items": { + "$ref": "#/components/schemas/GalaxyCluster" + }, + "type": "array", + "title": "Clusters", + "default": [] + } + }, + "type": "object", + "required": [ + "name", + "type", + "description", + "version", + "icon", + "namespace", + "enabled", + "local_only", + "default", + "org_id", + "orgc_id", + "created", + "modified", + "distribution", + "id" + ], + "title": "Galaxy" + }, + "GalaxyCluster": { + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "title": "Uuid" + }, + "collection_uuid": { "anyOf": [ { - "type": "boolean" + "type": "string", + "format": "uuid" }, { "type": "null" } ], - "title": "Caching Enabled", - "default": false + "title": "Collection Uuid" + }, + "type": { + "type": "string", + "title": "Type" + }, + "value": { + "type": "string", + "title": "Value" + }, + "tag_name": { + "type": "string", + "title": "Tag Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "galaxy_id": { + "type": "integer", + "title": "Galaxy Id" }, - "force_to_ids": { + "source": { + "type": "string", + "title": "Source" + }, + "authors": { "anyOf": [ { - "type": "boolean" + "items": {}, + "type": "array" }, { "type": "null" } ], - "title": "Force To Ids", - "default": false + "title": "Authors", + "default": [] }, - "orgc_id": { + "version": { "anyOf": [ { "type": "integer" @@ -12388,9 +13904,12 @@ "type": "null" } ], - "title": "Orgc Id" + "title": "Version" }, - "tag_collection_id": { + "distribution": { + "$ref": "#/components/schemas/DistributionLevel" + }, + "sharing_group_id": { "anyOf": [ { "type": "integer" @@ -12399,108 +13918,133 @@ "type": "null" } ], - "title": "Tag Collection Id" + "title": "Sharing Group Id" }, - "cached_elements": { + "org_id": { + "type": "integer", + "title": "Org Id" + }, + "orgc_id": { + "type": "integer", + "title": "Orgc Id" + }, + "extends_uuid": { "anyOf": [ { - "type": "integer" + "type": "string", + "format": "uuid" }, { "type": "null" } ], - "title": "Cached Elements" + "title": "Extends Uuid" }, - "coverage_by_other_feeds": { + "extends_version": { "anyOf": [ { - "type": "number" + "type": "integer" }, { "type": "null" } ], - "title": "Coverage By Other Feeds" + "title": "Extends Version" + }, + "published": { + "type": "boolean", + "title": "Published" + }, + "deleted": { + "type": "boolean", + "title": "Deleted" + }, + "id": { + "type": "integer", + "title": "Id" + }, + "relations": { + "items": { + "$ref": "#/components/schemas/GalaxyClusterRelation" + }, + "type": "array", + "title": "Relations", + "default": [] + }, + "elements": { + "items": { + "$ref": "#/components/schemas/GalaxyElement" + }, + "type": "array", + "title": "Elements", + "default": [] } }, "type": "object", "required": [ - "name", - "provider", - "url", - "source_format", - "input_source" + "uuid", + "type", + "value", + "tag_name", + "description", + "galaxy_id", + "source", + "distribution", + "org_id", + "orgc_id", + "published", + "deleted", + "id" ], - "title": "FeedCreate" + "title": "GalaxyCluster" }, - "FeedUpdate": { + "GalaxyClusterRelation": { "properties": { - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" + "galaxy_cluster_id": { + "type": "integer", + "title": "Galaxy Cluster Id" }, - "provider": { + "referenced_galaxy_cluster_id": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Provider" + "title": "Referenced Galaxy Cluster Id" }, - "url": { + "referenced_galaxy_cluster_uuid": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" } ], - "title": "Url" + "title": "Referenced Galaxy Cluster Uuid" }, - "rules": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Rules" + "referenced_galaxy_cluster_type": { + "type": "string", + "title": "Referenced Galaxy Cluster Type" }, - "enabled": { + "galaxy_cluster_uuid": { "anyOf": [ { - "type": "boolean" + "type": "string", + "format": "uuid" }, { "type": "null" } ], - "title": "Enabled" + "title": "Galaxy Cluster Uuid" }, "distribution": { - "anyOf": [ - { - "$ref": "#/components/schemas/DistributionLevel" - }, - { - "type": "null" - } - ] + "$ref": "#/components/schemas/DistributionLevel" }, "sharing_group_id": { "anyOf": [ @@ -12513,74 +14057,82 @@ ], "title": "Sharing Group Id" }, - "tag_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Tag Id" - }, "default": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], + "type": "boolean", "title": "Default" }, - "source_format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source Format" + "id": { + "type": "integer", + "title": "Id" }, - "fixed_event": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Fixed Event" + "tags": { + "items": { + "$ref": "#/components/schemas/GalaxyClusterRelationTag" + }, + "type": "array", + "title": "Tags", + "default": [] + } + }, + "type": "object", + "required": [ + "galaxy_cluster_id", + "referenced_galaxy_cluster_type", + "distribution", + "default", + "id" + ], + "title": "GalaxyClusterRelation" + }, + "GalaxyClusterRelationTag": { + "properties": { + "galaxy_cluster_relation_id": { + "type": "integer", + "title": "Galaxy Cluster Relation Id" }, - "delta_merge": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Delta Merge" + "tag_id": { + "type": "integer", + "title": "Tag Id" + } + }, + "type": "object", + "required": [ + "galaxy_cluster_relation_id", + "tag_id" + ], + "title": "GalaxyClusterRelationTag" + }, + "GalaxyElement": { + "properties": { + "key": { + "type": "string", + "title": "Key" }, - "event_uuid": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Event Uuid" + "value": { + "type": "string", + "title": "Value" }, - "publish": { + "galaxy_cluster_id": { + "type": "integer", + "title": "Galaxy Cluster Id" + }, + "id": { + "type": "integer", + "title": "Id" + } + }, + "type": "object", + "required": [ + "key", + "value", + "galaxy_cluster_id", + "id" + ], + "title": "GalaxyElement" + }, + "GalaxyUpdate": { + "properties": { + "default": { "anyOf": [ { "type": "boolean" @@ -12589,9 +14141,9 @@ "type": "null" } ], - "title": "Publish" + "title": "Default" }, - "override_ids": { + "enabled": { "anyOf": [ { "type": "boolean" @@ -12600,21 +14152,43 @@ "type": "null" } ], - "title": "Override Ids" + "title": "Enabled" }, - "settings": { + "local_only": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "boolean" }, { "type": "null" } ], - "title": "Settings" + "title": "Local Only" + } + }, + "type": "object", + "title": "GalaxyUpdate" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "Hunt": { + "properties": { + "name": { + "type": "string", + "title": "Name" }, - "input_source": { + "description": { "anyOf": [ { "type": "string" @@ -12623,87 +14197,71 @@ "type": "null" } ], - "title": "Input Source" + "title": "Description" }, - "delete_local_file": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Delete Local File" + "query": { + "type": "string", + "title": "Query" }, - "lookup_visible": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "hunt_type": { + "type": "string", + "enum": [ + "opensearch", + "rulezet", + "cpe", + "mitre-attack-pattern" ], - "title": "Lookup Visible" + "title": "Hunt Type", + "default": "opensearch" }, - "headers": { + "index_target": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "string", + "enum": [ + "attributes", + "events", + "correlations", + "attributes_and_events" + ] }, { "type": "null" } ], - "title": "Headers" + "title": "Index Target", + "default": "attributes" }, - "caching_enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "status": { + "type": "string", + "enum": [ + "active", + "paused" ], - "title": "Caching Enabled" + "title": "Status", + "default": "active" }, - "force_to_ids": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Force To Ids" + "id": { + "type": "integer", + "title": "Id" }, - "orgc_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Orgc Id" + "user_id": { + "type": "integer", + "title": "User Id" }, - "tag_collection_id": { + "last_run_at": { "anyOf": [ { - "type": "integer" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Tag Collection Id" + "title": "Last Run At" }, - "cached_elements": { + "last_match_count": { "anyOf": [ { "type": "integer" @@ -12712,348 +14270,354 @@ "type": "null" } ], - "title": "Cached Elements" + "title": "Last Match Count" }, - "coverage_by_other_feeds": { + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { "anyOf": [ { - "type": "number" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Coverage By Other Feeds" + "title": "Updated At" } }, "type": "object", - "title": "FeedUpdate" + "required": [ + "name", + "query", + "id", + "user_id", + "created_at" + ], + "title": "Hunt" }, - "Galaxy": { + "HuntCreate": { "properties": { - "uuid": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Uuid" - }, "name": { "type": "string", "title": "Name" }, - "type": { - "type": "string", - "title": "Type" - }, "description": { - "type": "string", - "title": "Description" - }, - "version": { - "type": "integer", - "title": "Version" - }, - "icon": { - "type": "string", - "title": "Icon" - }, - "namespace": { - "type": "string", - "title": "Namespace" - }, - "enabled": { - "type": "boolean", - "title": "Enabled" - }, - "local_only": { - "type": "boolean", - "title": "Local Only" - }, - "kill_chain_order": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "string" }, { "type": "null" } ], - "title": "Kill Chain Order", - "default": {} + "title": "Description" }, - "default": { - "type": "boolean", - "title": "Default" + "query": { + "type": "string", + "title": "Query" }, - "org_id": { - "type": "integer", - "title": "Org Id" + "hunt_type": { + "type": "string", + "enum": [ + "opensearch", + "rulezet", + "cpe", + "mitre-attack-pattern" + ], + "title": "Hunt Type", + "default": "opensearch" }, - "orgc_id": { - "type": "integer", - "title": "Orgc Id" + "index_target": { + "anyOf": [ + { + "type": "string", + "enum": [ + "attributes", + "events", + "correlations", + "attributes_and_events" + ] + }, + { + "type": "null" + } + ], + "title": "Index Target", + "default": "attributes" }, - "created": { + "status": { "type": "string", - "format": "date-time", - "title": "Created" + "enum": [ + "active", + "paused" + ], + "title": "Status", + "default": "active" + } + }, + "type": "object", + "required": [ + "name", + "query" + ], + "title": "HuntCreate" + }, + "HuntResults": { + "properties": { + "total": { + "type": "integer", + "title": "Total" }, - "modified": { + "hits": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Hits" + } + }, + "type": "object", + "required": [ + "total", + "hits" + ], + "title": "HuntResults" + }, + "HuntRunHistoryEntry": { + "properties": { + "run_at": { "type": "string", "format": "date-time", - "title": "Modified" + "title": "Run At" }, - "distribution": { - "$ref": "#/components/schemas/DistributionLevel" + "match_count": { + "type": "integer", + "title": "Match Count" + } + }, + "type": "object", + "required": [ + "run_at", + "match_count" + ], + "title": "HuntRunHistoryEntry" + }, + "HuntRunResult": { + "properties": { + "hunt": { + "$ref": "#/components/schemas/Hunt" }, - "id": { + "total": { "type": "integer", - "title": "Id" + "title": "Total" }, - "clusters": { + "hits": { "items": { - "$ref": "#/components/schemas/GalaxyCluster" + "additionalProperties": true, + "type": "object" }, "type": "array", - "title": "Clusters", - "default": [] + "title": "Hits" } }, "type": "object", "required": [ - "name", - "type", - "description", - "version", - "icon", - "namespace", - "enabled", - "local_only", - "default", - "org_id", - "orgc_id", - "created", - "modified", - "distribution", - "id" + "hunt", + "total", + "hits" ], - "title": "Galaxy" + "title": "HuntRunResult" }, - "GalaxyCluster": { + "HuntUpdate": { "properties": { - "uuid": { - "type": "string", - "format": "uuid", - "title": "Uuid" - }, - "collection_uuid": { + "name": { "anyOf": [ { - "type": "string", - "format": "uuid" + "type": "string" }, { "type": "null" } ], - "title": "Collection Uuid" - }, - "type": { - "type": "string", - "title": "Type" - }, - "value": { - "type": "string", - "title": "Value" - }, - "tag_name": { - "type": "string", - "title": "Tag Name" + "title": "Name" }, "description": { - "type": "string", - "title": "Description" - }, - "galaxy_id": { - "type": "integer", - "title": "Galaxy Id" - }, - "source": { - "type": "string", - "title": "Source" - }, - "authors": { "anyOf": [ { - "items": {}, - "type": "array" + "type": "string" }, { "type": "null" } ], - "title": "Authors", - "default": [] + "title": "Description" }, - "version": { + "query": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Version" - }, - "distribution": { - "$ref": "#/components/schemas/DistributionLevel" + "title": "Query" }, - "sharing_group_id": { + "hunt_type": { "anyOf": [ { - "type": "integer" + "type": "string", + "enum": [ + "opensearch", + "rulezet", + "cpe", + "mitre-attack-pattern" + ] }, { "type": "null" } ], - "title": "Sharing Group Id" - }, - "org_id": { - "type": "integer", - "title": "Org Id" - }, - "orgc_id": { - "type": "integer", - "title": "Orgc Id" + "title": "Hunt Type" }, - "extends_uuid": { + "index_target": { "anyOf": [ { "type": "string", - "format": "uuid" + "enum": [ + "attributes", + "events", + "correlations", + "attributes_and_events" + ] }, { "type": "null" } ], - "title": "Extends Uuid" + "title": "Index Target" }, - "extends_version": { + "status": { "anyOf": [ { - "type": "integer" + "type": "string", + "enum": [ + "active", + "paused" + ] }, { "type": "null" } ], - "title": "Extends Version" - }, - "published": { - "type": "boolean", - "title": "Published" - }, - "deleted": { - "type": "boolean", - "title": "Deleted" - }, - "id": { - "type": "integer", - "title": "Id" - }, - "relations": { - "items": { - "$ref": "#/components/schemas/GalaxyClusterRelation" - }, - "type": "array", - "title": "Relations", - "default": [] - }, - "elements": { - "items": { - "$ref": "#/components/schemas/GalaxyElement" - }, - "type": "array", - "title": "Elements", - "default": [] + "title": "Status" } }, "type": "object", - "required": [ - "uuid", - "type", - "value", - "tag_name", - "description", - "galaxy_id", - "source", - "distribution", - "org_id", - "orgc_id", - "published", - "deleted", - "id" + "title": "HuntUpdate" + }, + "LabExecuteRequest": { + "properties": { + "cell_id": { + "type": "string", + "title": "Cell Id" + }, + "source": { + "type": "string", + "title": "Source" + }, + "timeout_seconds": { + "type": "integer", + "title": "Timeout Seconds", + "default": 60 + } + }, + "type": "object", + "required": [ + "cell_id", + "source" ], - "title": "GalaxyCluster" + "title": "LabExecuteRequest" }, - "GalaxyClusterRelation": { + "LabExecution": { "properties": { - "galaxy_cluster_id": { + "id": { "type": "integer", - "title": "Galaxy Cluster Id" + "title": "Id" }, - "referenced_galaxy_cluster_id": { + "notebook_id": { + "type": "integer", + "title": "Notebook Id" + }, + "user_id": { + "type": "integer", + "title": "User Id" + }, + "cell_id": { + "type": "string", + "title": "Cell Id" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "success", + "error", + "interrupted" + ], + "title": "Status" + }, + "started_at": { "anyOf": [ { - "type": "integer" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Referenced Galaxy Cluster Id" + "title": "Started At" }, - "referenced_galaxy_cluster_uuid": { + "finished_at": { "anyOf": [ { "type": "string", - "format": "uuid" + "format": "date-time" }, { "type": "null" } ], - "title": "Referenced Galaxy Cluster Uuid" - }, - "referenced_galaxy_cluster_type": { - "type": "string", - "title": "Referenced Galaxy Cluster Type" + "title": "Finished At" }, - "galaxy_cluster_uuid": { + "error": { "anyOf": [ { - "type": "string", - "format": "uuid" + "type": "string" }, { "type": "null" } ], - "title": "Galaxy Cluster Uuid" + "title": "Error" }, - "distribution": { - "$ref": "#/components/schemas/DistributionLevel" + "outputs": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Outputs" }, - "sharing_group_id": { + "execution_count": { "anyOf": [ { "type": "integer" @@ -13062,140 +14626,135 @@ "type": "null" } ], - "title": "Sharing Group Id" - }, - "default": { - "type": "boolean", - "title": "Default" - }, - "id": { - "type": "integer", - "title": "Id" + "title": "Execution Count" }, - "tags": { - "items": { - "$ref": "#/components/schemas/GalaxyClusterRelationTag" - }, - "type": "array", - "title": "Tags", - "default": [] - } - }, - "type": "object", - "required": [ - "galaxy_cluster_id", - "referenced_galaxy_cluster_type", - "distribution", - "default", - "id" - ], - "title": "GalaxyClusterRelation" - }, - "GalaxyClusterRelationTag": { - "properties": { - "galaxy_cluster_relation_id": { - "type": "integer", - "title": "Galaxy Cluster Relation Id" + "celery_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Celery Task Id" }, - "tag_id": { - "type": "integer", - "title": "Tag Id" + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" } }, "type": "object", "required": [ - "galaxy_cluster_relation_id", - "tag_id" + "id", + "notebook_id", + "user_id", + "cell_id", + "status", + "created_at" ], - "title": "GalaxyClusterRelationTag" + "title": "LabExecution" }, - "GalaxyElement": { + "LabFolder": { "properties": { - "key": { + "name": { "type": "string", - "title": "Key" + "title": "Name" }, - "value": { + "visibility": { "type": "string", - "title": "Value" - }, - "galaxy_cluster_id": { - "type": "integer", - "title": "Galaxy Cluster Id" + "enum": [ + "personal", + "global", + "library" + ], + "title": "Visibility" }, - "id": { - "type": "integer", - "title": "Id" - } - }, - "type": "object", - "required": [ - "key", - "value", - "galaxy_cluster_id", - "id" - ], - "title": "GalaxyElement" - }, - "GalaxyUpdate": { - "properties": { - "default": { + "parent_id": { "anyOf": [ { - "type": "boolean" + "type": "integer" }, { "type": "null" } ], - "title": "Default" + "title": "Parent Id" }, - "enabled": { + "id": { + "type": "integer", + "title": "Id" + }, + "user_id": { + "type": "integer", + "title": "User Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { "anyOf": [ { - "type": "boolean" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Enabled" + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "name", + "visibility", + "id", + "user_id", + "created_at" + ], + "title": "LabFolder" + }, + "LabFolderCreate": { + "properties": { + "name": { + "type": "string", + "title": "Name" }, - "local_only": { + "visibility": { + "type": "string", + "enum": [ + "personal", + "global", + "library" + ], + "title": "Visibility" + }, + "parent_id": { "anyOf": [ { - "type": "boolean" + "type": "integer" }, { "type": "null" } ], - "title": "Local Only" - } - }, - "type": "object", - "title": "GalaxyUpdate" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" + "title": "Parent Id" } }, "type": "object", - "title": "HTTPValidationError" + "required": [ + "name", + "visibility" + ], + "title": "LabFolderCreate" }, - "Hunt": { + "LabFolderUpdate": { "properties": { "name": { - "type": "string", - "title": "Name" - }, - "description": { "anyOf": [ { "type": "string" @@ -13204,49 +14763,59 @@ "type": "null" } ], - "title": "Description" - }, - "query": { - "type": "string", - "title": "Query" - }, - "hunt_type": { - "type": "string", - "enum": [ - "opensearch", - "rulezet", - "cpe", - "mitre-attack-pattern" - ], - "title": "Hunt Type", - "default": "opensearch" + "title": "Name" }, - "index_target": { + "parent_id": { "anyOf": [ { - "type": "string", - "enum": [ - "attributes", - "events", - "correlations", - "attributes_and_events" - ] + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + } + }, + "type": "object", + "title": "LabFolderUpdate" + }, + "LabNotebook": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" }, { "type": "null" } ], - "title": "Index Target", - "default": "attributes" + "title": "Description" }, - "status": { + "visibility": { "type": "string", "enum": [ - "active", - "paused" + "personal", + "global", + "library" ], - "title": "Status", - "default": "active" + "title": "Visibility" + }, + "folder_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Folder Id" }, "id": { "type": "integer", @@ -13256,28 +14825,33 @@ "type": "integer", "title": "User Id" }, - "last_run_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" + "source": { + "type": "string", + "title": "Source", + "default": "" + }, + "cell_outputs": { + "additionalProperties": { + "items": { + "additionalProperties": true, + "type": "object" }, - { - "type": "null" - } - ], - "title": "Last Run At" + "type": "array" + }, + "type": "object", + "title": "Cell Outputs" }, - "last_match_count": { + "last_executed_at": { "anyOf": [ { - "type": "integer" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Last Match Count" + "title": "Last Executed At" }, "created_at": { "type": "string", @@ -13300,14 +14874,14 @@ "type": "object", "required": [ "name", - "query", + "visibility", "id", "user_id", "created_at" ], - "title": "Hunt" + "title": "LabNotebook" }, - "HuntCreate": { + "LabNotebookCreate": { "properties": { "name": { "type": "string", @@ -13324,148 +14898,128 @@ ], "title": "Description" }, - "query": { - "type": "string", - "title": "Query" - }, - "hunt_type": { + "visibility": { "type": "string", "enum": [ - "opensearch", - "rulezet", - "cpe", - "mitre-attack-pattern" + "personal", + "global", + "library" ], - "title": "Hunt Type", - "default": "opensearch" + "title": "Visibility" }, - "index_target": { + "folder_id": { "anyOf": [ { - "type": "string", - "enum": [ - "attributes", - "events", - "correlations", - "attributes_and_events" - ] + "type": "integer" }, { "type": "null" } ], - "title": "Index Target", - "default": "attributes" + "title": "Folder Id" }, - "status": { + "source": { "type": "string", - "enum": [ - "active", - "paused" - ], - "title": "Status", - "default": "active" + "title": "Source", + "default": "" } }, "type": "object", "required": [ "name", - "query" + "visibility" ], - "title": "HuntCreate" + "title": "LabNotebookCreate" }, - "HuntResults": { + "LabNotebookSummary": { "properties": { - "total": { - "type": "integer", - "title": "Total" + "name": { + "type": "string", + "title": "Name" }, - "hits": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array", - "title": "Hits" - } - }, - "type": "object", - "required": [ - "total", - "hits" - ], - "title": "HuntResults" - }, - "HuntRunHistoryEntry": { - "properties": { - "run_at": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "visibility": { "type": "string", - "format": "date-time", - "title": "Run At" + "enum": [ + "personal", + "global", + "library" + ], + "title": "Visibility" }, - "match_count": { + "folder_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Folder Id" + }, + "id": { "type": "integer", - "title": "Match Count" - } - }, - "type": "object", - "required": [ - "run_at", - "match_count" - ], - "title": "HuntRunHistoryEntry" - }, - "HuntRunResult": { - "properties": { - "hunt": { - "$ref": "#/components/schemas/Hunt" + "title": "Id" }, - "total": { + "user_id": { "type": "integer", - "title": "Total" + "title": "User Id" }, - "hits": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array", - "title": "Hits" - } - }, - "type": "object", - "required": [ - "hunt", - "total", - "hits" - ], - "title": "HuntRunResult" - }, - "HuntUpdate": { - "properties": { - "name": { + "last_executed_at": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Name" + "title": "Last Executed At" }, - "description": { + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Description" - }, - "query": { + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "name", + "visibility", + "id", + "user_id", + "created_at" + ], + "title": "LabNotebookSummary", + "description": "Tree-view payload \u2014 omits source/cell_outputs to keep the tree fetch small." + }, + "LabNotebookUpdate": { + "properties": { + "name": { "anyOf": [ { "type": "string" @@ -13474,60 +15028,76 @@ "type": "null" } ], - "title": "Query" + "title": "Name" }, - "hunt_type": { + "description": { "anyOf": [ { - "type": "string", - "enum": [ - "opensearch", - "rulezet", - "cpe", - "mitre-attack-pattern" - ] + "type": "string" }, { "type": "null" } ], - "title": "Hunt Type" + "title": "Description" }, - "index_target": { + "folder_id": { "anyOf": [ { - "type": "string", - "enum": [ - "attributes", - "events", - "correlations", - "attributes_and_events" - ] + "type": "integer" }, { "type": "null" } ], - "title": "Index Target" + "title": "Folder Id" }, - "status": { + "source": { "anyOf": [ { - "type": "string", - "enum": [ - "active", - "paused" - ] + "type": "string" }, { "type": "null" } ], - "title": "Status" + "title": "Source" } }, "type": "object", - "title": "HuntUpdate" + "title": "LabNotebookUpdate" + }, + "LabTree": { + "properties": { + "folders": { + "items": { + "$ref": "#/components/schemas/LabFolder" + }, + "type": "array", + "title": "Folders" + }, + "notebooks": { + "items": { + "$ref": "#/components/schemas/LabNotebookSummary" + }, + "type": "array", + "title": "Notebooks" + }, + "pinned_notebook_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Pinned Notebook Ids" + } + }, + "type": "object", + "required": [ + "folders", + "notebooks" + ], + "title": "LabTree", + "description": "Single response that returns Personal + Global + Library trees so the\nleft panel renders in one round trip. Library notebooks are read-only\nprebuilt content (seeded via CLI); users fork them to a personal copy\nbefore running." }, "Module": { "properties": { diff --git a/docs/features/index.md b/docs/features/index.md index b6442a6c..38a2ba22 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -12,6 +12,7 @@ | [Batch Import](batch-import.md) | Easily import a list of indicators and add them as attributes to an event in a single operation. | | [Retention](retention.md) | Configurable event retention period with automatic purge of expired events | | [Reactor Scripts](tech-lab/reactor.md) | User-defined Python scripts that react to platform events (event/attribute/object/correlation/sighting) and run in an isolated sandbox | +| [Notebooks](tech-lab/notebooks.md) | Interactive analyst notebooks with a pre-imported SDK (`mwlab`) for ad-hoc exploration of events, attributes, correlations, and enrichments | | [OpenSearch](opensearch/index.md) | Full-text search, dashboards, and ingestion pipelines | | [REST API](api/index.md) | FastAPI backend with automatic OpenAPI documentation | | **Storage** | Garage (S3-compatible) or local filesystem for attachments | diff --git a/docs/features/tech-lab/notebooks.md b/docs/features/tech-lab/notebooks.md new file mode 100644 index 00000000..bace8861 --- /dev/null +++ b/docs/features/tech-lab/notebooks.md @@ -0,0 +1,251 @@ +# Notebooks + +Tech Lab Notebooks are interactive, jupyter-style analyst notebooks running inside misp-workbench. They give analysts a Python notebook surface with a pre-imported SDK (`mwctipy`, exposed as the bound instance `mwlab`) that provides typed access to events, attributes, objects, correlations, sightings, and enrichment modules — everything the Reactor `ctx` exposes for reads, plus search helpers tailored to interactive use. + +Where **Reactor Scripts** are *push-triggered* automation (something happens → a script fires), **Notebooks** are the *pull* side: an analyst sitting in front of the data and exploring it interactively. + + + + +!!! info "Where to find it" + The **tech-lab** → **notebooks** view in the UI shows a two-panel layout: a file tree on the left (folders + notebooks, scoped Personal / Global) and a Monaco editor on the right rendering the active notebook as one scrolling document with `# %%` cell delimiters and inline outputs. + +## Concepts + +### Notebook + +A notebook is a single document edited as plain text with `# %% [id=] code|markdown` cell delimiters. The full source lives in one TEXT column; cell boundaries are reconstructed on the fly. Outputs from the most recent execution are stored separately so they don't pollute the editable source. + +### Kernel + +Each `(user_id, notebook_id)` pair gets its own long-lived **ipykernel** subprocess managed by the `lab-worker` container. Variables, imports, and state persist across cell executions until the kernel is interrupted, restarted, or idle-evicted (default: 30 minutes of inactivity, tunable via `LAB_KERNEL_IDLE_SECONDS`). + +When the worker process restarts, all kernels are lost. The next cell execution starts a fresh kernel transparently. + +### Visibility & ownership + +Every folder and notebook carries a `visibility` of either `personal` or `global`: + +| Visibility | Who sees it | Who can edit/delete | Who can run | +|---|---|---|---| +| `personal` | Only the owner | Only the owner | Only the owner | +| `global` | Anyone with `lab:read` | Only the owner | Anyone with `lab:run` | + +Global notebooks are owner-edit only. Non-owners see a read-only editor with a **Fork to personal** action that duplicates the source + outputs into a new personal notebook owned by the current user. + +Folders are fixed to a single visibility at creation; notebooks inside must match. Personal and Global are sibling top-level groups — items don't move between scopes, you fork to copy across. + +### Isolation + +The kernel runs inside the dedicated `lab-worker` container with `mem_limit=512m` and `pids_limit=256`. Unlike Reactor's restricted `__builtins__`, notebooks deliberately use the **full Python builtins set** — analysts need `import pandas`, dict comprehensions, and the rest of normal Python. The container is the security boundary; the SDK exposes only read methods in MVP. + +### Audit log + +Every `mwlab.enrich(...)` call records an audit row under the notebook owner's identity with `actor_type=lab_notebook` and `actor_credential_id=`. This lets admins trace any third-party API call back to the notebook that triggered it. + +## The `mwlab` instance + +`mwlab` is pre-bound in every notebook kernel — no import needed. It is a `MwLab` instance scoped to the current `(user_id, notebook_id)`. + +### Single-record reads + +| Method | Returns | +|---|---| +| `mwlab.get_event(event_uuid)` | `dict \| None` — the event document | +| `mwlab.get_attribute(attribute_uuid)` | `dict \| None` | +| `mwlab.get_object(object_uuid)` | `dict \| None` | + +### Search + +| Method | Description | +|---|---| +| `mwlab.search_events(query=None, tags=None, size=50)` | OpenSearch query. `query` matches against event `info`; `tags` is an AND-list of tag names. Returns a list of dicts. | +| `mwlab.search_attributes(value=None, type=None, size=50)` | Exact-match on `type`, free-text match on `value`. | + +### Modules and enrichment + +| Method | Description | +|---|---| +| `mwlab.modules(enabled_only=True)` | List available [misp-modules](https://github.com/MISP/misp-modules) with their input/output types. | +| `mwlab.enrich(value, type, module, config=None)` | Run a module against one indicator. Audited under `actor_type=lab_notebook`. Returns the module's raw response dict. | + +### Convenience + +| Method | Description | +|---|---| +| `mwlab.dataframe(rows)` | Wrap a list of dicts in a `pandas.DataFrame`. | + +## The `render` module + +`render` is imported at kernel startup alongside `mwlab`. It returns raw HTML strings — wrap them in `IPython.display.HTML(...)` to render in a cell output. + +| Function | Description | +|---|---| +| `render.timeline(events)` | One line per event, sorted by `date` desc. | +| `render.tag_cloud(items)` | Tag frequency cloud sized by occurrence count. | + +For real charts, `import matplotlib` or `import altair` directly — they ship in the lab-worker image. + +## Cell format + +Notebooks are stored as a single text document with cell delimiters: + +```python +# %% [id=8b7e2c1a-...] code +ev = mwlab.search_events(tags=["tlp:white"], size=3) +mwlab.dataframe(ev) + +# %% [id=f0a91d4b-...] markdown +## Notes +This is rendered as Markdown. + +# %% [id=c2d3e4f5-...] code +from IPython.display import HTML +HTML(render.timeline(ev)) +``` + +Missing cell IDs are auto-generated on first save. Missing types default to `code`. The editor maintains stable IDs across saves so re-running a specific cell after edits works as expected. + +### Run gating + +While a cell is executing, the **Run** button is disabled and **Run all** is hidden. ipykernel serialises requests internally, but the UI gate keeps users from accidentally piling up queued cells. + +### Run all + +**Run all** parses the notebook source into ordered code cells and chains them server-side as a single Celery chain on the `lab_kernel` queue. The chain halts on the first error — failed cells don't cascade execution into subsequent ones. + +### Outputs + +Cell outputs are rendered in a Monaco view zone directly under the cell: + +| MIME type | Rendered as | +|---|---| +| `text/plain`, `stream` | `
    ` |
    +| `text/html` | sanitised `v-html` |
    +| `image/png`, `image/jpeg` | inline `` (base64) |
    +| `application/json` | pretty-printed |
    +| `error` | red traceback `
    ` |
    +
    +For the owner, outputs persist into `cell_outputs` on the notebook row and are restored on reload. For non-owners running a global notebook, outputs live only in the execution rows for that session — they are not written back to the shared notebook.
    +
    +## Forking
    +
    +Open any global notebook you don't own and click **Fork to personal**. A new notebook is created under your Personal tree with:
    +
    +- the same source + cell outputs as the original;
    +- regenerated cell IDs (so the original and fork can be open simultaneously without execution conflicts);
    +- name `" (fork)"`;
    +- `folder_id = NULL` (lands at the Personal root — move it after).
    +
    +## Import / export
    +
    +| Action | Format |
    +|---|---|
    +| **Export** | Downloads the notebook as a `nbformat`-compliant `.ipynb` so it opens in stock JupyterLab. |
    +| **Import** | Upload an `.ipynb`; the server normalises it into the delimiter-source shape and creates a personal notebook. |
    +
    +## Examples
    +
    +### Find recent IOCs by tag
    +
    +```python
    +events = mwlab.search_events(tags=["tlp:white", "type:OSINT"], size=20)
    +mwlab.dataframe(events)
    +```
    +
    +
    +
    +
    +### Geolocate an IP
    +
    +```python
    +result = mwlab.enrich("8.8.8.8", "ip-src", "mmdb_lookup")
    +for obj in (result.get("results") or {}).get("Object", []):
    +    for attr in obj.get("Attribute", []):
    +        print(obj.get("name"), attr.get("object_relation"), "=", attr.get("value"))
    +```
    +
    +
    +
    +### Build a timeline view
    +
    +```python
    +from IPython.display import HTML
    +
    +events = mwlab.search_events(query="phishing", size=10)
    +HTML(render.timeline(events))
    +```
    +
    +
    +
    +### Tag cloud across recent events
    +
    +```python
    +from IPython.display import HTML
    +
    +events = mwlab.search_events(size=100)
    +HTML(render.tag_cloud(events))
    +```
    +
    +
    +
    +### Pivot from an attribute to its event
    +
    +```python
    +hits = mwlab.search_attributes(value="example.com", type="domain")
    +for attr in hits:
    +    event = mwlab.get_event(attr["event_uuid"])
    +    print(attr["uuid"], "→", event["info"])
    +```
    +
    +
    +
    +## Installing extra Python packages
    +
    +The notebook kernel runs inside the `lab-worker` container, whose Python environment is fixed at image build time. The dependencies in the `[tool.poetry.group.lab]` group of `api/pyproject.toml` (`ipykernel`, `jupyter-client`, `pandas`, plus the in-repo `mwctipy` SDK) are what's available out of the box.
    +
    +To add another package, add it to the `lab` group and rebuild:
    +
    +```bash
    +# inside api/
    +poetry add --group lab altair
    +docker compose -f docker-compose.yml -f docker-compose.dev.yml \
    +  --env-file=.env.dev build lab-worker
    +docker compose -f docker-compose.yml -f docker-compose.dev.yml \
    +  --env-file=.env.dev up -d lab-worker
    +```
    +
    +In-notebook `%pip install` is technically possible (ipykernel supports it), but the install is wiped on the next kernel restart or idle eviction. Use it only for ad-hoc experiments.
    +
    +## Permissions
    +
    +| Scope | Allows |
    +|---|---|
    +| `lab:read` | List / view notebooks and folders, read execution history |
    +| `lab:create` | Create notebooks and folders, fork global notebooks |
    +| `lab:update` | Edit notebooks you own, rename / move folders you own |
    +| `lab:delete` | Delete notebooks and folders you own |
    +| `lab:run` | Execute cells (any reader of a notebook can run it; each viewer gets their own kernel) |
    +
    +Visibility and ownership are enforced row-by-row on top of these scopes — `lab:update` on a global notebook you didn't create still returns 403.
    +
    +## Limits
    +
    +| Bound | Default | Where set |
    +|---|---|---|
    +| Kernel idle timeout | 1800s (30 min) | `LAB_KERNEL_IDLE_SECONDS` env var on `lab-worker` |
    +| Cell execution time | 60s (configurable per execute call) | `LabExecuteRequest.timeout_seconds` |
    +| Worker memory | 512 MB | `lab-worker.mem_limit` in compose |
    +| Worker process count | 256 | `lab-worker.pids_limit` in compose |
    +| Kernel concurrency | 8 threads | `--concurrency=8` on the worker command |
    +
    +## Out of scope (today)
    +
    +These are intentional omissions from the MVP — none of them block the analyst workflow, and each warrants its own design pass.
    +
    +- **Write APIs** (`add_attribute`, `tag_event`, ...). Reactor scripts already cover authored writes; notebooks stay read-only until a `lab:write` scope is added.
    +- **Collaborative editing** of global notebooks. MVP is owner-only edit; others fork.
    +- **Per-viewer outputs** on global notebooks. Non-owner outputs live only in execution rows for the session.
    +- **Org-scoped sharing** (between Personal and Global).
    +- **Scheduled notebook runs** (papermill-style). Reactor covers trigger-driven automation.
    +- **In-cell debugger / profiler.** Reactor's `pyinstrument` + flame-graph could be wired here later.
    diff --git a/docs/index.md b/docs/index.md
    index a0fc963a..9ff907f0 100644
    --- a/docs/index.md
    +++ b/docs/index.md
    @@ -18,6 +18,7 @@ A modern MISP-compatible threat intelligence platform. It provides a self-contai
     | [Batch Import](features/batch-import.md) | Easily import a list of indicators and add them as attributes to an event in a single operation. |
     | [Retention](features/retention.md) | Configurable event retention period with automatic purge of expired events |
     | [Reactor Scripts](features/tech-lab/reactor.md) | User-defined Python scripts that react to platform events and run in an isolated sandbox |
    +| [Notebooks](features/tech-lab/notebooks.md) | Interactive analyst notebooks with a pre-imported SDK (`mwlab`) for ad-hoc exploration of events, attributes, correlations, and enrichments |
     | [OpenSearch](features/opensearch/index.md) | Full-text search, dashboards, and ingestion pipelines |
     | [REST API](features/api/index.md) | FastAPI backend with automatic OpenAPI documentation |
     | **Storage** | Garage (S3-compatible) or local filesystem for attachments |
    diff --git a/docs/screenshots/tech-lab/notebooks/misp-workbench-1_tech-lab_notebooks.png b/docs/screenshots/tech-lab/notebooks/misp-workbench-1_tech-lab_notebooks.png
    new file mode 100644
    index 00000000..ad6b2718
    Binary files /dev/null and b/docs/screenshots/tech-lab/notebooks/misp-workbench-1_tech-lab_notebooks.png differ
    diff --git a/docs/screenshots/tech-lab/notebooks/misp-workbench-2_tech-lab_notebooks_search.png b/docs/screenshots/tech-lab/notebooks/misp-workbench-2_tech-lab_notebooks_search.png
    new file mode 100644
    index 00000000..7db28ff6
    Binary files /dev/null and b/docs/screenshots/tech-lab/notebooks/misp-workbench-2_tech-lab_notebooks_search.png differ
    diff --git a/docs/screenshots/tech-lab/notebooks/misp-workbench-3_tech-lab_notebooks_geolocation.png b/docs/screenshots/tech-lab/notebooks/misp-workbench-3_tech-lab_notebooks_geolocation.png
    new file mode 100644
    index 00000000..f597fff2
    Binary files /dev/null and b/docs/screenshots/tech-lab/notebooks/misp-workbench-3_tech-lab_notebooks_geolocation.png differ
    diff --git a/docs/screenshots/tech-lab/notebooks/misp-workbench-4_tech-lab_notebooks_timeline.png b/docs/screenshots/tech-lab/notebooks/misp-workbench-4_tech-lab_notebooks_timeline.png
    new file mode 100644
    index 00000000..8bd040ab
    Binary files /dev/null and b/docs/screenshots/tech-lab/notebooks/misp-workbench-4_tech-lab_notebooks_timeline.png differ
    diff --git a/docs/screenshots/tech-lab/notebooks/misp-workbench-5_tech-lab_notebooks_tag_cloud.png b/docs/screenshots/tech-lab/notebooks/misp-workbench-5_tech-lab_notebooks_tag_cloud.png
    new file mode 100644
    index 00000000..694a8032
    Binary files /dev/null and b/docs/screenshots/tech-lab/notebooks/misp-workbench-5_tech-lab_notebooks_tag_cloud.png differ
    diff --git a/docs/screenshots/tech-lab/notebooks/misp-workbench-6_tech-lab_notebooks_pivot.png b/docs/screenshots/tech-lab/notebooks/misp-workbench-6_tech-lab_notebooks_pivot.png
    new file mode 100644
    index 00000000..ff33f731
    Binary files /dev/null and b/docs/screenshots/tech-lab/notebooks/misp-workbench-6_tech-lab_notebooks_pivot.png differ
    diff --git a/frontend/src/components/diagnostics/DiagnosticsIndex.vue b/frontend/src/components/diagnostics/DiagnosticsIndex.vue
    index 086fa7ec..bbdc8d99 100644
    --- a/frontend/src/components/diagnostics/DiagnosticsIndex.vue
    +++ b/frontend/src/components/diagnostics/DiagnosticsIndex.vue
    @@ -7,6 +7,7 @@ import RedisCard from "@/components/diagnostics/RedisCard.vue";
     import PostgresCard from "@/components/diagnostics/PostgresCard.vue";
     import StorageCard from "@/components/diagnostics/StorageCard.vue";
     import MispModulesCard from "@/components/diagnostics/MispModulesCard.vue";
    +import LabWorkerCard from "@/components/diagnostics/LabWorkerCard.vue";
     import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
     import { faRotate } from "@fortawesome/free-solid-svg-icons";
     
    @@ -26,6 +27,7 @@ function refresh() {
       diagnosticsStore.getPostgres();
       diagnosticsStore.getStorage();
       diagnosticsStore.getModules();
    +  diagnosticsStore.getLab();
       lastUpdated.value = new Date();
     }
     
    @@ -105,6 +107,7 @@ function formatTime(date) {
             
             
             
    +        
           
         
       
    diff --git a/frontend/src/components/diagnostics/LabWorkerCard.vue b/frontend/src/components/diagnostics/LabWorkerCard.vue
    new file mode 100644
    index 00000000..a06b46ac
    --- /dev/null
    +++ b/frontend/src/components/diagnostics/LabWorkerCard.vue
    @@ -0,0 +1,132 @@
    +
    +
    +
    diff --git a/frontend/src/components/menu/Menu.vue b/frontend/src/components/menu/Menu.vue
    index db778272..d8ea0ee7 100644
    --- a/frontend/src/components/menu/Menu.vue
    +++ b/frontend/src/components/menu/Menu.vue
    @@ -190,6 +190,13 @@ function navAndClose(path) {
                     >reactor scripts
                 
    +            
  • + notebooks +