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
|
!config/.gitkeep
|
||||||
osu-web-master/*
|
osu-web-master/*
|
||||||
performance-server
|
performance-server
|
||||||
|
plugins/
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ ENV PYTHONDONTWRITEBYTECODE=1
|
|||||||
ENV UV_PROJECT_ENVIRONMENT=/app/.venv
|
ENV UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY packages/ ./packages/
|
||||||
|
|
||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
COPY tools/ ./tools/
|
COPY tools/ ./tools/
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
COPY static/ ./app/static/
|
COPY static/ ./static/
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
COPY main.py ./
|
COPY main.py ./
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ done
|
|||||||
echo "Database connected."
|
echo "Database connected."
|
||||||
|
|
||||||
echo "Running alembic..."
|
echo "Running alembic..."
|
||||||
uv run --no-sync alembic upgrade head
|
uv run --no-sync g0v0-migrate upgrade-all
|
||||||
|
|
||||||
# 把控制权交给最终命令
|
# 把控制权交给最终命令
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ target_metadata = SQLModel.metadata
|
|||||||
# ... etc.
|
# ... 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:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode.
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
@@ -44,13 +50,13 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
url = settings.database_url
|
url = settings.database_url
|
||||||
print(url)
|
|
||||||
context.configure(
|
context.configure(
|
||||||
url=url,
|
url=url,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
compare_type=True,
|
compare_type=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
include_object=include_object,
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
@@ -58,7 +64,12 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> 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():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
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",
|
"cryptography>=41.0.7",
|
||||||
"fastapi>=0.104.1",
|
"fastapi>=0.104.1",
|
||||||
"fastapi-limiter>=0.1.6",
|
"fastapi-limiter>=0.1.6",
|
||||||
|
"g0v0-migrations",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"jinja2>=3.1.0",
|
"jinja2>=3.1.0",
|
||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
@@ -123,10 +124,12 @@ exclude = ["migrations/", ".venv/", "venv/"]
|
|||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
members = [
|
members = [
|
||||||
"packages/osupyparser",
|
"packages/osupyparser",
|
||||||
|
"packages/g0v0-migrations",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
osupyparser = { git = "https://github.com/MingxuanGame/osupyparser.git" }
|
osupyparser = { git = "https://github.com/MingxuanGame/osupyparser.git" }
|
||||||
|
g0v0-migrations = { workspace = true }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
29
uv.lock
generated
29
uv.lock
generated
@@ -2,6 +2,12 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[manifest]
|
||||||
|
members = [
|
||||||
|
"g0v0-migrations",
|
||||||
|
"g0v0-server",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aioboto3"
|
name = "aioboto3"
|
||||||
version = "15.5.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "g0v0-server"
|
name = "g0v0-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -729,6 +756,7 @@ dependencies = [
|
|||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastapi-limiter" },
|
{ name = "fastapi-limiter" },
|
||||||
|
{ name = "g0v0-migrations" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "loguru" },
|
{ name = "loguru" },
|
||||||
@@ -779,6 +807,7 @@ requires-dist = [
|
|||||||
{ name = "cryptography", specifier = ">=41.0.7" },
|
{ name = "cryptography", specifier = ">=41.0.7" },
|
||||||
{ name = "fastapi", specifier = ">=0.104.1" },
|
{ name = "fastapi", specifier = ">=0.104.1" },
|
||||||
{ name = "fastapi-limiter", specifier = ">=0.1.6" },
|
{ name = "fastapi-limiter", specifier = ">=0.1.6" },
|
||||||
|
{ name = "g0v0-migrations", editable = "packages/g0v0-migrations" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
{ name = "loguru", specifier = ">=0.7.3" },
|
{ name = "loguru", specifier = ">=0.7.3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user