diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..c62a33eb --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = control_plane/storage/migrations +prepend_sys_path = . +path_separator = os +timezone = UTC + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/control_plane/contracts/lane_summary.py b/control_plane/contracts/lane_summary.py new file mode 100644 index 00000000..0379c3d9 --- /dev/null +++ b/control_plane/contracts/lane_summary.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, ConfigDict + +from control_plane.contracts.backup_gate_record import BackupGateRecord +from control_plane.contracts.deployment_record import DeploymentRecord +from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord +from control_plane.contracts.dokploy_target_record import DokployTargetRecord +from control_plane.contracts.environment_inventory import EnvironmentInventory +from control_plane.contracts.odoo_instance_override_record import OdooInstanceOverrideRecord +from control_plane.contracts.promotion_record import PromotionRecord +from control_plane.contracts.release_tuple_record import ReleaseTupleRecord +from control_plane.contracts.runtime_environment_record import RuntimeEnvironmentRecord +from control_plane.contracts.secret_record import SecretBinding + + +class LaunchplaneLaneSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + context: str + instance: str + inventory: EnvironmentInventory | None = None + release_tuple: ReleaseTupleRecord | None = None + latest_deployment: DeploymentRecord | None = None + latest_promotion: PromotionRecord | None = None + latest_backup_gate: BackupGateRecord | None = None + dokploy_target_id: DokployTargetIdRecord | None = None + dokploy_target: DokployTargetRecord | None = None + runtime_environment_records: tuple[RuntimeEnvironmentRecord, ...] = () + odoo_instance_override: OdooInstanceOverrideRecord | None = None + secret_bindings: tuple[SecretBinding, ...] = () diff --git a/control_plane/contracts/preview_summary.py b/control_plane/contracts/preview_summary.py new file mode 100644 index 00000000..3c0be8d1 --- /dev/null +++ b/control_plane/contracts/preview_summary.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict, Field + +from control_plane.contracts.preview_generation_record import PreviewGenerationRecord +from control_plane.contracts.preview_record import PreviewRecord + + +class LaunchplanePreviewSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + preview: PreviewRecord + latest_generation: PreviewGenerationRecord | None = None + recent_generations: tuple[PreviewGenerationRecord, ...] = Field(default_factory=tuple) diff --git a/control_plane/storage/migrations/__init__.py b/control_plane/storage/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/control_plane/storage/migrations/env.py b/control_plane/storage/migrations/env.py new file mode 100644 index 00000000..d52877db --- /dev/null +++ b/control_plane/storage/migrations/env.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from logging.config import fileConfig +import os + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from control_plane.storage.postgres import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _database_url() -> str: + configured_url = config.get_main_option("sqlalchemy.url") + if configured_url: + return configured_url + environment_url = os.environ.get("LAUNCHPLANE_DATABASE_URL", "").strip() + if environment_url: + return environment_url + raise RuntimeError( + "Set sqlalchemy.url or LAUNCHPLANE_DATABASE_URL before running Launchplane migrations." + ) + + +def run_migrations_offline() -> None: + context.configure( + url=_database_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = _database_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/control_plane/storage/migrations/script.py.mako b/control_plane/storage/migrations/script.py.mako new file mode 100644 index 00000000..948ea71f --- /dev/null +++ b/control_plane/storage/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/control_plane/storage/migrations/versions/__init__.py b/control_plane/storage/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/control_plane/storage/migrations/versions/fe94a0486977_baseline_current_schema.py b/control_plane/storage/migrations/versions/fe94a0486977_baseline_current_schema.py new file mode 100644 index 00000000..b56abe8d --- /dev/null +++ b/control_plane/storage/migrations/versions/fe94a0486977_baseline_current_schema.py @@ -0,0 +1,442 @@ +"""baseline current schema + +Revision ID: fe94a0486977 +Revises: +Create Date: 2026-04-28 15:18:59.547980+00:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "fe94a0486977" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "launchplane_artifact_manifests", + sa.Column("artifact_id", sa.String(), nullable=False), + sa.Column("source_commit", sa.String(), nullable=False), + sa.Column("image_repository", sa.String(), nullable=False), + sa.Column("image_digest", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("artifact_id"), + ) + op.create_index( + "launchplane_artifact_manifests_artifact_idx", + "launchplane_artifact_manifests", + [sa.text("artifact_id DESC")], + ) + op.create_table( + "launchplane_backup_gates", + sa.Column("record_id", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("created_at", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("record_id"), + ) + op.create_index( + "launchplane_backup_gates_context_instance_idx", + "launchplane_backup_gates", + ["context", "instance", sa.text("created_at DESC")], + ) + op.create_table( + "launchplane_deployments", + sa.Column("record_id", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("artifact_id", sa.String(), nullable=False), + sa.Column("source_git_ref", sa.String(), nullable=False), + sa.Column("deploy_started_at", sa.String(), nullable=False), + sa.Column("deploy_finished_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("record_id"), + ) + op.create_index( + "launchplane_deployments_context_instance_idx", + "launchplane_deployments", + [ + "context", + "instance", + sa.text("deploy_finished_at DESC"), + sa.text("deploy_started_at DESC"), + ], + ) + op.create_table( + "launchplane_dokploy_target_ids", + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("target_id", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("context", "instance"), + ) + op.create_index( + "launchplane_dokploy_target_ids_updated_idx", + "launchplane_dokploy_target_ids", + [sa.text("updated_at DESC")], + ) + op.create_table( + "launchplane_dokploy_targets", + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("context", "instance"), + ) + op.create_index( + "launchplane_dokploy_targets_updated_idx", + "launchplane_dokploy_targets", + [sa.text("updated_at DESC")], + ) + op.create_table( + "launchplane_idempotency_records", + sa.Column("record_id", sa.String(), nullable=False), + sa.Column("scope", sa.String(), nullable=False), + sa.Column("route_path", sa.String(), nullable=False), + sa.Column("idempotency_key", sa.String(), nullable=False), + sa.Column("request_fingerprint", sa.String(), nullable=False), + sa.Column("response_status_code", sa.Integer(), nullable=False), + sa.Column("response_trace_id", sa.String(), nullable=False), + sa.Column("recorded_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("record_id"), + ) + op.create_index( + "launchplane_idempotency_scope_route_key_idx", + "launchplane_idempotency_records", + ["scope", "route_path", "idempotency_key"], + unique=True, + ) + op.create_table( + "launchplane_inventory", + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("artifact_id", sa.String(), nullable=False), + sa.Column("source_git_ref", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column("deployment_record_id", sa.String(), nullable=False), + sa.Column("promotion_record_id", sa.String(), nullable=False), + sa.Column("promoted_from_instance", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("context", "instance"), + ) + op.create_index( + "launchplane_inventory_updated_idx", "launchplane_inventory", [sa.text("updated_at DESC")] + ) + op.create_table( + "launchplane_odoo_instance_overrides", + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("context", "instance"), + ) + op.create_index( + "launchplane_odoo_instance_overrides_updated_idx", + "launchplane_odoo_instance_overrides", + [sa.text("updated_at DESC")], + ) + op.create_table( + "launchplane_preview_generations", + sa.Column("generation_id", sa.String(), nullable=False), + sa.Column("preview_id", sa.String(), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("state", sa.String(), nullable=False), + sa.Column("requested_at", sa.String(), nullable=False), + sa.Column("finished_at", sa.String(), nullable=False), + sa.Column("artifact_id", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("generation_id"), + ) + op.create_index( + "launchplane_preview_generations_preview_idx", + "launchplane_preview_generations", + ["preview_id", sa.text("sequence DESC"), sa.text("requested_at DESC")], + ) + op.create_table( + "launchplane_preview_records", + sa.Column("preview_id", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("anchor_repo", sa.String(), nullable=False), + sa.Column("anchor_pr_number", sa.Integer(), nullable=False), + sa.Column("state", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("preview_id"), + ) + op.create_index( + "launchplane_preview_records_lookup_idx", + "launchplane_preview_records", + ["context", "anchor_repo", "anchor_pr_number", sa.text("updated_at DESC")], + ) + op.create_table( + "launchplane_promotions", + sa.Column("record_id", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("from_instance", sa.String(), nullable=False), + sa.Column("to_instance", sa.String(), nullable=False), + sa.Column("artifact_id", sa.String(), nullable=False), + sa.Column("deploy_started_at", sa.String(), nullable=False), + sa.Column("deploy_finished_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("record_id"), + ) + op.create_index( + "launchplane_promotions_context_path_idx", + "launchplane_promotions", + [ + "context", + "from_instance", + "to_instance", + sa.text("deploy_finished_at DESC"), + sa.text("deploy_started_at DESC"), + ], + ) + op.create_table( + "launchplane_release_tuples", + sa.Column("context", sa.String(), nullable=False), + sa.Column("channel", sa.String(), nullable=False), + sa.Column("tuple_id", sa.String(), nullable=False), + sa.Column("artifact_id", sa.String(), nullable=False), + sa.Column("minted_at", sa.String(), nullable=False), + sa.Column("provenance", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("context", "channel"), + ) + op.create_index( + "launchplane_release_tuples_minted_idx", + "launchplane_release_tuples", + [sa.text("minted_at DESC")], + ) + op.create_table( + "launchplane_runtime_environments", + sa.Column("scope", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("scope", "context", "instance"), + ) + op.create_index( + "launchplane_runtime_environments_updated_idx", + "launchplane_runtime_environments", + [sa.text("updated_at DESC")], + ) + op.create_table( + "launchplane_secret_audit_events", + sa.Column("event_id", sa.String(), nullable=False), + sa.Column("secret_id", sa.String(), nullable=False), + sa.Column("event_type", sa.String(), nullable=False), + sa.Column("recorded_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("event_id"), + ) + op.create_index( + "launchplane_secret_audit_events_secret_idx", + "launchplane_secret_audit_events", + ["secret_id", sa.text("recorded_at DESC")], + ) + op.create_table( + "launchplane_secret_bindings", + sa.Column("binding_id", sa.String(), nullable=False), + sa.Column("secret_id", sa.String(), nullable=False), + sa.Column("integration", sa.String(), nullable=False), + sa.Column("binding_key", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("binding_id"), + ) + op.create_index( + "launchplane_secret_bindings_lookup_idx", + "launchplane_secret_bindings", + ["integration", "context", "instance", "binding_key", sa.text("updated_at DESC")], + ) + op.create_table( + "launchplane_secret_versions", + sa.Column("version_id", sa.String(), nullable=False), + sa.Column("secret_id", sa.String(), nullable=False), + sa.Column("created_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("version_id"), + ) + op.create_index( + "launchplane_secret_versions_secret_idx", + "launchplane_secret_versions", + ["secret_id", sa.text("created_at DESC")], + ) + op.create_table( + "launchplane_secrets", + sa.Column("secret_id", sa.String(), nullable=False), + sa.Column("scope", sa.String(), nullable=False), + sa.Column("integration", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("context", sa.String(), nullable=False), + sa.Column("instance", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("current_version_id", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column( + "payload", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("secret_id"), + ) + op.create_index( + "launchplane_secrets_lookup_idx", + "launchplane_secrets", + ["integration", "context", "instance", sa.text("updated_at DESC")], + ) + op.create_index( + "launchplane_secrets_scope_name_idx", + "launchplane_secrets", + ["scope", "integration", "name", "context", "instance"], + unique=True, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("launchplane_secrets_scope_name_idx", table_name="launchplane_secrets") + op.drop_index("launchplane_secrets_lookup_idx", table_name="launchplane_secrets") + op.drop_table("launchplane_secrets") + op.drop_index( + "launchplane_secret_versions_secret_idx", table_name="launchplane_secret_versions" + ) + op.drop_table("launchplane_secret_versions") + op.drop_index( + "launchplane_secret_bindings_lookup_idx", table_name="launchplane_secret_bindings" + ) + op.drop_table("launchplane_secret_bindings") + op.drop_index( + "launchplane_secret_audit_events_secret_idx", table_name="launchplane_secret_audit_events" + ) + op.drop_table("launchplane_secret_audit_events") + op.drop_index( + "launchplane_runtime_environments_updated_idx", + table_name="launchplane_runtime_environments", + ) + op.drop_table("launchplane_runtime_environments") + op.drop_index("launchplane_release_tuples_minted_idx", table_name="launchplane_release_tuples") + op.drop_table("launchplane_release_tuples") + op.drop_index("launchplane_promotions_context_path_idx", table_name="launchplane_promotions") + op.drop_table("launchplane_promotions") + op.drop_index( + "launchplane_preview_records_lookup_idx", table_name="launchplane_preview_records" + ) + op.drop_table("launchplane_preview_records") + op.drop_index( + "launchplane_preview_generations_preview_idx", table_name="launchplane_preview_generations" + ) + op.drop_table("launchplane_preview_generations") + op.drop_index( + "launchplane_odoo_instance_overrides_updated_idx", + table_name="launchplane_odoo_instance_overrides", + ) + op.drop_table("launchplane_odoo_instance_overrides") + op.drop_index("launchplane_inventory_updated_idx", table_name="launchplane_inventory") + op.drop_table("launchplane_inventory") + op.drop_index( + "launchplane_idempotency_scope_route_key_idx", table_name="launchplane_idempotency_records" + ) + op.drop_table("launchplane_idempotency_records") + op.drop_index( + "launchplane_dokploy_targets_updated_idx", table_name="launchplane_dokploy_targets" + ) + op.drop_table("launchplane_dokploy_targets") + op.drop_index( + "launchplane_dokploy_target_ids_updated_idx", table_name="launchplane_dokploy_target_ids" + ) + op.drop_table("launchplane_dokploy_target_ids") + op.drop_index( + "launchplane_deployments_context_instance_idx", table_name="launchplane_deployments" + ) + op.drop_table("launchplane_deployments") + op.drop_index( + "launchplane_backup_gates_context_instance_idx", table_name="launchplane_backup_gates" + ) + op.drop_table("launchplane_backup_gates") + op.drop_index( + "launchplane_artifact_manifests_artifact_idx", table_name="launchplane_artifact_manifests" + ) + op.drop_table("launchplane_artifact_manifests") + # ### end Alembic commands ### diff --git a/control_plane/storage/postgres.py b/control_plane/storage/postgres.py index 362bbc88..fab71fe8 100644 --- a/control_plane/storage/postgres.py +++ b/control_plane/storage/postgres.py @@ -16,9 +16,11 @@ from control_plane.contracts.environment_inventory import EnvironmentInventory from control_plane.contracts.idempotency_record import LaunchplaneIdempotencyRecord from control_plane.contracts.idempotency_record import build_launchplane_idempotency_record_id +from control_plane.contracts.lane_summary import LaunchplaneLaneSummary from control_plane.contracts.odoo_instance_override_record import OdooInstanceOverrideRecord from control_plane.contracts.preview_generation_record import PreviewGenerationRecord from control_plane.contracts.preview_record import PreviewRecord +from control_plane.contracts.preview_summary import LaunchplanePreviewSummary from control_plane.contracts.promotion_record import PromotionRecord from control_plane.contracts.release_tuple_record import ReleaseTupleRecord from control_plane.contracts.runtime_environment_record import RuntimeEnvironmentRecord @@ -401,6 +403,22 @@ def _read_model( ) return self._read_payload(model_type=model_type, payload=getattr(row, "payload")) + def _read_optional_model( + self, + *, + model_type: type[RecordModel], + orm_model: type[Base], + filters: Sequence[object], + ) -> RecordModel | None: + try: + return self._read_model( + model_type=model_type, + orm_model=orm_model, + filters=filters, + ) + except FileNotFoundError: + return None + def _list_models( self, *, @@ -745,6 +763,46 @@ def list_preview_generation_records( limit=limit, ) + def read_preview_summary( + self, + *, + preview_id: str, + generation_limit: int | None = 10, + ) -> LaunchplanePreviewSummary: + preview = self.read_preview_record(preview_id) + recent_generations = self.list_preview_generation_records( + preview_id=preview_id, + limit=generation_limit, + ) + return LaunchplanePreviewSummary( + preview=preview, + latest_generation=next(iter(recent_generations), None), + recent_generations=recent_generations, + ) + + def list_preview_summaries( + self, + *, + context_name: str = "", + anchor_repo: str = "", + anchor_pr_number: int | None = None, + preview_limit: int | None = None, + generation_limit: int | None = 1, + ) -> tuple[LaunchplanePreviewSummary, ...]: + previews = self.list_preview_records( + context_name=context_name, + anchor_repo=anchor_repo, + anchor_pr_number=anchor_pr_number, + limit=preview_limit, + ) + return tuple( + self.read_preview_summary( + preview_id=preview.preview_id, + generation_limit=generation_limit, + ) + for preview in previews + ) + def write_release_tuple_record(self, record: ReleaseTupleRecord) -> None: self._write_row( LaunchplaneReleaseTupleRow( @@ -856,10 +914,24 @@ def write_runtime_environment_record(self, record: RuntimeEnvironmentRecord) -> ) ) - def list_runtime_environment_records(self) -> tuple[RuntimeEnvironmentRecord, ...]: + def list_runtime_environment_records( + self, + *, + scope: str = "", + context_name: str = "", + instance_name: str = "", + ) -> tuple[RuntimeEnvironmentRecord, ...]: + filters: list[object] = [] + if scope: + filters.append(LaunchplaneRuntimeEnvironmentRow.scope == scope) + if context_name: + filters.append(LaunchplaneRuntimeEnvironmentRow.context == context_name) + if instance_name: + filters.append(LaunchplaneRuntimeEnvironmentRow.instance == instance_name) return self._list_models( model_type=RuntimeEnvironmentRecord, orm_model=LaunchplaneRuntimeEnvironmentRow, + filters=filters, order_by=( LaunchplaneRuntimeEnvironmentRow.scope.asc(), LaunchplaneRuntimeEnvironmentRow.context.asc(), @@ -867,6 +939,96 @@ def list_runtime_environment_records(self) -> tuple[RuntimeEnvironmentRecord, .. ), ) + def read_lane_summary(self, *, context_name: str, instance_name: str) -> LaunchplaneLaneSummary: + runtime_environment_records = ( + *self.list_runtime_environment_records(scope="global"), + *self.list_runtime_environment_records(scope="context", context_name=context_name), + *self.list_runtime_environment_records( + scope="instance", + context_name=context_name, + instance_name=instance_name, + ), + ) + return LaunchplaneLaneSummary( + context=context_name, + instance=instance_name, + inventory=self._read_optional_model( + model_type=EnvironmentInventory, + orm_model=LaunchplaneInventoryRow, + filters=( + LaunchplaneInventoryRow.context == context_name, + LaunchplaneInventoryRow.instance == instance_name, + ), + ), + release_tuple=self._read_optional_model( + model_type=ReleaseTupleRecord, + orm_model=LaunchplaneReleaseTupleRow, + filters=( + LaunchplaneReleaseTupleRow.context == context_name, + LaunchplaneReleaseTupleRow.channel == instance_name, + ), + ), + latest_deployment=next( + iter( + self.list_deployment_records( + context_name=context_name, + instance_name=instance_name, + limit=1, + ) + ), + None, + ), + latest_promotion=next( + iter( + self.list_promotion_records( + context_name=context_name, + to_instance_name=instance_name, + limit=1, + ) + ), + None, + ), + latest_backup_gate=next( + iter( + self.list_backup_gate_records( + context_name=context_name, + instance_name=instance_name, + limit=1, + ) + ), + None, + ), + dokploy_target_id=self._read_optional_model( + model_type=DokployTargetIdRecord, + orm_model=LaunchplaneDokployTargetIdRow, + filters=( + LaunchplaneDokployTargetIdRow.context == context_name, + LaunchplaneDokployTargetIdRow.instance == instance_name, + ), + ), + dokploy_target=self._read_optional_model( + model_type=DokployTargetRecord, + orm_model=LaunchplaneDokployTargetRow, + filters=( + LaunchplaneDokployTargetRow.context == context_name, + LaunchplaneDokployTargetRow.instance == instance_name, + ), + ), + runtime_environment_records=runtime_environment_records, + odoo_instance_override=self._read_optional_model( + model_type=OdooInstanceOverrideRecord, + orm_model=LaunchplaneOdooInstanceOverrideRow, + filters=( + LaunchplaneOdooInstanceOverrideRow.context == context_name, + LaunchplaneOdooInstanceOverrideRow.instance == instance_name, + ), + ), + secret_bindings=self.list_secret_bindings( + context_name=context_name, + instance_name=instance_name, + ), + ) + def write_odoo_instance_override_record(self, record: OdooInstanceOverrideRecord) -> None: self._write_row( LaunchplaneOdooInstanceOverrideRow( diff --git a/docs/records.md b/docs/records.md index a86b2337..ae13df01 100644 --- a/docs/records.md +++ b/docs/records.md @@ -5,19 +5,125 @@ title: Records ## Storage Policy - Persist local-dev records as JSON files in a local state directory. -- Use Postgres-backed Launchplane core-record tables for shared-service ingress when - Launchplane is running with `LAUNCHPLANE_DATABASE_URL` or `launchplane service serve - --database-url ...`. +- Use Postgres-backed Launchplane core-record tables for shared-service ingress + when Launchplane is running with `LAUNCHPLANE_DATABASE_URL` or + `launchplane service serve --database-url ...`. - Use Postgres-backed Launchplane secret tables for managed secret records when Launchplane is running with `LAUNCHPLANE_DATABASE_URL` and `LAUNCHPLANE_MASTER_ENCRYPTION_KEY`. +- Manage shared-service Postgres schema changes with Alembic migrations. The + current baseline revision captures the SQLAlchemy ORM schema that earlier + deployments created through `create_all`; future GUI/write-flow schema changes + need explicit migrations instead of relying on implicit table creation. - Keep git history separate from operational history. - Favor append-style writes for promotion records. -This file layout describes today's local Launchplane implementation, not the final -cross-product communication boundary. The stable long-term contract should be -Launchplane's authenticated service ingress plus the durable record semantics those -API payloads map onto. +## Schema Migrations + +Launchplane uses SQLAlchemy ORM models as the persistence boundary and Alembic as +the versioned migration mechanism for shared-service Postgres databases. Runtime +code can still call `ensure_schema()` for compatibility and ephemeral test/local +databases, but new production schema changes should land as Alembic revisions. + +For a fresh database, apply the current schema with: + +```bash +LAUNCHPLANE_DATABASE_URL=postgresql+psycopg://... uv run alembic upgrade head +``` + +For an existing Launchplane database that already has the tables created by the +pre-migration `create_all` path, adopt the baseline by stamping the database at +the current revision after confirming the live table shape matches the ORM: + +```bash +LAUNCHPLANE_DATABASE_URL=postgresql+psycopg://... uv run alembic stamp head +``` + +JSONB `payload` columns remain durable evidence envelopes and original typed +payload snapshots. Fields that the GUI or drivers need to filter, order, join, +authorize, constrain, display regularly, or act on should be promoted into ORM +columns/tables and migrated explicitly while keeping the payload copy as +historical evidence. + +## ORM Query Boundary + +Launchplane's Postgres storage layer should expose GUI and driver reads through +typed repository methods, not through service/UI code that reaches into JSONB +payloads. The first GUI-facing repository projections are: + +- `LaunchplaneLaneSummary`: lane inventory, release tuple, latest deployment, + latest promotion, latest backup gate, target metadata, runtime environment + records, Odoo override metadata, and secret binding status. +- `LaunchplanePreviewSummary`: preview identity plus recent/latest generation + state. + +These summaries are read models, not new durable record families. They compose +existing ORM rows and contract payloads behind the storage boundary so the next +driver descriptor and GUI slices can consume a stable API shape. + +## Field Promotion Audit + +The current ORM tables already model the first layer of queryable operational +state. Use this audit when deciding whether a new GUI or driver field belongs in +an ORM column/table or remains only in the evidence payload. + +- Artifact manifest: modeled fields are `artifact_id`, `source_commit`, + `image_repository`, and `image_digest`. Source input details, addon selectors, + and provider/build evidence stay payload-only until they become normal query + or action fields. +- Backup gate: modeled fields are `record_id`, `context`, `instance`, + `created_at`, and `status`. Concrete backup paths and provider-specific backup + evidence stay payload-only. +- Deployment: modeled fields are `record_id`, `context`, `instance`, + `artifact_id`, `source_git_ref`, and deploy timestamps. Resolved provider + evidence, health detail, and post-deploy product facts stay payload-only. +- Promotion: modeled fields are `record_id`, `context`, `from_instance`, + `to_instance`, `artifact_id`, and deploy timestamps. Rollback annotations, + backup evidence detail, and provider health envelopes stay payload-only. +- Inventory: modeled fields are `context`, `instance`, `artifact_id`, + `source_git_ref`, `updated_at`, and linked deployment/promotion ids. Full + deploy evidence and product-specific live facts stay payload-only. +- Preview: modeled fields are `preview_id`, `context`, `anchor_repo`, + `anchor_pr_number`, `state`, and `updated_at`. Canonical URLs, lifecycle + notes, and provider route evidence stay payload-only. +- Preview generation: modeled fields are `generation_id`, `preview_id`, + `sequence`, `state`, `requested_at`, `finished_at`, and `artifact_id`. Source + map, PR summaries, deploy/verify evidence, and failure details stay + payload-only. +- Release tuple: modeled fields are `context`, `channel`, `tuple_id`, + `artifact_id`, `minted_at`, and `provenance`. Repo SHA maps and source + provenance details stay payload-only. +- Dokploy target id: modeled fields are `context`, `instance`, `target_id`, and + `updated_at`. Provider lookup/import evidence stays payload-only. +- Dokploy target: modeled fields are `context`, `instance`, and `updated_at`. + Provider-specific names, domains, policies, schedule, and app details stay + payload-only until a provider-neutral target model needs them. +- Runtime environment: modeled fields are `scope`, `context`, `instance`, and + `updated_at`. Individual key/value settings stay payload-only until GUI + filtering or editing requires a setting table. +- Odoo instance override: modeled fields are `context`, `instance`, and + `updated_at`. Typed Odoo override payloads stay payload-only until + cross-driver settings need generic structure. +- Secret: modeled fields are `secret_id`, `scope`, `integration`, `name`, + `context`, `instance`, `status`, `current_version_id`, and `updated_at`. + Descriptions, validation detail, and encrypted version payloads stay + payload-only. +- Secret binding: modeled fields are `binding_id`, `secret_id`, `integration`, + `binding_key`, `context`, `instance`, `status`, and `updated_at`. Binding + implementation details beyond status and lookup stay payload-only. +- Secret audit event: modeled fields are `event_id`, `secret_id`, `event_type`, + and `recorded_at`. Actor, detail, and metadata stay payload-only until audit + filtering needs more columns. + +Promote a payload field into ORM structure when Launchplane needs to filter, +order, join, authorize, constrain, display it regularly, or drive an action from +it. Keep unstable provider envelopes, replay/debug context, and raw evidence in +JSONB until they graduate into normal product behavior. + +This file layout describes today's local Launchplane implementation, not the +final cross-product communication boundary. The stable long-term contract should +be Launchplane's authenticated service ingress plus the durable record semantics +those API payloads map onto. These records are the durable Odoo-first Launchplane truth for this repo today. Stable lane records (`testing`, `prod`) and preview records are separate on @@ -29,9 +135,9 @@ deployment, promotion, inventory, and preview evidence ingestion before this control plane takes over product-specific runtime actions. Under the target Launchplane shape, product workflows and drivers should speak in -typed evidence payloads. Launchplane may still store those facts in file-backed JSON -for local development, but the shared-service path should write the same record -nouns into Postgres-backed tables without inventing a second record model. +typed evidence payloads. Launchplane may still store those facts in file-backed +JSON for local development, but the shared-service path should write the same +record nouns into Postgres-backed tables without inventing a second record model. ## Layout @@ -157,10 +263,10 @@ state/ deployment record ids that established the promoted state. - Externally produced promotion evidence can mint the same destination tuple through `release-tuples write-from-promotion` when the stored promotion - record carries explicit `deployment_record_id` linkage and Launchplane already has - the current source tuple for the promoted-from lane. -- Launchplane previews are not long-lived release-tuple channels; they derive their - baseline from stored tuple evidence plus preview generation records. + record carries explicit `deployment_record_id` linkage and Launchplane already + has the current source tuple for the promoted-from lane. +- Launchplane previews are not long-lived release-tuple channels; they derive + their baseline from stored tuple evidence plus preview generation records. - Local-dev tuple records live under `state/`; shared-service runtime baseline authority comes from the same release-tuple record shape in Postgres-backed storage. Neither path rewrites any tracked TOML catalog implicitly. @@ -257,16 +363,16 @@ state/ - Higher-level transition commands such as generation request/ready/failed reuse the same stored generation records while updating preview linkage semantics through the Launchplane transition helpers. -- `launchplane-previews write-from-generation` is the first explicit evidence-ingest - surface for that path: it accepts typed preview plus generation evidence, - writes the generation record, and refreshes the preview linkage according to - the ingested generation state. -- Together with `launchplane-previews write-destroyed`, Launchplane can now ingest the - full external preview lifecycle: create or refresh route evidence, persist - generation outcome, and record confirmed cleanup. +- `launchplane-previews write-from-generation` is the first explicit + evidence-ingest surface for that path: it accepts typed preview plus + generation evidence, writes the generation record, and refreshes the preview + linkage according to the ingested generation state. +- Together with `launchplane-previews write-destroyed`, Launchplane can now + ingest the full external preview lifecycle: create or refresh route evidence, + persist generation outcome, and record confirmed cleanup. - Those CLI surfaces should be treated as temporary adapters for the target - Launchplane API payloads, not as the final integration boundary external products - are expected to couple to forever. + Launchplane API payloads, not as the final integration boundary external + products are expected to couple to forever. ## Launchplane Preview Enablement Record diff --git a/pyproject.toml b/pyproject.toml index 39d8ff85..174a707f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "psycopg[binary]>=3.3.3", "pyjwt[crypto]>=2.12.1", "sqlalchemy>=2.0", + "alembic>=1.18.4", ] [project.optional-dependencies] diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py index cc3c7dfa..228b3efd 100644 --- a/tests/test_postgres_store.py +++ b/tests/test_postgres_store.py @@ -3,7 +3,10 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from alembic import command as alembic_command +from alembic.config import Config as AlembicConfig from click.testing import CliRunner +from sqlalchemy.exc import SQLAlchemyError from control_plane.cli import main from control_plane.contracts.artifact_identity import ( @@ -41,11 +44,19 @@ from control_plane.storage.filesystem import FilesystemRecordStore from control_plane.storage.postgres import PostgresRecordStore +REPO_ROOT = Path(__file__).resolve().parents[1] + def _sqlite_database_url(database_path: Path) -> str: return f"sqlite+pysqlite:///{database_path}" +def _alembic_config(database_url: str) -> AlembicConfig: + config = AlembicConfig(str(REPO_ROOT / "alembic.ini")) + config.set_main_option("sqlalchemy.url", database_url) + return config + + def _deployment_record(*, record_id: str, started_at: str, finished_at: str) -> DeploymentRecord: return DeploymentRecord( record_id=record_id, @@ -305,6 +316,36 @@ def _secret_audit_event(*, event_id: str, secret_id: str, recorded_at: str) -> S class PostgresRecordStoreTests(unittest.TestCase): + def test_alembic_baseline_creates_schema_used_by_record_store(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + database_url = _sqlite_database_url( + Path(temporary_directory_name) / "launchplane.sqlite3" + ) + alembic_command.upgrade(_alembic_config(database_url), "head") + + store = PostgresRecordStore(database_url=database_url) + manifest = _artifact_manifest() + store.write_artifact_manifest(manifest) + loaded = store.read_artifact_manifest(manifest.artifact_id) + store.close() + + self.assertEqual(loaded.artifact_id, manifest.artifact_id) + self.assertEqual(loaded.image.digest, "sha256:image123") + + def test_alembic_baseline_downgrades_to_empty_schema(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + database_path = Path(temporary_directory_name) / "launchplane.sqlite3" + database_url = _sqlite_database_url(database_path) + config = _alembic_config(database_url) + + alembic_command.upgrade(config, "head") + alembic_command.downgrade(config, "base") + + store = PostgresRecordStore(database_url=database_url) + with self.assertRaises(SQLAlchemyError): + store.list_artifact_manifests() + store.close() + def test_artifact_manifests_round_trip(self) -> None: with TemporaryDirectory() as temporary_directory_name: database_path = Path(temporary_directory_name) / "launchplane.sqlite3" @@ -516,6 +557,60 @@ def test_list_preview_records_filters_and_limits(self) -> None: ], ) + def test_preview_summaries_include_latest_generation(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + store = PostgresRecordStore( + database_url=_sqlite_database_url( + Path(temporary_directory_name) / "launchplane.sqlite3" + ) + ) + store.ensure_schema() + + preview = _preview_record( + preview_id="preview-verireel-testing-verireel-pr-123", + updated_at="2026-04-20T10:05:00Z", + pr_number=123, + ) + store.write_preview_record(preview) + store.write_preview_generation_record( + _preview_generation_record( + generation_id="preview-verireel-testing-verireel-pr-123-generation-0001", + preview_id=preview.preview_id, + ) + ) + store.write_preview_generation_record( + _preview_generation_record( + generation_id="preview-verireel-testing-verireel-pr-123-generation-0002", + preview_id=preview.preview_id, + ).model_copy( + update={ + "sequence": 2, + "requested_at": "2026-04-20T10:06:00Z", + "ready_at": "2026-04-20T10:08:00Z", + "finished_at": "2026-04-20T10:08:00Z", + "artifact_id": "artifact-verireel-pr-123-bbbbbbbb", + } + ) + ) + + summary = store.read_preview_summary(preview_id=preview.preview_id) + listed_summaries = store.list_preview_summaries( + context_name="verireel-testing", + anchor_repo="verireel", + generation_limit=1, + ) + store.close() + + self.assertEqual(summary.preview.preview_id, preview.preview_id) + self.assertEqual( + summary.latest_generation.generation_id, + "preview-verireel-testing-verireel-pr-123-generation-0002", + ) + self.assertEqual(len(summary.recent_generations), 2) + self.assertEqual(len(listed_summaries), 1) + self.assertEqual(len(listed_summaries[0].recent_generations), 1) + self.assertEqual(listed_summaries[0].latest_generation.sequence, 2) + def test_write_and_list_dokploy_target_id_records(self) -> None: with TemporaryDirectory() as temporary_directory_name: store = PostgresRecordStore( @@ -571,6 +666,87 @@ def test_write_and_list_runtime_environment_records(self) -> None: [("global", "", ""), ("instance", "opw", "local")], ) + def test_read_lane_summary_uses_repository_queries_for_gui_state(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + store = PostgresRecordStore( + database_url=_sqlite_database_url( + Path(temporary_directory_name) / "launchplane.sqlite3" + ) + ) + store.ensure_schema() + store.write_environment_inventory(_inventory_record()) + store.write_deployment_record( + _deployment_record( + record_id="deployment-20260420T153000Z-opw-testing", + started_at="2026-04-20T15:30:00Z", + finished_at="2026-04-20T15:32:00Z", + ) + ) + store.write_release_tuple_record(_release_tuple_record()) + store.write_dokploy_target_id_record( + _dokploy_target_id_record(context="opw", instance="testing") + ) + store.write_dokploy_target_record( + _dokploy_target_record(context="opw", instance="testing") + ) + store.write_runtime_environment_record( + _runtime_environment_record( + scope="global", + env={"ODOO_MASTER_PASSWORD": "shared-master"}, + ) + ) + store.write_runtime_environment_record( + _runtime_environment_record( + scope="context", + context="opw", + env={"ODOO_DB_USER": "opw"}, + ) + ) + store.write_runtime_environment_record( + _runtime_environment_record( + scope="instance", + context="opw", + instance="testing", + env={"ODOO_DB_NAME": "opw-testing"}, + ) + ) + store.write_odoo_instance_override_record( + _odoo_instance_override_record(context="opw", instance="testing") + ) + store.write_secret_binding( + _secret_binding( + binding_id="binding-dokploy-token", + secret_id="secret-dokploy-token", + updated_at="2026-04-20T18:07:00Z", + ) + ) + + summary = store.read_lane_summary(context_name="opw", instance_name="testing") + store.close() + + self.assertEqual(summary.context, "opw") + self.assertEqual(summary.instance, "testing") + self.assertEqual( + summary.inventory.artifact_identity.artifact_id, "artifact-20260420-a1b2c3d4" + ) + self.assertEqual(summary.release_tuple.channel, "testing") + self.assertEqual( + summary.latest_deployment.record_id, "deployment-20260420T153000Z-opw-testing" + ) + self.assertIsNone(summary.latest_promotion) + self.assertIsNone(summary.latest_backup_gate) + self.assertEqual(summary.dokploy_target_id.target_id, "compose-123") + self.assertEqual(summary.dokploy_target.target_name, "opw-testing") + self.assertEqual( + [ + (record.scope, record.context, record.instance) + for record in summary.runtime_environment_records + ], + [("global", "", ""), ("context", "opw", ""), ("instance", "opw", "testing")], + ) + self.assertEqual(summary.odoo_instance_override.config_parameters[0].key, "web.base.url") + self.assertEqual(summary.secret_bindings[0].binding_key, "DOKPLOY_TOKEN") + def test_write_read_and_list_odoo_instance_override_records(self) -> None: with TemporaryDirectory() as temporary_directory_name: store = PostgresRecordStore( diff --git a/uv.lock b/uv.lock index b741b32f..30834d0d 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -143,7 +157,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, @@ -151,7 +167,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, @@ -159,7 +177,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, @@ -170,6 +190,7 @@ name = "launchplane" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "click" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, @@ -185,6 +206,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.18.4" }, { name = "click", specifier = ">=8.3.3" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.20.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, @@ -242,6 +264,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, ] +[[package]] +name = "mako" +version = "1.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mypy" version = "1.20.2"