splent-framework 1.2.8__tar.gz → 1.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {splent_framework-1.2.8/src/splent_framework.egg-info → splent_framework-1.4.1}/PKG-INFO +2 -1
- {splent_framework-1.2.8 → splent_framework-1.4.1}/pyproject.toml +3 -2
- splent_framework-1.4.1/src/splent_framework/app_factory.py +92 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/blueprints/base_blueprint.py +34 -7
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/configuration/default_config.py +2 -3
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/fixtures/fixtures.py +23 -27
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/hooks/template_hooks.py +15 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/config_manager.py +5 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/feature_loader.py +134 -5
- splent_framework-1.4.1/src/splent_framework/managers/feature_manager.py +298 -0
- splent_framework-1.4.1/src/splent_framework/managers/locale_manager.py +79 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/migration_manager.py +12 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/migrations/feature_env.py +23 -2
- splent_framework-1.4.1/src/splent_framework/refinement/model_extender.py +84 -0
- splent_framework-1.4.1/src/splent_framework/refinement/parser.py +115 -0
- splent_framework-1.4.1/src/splent_framework/refinement/registry.py +75 -0
- splent_framework-1.4.1/src/splent_framework/refinement/validator.py +122 -0
- splent_framework-1.4.1/src/splent_framework/services/service_locator.py +61 -0
- splent_framework-1.4.1/src/splent_framework/signals/__init__.py +0 -0
- splent_framework-1.4.1/src/splent_framework/signals/registry.py +51 -0
- splent_framework-1.4.1/src/splent_framework/signals/signal_utils.py +77 -0
- splent_framework-1.4.1/src/splent_framework/utils/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/pyproject_reader.py +15 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1/src/splent_framework.egg-info}/PKG-INFO +2 -1
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/SOURCES.txt +13 -2
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/requires.txt +1 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_default_config.py +4 -4
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_migration_manager.py +5 -5
- splent_framework-1.4.1/tests/test_refinement.py +290 -0
- splent_framework-1.2.8/src/splent_framework/helpers/test_helpers_db.py +0 -13
- splent_framework-1.2.8/src/splent_framework/managers/feature_manager.py +0 -126
- {splent_framework-1.2.8 → splent_framework-1.4.1}/LICENSE +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/README.md +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/setup.cfg +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/_init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/blueprints/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/bootstraps/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/configuration/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/configuration/configuration.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/context/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/context/context_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/db.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/decorators/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/decorators/decorators.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/environment/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/environment/host.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/fixtures/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/helpers/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/hooks/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/locust/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/locust/common.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/db_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/error_handler_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/feature_order.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/jinja_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/logging_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/namespace_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/session_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/task_queue_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/migrations/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/namespaces/__init__.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/repositories → splent_framework-1.4.1/src/splent_framework/refinement}/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/repositories/BaseRepository.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/resources → splent_framework-1.4.1/src/splent_framework/repositories}/__init__.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/seeders → splent_framework-1.4.1/src/splent_framework/resources}/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/resources/generic_resource.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/seeders/BaseSeeder.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/selenium → splent_framework-1.4.1/src/splent_framework/seeders}/__init__.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/serialisers → splent_framework-1.4.1/src/splent_framework/selenium}/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/selenium/common.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/services → splent_framework-1.4.1/src/splent_framework/serialisers}/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/serialisers/serializer.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/services/BaseService.py +0 -0
- {splent_framework-1.2.8/src/splent_framework/utils → splent_framework-1.4.1/src/splent_framework/services}/__init__.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/app_loader.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/feature_utils.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/path_utils.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/dependency_links.txt +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/top_level.txt +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_base_blueprint_security.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_base_repository.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_config_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_config_manager_return.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_context_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_error_handler_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_feature_manager_parse.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_feature_order.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_feature_utils.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_generic_resource.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_namespace_manager.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_pyproject_reader.py +0 -0
- {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_pyproject_reader_env.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splent_framework
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: SPLENT-FRAMEWORK is a set of libraries for agile product development within SPLENT.
|
|
5
5
|
Author-email: DiversoLab <diversolab@us.es>
|
|
6
6
|
Project-URL: Homepage, https://github.com/diverso-lab/splent_framework
|
|
@@ -21,6 +21,7 @@ Requires-Dist: redis==5.2.1
|
|
|
21
21
|
Requires-Dist: pymysql==1.1.1
|
|
22
22
|
Requires-Dist: psutil==6.1.1
|
|
23
23
|
Requires-Dist: pytz==2024.2
|
|
24
|
+
Requires-Dist: flask-babel==4.0.0
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
26
27
|
Requires-Dist: pytest-cov>=6.0; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "splent_framework"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.1"
|
|
8
8
|
description = "SPLENT-FRAMEWORK is a set of libraries for agile product development within SPLENT."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -24,7 +24,8 @@ dependencies = [
|
|
|
24
24
|
"redis==5.2.1",
|
|
25
25
|
"pymysql==1.1.1",
|
|
26
26
|
"psutil==6.1.1",
|
|
27
|
-
"pytz==2024.2"
|
|
27
|
+
"pytz==2024.2",
|
|
28
|
+
"flask-babel==4.0.0"
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SPLENT App Factory — the canonical way to create a SPLENT product application.
|
|
3
|
+
|
|
4
|
+
Usage in a product's __init__.py::
|
|
5
|
+
|
|
6
|
+
from splent_framework import create_splent_app
|
|
7
|
+
|
|
8
|
+
def create_app(config_name="development"):
|
|
9
|
+
return create_splent_app(__name__, config_name)
|
|
10
|
+
|
|
11
|
+
The factory initialises all framework subsystems in the correct order.
|
|
12
|
+
Products should not import individual managers — the factory handles it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from flask import Flask
|
|
19
|
+
|
|
20
|
+
from splent_framework.managers.namespace_manager import NamespaceManager
|
|
21
|
+
from splent_framework.managers.config_manager import ConfigManager
|
|
22
|
+
from splent_framework.managers.migration_manager import MigrationManager
|
|
23
|
+
from splent_framework.managers.session_manager import SessionManager
|
|
24
|
+
from splent_framework.managers.logging_manager import LoggingManager
|
|
25
|
+
from splent_framework.managers.error_handler_manager import ErrorHandlerManager
|
|
26
|
+
from splent_framework.managers.jinja_manager import JinjaManager
|
|
27
|
+
from splent_framework.managers.locale_manager import LocaleManager
|
|
28
|
+
from splent_framework.managers.feature_manager import FeatureManager
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Initialisation pipeline — order matters.
|
|
33
|
+
# Each step is a (label, callable) pair. The callable receives (app, **kwargs).
|
|
34
|
+
_PIPELINE = [
|
|
35
|
+
("namespaces", lambda app, **kw: NamespaceManager.init_app(app)),
|
|
36
|
+
("config", lambda app, **kw: ConfigManager.init_app(app, kw.get("config_name", "development"))),
|
|
37
|
+
("database", lambda app, **kw: MigrationManager(app)),
|
|
38
|
+
("logging", lambda app, **kw: LoggingManager(app).setup_logging()),
|
|
39
|
+
("error_handlers", lambda app, **kw: ErrorHandlerManager(app).register_error_handlers()),
|
|
40
|
+
("locale", lambda app, **kw: LocaleManager(app)),
|
|
41
|
+
("jinja_context", lambda app, **kw: JinjaManager(app, kw.get("context", {}))),
|
|
42
|
+
("features", lambda app, **kw: FeatureManager(app, strict=kw.get("strict", False)).register_features()),
|
|
43
|
+
("sessions", lambda app, **kw: SessionManager(app)),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_splent_app(
|
|
48
|
+
import_name: str,
|
|
49
|
+
config_name: str = "development",
|
|
50
|
+
*,
|
|
51
|
+
strict: bool = False,
|
|
52
|
+
extra_context: dict | None = None,
|
|
53
|
+
) -> Flask:
|
|
54
|
+
"""Create and fully initialise a SPLENT Flask application.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
import_name : str
|
|
59
|
+
The ``__name__`` of the calling product package.
|
|
60
|
+
config_name : str
|
|
61
|
+
Configuration profile: ``"development"``, ``"testing"``, or ``"production"``.
|
|
62
|
+
strict : bool
|
|
63
|
+
If True, missing features raise errors instead of warnings.
|
|
64
|
+
extra_context : dict | None
|
|
65
|
+
Additional Jinja context variables injected into every template.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
Flask
|
|
70
|
+
A fully configured Flask application with all features loaded.
|
|
71
|
+
"""
|
|
72
|
+
app = Flask(import_name)
|
|
73
|
+
|
|
74
|
+
context = {"SPLENT_APP": os.getenv("SPLENT_APP", "")}
|
|
75
|
+
if extra_context:
|
|
76
|
+
context.update(extra_context)
|
|
77
|
+
|
|
78
|
+
kwargs = {
|
|
79
|
+
"config_name": config_name,
|
|
80
|
+
"context": context,
|
|
81
|
+
"strict": strict,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for label, init_fn in _PIPELINE:
|
|
85
|
+
try:
|
|
86
|
+
init_fn(app, **kwargs)
|
|
87
|
+
logger.debug("Initialised: %s", label)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.exception("Failed to initialise: %s", label)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
return app
|
{splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/blueprints/base_blueprint.py
RENAMED
|
@@ -57,15 +57,45 @@ class BaseBlueprint(Blueprint):
|
|
|
57
57
|
self.send_file,
|
|
58
58
|
)
|
|
59
59
|
|
|
60
|
+
def _resolve_asset_path(self, subfolder, filename):
|
|
61
|
+
"""Find an asset file, checking workspace root first (for compiled assets)."""
|
|
62
|
+
# Primary: feature_code_path (where the module was imported from)
|
|
63
|
+
primary = os.path.join(self.feature_code_path, "assets", subfolder, filename)
|
|
64
|
+
if os.path.isfile(primary):
|
|
65
|
+
return os.path.realpath(primary), os.path.realpath(
|
|
66
|
+
os.path.join(self.feature_code_path, "assets", subfolder)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Fallback: workspace root (editable feature may have compiled dist/ here)
|
|
70
|
+
workspace = os.getenv("WORKING_DIR")
|
|
71
|
+
if workspace:
|
|
72
|
+
# feature_code_path looks like .../src/splent_io/splent_feature_X
|
|
73
|
+
# We need just the feature package name
|
|
74
|
+
feature_name = os.path.basename(self.feature_code_path)
|
|
75
|
+
org_name = os.path.basename(os.path.dirname(self.feature_code_path))
|
|
76
|
+
fallback = os.path.join(
|
|
77
|
+
workspace, feature_name, "src", org_name, feature_name,
|
|
78
|
+
"assets", subfolder, filename,
|
|
79
|
+
)
|
|
80
|
+
if os.path.isfile(fallback):
|
|
81
|
+
return os.path.realpath(fallback), os.path.realpath(
|
|
82
|
+
os.path.join(
|
|
83
|
+
workspace, feature_name, "src", org_name, feature_name,
|
|
84
|
+
"assets", subfolder,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return None, None
|
|
89
|
+
|
|
60
90
|
def send_file(self, subfolder, filename):
|
|
61
91
|
allowed_subfolders = {"js", "css", "dist"}
|
|
62
92
|
if subfolder not in allowed_subfolders:
|
|
63
93
|
abort(404)
|
|
64
94
|
|
|
65
|
-
base_dir =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
requested_path, base_dir = self._resolve_asset_path(subfolder, filename)
|
|
96
|
+
|
|
97
|
+
if not requested_path:
|
|
98
|
+
abort(404)
|
|
69
99
|
|
|
70
100
|
# Prevent path traversal: resolved path must stay inside base_dir
|
|
71
101
|
if (
|
|
@@ -74,9 +104,6 @@ class BaseBlueprint(Blueprint):
|
|
|
74
104
|
):
|
|
75
105
|
abort(403)
|
|
76
106
|
|
|
77
|
-
if not os.path.isfile(requested_path):
|
|
78
|
-
abort(404)
|
|
79
|
-
|
|
80
107
|
if filename.endswith(".js"):
|
|
81
108
|
mimetype = "application/javascript"
|
|
82
109
|
elif filename.endswith(".css"):
|
|
@@ -16,7 +16,8 @@ class Config:
|
|
|
16
16
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
17
17
|
TEMPLATES_AUTO_RELOAD = True
|
|
18
18
|
UPLOAD_FOLDER = "uploads"
|
|
19
|
-
SESSION_TYPE
|
|
19
|
+
# SESSION_TYPE is NOT set here — it must come from a session feature
|
|
20
|
+
# (splent_feature_session_redis or splent_feature_session_filesystem)
|
|
20
21
|
|
|
21
22
|
def __init__(self):
|
|
22
23
|
# Resolved at instantiation time so runtime env changes are picked up
|
|
@@ -34,8 +35,6 @@ class DevelopmentConfig(Config):
|
|
|
34
35
|
class TestingConfig(Config):
|
|
35
36
|
TESTING = True
|
|
36
37
|
WTF_CSRF_ENABLED = False
|
|
37
|
-
SESSION_TYPE = "filesystem"
|
|
38
|
-
SESSION_FILE_DIR = "/tmp/flask_sessions"
|
|
39
38
|
|
|
40
39
|
def __init__(self):
|
|
41
40
|
super().__init__()
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from sqlalchemy import text
|
|
2
3
|
from splent_framework.db import db
|
|
3
4
|
from splent_framework.utils.app_loader import get_create_app_in_testing_mode
|
|
4
5
|
|
|
5
6
|
|
|
7
|
+
def _reset_db():
|
|
8
|
+
"""Drop and recreate all tables. Disables FK checks for MariaDB."""
|
|
9
|
+
db.session.remove()
|
|
10
|
+
with db.engine.connect() as conn:
|
|
11
|
+
conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
|
|
12
|
+
db.metadata.drop_all(bind=conn)
|
|
13
|
+
conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
|
|
14
|
+
db.metadata.create_all(bind=conn)
|
|
15
|
+
conn.commit()
|
|
16
|
+
|
|
17
|
+
|
|
6
18
|
@pytest.fixture(scope="session")
|
|
7
19
|
def test_app():
|
|
8
20
|
"""
|
|
@@ -11,59 +23,43 @@ def test_app():
|
|
|
11
23
|
"""
|
|
12
24
|
app = get_create_app_in_testing_mode()
|
|
13
25
|
with app.app_context():
|
|
14
|
-
|
|
15
|
-
db.create_all()
|
|
26
|
+
_reset_db()
|
|
16
27
|
yield app
|
|
17
28
|
|
|
18
29
|
|
|
19
30
|
@pytest.fixture(scope="function")
|
|
20
31
|
def test_client(test_app):
|
|
21
32
|
"""
|
|
22
|
-
Provides a test client
|
|
23
|
-
|
|
33
|
+
Provides a test client with a clean database for each test.
|
|
34
|
+
Tables are dropped and recreated before the test runs.
|
|
24
35
|
"""
|
|
25
36
|
with test_app.app_context():
|
|
26
|
-
|
|
27
|
-
db.drop_all()
|
|
28
|
-
db.create_all()
|
|
37
|
+
_reset_db()
|
|
29
38
|
with test_app.test_client() as client:
|
|
30
39
|
yield client
|
|
31
|
-
with test_app.app_context():
|
|
32
|
-
db.session.remove()
|
|
33
|
-
db.drop_all()
|
|
34
|
-
db.create_all()
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
@pytest.fixture(scope="module")
|
|
38
43
|
def test_client_module(test_app):
|
|
39
44
|
"""
|
|
40
45
|
Shared test client for the entire module.
|
|
41
|
-
Resets the database
|
|
46
|
+
Resets the database once at the start of the module.
|
|
47
|
+
Tests within the module share database state.
|
|
42
48
|
"""
|
|
43
49
|
with test_app.app_context():
|
|
44
|
-
|
|
45
|
-
db.drop_all()
|
|
46
|
-
db.create_all()
|
|
50
|
+
_reset_db()
|
|
47
51
|
|
|
48
52
|
with test_app.test_client() as client:
|
|
49
53
|
yield client
|
|
50
54
|
|
|
51
|
-
db.session.remove()
|
|
52
|
-
db.drop_all()
|
|
53
|
-
db.create_all()
|
|
54
|
-
|
|
55
55
|
|
|
56
56
|
@pytest.fixture(scope="function")
|
|
57
57
|
def clean_database(test_app):
|
|
58
58
|
"""
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
Manually resets the database within a test.
|
|
60
|
+
Combine with test_client_module when you need a selective
|
|
61
|
+
reset inside a module-scoped session.
|
|
61
62
|
"""
|
|
62
63
|
with test_app.app_context():
|
|
63
|
-
|
|
64
|
-
db.drop_all()
|
|
65
|
-
db.create_all()
|
|
64
|
+
_reset_db()
|
|
66
65
|
yield
|
|
67
|
-
db.session.remove()
|
|
68
|
-
db.drop_all()
|
|
69
|
-
db.create_all()
|
{splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/hooks/template_hooks.py
RENAMED
|
@@ -9,9 +9,24 @@ _hooks: dict[str, list] = {}
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def register_template_hook(name: str, func) -> None:
|
|
12
|
+
"""Append a callback to a named hook slot (additive)."""
|
|
12
13
|
_hooks.setdefault(name, []).append(func)
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def replace_template_hook(name: str, func) -> None:
|
|
17
|
+
"""Replace ALL existing callbacks for a named hook slot.
|
|
18
|
+
|
|
19
|
+
Used by refinement features to override a base feature's hook content.
|
|
20
|
+
"""
|
|
21
|
+
_hooks[name] = [func]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def remove_template_hook(name: str, func) -> None:
|
|
25
|
+
"""Remove a specific callback from a hook slot."""
|
|
26
|
+
if name in _hooks:
|
|
27
|
+
_hooks[name] = [f for f in _hooks[name] if f is not func]
|
|
28
|
+
|
|
29
|
+
|
|
15
30
|
def get_template_hooks(name: str) -> list:
|
|
16
31
|
return _hooks.get(name, [])
|
|
17
32
|
|
{splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/config_manager.py
RENAMED
|
@@ -47,3 +47,8 @@ class ConfigManager:
|
|
|
47
47
|
config_data[k] = getattr(config_instance, k)
|
|
48
48
|
|
|
49
49
|
self.app.config.from_mapping(config_data)
|
|
50
|
+
|
|
51
|
+
# Trace: mark all product-level config keys
|
|
52
|
+
trace = self.app.extensions.setdefault("splent_config_trace", {})
|
|
53
|
+
for key, value in config_data.items():
|
|
54
|
+
trace[key] = {"value": value, "source": f"product ({splent_app})", "action": "set"}
|
{splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/feature_loader.py
RENAMED
|
@@ -28,7 +28,7 @@ from flask import Blueprint
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
30
30
|
# Conventional submodules attempted on every feature package
|
|
31
|
-
FEATURE_SUBMODULES = ("routes", "models", "hooks")
|
|
31
|
+
FEATURE_SUBMODULES = ("routes", "models", "hooks", "signals")
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
# ---------------------------------------------------------------------------
|
|
@@ -232,21 +232,76 @@ class FeatureIntegrator:
|
|
|
232
232
|
Responsibilities (in order):
|
|
233
233
|
1. Inject feature configuration via ``<feature>.config.inject_config(app)``
|
|
234
234
|
2. Call ``<feature>.init_feature(app)`` if present
|
|
235
|
-
3.
|
|
235
|
+
3. Apply service overrides from the RefinementRegistry
|
|
236
|
+
4. Register all Flask Blueprints found in the feature modules
|
|
236
237
|
"""
|
|
237
238
|
|
|
238
|
-
def __init__(self, app, strict: bool = True) -> None:
|
|
239
|
+
def __init__(self, app, strict: bool = True, registry=None) -> None:
|
|
239
240
|
self._app = app
|
|
240
241
|
self._strict = strict
|
|
242
|
+
self._registry = registry
|
|
241
243
|
|
|
242
244
|
def integrate(self, module, import_name: str) -> None:
|
|
243
245
|
"""Run all integration steps for the given feature module."""
|
|
244
246
|
self._inject_config(import_name)
|
|
247
|
+
self._apply_model_extensions(import_name)
|
|
245
248
|
self._call_init(module, import_name)
|
|
249
|
+
self._apply_service_overrides(import_name)
|
|
246
250
|
self._register_blueprints(module, import_name)
|
|
251
|
+
self._register_translations(module, import_name)
|
|
252
|
+
self._register_commands(import_name)
|
|
247
253
|
|
|
248
254
|
# ------------------------------------------------------------------
|
|
249
255
|
|
|
256
|
+
def _apply_model_extensions(self, import_name: str) -> None:
|
|
257
|
+
"""Apply model mixins declared by refinement features targeting this feature."""
|
|
258
|
+
if not self._registry:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
feature_name = import_name.rsplit(".", 1)[-1]
|
|
262
|
+
overrides = self._registry.get_overrides(feature_name, "model")
|
|
263
|
+
if not overrides:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
from splent_framework.refinement.model_extender import apply_model_mixin
|
|
267
|
+
|
|
268
|
+
for entry in overrides:
|
|
269
|
+
# Import the mixin from the refiner's module
|
|
270
|
+
refiner_org = "splent_io" # convention
|
|
271
|
+
mixin_module_name = f"{refiner_org}.{entry.refiner}.models"
|
|
272
|
+
try:
|
|
273
|
+
import importlib
|
|
274
|
+
|
|
275
|
+
mod = importlib.import_module(mixin_module_name)
|
|
276
|
+
mixin_cls = getattr(mod, entry.replacement)
|
|
277
|
+
apply_model_mixin(entry.target, mixin_cls)
|
|
278
|
+
except (ImportError, AttributeError) as e:
|
|
279
|
+
logger.warning(
|
|
280
|
+
"Cannot apply model mixin %s from %s: %s",
|
|
281
|
+
entry.replacement, entry.refiner, e,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _apply_service_overrides(self, import_name: str) -> None:
|
|
285
|
+
"""Log service overrides applied by this feature (if it's a refiner).
|
|
286
|
+
|
|
287
|
+
The actual override happens naturally: the refiner's init_feature()
|
|
288
|
+
calls register_service() with the same key, overwriting the base.
|
|
289
|
+
Load order (UVL) guarantees the refiner runs after the base.
|
|
290
|
+
"""
|
|
291
|
+
if not self._registry:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
feature_name = import_name.rsplit(".", 1)[-1]
|
|
295
|
+
if not self._registry.is_refiner(feature_name):
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
for entry in self._registry.all_entries():
|
|
299
|
+
if entry.refiner == feature_name and entry.category == "service":
|
|
300
|
+
logger.info(
|
|
301
|
+
"Service override: %s.%s → %s (by %s)",
|
|
302
|
+
entry.base, entry.target, entry.replacement, entry.refiner,
|
|
303
|
+
)
|
|
304
|
+
|
|
250
305
|
def _inject_config(self, import_name: str) -> None:
|
|
251
306
|
try:
|
|
252
307
|
config_mod = importlib.import_module(f"{import_name}.config")
|
|
@@ -258,15 +313,35 @@ class FeatureIntegrator:
|
|
|
258
313
|
raise FeatureError(f"Error importing {import_name}.config: {e}") from e
|
|
259
314
|
|
|
260
315
|
if hasattr(config_mod, "inject_config"):
|
|
316
|
+
snapshot = dict(self._app.config)
|
|
261
317
|
try:
|
|
262
318
|
config_mod.inject_config(self._app)
|
|
263
319
|
except Exception as e:
|
|
264
320
|
raise FeatureError(
|
|
265
321
|
f"Error in {import_name}.config.inject_config: {e}"
|
|
266
322
|
) from e
|
|
323
|
+
self._trace_config_changes(snapshot, import_name)
|
|
267
324
|
elif self._strict:
|
|
268
325
|
raise FeatureError(f"{import_name}.config lacks inject_config(app)")
|
|
269
326
|
|
|
327
|
+
def _trace_config_changes(self, snapshot: dict, source: str) -> None:
|
|
328
|
+
"""Record which config keys were added or changed by *source*."""
|
|
329
|
+
trace = self._app.extensions.setdefault("splent_config_trace", {})
|
|
330
|
+
for key, value in self._app.config.items():
|
|
331
|
+
if not key.isupper():
|
|
332
|
+
continue
|
|
333
|
+
if key not in snapshot:
|
|
334
|
+
trace[key] = {"value": value, "source": source, "action": "added"}
|
|
335
|
+
elif snapshot[key] != value:
|
|
336
|
+
prev = trace.get(key, {})
|
|
337
|
+
trace[key] = {
|
|
338
|
+
"value": value,
|
|
339
|
+
"source": source,
|
|
340
|
+
"action": "overwritten",
|
|
341
|
+
"prev_source": prev.get("source", "product"),
|
|
342
|
+
"prev_value": snapshot[key],
|
|
343
|
+
}
|
|
344
|
+
|
|
270
345
|
def _call_init(self, module, import_name: str) -> None:
|
|
271
346
|
if hasattr(module, "init_feature"):
|
|
272
347
|
try:
|
|
@@ -280,7 +355,9 @@ class FeatureIntegrator:
|
|
|
280
355
|
|
|
281
356
|
def _register_blueprints(self, module, import_name: str) -> None:
|
|
282
357
|
candidates = self._collect_candidate_modules(module, import_name)
|
|
283
|
-
registered = sum(
|
|
358
|
+
registered = sum(
|
|
359
|
+
self._register_from_module(mod, import_name) for mod in candidates
|
|
360
|
+
)
|
|
284
361
|
|
|
285
362
|
if registered == 0:
|
|
286
363
|
logger.warning("No blueprints registered for %s", import_name)
|
|
@@ -297,9 +374,10 @@ class FeatureIntegrator:
|
|
|
297
374
|
if f"{import_name}.{sub}" in sys.modules
|
|
298
375
|
]
|
|
299
376
|
|
|
300
|
-
def _register_from_module(self, mod: types.ModuleType) -> int:
|
|
377
|
+
def _register_from_module(self, mod: types.ModuleType, import_name: str) -> int:
|
|
301
378
|
"""Register all Blueprint instances found in *mod*. Returns count registered."""
|
|
302
379
|
registered = 0
|
|
380
|
+
trace = self._app.extensions.setdefault("splent_blueprint_trace", {})
|
|
303
381
|
for attr in dir(mod):
|
|
304
382
|
try:
|
|
305
383
|
obj = getattr(mod, attr)
|
|
@@ -319,12 +397,63 @@ class FeatureIntegrator:
|
|
|
319
397
|
|
|
320
398
|
try:
|
|
321
399
|
self._app.register_blueprint(obj)
|
|
400
|
+
trace[obj.name] = import_name
|
|
322
401
|
registered += 1
|
|
323
402
|
except Exception as e:
|
|
324
403
|
logger.error("Failed to register blueprint '%s': %s", obj.name, e)
|
|
325
404
|
|
|
326
405
|
return registered
|
|
327
406
|
|
|
407
|
+
def _register_translations(self, module, import_name: str) -> None:
|
|
408
|
+
"""Register the feature's translations/ directory with Flask-Babel."""
|
|
409
|
+
pkg_dir = os.path.dirname(module.__file__) if hasattr(module, "__file__") else None
|
|
410
|
+
if not pkg_dir:
|
|
411
|
+
return
|
|
412
|
+
translations_dir = os.path.join(pkg_dir, "translations")
|
|
413
|
+
if os.path.isdir(translations_dir):
|
|
414
|
+
from splent_framework.managers.locale_manager import LocaleManager
|
|
415
|
+
LocaleManager.register_translation_dir(self._app, translations_dir)
|
|
416
|
+
|
|
417
|
+
def _register_commands(self, import_name: str) -> None:
|
|
418
|
+
"""Collect Click commands from the feature's ``commands`` submodule.
|
|
419
|
+
|
|
420
|
+
If the feature defines ``<package>.commands`` with a ``cli_commands``
|
|
421
|
+
list, each Click command is stored in
|
|
422
|
+
``app.extensions["splent_feature_commands"]`` keyed by feature short
|
|
423
|
+
name (e.g. ``"mail"``). The CLI reads this registry at runtime.
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
cmd_mod = importlib.import_module(f"{import_name}.commands")
|
|
427
|
+
except ModuleNotFoundError:
|
|
428
|
+
return
|
|
429
|
+
except Exception as e:
|
|
430
|
+
logger.warning("Error importing %s.commands: %s", import_name, e)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
commands = getattr(cmd_mod, "cli_commands", None)
|
|
434
|
+
if not commands:
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
# Derive short name: "splent_feature_mail" → "mail"
|
|
438
|
+
feature_pkg = import_name.rsplit(".", 1)[-1]
|
|
439
|
+
short = (
|
|
440
|
+
feature_pkg[len("splent_feature_"):]
|
|
441
|
+
if feature_pkg.startswith("splent_feature_")
|
|
442
|
+
else feature_pkg
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
registry = self._app.extensions.setdefault("splent_feature_commands", {})
|
|
446
|
+
from click import BaseCommand
|
|
447
|
+
valid = [c for c in commands if isinstance(c, BaseCommand)]
|
|
448
|
+
if valid:
|
|
449
|
+
registry[short] = valid
|
|
450
|
+
logger.info(
|
|
451
|
+
"Registered %d CLI command(s) for feature '%s': %s",
|
|
452
|
+
len(valid),
|
|
453
|
+
short,
|
|
454
|
+
[c.name for c in valid],
|
|
455
|
+
)
|
|
456
|
+
|
|
328
457
|
|
|
329
458
|
# ---------------------------------------------------------------------------
|
|
330
459
|
# Orchestrator: load a single feature (DIP — collaborators injected)
|