From 24d9dbf38b117e623608303b8c4747f15569ea14 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sun, 12 Jul 2026 16:55:46 +0800 Subject: [PATCH] feat(cloud-store): add Alembic baseline migration --- .../cloud-control-plane-integration/tasks.md | 2 +- packages/cloud-platform/cloud/db_models.py | 29 ++++- .../cloud/migrations/__init__.py | 1 + .../cloud/migrations/alembic.ini | 38 ++++++ .../cloud-platform/cloud/migrations/env.py | 63 ++++++++++ .../cloud/migrations/script.py.mako | 22 ++++ .../versions/0001_cloud_repository.py | 112 ++++++++++++++++++ .../cloud/migrations/versions/__init__.py | 1 + packages/cloud-platform/pyproject.toml | 4 + uv.lock | 58 +++++++++ 10 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 packages/cloud-platform/cloud/migrations/__init__.py create mode 100644 packages/cloud-platform/cloud/migrations/alembic.ini create mode 100644 packages/cloud-platform/cloud/migrations/env.py create mode 100644 packages/cloud-platform/cloud/migrations/script.py.mako create mode 100644 packages/cloud-platform/cloud/migrations/versions/0001_cloud_repository.py create mode 100644 packages/cloud-platform/cloud/migrations/versions/__init__.py diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index a38e30a..c8ec09e 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -11,7 +11,7 @@ - [x] 2.1 Define the cloud repository contract for hosts, device snapshots, plugins, tasks, attempts, leases, reservations, and transactional assignment operations. - [x] 2.2 Implement SQLAlchemy models and a repository adapter that preserves existing `CloudStore` observable behavior. - [x] 2.3 Add PostgreSQL and SQLite database URL support with engine/session lifecycle owned by the cloud application. -- [ ] 2.4 Add Alembic configuration and a baseline migration that preserves existing host, device, task, and plugin data while adding lease/attempt/result fields. +- [x] 2.4 Add Alembic configuration and a baseline migration that preserves existing host, device, task, and plugin data while adding lease/attempt/result fields. - [ ] 2.5 Add forward/downgrade migration tests and schema-version readiness checks. - [ ] 2.6 Run repository contract tests against SQLite and PostgreSQL, including rollback and process-restart cases. diff --git a/packages/cloud-platform/cloud/db_models.py b/packages/cloud-platform/cloud/db_models.py index 5610c7e..3fe5648 100644 --- a/packages/cloud-platform/cloud/db_models.py +++ b/packages/cloud-platform/cloud/db_models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from sqlalchemy import Integer, String, Text +from sqlalchemy import Integer, String, Text, text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -37,9 +37,36 @@ class ScheduledTaskRow(Base): status: Mapped[str] = mapped_column(String, nullable=False) assigned_device_id: Mapped[str | None] = mapped_column(String, nullable=True) assigned_host_id: Mapped[str | None] = mapped_column(String, nullable=True) + attempt_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + ) + lease_id: Mapped[str | None] = mapped_column(String, nullable=True) + lease_expires_at: Mapped[str | None] = mapped_column(String, nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + result_json: Mapped[str | None] = mapped_column(Text, nullable=True) + updated_at: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[str] = mapped_column(String, nullable=False) +class TaskAttemptRow(Base): + __tablename__ = "task_attempts" + + task_id: Mapped[str] = mapped_column(String, primary_key=True) + attempt: Mapped[int] = mapped_column(Integer, primary_key=True) + lease_id: Mapped[str] = mapped_column(String, nullable=False) + host_id: Mapped[str] = mapped_column(String, nullable=False) + device_id: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False) + lease_expires_at: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + completed_at: Mapped[str | None] = mapped_column(String, nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + result_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + class PluginRow(Base): __tablename__ = "plugins" diff --git a/packages/cloud-platform/cloud/migrations/__init__.py b/packages/cloud-platform/cloud/migrations/__init__.py new file mode 100644 index 0000000..6fcded6 --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/__init__.py @@ -0,0 +1 @@ +"""Alembic migration environment bundled with device-cloud-platform.""" diff --git a/packages/cloud-platform/cloud/migrations/alembic.ini b/packages/cloud-platform/cloud/migrations/alembic.ini new file mode 100644 index 0000000..e2b5fbb --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = %(here)s +prepend_sys_path = . +sqlalchemy.url = sqlite:///cloud/cloud.sqlite3 + +[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/packages/cloud-platform/cloud/migrations/env.py b/packages/cloud-platform/cloud/migrations/env.py new file mode 100644 index 0000000..0cb7232 --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/env.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from cloud.database import normalize_database_url +from cloud.db_models 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: + return normalize_database_url( + os.environ.get("CLOUD_DATABASE_URL") + or config.get_main_option("sqlalchemy.url") + ) + + +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: + supplied_connection = config.attributes.get("connection") + if supplied_connection is not None: + context.configure(connection=supplied_connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + return + + configuration = config.get_section(config.config_ini_section) or {} + 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() + connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/packages/cloud-platform/cloud/migrations/script.py.mako b/packages/cloud-platform/cloud/migrations/script.py.mako new file mode 100644 index 0000000..36cd8ef --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/packages/cloud-platform/cloud/migrations/versions/0001_cloud_repository.py b/packages/cloud-platform/cloud/migrations/versions/0001_cloud_repository.py new file mode 100644 index 0000000..6b574f4 --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/versions/0001_cloud_repository.py @@ -0,0 +1,112 @@ +"""Create the cloud repository schema and lease fields.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0001_cloud_repository" +down_revision = None +branch_labels = None +depends_on = None + + +LEASE_COLUMNS = ( + sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("lease_id", sa.String(), nullable=True), + sa.Column("lease_expires_at", sa.String(), nullable=True), + sa.Column("failure_reason", sa.Text(), nullable=True), + sa.Column("result_json", sa.Text(), nullable=True), + sa.Column("updated_at", sa.String(), nullable=True), +) + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "host_registrations" not in tables: + _create_base_tables() + else: + existing_columns = { + column["name"] for column in inspector.get_columns("scheduled_tasks") + } + with op.batch_alter_table("scheduled_tasks") as batch: + for column in LEASE_COLUMNS: + if column.name not in existing_columns: + batch.add_column(column.copy()) + + inspector = sa.inspect(op.get_bind()) + if "task_attempts" not in inspector.get_table_names(): + _create_attempts_table() + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "task_attempts" in tables: + op.drop_table("task_attempts") + if "scheduled_tasks" not in tables: + return + existing_columns = { + column["name"] for column in inspector.get_columns("scheduled_tasks") + } + with op.batch_alter_table("scheduled_tasks") as batch: + for column in reversed(LEASE_COLUMNS): + if column.name in existing_columns: + batch.drop_column(column.name) + + +def _create_base_tables() -> None: + op.create_table( + "host_registrations", + sa.Column("host_id", sa.String(), primary_key=True), + sa.Column("address", sa.String(), nullable=True), + sa.Column("last_seen_at", sa.String(), nullable=False), + ) + op.create_table( + "pooled_devices", + sa.Column("host_id", sa.String(), primary_key=True), + sa.Column("device_id", sa.String(), primary_key=True), + sa.Column("driver_type", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("capability_tags_json", sa.Text(), nullable=False), + sa.Column("synced_at", sa.String(), nullable=True), + ) + op.create_table( + "scheduled_tasks", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("goal", sa.Text(), nullable=True), + sa.Column("workflow_definition_id", sa.String(), nullable=True), + sa.Column("constraints_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("assigned_device_id", sa.String(), nullable=True), + sa.Column("assigned_host_id", sa.String(), nullable=True), + *[column.copy() for column in LEASE_COLUMNS], + sa.Column("created_at", sa.String(), nullable=False), + ) + op.create_table( + "plugins", + sa.Column("name", sa.String(), primary_key=True), + sa.Column("version", sa.String(), nullable=False), + sa.Column("entry_point_kind", sa.String(), nullable=False), + sa.Column("target", sa.String(), nullable=False), + sa.Column("wired", sa.Integer(), nullable=False), + ) + + +def _create_attempts_table() -> None: + op.create_table( + "task_attempts", + sa.Column("task_id", sa.String(), primary_key=True), + sa.Column("attempt", sa.Integer(), primary_key=True), + sa.Column("lease_id", sa.String(), nullable=False), + sa.Column("host_id", sa.String(), nullable=False), + sa.Column("device_id", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("lease_expires_at", sa.String(), nullable=False), + sa.Column("created_at", sa.String(), nullable=False), + sa.Column("completed_at", sa.String(), nullable=True), + sa.Column("failure_reason", sa.Text(), nullable=True), + sa.Column("result_json", sa.Text(), nullable=True), + ) diff --git a/packages/cloud-platform/cloud/migrations/versions/__init__.py b/packages/cloud-platform/cloud/migrations/versions/__init__.py new file mode 100644 index 0000000..b2a008a --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Cloud database schema revisions.""" diff --git a/packages/cloud-platform/pyproject.toml b/packages/cloud-platform/pyproject.toml index e4f0ef4..82f939b 100644 --- a/packages/cloud-platform/pyproject.toml +++ b/packages/cloud-platform/pyproject.toml @@ -5,6 +5,7 @@ description = "Cloud scheduling, device pooling, plugins, and SDK for Device Age readme = "README.md" requires-python = ">=3.14" dependencies = [ + "alembic>=1.14.0", "device-agent-runtime==0.1.0", "psycopg[binary]>=3.2.0", "sqlalchemy>=2.0.0", @@ -18,5 +19,8 @@ build-backend = "setuptools.build_meta" where = ["."] include = ["cloud*"] +[tool.setuptools.package-data] +cloud = ["migrations/*.ini", "migrations/*.mako"] + [tool.uv.sources] device-agent-runtime = { workspace = true } diff --git a/uv.lock b/uv.lock index ac66bf0..494f4a5 100644 --- a/uv.lock +++ b/uv.lock @@ -112,6 +112,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/77/cd71a481bb7a76b0e9d0b6bf47711c627b1dd079001ea246893f19a9d04c/aistudio_sdk-0.3.8-py3-none-any.whl", hash = "sha256:bfc96af7243ac2ee3303014849aa4028717cce5afa622af8cfe3a1d44d1941d6", size = 62972, upload-time = "2025-09-19T11:42:39.384Z" }, ] +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -486,6 +500,7 @@ name = "device-cloud-platform" version = "0.1.0" source = { editable = "packages/cloud-platform" } dependencies = [ + { name = "alembic" }, { name = "device-agent-runtime" }, { name = "psycopg", extra = ["binary"] }, { name = "sqlalchemy" }, @@ -493,6 +508,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.14.0" }, { name = "device-agent-runtime", editable = "." }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, @@ -858,6 +874,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[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/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 = "mcp" version = "1.28.1"