Merge branch '217-flask-alembic' into 'main'

SQL migrations with flask-alembic

Closes #217

See merge request yaal/canaille!214
This commit is contained in:
Éloi Rivard 2025-01-10 11:33:40 +00:00
commit fed0dc9042
21 changed files with 580 additions and 35 deletions

View file

@ -86,6 +86,7 @@ def create_app(
config: dict = None,
validate: bool = True,
backend=None,
init_backend=None,
env_file: str = None,
env_prefix: str = "",
):
@ -119,7 +120,7 @@ def create_app(
sentry_sdk = setup_sentry(app)
try:
setup_logging(app)
backend = setup_backend(app, backend)
backend = setup_backend(app, backend, init_backend)
setup_features(app)
setup_flask_converters(app)
setup_blueprints(app)

View file

@ -54,7 +54,7 @@ def install():
from canaille.app.installation import install
try:
install(current_app.config)
install(current_app)
except ConfigurationException as exc: # pragma: no cover
print(exc)

View file

@ -63,7 +63,7 @@ class Backend:
def instance(cls):
return cls._instance
def init_app(self, app):
def init_app(self, app, init_backend=None):
@app.before_request
def before_request():
return self.setup()
@ -79,7 +79,7 @@ class Backend:
self.teardown()
@classmethod
def install(self, config):
def install(self, app):
"""Prepare the database to host canaille data."""
raise NotImplementedError()
@ -203,7 +203,7 @@ class Backend:
models.register(getattr(backend_models, model_name))
def setup_backend(app, backend=None):
def setup_backend(app, backend=None, init_backend=None):
if not backend:
prefix = "CANAILLE_"
available_backends_names = [
@ -224,7 +224,7 @@ def setup_backend(app, backend=None):
module, f"{backend_name.title()}Backend", None
) or getattr(module, f"{backend_name.upper()}Backend", None)
backend = backend_class(app.config)
backend.init_app(app)
backend.init_app(app, init_backend)
with app.app_context():
g.backend = backend

View file

@ -61,9 +61,9 @@ class LDAPBackend(Backend):
setup_ldap_models(config)
@classmethod
def install(cls, config):
cls.setup_schemas(config)
with cls(config).session():
def install(cls, app):
cls.setup_schemas(app.config)
with cls(app.config).session():
models.Token.install()
models.AuthorizationCode.install()
models.Client.install()

View file

@ -41,7 +41,7 @@ class MemoryBackend(Backend):
)
@classmethod
def install(cls, config):
def install(cls, app):
pass
def setup(self):

View file

