From 3880749de06bd25a9533dc253d4bb9e48c20b786 Mon Sep 17 00:00:00 2001 From: Katsujukou Kineya Date: Fri, 20 Sep 2024 14:48:52 +0900 Subject: [PATCH 1/2] refine table scheme --- backend/oqtopus_cloud/common/model_util.py | 103 +++++++++++++++++- .../oqtopus_cloud/common/models/__init__.py | 13 ++- backend/oqtopus_cloud/common/models/base.py | 4 +- backend/oqtopus_cloud/common/models/device.py | 57 +++++----- .../common/models/quantum_gate.py | 16 +++ backend/oqtopus_cloud/common/models/task.py | 4 +- backend/oqtopus_cloud/common/session.py | 4 +- 7 files changed, 164 insertions(+), 37 deletions(-) create mode 100644 backend/oqtopus_cloud/common/models/quantum_gate.py diff --git a/backend/oqtopus_cloud/common/model_util.py b/backend/oqtopus_cloud/common/model_util.py index 1a00014..63e886e 100644 --- a/backend/oqtopus_cloud/common/model_util.py +++ b/backend/oqtopus_cloud/common/model_util.py @@ -1,4 +1,8 @@ -from typing import Any, Dict +import re +from enum import Enum +from typing import Any, Callable, Dict, Type, TypeVar + +from sqlalchemy import JSON, String, TypeDecorator def model_to_dict(model: Any) -> Dict[Any, Any]: @@ -16,3 +20,100 @@ def model_to_schema_dict( for model_field, schema_field in map_model_to_schema.items(): schema_dict[schema_field] = model_dict[model_field] return schema_dict + + +T = TypeVar("T", bound=Enum) + + +class JsonType(TypeDecorator): + impl = JSON + + def __init__( + self, encoder: Callable[[Type[T]], Any], decoder: Callable[[Any], Type[T]] + ): + super(JsonType, self).__init__() + self.encoder = encoder + self.decoder = decoder + + def process_bind_param(self, value, dialect): + return self.encoder(value) + + def process_result_value(self, value, dialect): + return self.decoder(value) + + +class StringList(TypeDecorator): + impl = String + + def __init__(self, item_type): + super().__init__() + self.item_type = item_type + + def process_bind_param(self, value, dialect): + # if not isinstance(value, list): + # raise ValueError("value should be a list") + + def process_value(v): + # if isinstance(self.item_type, TypeEngine): + # bind_proccessor = self.item_type.bind_processor(dialect) + # if bind_proccessor is not None: + # return bind_proccessor(v) + + # elif isinstance(self.item_type, TypeDecorator): + # return self.item_type.process_bind_param(v, dialect) + + if isinstance(self.item_type, TypeDecorator): + return self.item_type.process_bind_param(v, dialect) + return None + + def escape(s): + return s.replace(",", r"\,") if isinstance(s, str) else str(s) + + return ",".join([escape(process_value(v)) for v in value]) + + def process_result_value(self, value, dialect): + def unescape(s): + return re.sub(r"(? Any: @@ -48,7 +48,7 @@ def get_secret() -> Any: return json.loads(secret) -def get_db() -> Generator: +def get_db() -> Generator[Session, None, None]: """Returns a database session. This function creates a database session using the SQLAlchemy engine and sessionmaker. From 6fd3984431c3b21f7787375716e2ecaaa1f3220e Mon Sep 17 00:00:00 2001 From: Katsujukou Kineya Date: Fri, 20 Sep 2024 14:49:56 +0900 Subject: [PATCH 2/2] Alembic --- backend/Makefile | 19 ++- backend/alembic.ini | 116 ++++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 102 +++++++++++++++ backend/alembic/script.py.mako | 26 ++++ .../20240911__38a0365a40fd__create_devices.py | 45 +++++++ ...240912__9b835b4d8801__alter_tasks_table.py | 40 ++++++ ...cb77f1319fd__create_quantum_gates_table.py | 63 ++++++++++ poetry.lock | 72 ++++++++++- pyproject.toml | 2 + 10 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20240911__38a0365a40fd__create_devices.py create mode 100644 backend/alembic/versions/20240912__9b835b4d8801__alter_tasks_table.py create mode 100644 backend/alembic/versions/20240913__dcb77f1319fd__create_quantum_gates_table.py diff --git a/backend/Makefile b/backend/Makefile index ff9e0e8..08b7505 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -2,7 +2,7 @@ SHELL := bash .SHELLFLAGS := -eu -o pipefail -c .DEFAULT_GOAL := help -.PHONY: generate-user-schema generate-provider-schema generate-all-schema run-user run-provider up down fmt lint test help +.PHONY: generate-user-schema generate-provider-schema generate-all-schema run-user run-provider up down mig-gen mig-up fmt lint test help generate-user-schema: ## Generate user schema @cd oas && $(MAKE) generate-user @@ -60,6 +60,18 @@ up: ## Start the DB down: ## Stop the DB @docker compose down +mig-up: ## Run migration + @poetry run alembic upgrade head + +mig-down: ## Rollback to specific revision + @poetry run alembic downgrade "$(REV)" + +mig-gen: ## Generatet migration script + @poetry run alembic revision --autogenerate -m "$(ARG)" + +# db-seed: +# @poetry run + fmt-common: ## Format common code @poetry run ruff format oqtopus_cloud/common @@ -69,7 +81,10 @@ fmt-user: ## Format user code fmt-provider: ## Format provider code @poetry run ruff format oqtopus_cloud/provider -fmt-all: fmt-common fmt-user fmt-provider ## Format all code +fmt-migration: ## Format migration script code + @poetry run ruff format alembic/versions + +fmt-all: fmt-common fmt-user fmt-provider fmt-migration ## Format all code lint-common: ## Run common linters @poetry run ruff check oqtopus_cloud/common diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..48ed1f2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +file_template = %%(year)d%%(month).2d%%(day).2d__%%(rev)s__%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = mysql+pymysql://%(DB_USER)s:%(DB_PASS)s@%(DB_HOST)s:3306/%(DB_NAME)s + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[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/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..98d2fc0 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,102 @@ +from logging.config import fileConfig +from os import environ + +from alembic import context +from oqtopus_cloud.common.models import Base +from sqlalchemy import TypeDecorator, engine_from_config, pool + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def render_item(type_, obj, autogen_context): + """Apply custom rendering for selected items.""" + + if type_ == "type" and isinstance(obj, TypeDecorator): + return f"sa.{obj.impl!r}" + + # default rendering for other objects + return False + + +def set_env_var(key: str): + val = environ.get(key) + if val is not None: + config.set_section_option("alembic", key, val) + else: + raise KeyError(f'Environment variable "{key}" is not set') + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + set_env_var("DB_HOST") + set_env_var("DB_USER") + set_env_var("DB_PASS") + set_env_var("DB_NAME") + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_item=render_item, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[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/backend/alembic/versions/20240911__38a0365a40fd__create_devices.py b/backend/alembic/versions/20240911__38a0365a40fd__create_devices.py new file mode 100644 index 0000000..c05e43a --- /dev/null +++ b/backend/alembic/versions/20240911__38a0365a40fd__create_devices.py @@ -0,0 +1,45 @@ +"""create devices + +Revision ID: 38a0365a40fd +Revises: +Create Date: 2024-09-11 16:35:08.445166 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "38a0365a40fd" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "devices", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("device_type", sa.String(length=128), nullable=False), + sa.Column("availability", sa.Boolean(), nullable=False), + sa.Column("restart_at", sa.DateTime(), nullable=True), + sa.Column("pending_tasks", sa.Integer(), nullable=False), + sa.Column("n_qubits", sa.Integer(), nullable=False), + sa.Column("n_nodes", sa.Integer(), nullable=True), + sa.Column("basis_gates", sa.String(length=256), nullable=False), + sa.Column("instructions", sa.String(length=64), nullable=False), + sa.Column("calibration_data", sa.Text(), nullable=False), + sa.Column("calibrated_at", sa.DateTime(), nullable=False), + sa.Column("description", sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("devices") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/20240912__9b835b4d8801__alter_tasks_table.py b/backend/alembic/versions/20240912__9b835b4d8801__alter_tasks_table.py new file mode 100644 index 0000000..4abe479 --- /dev/null +++ b/backend/alembic/versions/20240912__9b835b4d8801__alter_tasks_table.py @@ -0,0 +1,40 @@ +"""alter_tasks_table + +Revision ID: 9b835b4d8801 +Revises: 38a0365a40fd +Create Date: 2024-09-12 17:11:57.962993 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '9b835b4d8801' +down_revision: Union[str, None] = '38a0365a40fd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('devices', 'calibration_data', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + nullable=True) + op.alter_column('devices', 'calibrated_at', + existing_type=mysql.DATETIME(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('devices', 'calibrated_at', + existing_type=mysql.DATETIME(), + nullable=False) + op.alter_column('devices', 'calibration_data', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + nullable=False) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/20240913__dcb77f1319fd__create_quantum_gates_table.py b/backend/alembic/versions/20240913__dcb77f1319fd__create_quantum_gates_table.py new file mode 100644 index 0000000..fbcc23f --- /dev/null +++ b/backend/alembic/versions/20240913__dcb77f1319fd__create_quantum_gates_table.py @@ -0,0 +1,63 @@ +"""create_quantum_gates + +Revision ID: dcb77f1319fd +Revises: 9b835b4d8801 +Create Date: 2024-09-13 16:49:39.753049 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "dcb77f1319fd" +down_revision: Union[str, None] = "9b835b4d8801" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "quantum_gates", + sa.Column("id", sa.String(length=16), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("n_qubits", sa.Integer(), nullable=False), + sa.Column("n_clbits", sa.Integer(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "gate_supports", + sa.Column("device_id", sa.String(length=64), nullable=False), + sa.Column("gate_id", sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint( + ["device_id"], + ["devices.id"], + ), + sa.ForeignKeyConstraint( + ["gate_id"], + ["quantum_gates.id"], + ), + sa.PrimaryKeyConstraint("device_id", "gate_id"), + ) + op.drop_column("devices", "basis_gates") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "devices", + sa.Column( + "basis_gates", + mysql.VARCHAR(collation="utf8mb4_unicode_ci", length=256), + nullable=False, + ), + ) + op.drop_table("gate_supports") + op.drop_table("quantum_gates") + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index 99566b0..9ed044b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,24 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "alembic" +version = "1.13.2" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"}, + {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["backports.zoneinfo"] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1293,6 +1312,25 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "mako" +version = "1.3.5" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"}, + {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "mangum" version = "0.17.0" @@ -1621,6 +1659,38 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "mysql" +version = "0.0.3" +description = "Virtual package for MySQL-python" +optional = false +python-versions = "*" +files = [ + {file = "mysql-0.0.3-py3-none-any.whl", hash = "sha256:8893cb143a5ac525c49ef358a23b8a0dc9721a95646f7bab6ca2f384c18a6a9a"}, + {file = "mysql-0.0.3.tar.gz", hash = "sha256:fd7bae7d7301ce7cd3932e5ff7f77bbc8e34872108252866e08d16d6b8e8de8c"}, +] + +[package.dependencies] +mysqlclient = "*" + +[[package]] +name = "mysqlclient" +version = "2.2.4" +description = "Python interface to MySQL" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mysqlclient-2.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:ac44777eab0a66c14cb0d38965572f762e193ec2e5c0723bcd11319cc5b693c5"}, + {file = "mysqlclient-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:329e4eec086a2336fe3541f1ce095d87a6f169d1cc8ba7b04ac68bcb234c9711"}, + {file = "mysqlclient-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:e1ebe3f41d152d7cb7c265349fdb7f1eca86ccb0ca24a90036cde48e00ceb2ab"}, + {file = "mysqlclient-2.2.4-cp38-cp38-win_amd64.whl", hash = "sha256:3c318755e06df599338dad7625f884b8a71fcf322a9939ef78c9b3db93e1de7a"}, + {file = "mysqlclient-2.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:9d4c015480c4a6b2b1602eccd9846103fc70606244788d04aa14b31c4bd1f0e2"}, + {file = "mysqlclient-2.2.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d43987bb9626096a302ca6ddcdd81feaeca65ced1d5fe892a6a66b808326aa54"}, + {file = "mysqlclient-2.2.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4e80dcad884dd6e14949ac6daf769123223a52a6805345608bf49cdaf7bc8b3a"}, + {file = "mysqlclient-2.2.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9d3310295cb682232cadc28abd172f406c718b9ada41d2371259098ae37779d3"}, + {file = "mysqlclient-2.2.4.tar.gz", hash = "sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41"}, +] + [[package]] name = "packaging" version = "24.1" @@ -2565,4 +2635,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12.4" -content-hash = "6d8dbef1dd83b061c350e1528507d2262e616e6eca30f68adc31b3d14bc2eda8" +content-hash = "1502bd0dd1d4a12f9abe91b9679c4e1eb9ecd52ea3a964624e5ea48aa7bffd93" diff --git a/pyproject.toml b/pyproject.toml index 1043753..d7b59b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ httpx = "^0.27.0" boto3-stubs = "^1.34.74" shandy-sqlfmt = "^0.21.2" pytest-sqlalchemy-mock = "^0.1.5" +alembic = "^1.13.2" +mysql = "^0.0.3" [tool.poetry.group.docs.dependencies]