Compare commits
2 Commits
525d38224c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c30bd1c0c5 | |||
| 85e6e502e5 |
@@ -15,6 +15,7 @@ Architecture et composants clés :
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -36,6 +37,10 @@ def _migrate_db(app):
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
if db.engine.url.database in (None, ":memory:"):
|
||||||
|
return # Une base mémoire est initialisée par create_all().
|
||||||
|
|
||||||
db_path = os.path.join(app.instance_path, "worklog.db")
|
db_path = os.path.join(app.instance_path, "worklog.db")
|
||||||
if not os.path.exists(db_path):
|
if not os.path.exists(db_path):
|
||||||
return # Nouvelle DB, create_all() s'en charge
|
return # Nouvelle DB, create_all() s'en charge
|
||||||
@@ -120,7 +125,12 @@ def _date_fr(d):
|
|||||||
return f"{jour} {d.day} {mois} {d.year}"
|
return f"{jour} {d.day} {mois} {d.year}"
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_path=None):
|
def create_app(
|
||||||
|
config_path: str | None = None,
|
||||||
|
*,
|
||||||
|
database_uri: str | None = None,
|
||||||
|
engine_options: Mapping[str, object] | None = None,
|
||||||
|
) -> Flask:
|
||||||
"""Factory de création et de configuration de l'application Flask.
|
"""Factory de création et de configuration de l'application Flask.
|
||||||
|
|
||||||
Cette fonction réalise les étapes suivantes :
|
Cette fonction réalise les étapes suivantes :
|
||||||
@@ -136,6 +146,11 @@ def create_app(config_path=None):
|
|||||||
Paramètres:
|
Paramètres:
|
||||||
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
|
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
|
||||||
Par défaut, cherche `config.toml` à la racine du projet.
|
Par défaut, cherche `config.toml` à la racine du projet.
|
||||||
|
database_uri (str | None): URI SQLAlchemy à utiliser à la place de la base SQLite
|
||||||
|
de l'instance. Cette option est appliquée avant l'initialisation
|
||||||
|
de Flask-SQLAlchemy.
|
||||||
|
engine_options (Mapping[str, object] | None): Options SQLAlchemy appliquées avant
|
||||||
|
l'initialisation de Flask-SQLAlchemy.
|
||||||
|
|
||||||
Retourne:
|
Retourne:
|
||||||
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
|
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
|
||||||
@@ -144,9 +159,11 @@ def create_app(config_path=None):
|
|||||||
|
|
||||||
os.makedirs(app.instance_path, exist_ok=True)
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = (
|
if database_uri is None:
|
||||||
f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
|
database_uri = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
|
||||||
)
|
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
|
||||||
|
if engine_options is not None:
|
||||||
|
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = engine_options
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
||||||
|
|
||||||
@@ -159,6 +176,11 @@ def create_app(config_path=None):
|
|||||||
else:
|
else:
|
||||||
app.config["TOML"] = {}
|
app.config["TOML"] = {}
|
||||||
|
|
||||||
|
from app.config_loader import get_home_assistant_config
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
app.jinja_env.filters["date_fr"] = _date_fr
|
app.jinja_env.filters["date_fr"] = _date_fr
|
||||||
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
||||||
|
|||||||
@@ -21,8 +21,28 @@ Contrat TOML :
|
|||||||
- Certains types de journées (Télétravail, Maladie, Congé, RTT, Férié) n'impliquent aucun déplacement physique.
|
- Certains types de journées (Télétravail, Maladie, Congé, RTT, Férié) n'impliquent aucun déplacement physique.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
_HOME_ASSISTANT_REQUIRED_KEYS = {
|
||||||
|
"timezone",
|
||||||
|
"default_day_type",
|
||||||
|
"default_journey_profile_id",
|
||||||
|
"default_motor_vehicle_id",
|
||||||
|
}
|
||||||
|
_VALID_DAY_TYPES = {
|
||||||
|
"WORK",
|
||||||
|
"TT",
|
||||||
|
"GARDE",
|
||||||
|
"ASTREINTE",
|
||||||
|
"FORMATION",
|
||||||
|
"RTT",
|
||||||
|
"CONGE",
|
||||||
|
"MALADE",
|
||||||
|
"FERIE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_vehicles():
|
def get_vehicles():
|
||||||
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
|
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
|
||||||
@@ -58,6 +78,66 @@ def get_journeys():
|
|||||||
return current_app.config.get("TOML", {}).get("journeys", {})
|
return current_app.config.get("TOML", {}).get("journeys", {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_home_assistant_config() -> dict[str, str] | None:
|
||||||
|
"""Retourne la configuration Home Assistant, après validation.
|
||||||
|
|
||||||
|
L'absence de section désactive la future intégration et reste compatible avec
|
||||||
|
les anciens fichiers TOML. Une section présente doit en revanche être
|
||||||
|
complète et cohérente avec les véhicules, trajets et types de journées
|
||||||
|
connus de l'application.
|
||||||
|
"""
|
||||||
|
config = current_app.config.get("TOML", {}).get("home_assistant")
|
||||||
|
if config is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("Configuration [home_assistant] invalide : la section doit être une table")
|
||||||
|
|
||||||
|
missing = _HOME_ASSISTANT_REQUIRED_KEYS - config.keys()
|
||||||
|
if missing:
|
||||||
|
missing_keys = ", ".join(sorted(missing))
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] incomplète : clé(s) manquante(s) {missing_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
not isinstance(config[key], str) or not config[key] for key in _HOME_ASSISTANT_REQUIRED_KEYS
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration [home_assistant] invalide : toutes les valeurs doivent être des chaînes non vides"
|
||||||
|
)
|
||||||
|
|
||||||
|
timezone = config["timezone"]
|
||||||
|
try:
|
||||||
|
ZoneInfo(timezone)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : fuseau horaire inconnu {timezone!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
day_type = config["default_day_type"]
|
||||||
|
if day_type not in _VALID_DAY_TYPES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : type de journée inconnu {day_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
journey_id = config["default_journey_profile_id"]
|
||||||
|
if journey_id not in get_journeys():
|
||||||
|
raise ValueError(f"Configuration [home_assistant] invalide : trajet inconnu {journey_id!r}")
|
||||||
|
|
||||||
|
vehicle_id = config["default_motor_vehicle_id"]
|
||||||
|
vehicle = get_vehicles().get(vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : véhicule inconnu {vehicle_id!r}"
|
||||||
|
)
|
||||||
|
if vehicle.get("type") != "moteur":
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : le véhicule {vehicle_id!r} n'est pas un véhicule moteur"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {key: config[key] for key in _HOME_ASSISTANT_REQUIRED_KEYS}
|
||||||
|
|
||||||
|
|
||||||
def journey_has_motor(journey_profile_id: str | None) -> bool:
|
def journey_has_motor(journey_profile_id: str | None) -> bool:
|
||||||
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
|
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ distances = { moteur = 14, velo = 8 }
|
|||||||
name = "Vélo seul"
|
name = "Vélo seul"
|
||||||
distances = { velo = 24 }
|
distances = { velo = 24 }
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
|
||||||
# --- Barème kilométrique voitures 2025 (revenus 2024) ---
|
# --- Barème kilométrique voitures 2025 (revenus 2024) ---
|
||||||
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
|
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
|
||||||
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
|
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
|
||||||
|
|||||||
@@ -101,9 +101,20 @@ Ce fichier contient les paramètres qui ne changent pas fréquemment et qui déf
|
|||||||
- **Véhicules (`[vehicles.*]`)** : Nom, type (moteur ou velo), carburant (electric, diesel, essence, none), émissions de CO₂ par km, et puissance fiscale (CV).
|
- **Véhicules (`[vehicles.*]`)** : Nom, type (moteur ou velo), carburant (electric, diesel, essence, none), émissions de CO₂ par km, et puissance fiscale (CV).
|
||||||
- **Trajets (`[journeys.*]`)** : Profils de trajets prédéfinis (ex: "moteur_seul", "moteur_velo") avec les distances associées par type de moyen de transport.
|
- **Trajets (`[journeys.*]`)** : Profils de trajets prédéfinis (ex: "moteur_seul", "moteur_velo") avec les distances associées par type de moyen de transport.
|
||||||
- **Barème kilométrique (`[bareme_kilometrique.YYYY.*]`)** : Les tranches fiscales officielles de remboursement par année et par puissance fiscale (CV).
|
- **Barème kilométrique (`[bareme_kilometrique.YYYY.*]`)** : Les tranches fiscales officielles de remboursement par année et par puissance fiscale (CV).
|
||||||
|
- **Home Assistant (`[home_assistant]`)** : Les valeurs par défaut et le fuseau utilisés par la future API de présence :
|
||||||
|
```toml
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
```
|
||||||
|
Cette section est facultative pour préserver le fonctionnement de l'interface Web sur les configurations existantes. Si elle est présente, elle doit être complète et référencer un type de journée, un trajet et un véhicule à moteur existants ; l'application refuse alors de démarrer en cas d'erreur. La configuration est accessible via `get_home_assistant_config()` dans `app/config_loader.py`.
|
||||||
|
|
||||||
*Note : Ces données sont chargées en mémoire au démarrage de l'application dans `app.config["TOML"]`.*
|
*Note : Ces données sont chargées en mémoire au démarrage de l'application dans `app.config["TOML"]`.*
|
||||||
|
|
||||||
|
Le secret de la future API ne doit pas être ajouté au fichier TOML. Il proviendra de la variable d'environnement `WORKLOG_API_TOKEN` lorsqu'elle sera implémentée ; cette étape ne le stocke ni ne le gère.
|
||||||
|
|
||||||
### SQLite / `instance/worklog.db` (Données dynamiques)
|
### SQLite / `instance/worklog.db` (Données dynamiques)
|
||||||
La base de données stocke l'activité saisie par l'utilisateur :
|
La base de données stocke l'activité saisie par l'utilisateur :
|
||||||
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from app import db as _db
|
from app import db as _db
|
||||||
|
from tests.in_memory_db import IN_MEMORY_DATABASE_URI, in_memory_engine_options
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -48,6 +49,12 @@ distances = { moteur = 14, velo = 8 }
|
|||||||
name = "Vélo seul"
|
name = "Vélo seul"
|
||||||
distances = { velo = 24 }
|
distances = { velo = 24 }
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
|
||||||
[[bareme_kilometrique.2025.cv_5.tranches]]
|
[[bareme_kilometrique.2025.cv_5.tranches]]
|
||||||
km_max = 3000
|
km_max = 3000
|
||||||
taux = 0.548
|
taux = 0.548
|
||||||
@@ -66,9 +73,12 @@ forfait = 0
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
application = create_app(config_path=str(config_path))
|
application = create_app(
|
||||||
|
config_path=str(config_path),
|
||||||
|
database_uri=IN_MEMORY_DATABASE_URI,
|
||||||
|
engine_options=in_memory_engine_options(),
|
||||||
|
)
|
||||||
application.config["TESTING"] = True
|
application.config["TESTING"] = True
|
||||||
application.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
|
||||||
|
|
||||||
with application.app_context():
|
with application.app_context():
|
||||||
_db.create_all()
|
_db.create_all()
|
||||||
|
|||||||
20
tests/in_memory_db.py
Normal file
20
tests/in_memory_db.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Configuration SQLite mémoire partagée entre fixtures et helpers de tests.
|
||||||
|
|
||||||
|
Ce module est volontairement indépendant de ``conftest`` afin d'être
|
||||||
|
importable de manière portable, y compris sous ``pytest --import-mode=importlib``
|
||||||
|
(où ``conftest`` n'est pas importable comme module ordinaire). Il centralise la
|
||||||
|
configuration mémoire partagée entre la fixture ``app`` et les helpers de tests
|
||||||
|
qui appellent directement ``create_app(...)``, évitant qu'une factory de test
|
||||||
|
initialise accidentellement ``instance/worklog.db``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
IN_MEMORY_DATABASE_URI = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
def in_memory_engine_options() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"poolclass": StaticPool,
|
||||||
|
"connect_args": {"check_same_thread": False},
|
||||||
|
}
|
||||||
11
tests/test_app_factory.py
Normal file
11
tests/test_app_factory.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_fixture_uses_one_in_memory_database_connection(app):
|
||||||
|
with app.app_context():
|
||||||
|
assert str(db.engine.url) == "sqlite:///:memory:"
|
||||||
|
assert isinstance(db.engine.pool, StaticPool)
|
||||||
|
assert sa.inspect(db.engine).has_table("work_entries")
|
||||||
@@ -1,3 +1,39 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from tests.in_memory_db import IN_MEMORY_DATABASE_URI, in_memory_engine_options
|
||||||
|
|
||||||
|
_MINIMAL_CONFIG = """
|
||||||
|
[vehicles.citadine]
|
||||||
|
name = "Citadine"
|
||||||
|
type = "moteur"
|
||||||
|
|
||||||
|
[vehicles.velo]
|
||||||
|
name = "Vélo"
|
||||||
|
type = "velo"
|
||||||
|
|
||||||
|
[journeys.moteur_seul]
|
||||||
|
name = "Moteur seul"
|
||||||
|
distances = {{ moteur = 1 }}
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "{timezone}"
|
||||||
|
default_day_type = "{day_type}"
|
||||||
|
default_journey_profile_id = "{journey_id}"
|
||||||
|
default_motor_vehicle_id = "{vehicle_id}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app_with_home_assistant_config(tmp_path, **values):
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(_MINIMAL_CONFIG.format(**values), encoding="utf-8")
|
||||||
|
return create_app(
|
||||||
|
config_path=str(config_path),
|
||||||
|
database_uri=IN_MEMORY_DATABASE_URI,
|
||||||
|
engine_options=in_memory_engine_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_vehicles_returns_configured_vehicles(app):
|
def test_get_vehicles_returns_configured_vehicles(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import get_vehicles
|
from app.config_loader import get_vehicles
|
||||||
@@ -59,3 +95,82 @@ def test_day_types_without_journey(app):
|
|||||||
types = day_types_without_journey()
|
types = day_types_without_journey()
|
||||||
assert "TT" in types
|
assert "TT" in types
|
||||||
assert "WORK" not in types
|
assert "WORK" not in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_home_assistant_config_returns_validated_defaults(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app.config_loader import get_home_assistant_config
|
||||||
|
|
||||||
|
assert get_home_assistant_config() == {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"default_day_type": "WORK",
|
||||||
|
"default_journey_profile_id": "moteur_seul",
|
||||||
|
"default_motor_vehicle_id": "citadine",
|
||||||
|
}
|
||||||
|
assert app.config["HOME_ASSISTANT"]["default_motor_vehicle_id"] == "citadine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_assistant_section_absent_is_allowed(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app.config_loader import get_home_assistant_config
|
||||||
|
|
||||||
|
app.config["TOML"].pop("home_assistant")
|
||||||
|
assert get_home_assistant_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "message"),
|
||||||
|
[
|
||||||
|
("timezone", "Mars/NoSuchPlace", "fuseau horaire"),
|
||||||
|
("day_type", "UNKNOWN", "type de journée"),
|
||||||
|
("journey_id", "unknown_journey", "trajet inconnu"),
|
||||||
|
("vehicle_id", "unknown_vehicle", "véhicule inconnu"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_home_assistant_config_prevents_startup(tmp_path, field, value, message):
|
||||||
|
values = {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"day_type": "WORK",
|
||||||
|
"journey_id": "moteur_seul",
|
||||||
|
"vehicle_id": "citadine",
|
||||||
|
}
|
||||||
|
values[field] = value
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
_create_app_with_home_assistant_config(tmp_path, **values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_assistant_default_vehicle_must_be_motor_vehicle(tmp_path):
|
||||||
|
values = {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"day_type": "WORK",
|
||||||
|
"journey_id": "moteur_seul",
|
||||||
|
"vehicle_id": "velo",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="véhicule moteur"):
|
||||||
|
_create_app_with_home_assistant_config(tmp_path, **values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_home_assistant_config_prevents_startup(tmp_path):
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(
|
||||||
|
"""
|
||||||
|
[vehicles.citadine]
|
||||||
|
type = "moteur"
|
||||||
|
|
||||||
|
[journeys.moteur_seul]
|
||||||
|
distances = { moteur = 1 }
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="clé.*manquante"):
|
||||||
|
create_app(
|
||||||
|
config_path=str(config_path),
|
||||||
|
database_uri=IN_MEMORY_DATABASE_URI,
|
||||||
|
engine_options=in_memory_engine_options(),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user