From bb181d930a9c099a5ac3093f5edd262f806e4066 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 13 Dec 2025 17:56:34 +0800 Subject: [PATCH] feat(g0v0-migrate): implement g0v0 migration system with plugin support (#97) For details please view the PR. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 1 + Dockerfile | 3 +- docker-entrypoint.sh | 2 +- migrations/env.py | 15 +- .../g0v0_migrations/__init__.py | 0 .../g0v0_migrations/__main__.py | 417 ++++++++++++++++++ .../g0v0-migrations/g0v0_migrations/model.py | 51 +++ .../g0v0_migrations/templates/env.py | 138 ++++++ .../g0v0_migrations/templates/script.py.mako | 29 ++ .../g0v0-migrations/g0v0_migrations/utils.py | 50 +++ packages/g0v0-migrations/pyproject.toml | 15 + pyproject.toml | 3 + uv.lock | 29 ++ 13 files changed, 749 insertions(+), 4 deletions(-) create mode 100644 packages/g0v0-migrations/g0v0_migrations/__init__.py create mode 100644 packages/g0v0-migrations/g0v0_migrations/__main__.py create mode 100644 packages/g0v0-migrations/g0v0_migrations/model.py create mode 100644 packages/g0v0-migrations/g0v0_migrations/templates/env.py create mode 100644 packages/g0v0-migrations/g0v0_migrations/templates/script.py.mako create mode 100644 packages/g0v0-migrations/g0v0_migrations/utils.py create mode 100644 packages/g0v0-migrations/pyproject.toml diff --git a/.gitignore b/.gitignore index 6e6ca8c..c0d3c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -231,3 +231,4 @@ config/* !config/.gitkeep osu-web-master/* performance-server +plugins/ diff --git a/Dockerfile b/Dockerfile index 1eb7b87..64e95c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,13 +14,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV UV_PROJECT_ENVIRONMENT=/app/.venv COPY pyproject.toml uv.lock ./ +COPY packages/ ./packages/ RUN uv sync --frozen --no-dev COPY alembic.ini ./ COPY tools/ ./tools/ COPY migrations/ ./migrations/ -COPY static/ ./app/static/ +COPY static/ ./static/ COPY app/ ./app/ COPY main.py ./ diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 4749aa6..c78360e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -12,7 +12,7 @@ done echo "Database connected." echo "Running alembic..." -uv run --no-sync alembic upgrade head +uv run --no-sync g0v0-migrate upgrade-all # 把控制权交给最终命令 exec "$@" diff --git a/migrations/env.py b/migrations/env.py index 0b5f62f..8ddd318 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -31,6 +31,12 @@ target_metadata = SQLModel.metadata # ... etc. +def include_object(object, name, type_, reflected, compare_to) -> bool: # noqa: ARG001 + if type_ != "table": + return True + return not name.endswith("alembic_version") and not name.startswith("plugin_") + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -44,13 +50,13 @@ def run_migrations_offline() -> None: """ url = settings.database_url - print(url) context.configure( url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True, dialect_opts={"paramstyle": "named"}, + include_object=include_object, ) with context.begin_transaction(): @@ -58,7 +64,12 @@ def run_migrations_offline() -> None: def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_object=include_object, + ) with context.begin_transaction(): context.run_migrations() diff --git a/packages/g0v0-migrations/g0v0_migrations/__init__.py b/packages/g0v0-migrations/g0v0_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/g0v0-migrations/g0v0_migrations/__main__.py b/packages/g0v0-migrations/g0v0_migrations/__main__.py new file mode 100644 index 0000000..bd422d3 --- /dev/null +++ b/packages/g0v0-migrations/g0v0_migrations/__main__.py @@ -0,0 +1,417 @@ +from contextlib import contextmanager +from pathlib import Path + +from g0v0_migrations.model import ContextObject, G0v0ServerDatabaseConfig +from g0v0_migrations.utils import detect_g0v0_server_path, get_plugin_id + +import alembic.command +from alembic.config import Config as AlembicConfig +import click + + +def _ensure_migrations_path(obj: ContextObject) -> str: + migrations_path = obj["alembic_config"].get_section_option( + obj["alembic_config"].config_ini_section, "script_location" + ) + if migrations_path is None: + raise click.ClickException("Could not determine script_location from alembic config.") + if not Path(migrations_path).exists(): + Path(migrations_path).mkdir(parents=True, exist_ok=True) + Path(migrations_path).joinpath("versions").mkdir(parents=True, exist_ok=True) + return migrations_path + + +@contextmanager +def _ensure_env(obj: ContextObject, autogenerate: bool = False): + migrations_path = _ensure_migrations_path(obj) + for file in Path(__file__).parent.joinpath("templates").iterdir(): + if file.is_file(): + dest_file = Path(migrations_path).joinpath(file.name) + if dest_file.exists(): + continue + txt = file.read_text(encoding="utf-8") + if file.name == "env.py": + txt = txt.replace("", obj["plugin_id"] or "") + plugin_path = obj["plugin_path"] + if obj["plugin_id"] and plugin_path and autogenerate: + plugin_import_name = plugin_path.name.replace("-", "_") + txt = txt.replace( + "# ", + ( + f"import sys; " + f"sys.path.insert(0, r'{plugin_path.parent.as_posix()}'); " + f"sys.path.insert(0, r'{obj['g0v0_server_path'].as_posix()}'); " + f"from {plugin_import_name} import *; " + "from app.database import *" + ), + ) + dest_file.write_text(txt, encoding="utf-8") + db_config = G0v0ServerDatabaseConfig(_env_file=obj["g0v0_server_path"] / ".env") # pyright: ignore[reportCallIssue] + alembic_config = obj["alembic_config"] + original_url = obj["alembic_config"].get_section_option(alembic_config.config_ini_section, "sqlalchemy.url") + alembic_config.set_section_option(alembic_config.config_ini_section, "sqlalchemy.url", db_config.database_url) + try: + yield + finally: + if obj["plugin_id"]: + for file in Path(__file__).parent.joinpath("templates").iterdir(): + if file.is_file(): + dest_file = Path(migrations_path).joinpath(file.name) + if dest_file.exists(): + dest_file.unlink() + alembic_config.set_section_option( + alembic_config.config_ini_section, + "sqlalchemy.url", + original_url or "", + ) + + +@click.group() +@click.option( + "-c", + "--config", + default=None, + help="Path to config file of Alembic.", + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +@click.option( + "-p", + "--path", + "g0v0_server_path", + default=None, + help=( + "The directory path to g0v0-server. If not provided, " + "the current working directory and its parents will be searched." + ), + type=click.Path(exists=True, file_okay=False, path_type=Path), +) +@click.option( + "-n", + "--name", + default="alembic", + show_default=True, + help="Name of the migration. See https://alembic.sqlalchemy.org/en/latest/cookbook.html#multiple-environments", +) +@click.option( + "-P", + "--plugin-path", + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="Path to the plugin directory.", +) +@click.pass_context +def g0v0_migrate( + ctx: click.Context, + config: Path | None, + name: str, + g0v0_server_path: Path | None, + plugin_path: Path | None, +): + config_path: Path | None = config + if g0v0_server_path is None: + g0v0_server_path = detect_g0v0_server_path() + if g0v0_server_path is None: + raise click.ClickException("Could not detect g0v0-server path. Please provide it via --path option.") + click.echo(f"Detected g0v0-server at {g0v0_server_path}") + if config_path is None: + config_path = g0v0_server_path / "alembic.ini" + if not config_path.exists(): + raise click.ClickException( + f"Could not find alembic.ini at {config_path}. Please provide it via --config option." + ) + alembic_config = AlembicConfig(config_path.as_posix()) + alembic_config.config_ini_section = name + + # detect cwd is a plugin. + plugin_id: str | None = None + cwd = (plugin_path or Path.cwd()).resolve() + if cwd.joinpath("plugin.json").exists(): + try: + plugin_id = get_plugin_id(cwd) + except ValueError as e: + raise click.ClickException(str(e)) + click.echo(f"Detected plugin {plugin_id} at {cwd}") + + alembic_config.set_section_option( + alembic_config.config_ini_section, "script_location", cwd.joinpath("migrations").as_posix() + ) + + obj: ContextObject = { + "g0v0_server_path": g0v0_server_path, + "alembic_config": alembic_config, + "plugin_path": cwd, + "plugin_id": plugin_id, + } + ctx.obj = obj + + +@g0v0_migrate.command() +@click.option( + "-r", + "--rev-range", + default=None, + help="Specify a revision range; format is [start]:[end].", +) +@click.pass_context +def history(ctx: click.Context, rev_range: str | None): + """List changeset scripts in chronological order.""" + alembic.command.history(ctx.obj["alembic_config"], rev_range=rev_range) + + +@g0v0_migrate.command() +@click.option( + "-m", + "--message", + default="", + help="Message string to use with the revision.", +) +@click.option( + "--autogenerate", + default=False, + help="Populate revision script with candidate migration operations, based on comparison of database to model.", + is_flag=True, +) +@click.option( + "--sql", + default=False, + help="Don't emit SQL to database - dump to standard output/file instead. See https://alembic.sqlalchemy.org/en/latest/offline.html", + is_flag=True, +) +@click.option("--head", default="head", help="Specify head revision or @head to base new revision on.") +@click.option("--splice", is_flag=True, help="Allow a non-head revision as the 'head' to splice onto.") +@click.option("--branch-label", default=None, help="Specify a branch label to apply to the new revision.") +@click.option("--version-path", default=None, help="Specify a version path to place the new revision file in.") +@click.option("--rev-id", default=None, help="Specify a hardcoded revision id instead of generating one.") +@click.option("--depends-on", default=None, help="Specify one or more revisions that this revision depends on.") +@click.pass_context +def revision( + ctx: click.Context, + message: str, + autogenerate: bool, + sql: bool, + head: str, + splice: bool, + branch_label: str | None, + version_path: str | None, + rev_id: str | None, + depends_on: str | None, +): + """Create a new revision file.""" + obj: ContextObject = ctx.obj + + with _ensure_env(obj, autogenerate=autogenerate): + alembic.command.revision( + obj["alembic_config"], + message=message, + autogenerate=autogenerate, + sql=sql, + head=head, + splice=splice, + branch_label=branch_label, + version_path=version_path, + rev_id=rev_id, + depends_on=depends_on, + ) + + +@g0v0_migrate.command() +@click.argument( + "revision", +) +@click.option( + "--sql", + is_flag=True, + help="Don't emit SQL to database - dump to standard output/file instead. See https://alembic.sqlalchemy.org/en/latest/offline.html", +) +@click.pass_context +def upgrade(ctx: click.Context, revision: str, sql: bool): + """Upgrade to a later version.""" + obj: ContextObject = ctx.obj + + with _ensure_env(ctx.obj): + alembic.command.upgrade(obj["alembic_config"], revision, sql=sql) + + +@g0v0_migrate.command() +@click.argument( + "revision", +) +@click.option( + "--sql", + is_flag=True, + help="Don't emit SQL to database - dump to standard output/file instead. See https://alembic.sqlalchemy.org/en/latest/offline.html", +) +@click.pass_context +def downgrade(ctx: click.Context, revision: str, sql: bool): + """Downgrade to an earlier version.""" + obj: ContextObject = ctx.obj + + with _ensure_env(ctx.obj): + alembic.command.downgrade(obj["alembic_config"], revision, sql=sql) + + +@g0v0_migrate.command() +@click.pass_context +def current(ctx: click.Context): + """Display the current revision for each database.""" + obj: ContextObject = ctx.obj + + with _ensure_env(ctx.obj): + alembic.command.current(obj["alembic_config"]) + + +@g0v0_migrate.command() +@click.option("-v", "--verbose", is_flag=True, help="Use more verbose output.") +@click.pass_context +def branches(ctx: click.Context, verbose: bool): + """Show current branch points.""" + alembic.command.branches(ctx.obj["alembic_config"], verbose=verbose) + + +@g0v0_migrate.command() +@click.pass_context +def check(ctx: click.Context): + """Check if there are any new operations to be generated.""" + obj: ContextObject = ctx.obj + with _ensure_env(obj): + alembic.command.check(obj["alembic_config"]) + + +@g0v0_migrate.command() +@click.argument("revision") +@click.pass_context +def edit(ctx: click.Context, revision: str): + """Edit revision script(s) using $EDITOR.""" + alembic.command.edit(ctx.obj["alembic_config"], revision) + + +@g0v0_migrate.command() +@click.option( + "--sql", + is_flag=True, + help="Don't emit SQL to database - dump to standard output/file instead. See https://alembic.sqlalchemy.org/en/latest/offline.html", +) +@click.pass_context +def ensure_version(ctx: click.Context, sql: bool): + """Create the alembic version table if it doesn't exist already.""" + obj: ContextObject = ctx.obj + with _ensure_env(obj): + alembic.command.ensure_version(obj["alembic_config"], sql=sql) + + +@g0v0_migrate.command() +@click.option("-v", "--verbose", is_flag=True, help="Use more verbose output.") +@click.option( + "--resolve-dependencies", + is_flag=True, + help="Treat dependency versions as down revisions.", +) +@click.pass_context +def heads(ctx: click.Context, verbose: bool, resolve_dependencies: bool): + """Show current available heads in the script directory.""" + alembic.command.heads( + ctx.obj["alembic_config"], + verbose=verbose, + resolve_dependencies=resolve_dependencies, + ) + + +@g0v0_migrate.command() +@click.pass_context +def list_templates(ctx: click.Context): + """List available templates.""" + alembic.command.list_templates(ctx.obj["alembic_config"]) + + +@g0v0_migrate.command() +@click.argument("revisions", nargs=-1) +@click.option("-m", "--message", default=None, help="Message string to use with the revision.") +@click.option("--branch-label", default=None, help="Specify a branch label to apply to the new revision.") +@click.option("--rev-id", default=None, help="Specify a hardcoded revision id instead of generating one.") +@click.pass_context +def merge( + ctx: click.Context, + revisions: tuple[str, ...], + message: str | None, + branch_label: str | None, + rev_id: str | None, +): + """Merge two revisions together. Creates a new migration file.""" + obj: ContextObject = ctx.obj + with _ensure_env(obj): + alembic.command.merge( + obj["alembic_config"], + revisions=revisions, + message=message, + branch_label=branch_label, + rev_id=rev_id, + ) + + +@g0v0_migrate.command() +@click.argument("revision") +@click.pass_context +def show(ctx: click.Context, revision: str): + """Show the revision(s) denoted by the given symbol.""" + alembic.command.show(ctx.obj["alembic_config"], revision) + + +@g0v0_migrate.command() +@click.argument("revision") +@click.option( + "--sql", + is_flag=True, + help="Don't emit SQL to database - dump to standard output/file instead. See https://alembic.sqlalchemy.org/en/latest/offline.html", +) +@click.option("--tag", default=None, help="Arbitrary 'tag' name - can be used by custom env.py scripts.") +@click.option("--purge", is_flag=True, help="Unconditionally erase the version table before stamping.") +@click.pass_context +def stamp(ctx: click.Context, revision: str, sql: bool, tag: str | None, purge: bool): + """'stamp' the revision table with the given revision; don't run any migrations.""" + obj: ContextObject = ctx.obj + with _ensure_env(obj): + alembic.command.stamp(obj["alembic_config"], revision, sql=sql, tag=tag, purge=purge) + + +@g0v0_migrate.command() +@click.pass_context +def upgrade_all(ctx: click.Context): + """Upgrade the g0v0-server and all plugins' databases to the latest version.""" + obj: ContextObject = ctx.obj + + if obj["g0v0_server_path"] != Path.cwd().resolve(): + raise click.ClickException("Please run this command from the g0v0-server root directory.") + + # Upgrade g0v0-server + click.echo("Upgrading g0v0-server...") + with _ensure_env(obj): + alembic.command.upgrade(obj["alembic_config"], "head") + # Upgrade plugins + plugins_path = obj["g0v0_server_path"].joinpath("plugins") + if not plugins_path.exists(): + click.echo("No plugins directory found, skipping plugin upgrades.") + return + for plugin_dir in plugins_path.iterdir(): + if not plugin_dir.is_dir(): + continue + try: + plugin_id = get_plugin_id(plugin_dir) + except ValueError as e: + click.echo(f"{e}, skipping...") + continue + click.echo(f"Upgrading plugin {plugin_id}...") + alembic_config = obj["alembic_config"] + alembic_config.set_section_option( + alembic_config.config_ini_section, "script_location", plugin_dir.joinpath("migrations").as_posix() + ) + with _ensure_env( + { + **obj, + "plugin_id": plugin_id, + "plugin_path": plugin_dir, + } + ): + alembic.command.upgrade(obj["alembic_config"], "head") + + +if __name__ == "__main__": + g0v0_migrate() diff --git a/packages/g0v0-migrations/g0v0_migrations/model.py b/packages/g0v0-migrations/g0v0_migrations/model.py new file mode 100644 index 0000000..8528ba5 --- /dev/null +++ b/packages/g0v0-migrations/g0v0_migrations/model.py @@ -0,0 +1,51 @@ +from pathlib import Path +from typing import Annotated, TypedDict + +from alembic.config import Config as AlembicConfig +from pydantic import Field +from pydantic_settings import BaseSettings +from pydantic_settings.main import SettingsConfigDict + + +class G0v0ServerDatabaseConfig(BaseSettings): + model_config: SettingsConfigDict = SettingsConfigDict( + extra="ignore", + env_file_encoding="utf-8", + ) + + mysql_host: Annotated[ + str, + Field(default="localhost", description="MySQL 服务器地址"), + "数据库设置", + ] + mysql_port: Annotated[ + int, + Field(default=3306, description="MySQL 服务器端口"), + "数据库设置", + ] + mysql_database: Annotated[ + str, + Field(default="osu_api", description="MySQL 数据库名称"), + "数据库设置", + ] + mysql_user: Annotated[ + str, + Field(default="osu_api", description="MySQL 用户名"), + "数据库设置", + ] + mysql_password: Annotated[ + str, + Field(default="password", description="MySQL 密码"), + "数据库设置", + ] + + @property + def database_url(self) -> str: + return f"mysql+aiomysql://{self.mysql_user}:{self.mysql_password}@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}" + + +class ContextObject(TypedDict): + g0v0_server_path: Path + plugin_path: Path + alembic_config: AlembicConfig + plugin_id: str | None diff --git a/packages/g0v0-migrations/g0v0_migrations/templates/env.py b/packages/g0v0-migrations/g0v0_migrations/templates/env.py new file mode 100644 index 0000000..3b377e1 --- /dev/null +++ b/packages/g0v0-migrations/g0v0_migrations/templates/env.py @@ -0,0 +1,138 @@ +import asyncio # noqa: INP001 +from logging.config import fileConfig + +# +from alembic import context +from alembic.operations import ops +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlmodel import SQLModel + +# 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 = SQLModel.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. + +PLUGIN_NAME = "" +if PLUGIN_NAME == "": + raise ValueError( + "PLUGIN_NAME cannot be an empty string, please report a bug to developers: https://github.com/GooGuTeam/g0v0-server/issues" + ) +ALEMBIC_VERSION_TABLE_NAME = f"{PLUGIN_NAME}_alembic_version" + + +def is_plugin_prefix(name: str) -> bool: + return bool(PLUGIN_NAME) and name.startswith(f"plugin_{PLUGIN_NAME}_") + + +def process_revision_directives(context, revision, directives): # noqa: ARG001 + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + for op in [*script.upgrade_ops.ops, *script.downgrade_ops.ops]: + if isinstance(op, ops.RenameTableOp): + old_name = op.table_name + new_name = op.new_table_name + if not is_plugin_prefix(old_name): + op.table_name = f"plugin_{PLUGIN_NAME}_{old_name}" + if not is_plugin_prefix(new_name): + op.new_table_name = f"plugin_{PLUGIN_NAME}_{new_name}" + else: + table_name = getattr(op, "table_name", None) + if table_name and not is_plugin_prefix(table_name): + setattr(op, "table_name", f"plugin_{PLUGIN_NAME}_{table_name}") + + +def include_object(object, name, type_, reflected, compare_to) -> bool: # noqa: ARG001 + if type_ != "table": + return True + if name.startswith("plugin_"): + # Only include tables with the current plugin prefix to avoid affecting other plugins' tables + return is_plugin_prefix(name) + return not (name.endswith("alembic_version") and name != ALEMBIC_VERSION_TABLE_NAME) + + +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. + + """ + context.configure( + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + dialect_opts={"paramstyle": "named"}, + version_table=ALEMBIC_VERSION_TABLE_NAME, + process_revision_directives=process_revision_directives, + include_object=include_object, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + version_table=ALEMBIC_VERSION_TABLE_NAME, + process_revision_directives=process_revision_directives, + include_object=include_object, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + sa_config = config.get_section(config.config_ini_section, {}) + connectable = async_engine_from_config( + sa_config, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/packages/g0v0-migrations/g0v0_migrations/templates/script.py.mako b/packages/g0v0-migrations/g0v0_migrations/templates/script.py.mako new file mode 100644 index 0000000..697cf67 --- /dev/null +++ b/packages/g0v0-migrations/g0v0_migrations/templates/script.py.mako @@ -0,0 +1,29 @@ +"""${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 +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/packages/g0v0-migrations/g0v0_migrations/utils.py b/packages/g0v0-migrations/g0v0_migrations/utils.py new file mode 100644 index 0000000..576d02f --- /dev/null +++ b/packages/g0v0-migrations/g0v0_migrations/utils.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path +import re +import tomllib + + +def detect_g0v0_server_path() -> Path | None: + """Detect the g0v0 server path from the current working directory to parents. + + Returns: + The path to the g0v0 server, or None if not found. + """ + cwd = Path.cwd() + for path in [cwd, *list(cwd.parents)]: + if (pyproject := (path / "pyproject.toml")).exists(): + try: + content = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError: + continue + if "project" in content and content["project"].get("name") == "g0v0-server": + return path.resolve() + + return None + + +def get_plugin_id(plugin_path: Path) -> str: + """Get the plugin ID from the plugin.json file. + + Args: + plugin_path: The path to the plugin directory. + + Returns: + The plugin ID. + + Raises: + """ + if not plugin_path.joinpath("plugin.json").exists(): + raise ValueError(f"No plugin.json found at {plugin_path / 'plugin.json'}.") + try: + meta = json.loads(plugin_path.joinpath("plugin.json").read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Malformed plugin.json at {plugin_path / 'plugin.json'}: {e}") + plugin_id = meta.get("id") + if plugin_id is None: + raise ValueError(f"Could not detect plugin id from {plugin_path / 'plugin.json'}.") + if re.match(r"^[a-z0-9\-]+$", plugin_id) is None: + raise ValueError( + f"Invalid plugin id '{plugin_id}' in {plugin_path / 'plugin.json'}. Must match '^[a-z0-9\\-]+$'." + ) + return plugin_id diff --git a/packages/g0v0-migrations/pyproject.toml b/packages/g0v0-migrations/pyproject.toml new file mode 100644 index 0000000..555a16b --- /dev/null +++ b/packages/g0v0-migrations/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "g0v0-migrations" +version = "0.1.0" +description = "Migration tools for g0v0-server and plugins." +requires-python = ">=3.12" +dependencies = [ + "aiomysql>=0.3.2", + "alembic>=1.17.2", + "click>=8.3.0", + "pydantic>=2.11.9", + "pydantic-settings>=2.12.0", +] + +[project.scripts] +g0v0-migrate = "g0v0_migrations.__main__:g0v0_migrate" diff --git a/pyproject.toml b/pyproject.toml index 6dc4d31..fadab1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "cryptography>=41.0.7", "fastapi>=0.104.1", "fastapi-limiter>=0.1.6", + "g0v0-migrations", "httpx>=0.28.1", "jinja2>=3.1.0", "loguru>=0.7.3", @@ -123,10 +124,12 @@ exclude = ["migrations/", ".venv/", "venv/"] [tool.uv.workspace] members = [ "packages/osupyparser", + "packages/g0v0-migrations", ] [tool.uv.sources] osupyparser = { git = "https://github.com/MingxuanGame/osupyparser.git" } +g0v0-migrations = { workspace = true } [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index 00575ed..d8253ae 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.12" +[manifest] +members = [ + "g0v0-migrations", + "g0v0-server", +] + [[package]] name = "aioboto3" version = "15.5.0" @@ -714,6 +720,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "g0v0-migrations" +version = "0.1.0" +source = { editable = "packages/g0v0-migrations" } +dependencies = [ + { name = "aiomysql" }, + { name = "alembic" }, + { name = "click" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiomysql", specifier = ">=0.3.2" }, + { name = "alembic", specifier = ">=1.17.2" }, + { name = "click", specifier = ">=8.3.0" }, + { name = "pydantic", specifier = ">=2.11.9" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, +] + [[package]] name = "g0v0-server" version = "0.1.0" @@ -729,6 +756,7 @@ dependencies = [ { name = "cryptography" }, { name = "fastapi" }, { name = "fastapi-limiter" }, + { name = "g0v0-migrations" }, { name = "httpx" }, { name = "jinja2" }, { name = "loguru" }, @@ -779,6 +807,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=41.0.7" }, { name = "fastapi", specifier = ">=0.104.1" }, { name = "fastapi-limiter", specifier = ">=0.1.6" }, + { name = "g0v0-migrations", editable = "packages/g0v0-migrations" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "loguru", specifier = ">=0.7.3" },