forked from Github-Mirrors/canaille
feat: SQL migrations with flask-alembic
This commit is contained in:
parent
5aecb9a891
commit
733625e2bc
21 changed files with 580 additions and 35 deletions
|
@ -86,6 +86,7 @@ def create_app(
|
||||||
config: dict = None,
|
config: dict = None,
|
||||||
validate: bool = True,
|
validate: bool = True,
|
||||||
backend=None,
|
backend=None,
|
||||||
|
init_backend=None,
|
||||||
env_file: str = None,
|
env_file: str = None,
|
||||||
env_prefix: str = "",
|
env_prefix: str = "",
|
||||||
):
|
):
|
||||||
|
@ -119,7 +120,7 @@ def create_app(
|
||||||
sentry_sdk = setup_sentry(app)
|
sentry_sdk = setup_sentry(app)
|
||||||
try:
|
try:
|
||||||
setup_logging(app)
|
setup_logging(app)
|
||||||
backend = setup_backend(app, backend)
|
backend = setup_backend(app, backend, init_backend)
|
||||||
setup_features(app)
|
setup_features(app)
|
||||||
setup_flask_converters(app)
|
setup_flask_converters(app)
|
||||||
setup_blueprints(app)
|
setup_blueprints(app)
|
||||||
|
|
|
@ -54,7 +54,7 @@ def install():
|
||||||
from canaille.app.installation import install
|
from canaille.app.installation import install
|
||||||
|
|
||||||
try:
|
try:
|
||||||
install(current_app.config)
|
install(current_app)
|
||||||
|
|
||||||
except ConfigurationException as exc: # pragma: no cover
|
except ConfigurationException as exc: # pragma: no cover
|
||||||
print(exc)
|
print(exc)
|
||||||
|
|
|
@ -63,7 +63,7 @@ class Backend:
|
||||||
def instance(cls):
|
def instance(cls):
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def init_app(self, app):
|
def init_app(self, app, init_backend=None):
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def before_request():
|
def before_request():
|
||||||
return self.setup()
|
return self.setup()
|
||||||
|
@ -79,7 +79,7 @@ class Backend:
|
||||||
self.teardown()
|
self.teardown()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def install(self, config):
|
def install(self, app):
|
||||||
"""Prepare the database to host canaille data."""
|
"""Prepare the database to host canaille data."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@ -203,7 +203,7 @@ class Backend:
|
||||||
models.register(getattr(backend_models, model_name))
|
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:
|
if not backend:
|
||||||
prefix = "CANAILLE_"
|
prefix = "CANAILLE_"
|
||||||
available_backends_names = [
|
available_backends_names = [
|
||||||
|
@ -224,7 +224,7 @@ def setup_backend(app, backend=None):
|
||||||
module, f"{backend_name.title()}Backend", None
|
module, f"{backend_name.title()}Backend", None
|
||||||
) or getattr(module, f"{backend_name.upper()}Backend", None)
|
) or getattr(module, f"{backend_name.upper()}Backend", None)
|
||||||
backend = backend_class(app.config)
|
backend = backend_class(app.config)
|
||||||
backend.init_app(app)
|
backend.init_app(app, init_backend)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
g.backend = backend
|
g.backend = backend
|
||||||
|
|
|
@ -61,9 +61,9 @@ class LDAPBackend(Backend):
|
||||||
setup_ldap_models(config)
|
setup_ldap_models(config)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def install(cls, config):
|
def install(cls, app):
|
||||||
cls.setup_schemas(config)
|
cls.setup_schemas(app.config)
|
||||||
with cls(config).session():
|
with cls(app.config).session():
|
||||||
models.Token.install()
|
models.Token.install()
|
||||||
models.AuthorizationCode.install()
|
models.AuthorizationCode.install()
|
||||||
models.Client.install()
|
models.Client.install()
|
||||||
|
|
|
@ -41,7 +41,7 @@ class MemoryBackend(Backend):
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def install(cls, config):
|
def install(cls, app):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
from flask_alembic import Alembic
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
@ -15,14 +17,6 @@ from canaille.backends import get_lockout_delay_message
|
||||||
Base = declarative_base()
|
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):
|
class SQLModelEncoder(ModelEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, Password):
|
if isinstance(obj, Password):
|
||||||
|
@ -31,24 +25,45 @@ class SQLModelEncoder(ModelEncoder):
|
||||||
|
|
||||||
|
|
||||||
class SQLBackend(Backend):
|
class SQLBackend(Backend):
|
||||||
|
engine = None
|
||||||
db_session = None
|
db_session = None
|
||||||
json_encoder = SQLModelEncoder
|
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
|
@classmethod
|
||||||
def install(cls, config): # pragma: no cover
|
def install(cls, app): # pragma: no cover
|
||||||
engine = create_engine(
|
cls.init_alembic(app)
|
||||||
config["CANAILLE_SQL"]["DATABASE_URI"],
|
SQLBackend.alembic.upgrade()
|
||||||
echo=False,
|
|
||||||
future=True,
|
|
||||||
)
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
def setup(self, init=False):
|
@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:
|
if not self.db_session:
|
||||||
self.db_session = db_session(
|
self.db_session = Session(SQLBackend.engine)
|
||||||
self.config["CANAILLE_SQL"]["DATABASE_URI"],
|
|
||||||
init=init,
|
|
||||||
)
|
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -22,3 +22,14 @@ class SQLSettings(BaseModel):
|
||||||
Defines password hashing scheme in SQL database.
|
Defines password hashing scheme in SQL database.
|
||||||
examples : "mssql2000", "ldap_salted_sha1", "pbkdf2_sha512"
|
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.
|
||||||
|
"""
|
||||||
|
|
313
canaille/backends/sql/migrations/1736443094_init.py
Normal file
313
canaille/backends/sql/migrations/1736443094_init.py
Normal 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 ###
|
76
canaille/backends/sql/migrations/1736443538_0_0_58.py
Normal file
76
canaille/backends/sql/migrations/1736443538_0_0_58.py
Normal 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 ###
|
29
canaille/backends/sql/migrations/script.py.mako
Normal file
29
canaille/backends/sql/migrations/script.py.mako
Normal 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"}
|
|
@ -12,9 +12,14 @@ from canaille import create_app
|
||||||
version = importlib.metadata.version("canaille")
|
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(
|
@click.group(
|
||||||
cls=FlaskGroup,
|
cls=FlaskGroup,
|
||||||
create_app=create_app,
|
create_app=create_cli_app,
|
||||||
add_version_option=False,
|
add_version_option=False,
|
||||||
add_default_commands=False,
|
add_default_commands=False,
|
||||||
)
|
)
|
||||||
|
|
26
canaille/migrations/script.py.mako
Normal file
26
canaille/migrations/script.py.mako
Normal 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"}
|
|
@ -15,7 +15,7 @@ def populate(app):
|
||||||
from canaille.core.populate import fake_users
|
from canaille.core.populate import fake_users
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
app.backend.install(app.config)
|
app.backend.install(app)
|
||||||
with app.backend.session():
|
with app.backend.session():
|
||||||
if app.backend.query(models.User):
|
if app.backend.query(models.User):
|
||||||
return
|
return
|
||||||
|
|
|
@ -61,6 +61,7 @@ intersphinx_mapping = {
|
||||||
"python": ("https://docs.python.org/3", None),
|
"python": ("https://docs.python.org/3", None),
|
||||||
"authlib": ("https://docs.authlib.org/en/latest", None),
|
"authlib": ("https://docs.authlib.org/en/latest", None),
|
||||||
"flask": ("https://flask.palletsprojects.com", 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-babel": ("https://python-babel.github.io/flask-babel", None),
|
||||||
"flask-wtf": ("https://flask-wtf.readthedocs.io", None),
|
"flask-wtf": ("https://flask-wtf.readthedocs.io", None),
|
||||||
"pydantic": ("https://docs.pydantic.dev/latest", None),
|
"pydantic": ("https://docs.pydantic.dev/latest", None),
|
||||||
|
|
|
@ -18,6 +18,9 @@ SQL
|
||||||
Canaille can use any database supported by `SQLAlchemy <https://www.sqlalchemy.org/>`_, such as
|
Canaille can use any database supported by `SQLAlchemy <https://www.sqlalchemy.org/>`_, such as
|
||||||
sqlite, postgresql or mariadb.
|
sqlite, postgresql or mariadb.
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
It is used when the ``CANAILLE_SQL`` configuration parameter is defined. For instance:
|
It is used when the ``CANAILLE_SQL`` configuration parameter is defined. For instance:
|
||||||
|
|
||||||
.. code-block:: toml
|
.. 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>`.
|
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
|
LDAP
|
||||||
====
|
====
|
||||||
|
|
||||||
|
|
|
@ -66,6 +66,7 @@ sentry = [
|
||||||
]
|
]
|
||||||
|
|
||||||
sqlite = [
|
sqlite = [
|
||||||
|
"flask-alembic>=3.1.1",
|
||||||
"passlib >= 1.7.4",
|
"passlib >= 1.7.4",
|
||||||
"sqlalchemy >= 2.0.23",
|
"sqlalchemy >= 2.0.23",
|
||||||
"sqlalchemy-json >= 0.7.0",
|
"sqlalchemy-json >= 0.7.0",
|
||||||
|
@ -73,6 +74,7 @@ sqlite = [
|
||||||
]
|
]
|
||||||
|
|
||||||
postgresql = [
|
postgresql = [
|
||||||
|
"flask-alembic>=3.1.1",
|
||||||
"passlib >= 1.7.4",
|
"passlib >= 1.7.4",
|
||||||
"sqlalchemy[postgresql-psycopg2binary] >= 2.0.23",
|
"sqlalchemy[postgresql-psycopg2binary] >= 2.0.23",
|
||||||
"sqlalchemy-json >= 0.7.0",
|
"sqlalchemy-json >= 0.7.0",
|
||||||
|
@ -80,6 +82,7 @@ postgresql = [
|
||||||
]
|
]
|
||||||
|
|
||||||
mysql = [
|
mysql = [
|
||||||
|
"flask-alembic>=3.1.1",
|
||||||
"passlib >= 1.7.4",
|
"passlib >= 1.7.4",
|
||||||
"sqlalchemy[mysql-connector] >= 2.0.23",
|
"sqlalchemy[mysql-connector] >= 2.0.23",
|
||||||
"sqlalchemy-json >= 0.7.0",
|
"sqlalchemy-json >= 0.7.0",
|
||||||
|
|
|
@ -16,5 +16,5 @@ def sql_backend(sqlalchemy_configuration):
|
||||||
config_obj = settings_factory(sqlalchemy_configuration)
|
config_obj = settings_factory(sqlalchemy_configuration)
|
||||||
config_dict = config_obj.model_dump()
|
config_dict = config_obj.model_dump()
|
||||||
backend = SQLBackend(config_dict)
|
backend = SQLBackend(config_dict)
|
||||||
with backend.session(init=True):
|
with backend.session():
|
||||||
yield backend
|
yield backend
|
||||||
|
|
4
tests/backends/sql/test_alembic.py
Normal file
4
tests/backends/sql/test_alembic.py
Normal 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()
|
|
@ -5,7 +5,7 @@ from canaille.backends import Backend
|
||||||
|
|
||||||
def test_required_methods(testclient):
|
def test_required_methods(testclient):
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
Backend.install(config=None)
|
Backend.install(app=None)
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
Backend.validate({})
|
Backend.validate({})
|
||||||
|
|
|
@ -176,8 +176,10 @@ def jinja_cache_directory(tmp_path_factory):
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(configuration, backend, jinja_cache_directory):
|
def app(configuration, backend, jinja_cache_directory):
|
||||||
app = create_app(configuration, backend=backend)
|
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)
|
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():
|
with app.test_request_context():
|
||||||
yield app
|
yield app
|
||||||
|
|
||||||
|
|
46
uv.lock
46
uv.lock
|
@ -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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
@ -145,6 +159,7 @@ ldap = [
|
||||||
{ name = "python-ldap" },
|
{ name = "python-ldap" },
|
||||||
]
|
]
|
||||||
mysql = [
|
mysql = [
|
||||||
|
{ name = "flask-alembic" },
|
||||||
{ name = "passlib" },
|
{ name = "passlib" },
|
||||||
{ name = "sqlalchemy", extra = ["mysql-connector"] },
|
{ name = "sqlalchemy", extra = ["mysql-connector"] },
|
||||||
{ name = "sqlalchemy-json" },
|
{ name = "sqlalchemy-json" },
|
||||||
|
@ -159,6 +174,7 @@ otp = [
|
||||||
{ name = "qrcode" },
|
{ name = "qrcode" },
|
||||||
]
|
]
|
||||||
postgresql = [
|
postgresql = [
|
||||||
|
{ name = "flask-alembic" },
|
||||||
{ name = "passlib" },
|
{ name = "passlib" },
|
||||||
{ name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] },
|
{ name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] },
|
||||||
{ name = "sqlalchemy-json" },
|
{ name = "sqlalchemy-json" },
|
||||||
|
@ -175,6 +191,7 @@ sms = [
|
||||||
{ name = "smpplib" },
|
{ name = "smpplib" },
|
||||||
]
|
]
|
||||||
sqlite = [
|
sqlite = [
|
||||||
|
{ name = "flask-alembic" },
|
||||||
{ name = "passlib" },
|
{ name = "passlib" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "sqlalchemy-json" },
|
{ name = "sqlalchemy-json" },
|
||||||
|
@ -228,6 +245,9 @@ requires-dist = [
|
||||||
{ name = "authlib", marker = "extra == 'scim'", specifier = ">=1.3.0" },
|
{ name = "authlib", marker = "extra == 'scim'", specifier = ">=1.3.0" },
|
||||||
{ name = "email-validator", marker = "extra == 'front'", specifier = ">=2.0.0" },
|
{ name = "email-validator", marker = "extra == 'front'", specifier = ">=2.0.0" },
|
||||||
{ name = "flask", specifier = ">=3.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-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
|
||||||
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
|
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
|
||||||
{ name = "flask-wtf", specifier = ">=1.2.1" },
|
{ 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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "flask-babel"
|
name = "flask-babel"
|
||||||
version = "4.0.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.2"
|
version = "3.0.2"
|
||||||
|
|
Loading…
Reference in a new issue