@ -1,6 +1,8 @@
import datetime
from pathlib import Path
from flask import current_app
from flask_alembic import Alembic
from sqlalchemy import create_engine
from sqlalchemy import or_
from sqlalchemy import select
@ -15,14 +17,6 @@ from canaille.backends import get_lockout_delay_message
Base = declarative_base()
def db_session(db_uri=None, init=False):
engine = create_engine(db_uri, echo=False, future=True)
if init:
Base.metadata.create_all(engine)
session = Session(engine)
return session
class SQLModelEncoder(ModelEncoder):
def default(self, obj):
if isinstance(obj, Password):
@ -31,24 +25,45 @@ class SQLModelEncoder(ModelEncoder):
class SQLBackend(Backend):
engine = None
db_session = None
json_encoder = SQLModelEncoder
alembic = None
def __init__(self, config):
super().__init__(config)
SQLBackend.engine = create_engine(
self.config["CANAILLE_SQL"]["DATABASE_URI"], echo=False, future=True
)
SQLBackend.alembic = Alembic(metadatas=Base.metadata, engines=SQLBackend.engine)
@classmethod
def install(cls, config): # pragma: no cover
engine = create_engine(
config["CANAILLE_SQL"]["DATABASE_URI"],
echo=False,
future=True,
)
Base.metadata.create_all(engine)
def install(cls, app): # pragma: no cover
cls.init_alembic(app)
SQLBackend.alembic.upgrade()
def setup(self, init=False):
if not self.db_session:
self.db_session = db_session(
self.config["CANAILLE_SQL"]["DATABASE_URI"],
init=init,
@classmethod
def init_alembic(cls, app):
app.config["ALEMBIC"] = {
"script_location": str(Path(__file__).resolve().parent / "migrations"),
}
SQLBackend.alembic.init_app(app)
def init_app(self, app, init_backend=None):
super().init_app(app)
self.init_alembic(app)
init_backend = (
app.config["CANAILLE_SQL"]["AUTO_MIGRATE"]
if init_backend is None
else init_backend
)
if init_backend: # pragma: no cover
with app.app_context():
self.alembic.upgrade()
def setup(self):
if not self.db_session:
self.db_session = Session(SQLBackend.engine)
def teardown(self):
pass

View file

@ -22,3 +22,14 @@ class SQLSettings(BaseModel):
Defines password hashing scheme in SQL database.
examples : "mssql2000", "ldap_salted_sha1", "pbkdf2_sha512"
"""
AUTO_MIGRATE: bool = True
"""Whether to automatically apply database migrations.
If :data:`True`, database migrations will be automatically applied when Canaille web application is launched.
If :data:`False`, migrations must be applied manually with ``canaille db upgrade``.
.. note::
When running the CLI, migrations will never be applied.
"""

View file

@ -0,0 +1,313 @@
"""initial migration
Represents the state of the database in version 0.0.56
Revision ID: 1736443094
Revises:
Create Date: 2025-01-09 18:18:14.276914
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlalchemy_utils.types.password
from alembic import op
import canaille.backends.sql.utils
# revision identifiers, used by Alembic.
revision: str = "1736443094"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = ("default",)
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"client",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"last_modified",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("description", sa.String(), nullable=True),
sa.Column("preconsent", sa.Boolean(), nullable=True),
sa.Column("post_logout_redirect_uris", sa.JSON(), nullable=True),
sa.Column("client_id", sa.String(), nullable=True),
sa.Column("client_secret", sa.String(), nullable=True),
sa.Column(
"client_id_issued_at",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"client_secret_expires_at",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("client_name", sa.String(), nullable=True),
sa.Column("contacts", sa.JSON(), nullable=True),
sa.Column("client_uri", sa.String(), nullable=True),
sa.Column("redirect_uris", sa.JSON(), nullable=True),
sa.Column("logo_uri", sa.String(), nullable=True),
sa.Column("grant_types", sa.JSON(), nullable=True),
sa.Column("response_types", sa.JSON(), nullable=True),
sa.Column("scope", sa.JSON(), nullable=True),
sa.Column("tos_uri", sa.String(), nullable=True),
sa.Column("policy_uri", sa.String(), nullable=True),
sa.Column("jwks_uri", sa.String(), nullable=True),
sa.Column("jwk", sa.String(), nullable=True),
sa.Column("token_endpoint_auth_method", sa.String(), nullable=True),
sa.Column("software_id", sa.String(), nullable=True),
sa.Column("software_version", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"group",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"last_modified",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("display_name", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"user",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"last_modified",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("user_name", sa.String(), nullable=False),
sa.Column(
"password",
sqlalchemy_utils.types.password.PasswordType(max_length=4096),
nullable=True,
),
sa.Column("preferred_language", sa.String(), nullable=True),
sa.Column("family_name", sa.String(), nullable=True),
sa.Column("given_name", sa.String(), nullable=True),
sa.Column("formatted_name", sa.String(), nullable=True),
sa.Column("display_name", sa.String(), nullable=True),
sa.Column("emails", sa.JSON(), nullable=True),
sa.Column("phone_numbers", sa.JSON(), nullable=True),
sa.Column("formatted_address", sa.String(), nullable=True),
sa.Column("street", sa.String(), nullable=True),
sa.Column("postal_code", sa.String(), nullable=True),
sa.Column("locality", sa.String(), nullable=True),
sa.Column("region", sa.String(), nullable=True),
sa.Column("photo", sa.LargeBinary(), nullable=True),
sa.Column("profile_url", sa.String(), nullable=True),
sa.Column("employee_number", sa.String(), nullable=True),
sa.Column("department", sa.String(), nullable=True),
sa.Column("title", sa.String(), nullable=True),
sa.Column("organization", sa.String(), nullable=True),
sa.Column(
"lock_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_name"),
)
op.create_table(
"authorization_code",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"last_modified",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("authorization_code_id", sa.String(), nullable=True),
sa.Column("code", sa.String(), nullable=True),
sa.Column("client_id", sa.String(), nullable=False),
sa.Column("subject_id", sa.String(), nullable=False),
sa.Column("redirect_uri", sa.String(), nullable=True),
sa.Column("response_type", sa.String(), nullable=True),
sa.Column("scope", sa.JSON(), nullable=True),
sa.Column("nonce", sa.String(), nullable=True),
sa.Column(
"issue_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column("challenge", sa.String(), nullable=True),
sa.Column("challenge_method", sa.String(), nullable=True),
sa.Column(
"revokation_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.ForeignKeyConstraint(
["client_id"],
["client.id"],
),
sa.ForeignKeyConstraint(
["subject_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"client_audience_association_table",
sa.Column("audience_id", sa.String(), nullable=True),
sa.Column("client_id", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["audience_id"],
["client.id"],
),
sa.ForeignKeyConstraint(
["client_id"],
["client.id"],
),
sa.PrimaryKeyConstraint("audience_id", "client_id"),
)
op.create_table(
"consent",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"last_modified",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("consent_id", sa.String(), nullable=True),
sa.Column("subject_id", sa.String(), nullable=False),
sa.Column("client_id", sa.String(), nullable=False),
sa.Column("scope", sa.JSON(), nullable=True),
sa.Column(
"issue_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"revokation_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.ForeignKeyConstraint(
["client_id"],
["client.id"],
),
sa.ForeignKeyConstraint(
["subject_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"membership_association_table",
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("group_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["group_id"],
["group.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("user_id", "group_id"),
)
op.create_table(
"token",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column(
"last_modified",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("token_id", sa.String(), nullable=True),
sa.Column("access_token", sa.String(), nullable=True),
sa.Column("client_id", sa.String(), nullable=False),
sa.Column("subject_id", sa.String(), nullable=False),
sa.Column("type", sa.String(), nullable=True),
sa.Column("refresh_token", sa.String(), nullable=True),
sa.Column("scope", sa.JSON(), nullable=True),
sa.Column(
"issue_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column(
"revokation_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
sa.ForeignKeyConstraint(
["client_id"],
["client.id"],
),
sa.ForeignKeyConstraint(
["subject_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"token_audience_association_table",
sa.Column("token_id", sa.String(), nullable=True),
sa.Column("client_id", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["client_id"],
["client.id"],
),
sa.ForeignKeyConstraint(
["token_id"],
["token.id"],
),
sa.PrimaryKeyConstraint("token_id", "client_id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("token_audience_association_table")
op.drop_table("token")
op.drop_table("membership_association_table")
op.drop_table("consent")
op.drop_table("client_audience_association_table")
op.drop_table("authorization_code")
op.drop_table("user")
op.drop_table("group")
op.drop_table("client")
# ### end Alembic commands ###

View file

@ -0,0 +1,76 @@
"""0.0.58
Revision ID: 1736443538
Revises: 1736443094
Create Date: 2025-01-09 18:25:38.443578
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import canaille.backends.sql.utils
# revision identifiers, used by Alembic.
revision: str = "1736443538"
down_revision: str | None = "1736443094"
branch_labels: str | Sequence[str] | None = ()
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("token") as batch_op:
batch_op.alter_column("subject_id", existing_type=sa.VARCHAR(), nullable=True)
op.add_column(
"user",
sa.Column(
"password_last_update",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"user", sa.Column("_password_failure_timestamps", sa.JSON(), nullable=True)
)
op.add_column(
"user",
sa.Column(
"last_otp_login",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
)
op.add_column("user", sa.Column("secret_token", sa.String(), nullable=True))
op.add_column("user", sa.Column("hotp_counter", sa.Integer(), nullable=True))
op.add_column("user", sa.Column("one_time_password", sa.String(), nullable=True))
op.add_column(
"user",
sa.Column(
"one_time_password_emission_date",
canaille.backends.sql.utils.TZDateTime(timezone=True),
nullable=True,
),
)
with op.batch_alter_table("user") as batch_op:
batch_op.create_unique_constraint("uq_user_secret_token", ["secret_token"])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user") as batch_op:
batch_op.drop_constraint("uq_user_secret_token", type_="unique")
op.drop_column("user", "one_time_password_emission_date")
op.drop_column("user", "one_time_password")
op.drop_column("user", "hotp_counter")
op.drop_column("user", "secret_token")
op.drop_column("user", "last_otp_login")
op.drop_column("user", "_password_failure_timestamps")
op.drop_column("user", "password_last_update")
with op.batch_alter_table("token") as batch_op:
batch_op.alter_column("subject_id", existing_type=sa.VARCHAR(), nullable=False)
# ### end Alembic commands ###

View file

@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlalchemy_utils.types.password
from alembic import op
import canaille.backends.sql.utils
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -12,9 +12,14 @@ from canaille import create_app
version = importlib.metadata.version("canaille")
def create_cli_app(): # pragma: no cover
# Force the non-application of migrations
return create_app(init_backend=False)
@click.group(
cls=FlaskGroup,
create_app=create_app,
create_app=create_cli_app,
add_version_option=False,
add_default_commands=False,
)

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -15,7 +15,7 @@ def populate(app):
from canaille.core.populate import fake_users
with app.app_context():
app.backend.install(app.config)
app.backend.install(app)
with app.backend.session():
if app.backend.query(models.User):
return

View file

@ -61,6 +61,7 @@ intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"authlib": ("https://docs.authlib.org/en/latest", None),
"flask": ("https://flask.palletsprojects.com", None),
"flask-alembic": ("https://flask-alembic.readthedocs.io/en/latest", None),
"flask-babel": ("https://python-babel.github.io/flask-babel", None),
"flask-wtf": ("https://flask-wtf.readthedocs.io", None),
"pydantic": ("https://docs.pydantic.dev/latest", None),

View file

@ -18,6 +18,9 @@ SQL
Canaille can use any database supported by `SQLAlchemy <https://www.sqlalchemy.org/>`_, such as
sqlite, postgresql or mariadb.
Configuration
-------------
It is used when the ``CANAILLE_SQL`` configuration parameter is defined. For instance:
.. code-block:: toml
@ -28,6 +31,16 @@ It is used when the ``CANAILLE_SQL`` configuration parameter is defined. For ins
You can find more details on the SQL configuration in the :class:`dedicated section <canaille.backends.sql.configuration.SQLSettings>`.
Migrations
----------
By default, migrations are applied when you run the web application.
You can disable this behavior with the :attr:`~canaille.backends.sql.configuration.SQLSettings.AUTO_MIGRATE` setting.
Migrations are not automatically applied with the use of the CLI though.
Migrations are done with :doc:`flask-alembic <flask-alembic:use>`, that provides a dedicated CLI to manually tune migrations.
You can check the :doc:`flask-alembic documentation <flask-alembic:index>` and the ``canaille db`` command line if you are in trouble.
LDAP
====

View file

@ -66,6 +66,7 @@ sentry = [
]
sqlite = [
"flask-alembic>=3.1.1",
"passlib >= 1.7.4",
"sqlalchemy >= 2.0.23",
"sqlalchemy-json >= 0.7.0",
@ -73,6 +74,7 @@ sqlite = [
]
postgresql = [
"flask-alembic>=3.1.1",
"passlib >= 1.7.4",
"sqlalchemy[postgresql-psycopg2binary] >= 2.0.23",
"sqlalchemy-json >= 0.7.0",
@ -80,6 +82,7 @@ postgresql = [
]
mysql = [
"flask-alembic>=3.1.1",
"passlib >= 1.7.4",
"sqlalchemy[mysql-connector] >= 2.0.23",
"sqlalchemy-json >= 0.7.0",

View file

@ -16,5 +16,5 @@ def sql_backend(sqlalchemy_configuration):
config_obj = settings_factory(sqlalchemy_configuration)
config_dict = config_obj.model_dump()
backend = SQLBackend(config_dict)
with backend.session(init=True):
with backend.session():
yield backend

View file

@ -0,0 +1,4 @@
def test_migrations(app, backend):
"""Test downgrading back to the first revision, and then re-apply all migrations."""
backend.alembic.downgrade("base")
backend.alembic.upgrade()

View file

@ -5,7 +5,7 @@ from canaille.backends import Backend
def test_required_methods(testclient):
with pytest.raises(NotImplementedError):
Backend.install(config=None)
Backend.install(app=None)
with pytest.raises(NotImplementedError):
Backend.validate({})

View file

@ -176,8 +176,10 @@ def jinja_cache_directory(tmp_path_factory):
@pytest.fixture
def app(configuration, backend, jinja_cache_directory):
app = create_app(configuration, backend=backend)
# caches the Jinja compiled files for faster unit test execution
app.jinja_env.bytecode_cache = FileSystemBytecodeCache(jinja_cache_directory)
backend.install(app.config)
with app.app_context():
backend.install(app)
with app.test_request_context():
yield app

46
uv.lock
View file

@ -28,6 +28,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 },
]
[[package]]
name = "alembic"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/1e/8cb8900ba1b6360431e46fb7a89922916d3a1b017a8908a7c0499cc7e5f6/alembic-1.14.0.tar.gz", hash = "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b", size = 1916172 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/06/8b505aea3d77021b18dcbd8133aa1418f1a1e37e432a465b14c46b2c0eaa/alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25", size = 233482 },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@ -145,6 +159,7 @@ ldap = [
{ name = "python-ldap" },
]
mysql = [
{ name = "flask-alembic" },
{ name = "passlib" },
{ name = "sqlalchemy", extra = ["mysql-connector"] },
{ name = "sqlalchemy-json" },
@ -159,6 +174,7 @@ otp = [
{ name = "qrcode" },
]
postgresql = [
{ name = "flask-alembic" },
{ name = "passlib" },
{ name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] },
{ name = "sqlalchemy-json" },
@ -175,6 +191,7 @@ sms = [
{ name = "smpplib" },
]
sqlite = [
{ name = "flask-alembic" },
{ name = "passlib" },
{ name = "sqlalchemy" },
{ name = "sqlalchemy-json" },
@ -228,6 +245,9 @@ requires-dist = [
{ name = "authlib", marker = "extra == 'scim'", specifier = ">=1.3.0" },
{ name = "email-validator", marker = "extra == 'front'", specifier = ">=2.0.0" },
{ name = "flask", specifier = ">=3.0.0" },
{ name = "flask-alembic", marker = "extra == 'mysql'", specifier = ">=3.1.1" },
{ name = "flask-alembic", marker = "extra == 'postgresql'", specifier = ">=3.1.1" },
{ name = "flask-alembic", marker = "extra == 'sqlite'", specifier = ">=3.1.1" },
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
{ name = "flask-wtf", specifier = ">=1.2.1" },
@ -672,6 +692,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 },
]
[[package]]
name = "flask-alembic"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/08/84d5a3155bef151190bf665bc31389971daf9e82271086439bc8f8d3a7a5/flask_alembic-3.1.1.tar.gz", hash = "sha256:358a0ad8f74e2969273a8d89feaf5842f65cc909064b51fde4b51415790d92f4", size = 11943 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/32/9ecc1df856fd6ba96276393d374c8e7592c89bc1e5861c1c66d6a81b68f8/flask_alembic-3.1.1-py3-none-any.whl", hash = "sha256:1d0cda58518d4332d8563da555bee03107fe2169c7157f61a2e0759d0150209b", size = 12070 },
]
[[package]]
name = "flask-babel"
version = "4.0.0"
@ -1028,6 +1062,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/b2/6a22fb5c0885da3b00e116aee81f0b829ec9ac8f736cd414b4a09413fc7d/lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba", size = 3487557 },
]
[[package]]
name = "mako"
version = "1.3.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/d9/8518279534ed7dace1795d5a47e49d5299dd0994eed1053996402a8902f9/mako-1.3.8.tar.gz", hash = "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8", size = 392069 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/bf/7a6a36ce2e4cafdfb202752be68850e22607fccd692847c45c1ae3c17ba6/Mako-1.3.8-py3-none-any.whl", hash = "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", size = 78569 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"