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:
MingxuanGame
2025-12-13 17:56:34 +08:00
committed by GitHub
parent 511150cc4c
commit bb181d930a
13 changed files with 749 additions and 4 deletions

1
.gitignore vendored
View File

@@ -231,3 +231,4 @@ config/*
!config/.gitkeep !config/.gitkeep
osu-web-master/* osu-web-master/*
performance-server performance-server
plugins/

View File

@@ -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 ./

View File

@@ -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 "$@"

View File

@@ -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()

View 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()

View 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

View 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()

View File

@@ -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"}

View 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

View 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"

View File

@@ -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
View File

@@ -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" },