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>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -231,3 +231,4 @@ config/*
|
||||
!config/.gitkeep
|
||||
osu-web-master/*
|
||||
performance-server
|
||||
plugins/
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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()
|
||||
|
||||
417
packages/g0v0-migrations/g0v0_migrations/__main__.py
Normal file
417
packages/g0v0-migrations/g0v0_migrations/__main__.py
Normal file
@@ -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("<name_placeholder>", 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(
|
||||
"# <import_placeholder>",
|
||||
(
|
||||
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 <branchname>@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()
|
||||
51
packages/g0v0-migrations/g0v0_migrations/model.py
Normal file
51
packages/g0v0-migrations/g0v0_migrations/model.py
Normal file
@@ -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
|
||||
138
packages/g0v0-migrations/g0v0_migrations/templates/env.py
Normal file
138
packages/g0v0-migrations/g0v0_migrations/templates/env.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import asyncio # noqa: INP001
|
||||
from logging.config import fileConfig
|
||||
|
||||
# <import_placeholder>
|
||||
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 = "<name_placeholder>"
|
||||
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()
|
||||
@@ -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"}
|
||||
50
packages/g0v0-migrations/g0v0_migrations/utils.py
Normal file
50
packages/g0v0-migrations/g0v0_migrations/utils.py
Normal file
@@ -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
|
||||
15
packages/g0v0-migrations/pyproject.toml
Normal file
15
packages/g0v0-migrations/pyproject.toml
Normal file
@@ -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"
|
||||
@@ -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 = [
|
||||
|
||||
29
uv.lock
generated
29
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user