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.
Files changed (95) hide show
  1. {splent_framework-1.2.8/src/splent_framework.egg-info → splent_framework-1.4.1}/PKG-INFO +2 -1
  2. {splent_framework-1.2.8 → splent_framework-1.4.1}/pyproject.toml +3 -2
  3. splent_framework-1.4.1/src/splent_framework/app_factory.py +92 -0
  4. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/blueprints/base_blueprint.py +34 -7
  5. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/configuration/default_config.py +2 -3
  6. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/fixtures/fixtures.py +23 -27
  7. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/hooks/template_hooks.py +15 -0
  8. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/config_manager.py +5 -0
  9. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/feature_loader.py +134 -5
  10. splent_framework-1.4.1/src/splent_framework/managers/feature_manager.py +298 -0
  11. splent_framework-1.4.1/src/splent_framework/managers/locale_manager.py +79 -0
  12. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/migration_manager.py +12 -0
  13. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/migrations/feature_env.py +23 -2
  14. splent_framework-1.4.1/src/splent_framework/refinement/model_extender.py +84 -0
  15. splent_framework-1.4.1/src/splent_framework/refinement/parser.py +115 -0
  16. splent_framework-1.4.1/src/splent_framework/refinement/registry.py +75 -0
  17. splent_framework-1.4.1/src/splent_framework/refinement/validator.py +122 -0
  18. splent_framework-1.4.1/src/splent_framework/services/service_locator.py +61 -0
  19. splent_framework-1.4.1/src/splent_framework/signals/__init__.py +0 -0
  20. splent_framework-1.4.1/src/splent_framework/signals/registry.py +51 -0
  21. splent_framework-1.4.1/src/splent_framework/signals/signal_utils.py +77 -0
  22. splent_framework-1.4.1/src/splent_framework/utils/__init__.py +0 -0
  23. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/pyproject_reader.py +15 -0
  24. {splent_framework-1.2.8 → splent_framework-1.4.1/src/splent_framework.egg-info}/PKG-INFO +2 -1
  25. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/SOURCES.txt +13 -2
  26. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/requires.txt +1 -0
  27. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_default_config.py +4 -4
  28. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_migration_manager.py +5 -5
  29. splent_framework-1.4.1/tests/test_refinement.py +290 -0
  30. splent_framework-1.2.8/src/splent_framework/helpers/test_helpers_db.py +0 -13
  31. splent_framework-1.2.8/src/splent_framework/managers/feature_manager.py +0 -126
  32. {splent_framework-1.2.8 → splent_framework-1.4.1}/LICENSE +0 -0
  33. {splent_framework-1.2.8 → splent_framework-1.4.1}/README.md +0 -0
  34. {splent_framework-1.2.8 → splent_framework-1.4.1}/setup.cfg +0 -0
  35. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/_init__.py +0 -0
  36. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/blueprints/__init__.py +0 -0
  37. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/bootstraps/__init__.py +0 -0
  38. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -0
  39. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/configuration/__init__.py +0 -0
  40. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/configuration/configuration.py +0 -0
  41. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/context/__init__.py +0 -0
  42. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/context/context_manager.py +0 -0
  43. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/db.py +0 -0
  44. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/decorators/__init__.py +0 -0
  45. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/decorators/decorators.py +0 -0
  46. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/environment/__init__.py +0 -0
  47. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/environment/host.py +0 -0
  48. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/fixtures/__init__.py +0 -0
  49. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/helpers/__init__.py +0 -0
  50. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
  51. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/hooks/__init__.py +0 -0
  52. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/locust/__init__.py +0 -0
  53. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/locust/common.py +0 -0
  54. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/__init__.py +0 -0
  55. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/db_manager.py +0 -0
  56. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/error_handler_manager.py +0 -0
  57. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/feature_order.py +0 -0
  58. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/jinja_manager.py +0 -0
  59. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/logging_manager.py +0 -0
  60. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/namespace_manager.py +0 -0
  61. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/session_manager.py +0 -0
  62. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/managers/task_queue_manager.py +0 -0
  63. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/migrations/__init__.py +0 -0
  64. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/namespaces/__init__.py +0 -0
  65. {splent_framework-1.2.8/src/splent_framework/repositories → splent_framework-1.4.1/src/splent_framework/refinement}/__init__.py +0 -0
  66. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/repositories/BaseRepository.py +0 -0
  67. {splent_framework-1.2.8/src/splent_framework/resources → splent_framework-1.4.1/src/splent_framework/repositories}/__init__.py +0 -0
  68. {splent_framework-1.2.8/src/splent_framework/seeders → splent_framework-1.4.1/src/splent_framework/resources}/__init__.py +0 -0
  69. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/resources/generic_resource.py +0 -0
  70. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/seeders/BaseSeeder.py +0 -0
  71. {splent_framework-1.2.8/src/splent_framework/selenium → splent_framework-1.4.1/src/splent_framework/seeders}/__init__.py +0 -0
  72. {splent_framework-1.2.8/src/splent_framework/serialisers → splent_framework-1.4.1/src/splent_framework/selenium}/__init__.py +0 -0
  73. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/selenium/common.py +0 -0
  74. {splent_framework-1.2.8/src/splent_framework/services → splent_framework-1.4.1/src/splent_framework/serialisers}/__init__.py +0 -0
  75. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/serialisers/serializer.py +0 -0
  76. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/services/BaseService.py +0 -0
  77. {splent_framework-1.2.8/src/splent_framework/utils → splent_framework-1.4.1/src/splent_framework/services}/__init__.py +0 -0
  78. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/app_loader.py +0 -0
  79. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/feature_utils.py +0 -0
  80. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework/utils/path_utils.py +0 -0
  81. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/dependency_links.txt +0 -0
  82. {splent_framework-1.2.8 → splent_framework-1.4.1}/src/splent_framework.egg-info/top_level.txt +0 -0
  83. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_base_blueprint_security.py +0 -0
  84. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_base_repository.py +0 -0
  85. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_config_manager.py +0 -0
  86. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_config_manager_return.py +0 -0
  87. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_context_manager.py +0 -0
  88. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_error_handler_manager.py +0 -0
  89. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_feature_manager_parse.py +0 -0
  90. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_feature_order.py +0 -0
  91. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_feature_utils.py +0 -0
  92. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_generic_resource.py +0 -0
  93. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_namespace_manager.py +0 -0
  94. {splent_framework-1.2.8 → splent_framework-1.4.1}/tests/test_pyproject_reader.py +0 -0
  95. {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.2.8
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.2.8"
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
@@ -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 = os.path.realpath(
66
- os.path.join(self.feature_code_path, "assets", subfolder)
67
- )
68
- requested_path = os.path.realpath(os.path.join(base_dir, filename))
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 = "filesystem"
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
- db.drop_all()
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 and resets the DB before and after each test.
23
- This ensures test isolation.
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
- db.session.remove()
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 only at the start and end of the module.
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
- db.session.remove()
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
- Provides a manual DB reset during a test when needed.
60
- Useful if you want to control when the DB is cleaned.
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
- db.session.remove()
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()
@@ -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
 
@@ -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"}
@@ -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. Register all Flask Blueprints found in the feature modules
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(self._register_from_module(mod) for mod in candidates)
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)