splent-framework 1.2.8__tar.gz → 1.2.10__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 (91) hide show
  1. {splent_framework-1.2.8/src/splent_framework.egg-info → splent_framework-1.2.10}/PKG-INFO +1 -1
  2. {splent_framework-1.2.8 → splent_framework-1.2.10}/pyproject.toml +1 -1
  3. splent_framework-1.2.10/src/splent_framework/app_factory.py +90 -0
  4. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/hooks/template_hooks.py +15 -0
  5. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/feature_loader.py +55 -2
  6. splent_framework-1.2.10/src/splent_framework/managers/feature_manager.py +277 -0
  7. splent_framework-1.2.10/src/splent_framework/refinement/model_extender.py +84 -0
  8. splent_framework-1.2.10/src/splent_framework/refinement/parser.py +115 -0
  9. splent_framework-1.2.10/src/splent_framework/refinement/registry.py +75 -0
  10. splent_framework-1.2.10/src/splent_framework/refinement/validator.py +122 -0
  11. splent_framework-1.2.10/src/splent_framework/services/service_locator.py +61 -0
  12. splent_framework-1.2.10/src/splent_framework/utils/__init__.py +0 -0
  13. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/utils/pyproject_reader.py +15 -0
  14. {splent_framework-1.2.8 → splent_framework-1.2.10/src/splent_framework.egg-info}/PKG-INFO +1 -1
  15. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework.egg-info/SOURCES.txt +9 -1
  16. splent_framework-1.2.10/tests/test_refinement.py +290 -0
  17. splent_framework-1.2.8/src/splent_framework/managers/feature_manager.py +0 -126
  18. {splent_framework-1.2.8 → splent_framework-1.2.10}/LICENSE +0 -0
  19. {splent_framework-1.2.8 → splent_framework-1.2.10}/README.md +0 -0
  20. {splent_framework-1.2.8 → splent_framework-1.2.10}/setup.cfg +0 -0
  21. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/_init__.py +0 -0
  22. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/blueprints/__init__.py +0 -0
  23. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/blueprints/base_blueprint.py +0 -0
  24. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/bootstraps/__init__.py +0 -0
  25. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -0
  26. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/configuration/__init__.py +0 -0
  27. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/configuration/configuration.py +0 -0
  28. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/configuration/default_config.py +0 -0
  29. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/context/__init__.py +0 -0
  30. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/context/context_manager.py +0 -0
  31. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/db.py +0 -0
  32. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/decorators/__init__.py +0 -0
  33. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/decorators/decorators.py +0 -0
  34. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/environment/__init__.py +0 -0
  35. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/environment/host.py +0 -0
  36. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/fixtures/__init__.py +0 -0
  37. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/fixtures/fixtures.py +0 -0
  38. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/helpers/__init__.py +0 -0
  39. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
  40. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/helpers/test_helpers_db.py +0 -0
  41. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/hooks/__init__.py +0 -0
  42. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/locust/__init__.py +0 -0
  43. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/locust/common.py +0 -0
  44. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/__init__.py +0 -0
  45. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/config_manager.py +0 -0
  46. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/db_manager.py +0 -0
  47. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/error_handler_manager.py +0 -0
  48. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/feature_order.py +0 -0
  49. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/jinja_manager.py +0 -0
  50. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/logging_manager.py +0 -0
  51. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/migration_manager.py +0 -0
  52. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/namespace_manager.py +0 -0
  53. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/session_manager.py +0 -0
  54. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/managers/task_queue_manager.py +0 -0
  55. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/migrations/__init__.py +0 -0
  56. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/migrations/feature_env.py +0 -0
  57. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/namespaces/__init__.py +0 -0
  58. {splent_framework-1.2.8/src/splent_framework/repositories → splent_framework-1.2.10/src/splent_framework/refinement}/__init__.py +0 -0
  59. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/repositories/BaseRepository.py +0 -0
  60. {splent_framework-1.2.8/src/splent_framework/resources → splent_framework-1.2.10/src/splent_framework/repositories}/__init__.py +0 -0
  61. {splent_framework-1.2.8/src/splent_framework/seeders → splent_framework-1.2.10/src/splent_framework/resources}/__init__.py +0 -0
  62. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/resources/generic_resource.py +0 -0
  63. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/seeders/BaseSeeder.py +0 -0
  64. {splent_framework-1.2.8/src/splent_framework/selenium → splent_framework-1.2.10/src/splent_framework/seeders}/__init__.py +0 -0
  65. {splent_framework-1.2.8/src/splent_framework/serialisers → splent_framework-1.2.10/src/splent_framework/selenium}/__init__.py +0 -0
  66. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/selenium/common.py +0 -0
  67. {splent_framework-1.2.8/src/splent_framework/services → splent_framework-1.2.10/src/splent_framework/serialisers}/__init__.py +0 -0
  68. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/serialisers/serializer.py +0 -0
  69. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/services/BaseService.py +0 -0
  70. {splent_framework-1.2.8/src/splent_framework/utils → splent_framework-1.2.10/src/splent_framework/services}/__init__.py +0 -0
  71. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/utils/app_loader.py +0 -0
  72. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/utils/feature_utils.py +0 -0
  73. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework/utils/path_utils.py +0 -0
  74. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework.egg-info/dependency_links.txt +0 -0
  75. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework.egg-info/requires.txt +0 -0
  76. {splent_framework-1.2.8 → splent_framework-1.2.10}/src/splent_framework.egg-info/top_level.txt +0 -0
  77. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_base_blueprint_security.py +0 -0
  78. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_base_repository.py +0 -0
  79. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_config_manager.py +0 -0
  80. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_config_manager_return.py +0 -0
  81. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_context_manager.py +0 -0
  82. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_default_config.py +0 -0
  83. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_error_handler_manager.py +0 -0
  84. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_feature_manager_parse.py +0 -0
  85. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_feature_order.py +0 -0
  86. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_feature_utils.py +0 -0
  87. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_generic_resource.py +0 -0
  88. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_migration_manager.py +0 -0
  89. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_namespace_manager.py +0 -0
  90. {splent_framework-1.2.8 → splent_framework-1.2.10}/tests/test_pyproject_reader.py +0 -0
  91. {splent_framework-1.2.8 → splent_framework-1.2.10}/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.2.10
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
@@ -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.2.10"
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"
@@ -0,0 +1,90 @@
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.feature_manager import FeatureManager
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Initialisation pipeline — order matters.
32
+ # Each step is a (label, callable) pair. The callable receives (app, **kwargs).
33
+ _PIPELINE = [
34
+ ("namespaces", lambda app, **kw: NamespaceManager.init_app(app)),
35
+ ("config", lambda app, **kw: ConfigManager.init_app(app, kw.get("config_name", "development"))),
36
+ ("database", lambda app, **kw: MigrationManager(app)),
37
+ ("sessions", lambda app, **kw: SessionManager(app)),
38
+ ("logging", lambda app, **kw: LoggingManager(app).setup_logging()),
39
+ ("error_handlers", lambda app, **kw: ErrorHandlerManager(app).register_error_handlers()),
40
+ ("jinja_context", lambda app, **kw: JinjaManager(app, kw.get("context", {}))),
41
+ ("features", lambda app, **kw: FeatureManager(app, strict=kw.get("strict", False)).register_features()),
42
+ ]
43
+
44
+
45
+ def create_splent_app(
46
+ import_name: str,
47
+ config_name: str = "development",
48
+ *,
49
+ strict: bool = False,
50
+ extra_context: dict | None = None,
51
+ ) -> Flask:
52
+ """Create and fully initialise a SPLENT Flask application.
53
+
54
+ Parameters
55
+ ----------
56
+ import_name : str
57
+ The ``__name__`` of the calling product package.
58
+ config_name : str
59
+ Configuration profile: ``"development"``, ``"testing"``, or ``"production"``.
60
+ strict : bool
61
+ If True, missing features raise errors instead of warnings.
62
+ extra_context : dict | None
63
+ Additional Jinja context variables injected into every template.
64
+
65
+ Returns
66
+ -------
67
+ Flask
68
+ A fully configured Flask application with all features loaded.
69
+ """
70
+ app = Flask(import_name)
71
+
72
+ context = {"SPLENT_APP": os.getenv("SPLENT_APP", "")}
73
+ if extra_context:
74
+ context.update(extra_context)
75
+
76
+ kwargs = {
77
+ "config_name": config_name,
78
+ "context": context,
79
+ "strict": strict,
80
+ }
81
+
82
+ for label, init_fn in _PIPELINE:
83
+ try:
84
+ init_fn(app, **kwargs)
85
+ logger.debug("Initialised: %s", label)
86
+ except Exception:
87
+ logger.exception("Failed to initialise: %s", label)
88
+ raise
89
+
90
+ return app
@@ -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
 
@@ -232,21 +232,74 @@ 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)
247
251
 
248
252
  # ------------------------------------------------------------------
249
253
 
254
+ def _apply_model_extensions(self, import_name: str) -> None:
255
+ """Apply model mixins declared by refinement features targeting this feature."""
256
+ if not self._registry:
257
+ return
258
+
259
+ feature_name = import_name.rsplit(".", 1)[-1]
260
+ overrides = self._registry.get_overrides(feature_name, "model")
261
+ if not overrides:
262
+ return
263
+
264
+ from splent_framework.refinement.model_extender import apply_model_mixin
265
+
266
+ for entry in overrides:
267
+ # Import the mixin from the refiner's module
268
+ refiner_org = "splent_io" # convention
269
+ mixin_module_name = f"{refiner_org}.{entry.refiner}.models"
270
+ try:
271
+ import importlib
272
+
273
+ mod = importlib.import_module(mixin_module_name)
274
+ mixin_cls = getattr(mod, entry.replacement)
275
+ apply_model_mixin(entry.target, mixin_cls)
276
+ except (ImportError, AttributeError) as e:
277
+ logger.warning(
278
+ "Cannot apply model mixin %s from %s: %s",
279
+ entry.replacement, entry.refiner, e,
280
+ )
281
+
282
+ def _apply_service_overrides(self, import_name: str) -> None:
283
+ """Log service overrides applied by this feature (if it's a refiner).
284
+
285
+ The actual override happens naturally: the refiner's init_feature()
286
+ calls register_service() with the same key, overwriting the base.
287
+ Load order (UVL) guarantees the refiner runs after the base.
288
+ """
289
+ if not self._registry:
290
+ return
291
+
292
+ feature_name = import_name.rsplit(".", 1)[-1]
293
+ if not self._registry.is_refiner(feature_name):
294
+ return
295
+
296
+ for entry in self._registry.all_entries():
297
+ if entry.refiner == feature_name and entry.category == "service":
298
+ logger.info(
299
+ "Service override: %s.%s → %s (by %s)",
300
+ entry.base, entry.target, entry.replacement, entry.refiner,
301
+ )
302
+
250
303
  def _inject_config(self, import_name: str) -> None:
251
304
  try:
252
305
  config_mod = importlib.import_module(f"{import_name}.config")
@@ -0,0 +1,277 @@
1
+ """
2
+ FeatureManager — public API for loading all features declared in pyproject.toml.
3
+
4
+ For the low-level loading pipeline (parsing, path resolution, imports, Flask
5
+ integration) see feature_loader.py.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import tomllib
11
+
12
+ from splent_framework.utils.path_utils import PathUtils
13
+ from splent_framework.utils.pyproject_reader import PyprojectReader
14
+ from splent_framework.managers.feature_loader import (
15
+ FeatureEntryParser,
16
+ FeatureError,
17
+ FeatureIntegrator,
18
+ FeatureLinkResolver,
19
+ FeatureLoader,
20
+ FeatureRef, # re-exported for backward compatibility
21
+ )
22
+ from splent_framework.managers.feature_order import FeatureLoadOrderResolver
23
+ from splent_framework.refinement.registry import (
24
+ RefinementEntry,
25
+ get_registry,
26
+ clear_registry,
27
+ )
28
+ from splent_framework.refinement.parser import (
29
+ parse_extensible,
30
+ parse_refinement,
31
+ )
32
+ from splent_framework.refinement.validator import validate_refinements
33
+
34
+ __all__ = ["FeatureManager", "FeatureError", "FeatureRef"]
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class FeatureManager:
40
+ """
41
+ Load and register all features declared in the active product's pyproject.toml.
42
+
43
+ Load order is determined by the UVL constraints file: if the UVL declares
44
+ 'A => B' (A requires B), B is guaranteed to load before A. When no UVL
45
+ is available the order from pyproject.toml is preserved.
46
+
47
+ Usage::
48
+
49
+ FeatureManager(app, strict=False).register_features()
50
+ """
51
+
52
+ def __init__(self, app, *, strict: bool = True) -> None:
53
+ self._app = app
54
+ self._strict = strict
55
+ self._registered = False
56
+ self._parser = FeatureEntryParser()
57
+ self._order_resolver = FeatureLoadOrderResolver(self._parser)
58
+
59
+ def register_features(self) -> None:
60
+ """Parse pyproject.toml, resolve load order via UVL, and load every feature."""
61
+ if self._registered:
62
+ return
63
+ self._registered = True
64
+
65
+ splent_app = self._require_splent_app()
66
+ product_dir = os.path.join(PathUtils.get_working_dir(), splent_app)
67
+
68
+ features_raw = self._read_features(product_dir)
69
+ if not features_raw:
70
+ logger.info("No features declared.")
71
+ return
72
+
73
+ uvl_path = self._resolve_uvl_path(product_dir)
74
+ ordered = self._order_resolver.resolve(features_raw, uvl_path)
75
+
76
+ # ── Refinement: collect, validate, populate registry ────────────
77
+ self._setup_refinement_registry(product_dir, ordered)
78
+
79
+ # ── Load features ─────────────────────────────────────────────
80
+ features_dir = os.path.join(product_dir, "features")
81
+ registry = get_registry()
82
+ loader = FeatureLoader(
83
+ features_dir,
84
+ FeatureIntegrator(self._app, strict=self._strict, registry=registry),
85
+ )
86
+ for entry in ordered:
87
+ loader.load(self._parser.parse(entry))
88
+
89
+ # Advance lifecycle state to "active"
90
+ # (this block continues below)
91
+ try:
92
+ from splent_cli.utils.lifecycle import (
93
+ advance_state,
94
+ resolve_feature_key_from_entry,
95
+ )
96
+
97
+ key, ns, name, version = resolve_feature_key_from_entry(entry)
98
+ advance_state(
99
+ product_dir,
100
+ splent_app,
101
+ key,
102
+ to="active",
103
+ namespace=ns,
104
+ name=name,
105
+ version=version,
106
+ )
107
+ except Exception:
108
+ pass # CLI may not be installed (e.g. production without dev deps)
109
+
110
+ # ── Post-load: apply template overrides ───────────────────────
111
+ self._apply_template_overrides(registry)
112
+
113
+ def get_features(self) -> list[str]:
114
+ """Return the raw feature entries from the active product's pyproject.toml."""
115
+ splent_app = self._require_splent_app()
116
+ product_dir = os.path.join(PathUtils.get_working_dir(), splent_app)
117
+ return self._read_features(product_dir)
118
+
119
+ # ------------------------------------------------------------------
120
+ # Private helpers
121
+ # ------------------------------------------------------------------
122
+
123
+ def _apply_template_overrides(self, registry) -> None:
124
+ """Reorder Jinja blueprint template loaders so refiner templates win.
125
+
126
+ Flask's DispatchingJinjaLoader searches blueprints in registration order.
127
+ Since refiners load AFTER their base, their templates would normally lose.
128
+ We swap the refiner's blueprint ahead of the base's in the internal list.
129
+ """
130
+ template_overrides = [
131
+ e for e in registry.all_entries() if e.category == "template"
132
+ ]
133
+ if not template_overrides:
134
+ return
135
+
136
+ # Build a map: blueprint_name -> registration order index
137
+ bp_names = list(self._app.blueprints.keys())
138
+
139
+ for entry in template_overrides:
140
+ # Find the refiner's blueprint (convention: refiner registers its own bp)
141
+ # The refiner's templates are served from its own blueprint template folder.
142
+ # We need its blueprint to appear BEFORE the base's in the search order.
143
+ # Flask 3.x uses app.blueprints (ordered dict), and the
144
+ # DispatchingJinjaLoader iterates app.iter_blueprints() which
145
+ # returns them in reverse registration order (last registered = first searched).
146
+ # So the refiner, being loaded after the base, is already searched first.
147
+ # This means template override works out of the box for same-path templates.
148
+ logger.info(
149
+ "Template override: %s (from %s, by %s)",
150
+ entry.target, entry.base, entry.refiner,
151
+ )
152
+
153
+ def _setup_refinement_registry(
154
+ self, product_dir: str, ordered: list[str]
155
+ ) -> None:
156
+ """Read extensible/refinement declarations from feature pyproject files,
157
+ validate them, and populate the global RefinementRegistry."""
158
+ clear_registry()
159
+ registry = get_registry()
160
+
161
+ features_dir = os.path.join(product_dir, "features")
162
+ resolver = FeatureLinkResolver()
163
+
164
+ extensibles = {} # feature_name -> ExtensibleContract
165
+ refinements = {} # feature_name -> RefinementConfig
166
+ known_features = set()
167
+
168
+ for entry in ordered:
169
+ ref = self._parser.parse(entry)
170
+ known_features.add(ref.name)
171
+
172
+ # Try to find and read pyproject.toml for this feature
173
+ pyproject_data = self._read_feature_pyproject(features_dir, ref)
174
+ if not pyproject_data:
175
+ continue
176
+
177
+ splent = pyproject_data.get("tool", {}).get("splent", {})
178
+
179
+ # Extensible contract
180
+ ext_raw = splent.get("contract", {}).get("extensible", {})
181
+ if ext_raw:
182
+ extensibles[ref.name] = parse_extensible(ext_raw)
183
+
184
+ # Refinement config
185
+ ref_raw = splent.get("refinement", {})
186
+ ref_config = parse_refinement(ref_raw)
187
+ if ref_config:
188
+ refinements[ref.name] = ref_config
189
+
190
+ # Validate
191
+ errors = validate_refinements(refinements, extensibles, known_features)
192
+ if errors:
193
+ msg = "Refinement validation failed:\n" + "\n".join(
194
+ f" - {e}" for e in errors
195
+ )
196
+ raise FeatureError(msg)
197
+
198
+ # Populate registry
199
+ for refiner_name, config in refinements.items():
200
+ for svc in config.overrides_services:
201
+ registry.register(RefinementEntry(
202
+ refiner=refiner_name, base=config.refines,
203
+ category="service", target=svc.target,
204
+ replacement=svc.replacement, action="override",
205
+ ))
206
+ for tpl in config.overrides_templates:
207
+ registry.register(RefinementEntry(
208
+ refiner=refiner_name, base=config.refines,
209
+ category="template", target=tpl.target,
210
+ replacement=tpl.replacement, action="override",
211
+ ))
212
+ for hook in config.overrides_hooks:
213
+ registry.register(RefinementEntry(
214
+ refiner=refiner_name, base=config.refines,
215
+ category="hook", target=hook.target,
216
+ replacement=hook.replacement, action="replace",
217
+ ))
218
+ for model in config.extends_models:
219
+ registry.register(RefinementEntry(
220
+ refiner=refiner_name, base=config.refines,
221
+ category="model", target=model.target,
222
+ replacement=model.mixin, action="extend",
223
+ ))
224
+ for route in config.adds_routes:
225
+ registry.register(RefinementEntry(
226
+ refiner=refiner_name, base=config.refines,
227
+ category="route", target=route.blueprint,
228
+ replacement=route.module, action="add",
229
+ ))
230
+
231
+ def _read_feature_pyproject(
232
+ self, features_dir: str, ref: FeatureRef
233
+ ) -> dict | None:
234
+ """Read and return the pyproject.toml data for a feature, or None."""
235
+ resolver = FeatureLinkResolver()
236
+ try:
237
+ feature_dir = resolver.resolve(features_dir, ref)
238
+ except FeatureError:
239
+ # Pip-installed, no local dir — try workspace root
240
+ workspace = PathUtils.get_working_dir()
241
+ candidate = os.path.join(workspace, ref.name, "pyproject.toml")
242
+ if os.path.isfile(candidate):
243
+ with open(candidate, "rb") as f:
244
+ return tomllib.load(f)
245
+ return None
246
+
247
+ pyproject_path = os.path.join(feature_dir, "pyproject.toml")
248
+ if not os.path.isfile(pyproject_path):
249
+ return None
250
+ with open(pyproject_path, "rb") as f:
251
+ return tomllib.load(f)
252
+
253
+ def _require_splent_app(self) -> str:
254
+ splent_app = os.getenv("SPLENT_APP")
255
+ if not splent_app:
256
+ raise FeatureError("SPLENT_APP not set")
257
+ return splent_app
258
+
259
+ def _read_features(self, product_dir: str) -> list[str]:
260
+ try:
261
+ env = os.getenv("SPLENT_ENV")
262
+ return PyprojectReader.for_product(product_dir).features_for_env(env)
263
+ except FileNotFoundError as e:
264
+ raise FeatureError(str(e)) from e
265
+ except (RuntimeError, ValueError) as e:
266
+ raise FeatureError(f"Failed to parse features: {e}") from e
267
+
268
+ def _resolve_uvl_path(self, product_dir: str) -> str | None:
269
+ """Return the absolute path to the product's UVL file, or None."""
270
+ try:
271
+ uvl_cfg = PyprojectReader.for_product(product_dir).uvl_config
272
+ except (FileNotFoundError, RuntimeError):
273
+ return None
274
+ uvl_file = uvl_cfg.get("file")
275
+ if not uvl_file:
276
+ return None
277
+ return os.path.join(product_dir, "uvl", uvl_file)
@@ -0,0 +1,84 @@
1
+ """
2
+ Model extension via mixin injection.
3
+
4
+ Applies mixin classes to existing SQLAlchemy models, adding new columns
5
+ and methods without modifying the base feature's source code.
6
+ """
7
+
8
+ import logging
9
+
10
+ from splent_framework.db import db
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def apply_model_mixin(model_name: str, mixin_cls: type) -> bool:
16
+ """Apply a mixin class to an existing SQLAlchemy model.
17
+
18
+ Adds columns and methods from mixin_cls to the model class found
19
+ in SQLAlchemy's mapper registry.
20
+
21
+ Parameters
22
+ ----------
23
+ model_name : str
24
+ Name of the model class (e.g. "User").
25
+ mixin_cls : type
26
+ Mixin class with db.Column attributes and/or methods.
27
+
28
+ Returns
29
+ -------
30
+ bool
31
+ True if the mixin was applied, False if model not found.
32
+ """
33
+ # Find the model class in SQLAlchemy's registry
34
+ model_cls = _find_model(model_name)
35
+ if model_cls is None:
36
+ logger.warning("Model '%s' not found in registry — cannot apply mixin.", model_name)
37
+ return False
38
+
39
+ applied = 0
40
+
41
+ # Add columns from mixin
42
+ for attr_name in dir(mixin_cls):
43
+ if attr_name.startswith("_"):
44
+ continue
45
+
46
+ attr = getattr(mixin_cls, attr_name)
47
+
48
+ # Column injection
49
+ if isinstance(attr, db.Column):
50
+ if hasattr(model_cls, attr_name):
51
+ logger.warning(
52
+ "Column '%s' already exists on %s — skipping.",
53
+ attr_name, model_name,
54
+ )
55
+ continue
56
+ col = attr.copy()
57
+ col.name = col.name or attr_name
58
+ model_cls.__table__.append_column(col)
59
+ setattr(model_cls, attr_name, col)
60
+ applied += 1
61
+ logger.info("Extended %s with column: %s", model_name, attr_name)
62
+
63
+ # Method injection (non-column attributes)
64
+ elif callable(attr) and not isinstance(attr, type):
65
+ setattr(model_cls, attr_name, attr)
66
+ applied += 1
67
+ logger.info("Extended %s with method: %s", model_name, attr_name)
68
+
69
+ if applied:
70
+ logger.info(
71
+ "Model %s extended with %d attribute(s) from %s",
72
+ model_name, applied, mixin_cls.__name__,
73
+ )
74
+
75
+ return applied > 0
76
+
77
+
78
+ def _find_model(name: str) -> type | None:
79
+ """Find a SQLAlchemy model class by name in the mapper registry."""
80
+ for mapper in db.Model.registry.mappers:
81
+ cls = mapper.class_
82
+ if cls.__name__ == name:
83
+ return cls
84
+ return None
@@ -0,0 +1,115 @@
1
+ """
2
+ RefinementParser — reads extensible and refinement declarations from pyproject.toml.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class ExtensibleContract:
12
+ """What a base feature declares as overridable/extendable."""
13
+
14
+ services: list[str] = field(default_factory=list)
15
+ templates: list[str] = field(default_factory=list)
16
+ models: list[str] = field(default_factory=list)
17
+ hooks: list[str] = field(default_factory=list)
18
+ routes: bool = False # if True, other features can add routes to this blueprint
19
+
20
+
21
+ @dataclass
22
+ class RefinementOverride:
23
+ target: str
24
+ replacement: str = ""
25
+
26
+
27
+ @dataclass
28
+ class RefinementModelExtension:
29
+ target: str # base model name, e.g. "User"
30
+ mixin: str # mixin class name, e.g. "User2FAMixin"
31
+
32
+
33
+ @dataclass
34
+ class RefinementRouteAddition:
35
+ blueprint: str # base blueprint name, e.g. "auth"
36
+ module: str # module with routes, e.g. "routes_2fa"
37
+
38
+
39
+ @dataclass
40
+ class RefinementConfig:
41
+ """What a refining feature declares about its overrides."""
42
+
43
+ refines: str # base feature name, e.g. "splent_feature_auth"
44
+ overrides_services: list[RefinementOverride] = field(default_factory=list)
45
+ overrides_templates: list[RefinementOverride] = field(default_factory=list)
46
+ overrides_hooks: list[RefinementOverride] = field(default_factory=list)
47
+ extends_models: list[RefinementModelExtension] = field(default_factory=list)
48
+ adds_routes: list[RefinementRouteAddition] = field(default_factory=list)
49
+
50
+
51
+ def parse_extensible(raw: dict) -> ExtensibleContract:
52
+ """Parse [tool.splent.contract.extensible] from pyproject.toml data."""
53
+ if not raw:
54
+ return ExtensibleContract()
55
+
56
+ return ExtensibleContract(
57
+ services=_list(raw.get("services")),
58
+ templates=_list(raw.get("templates")),
59
+ models=_list(raw.get("models")),
60
+ hooks=_list(raw.get("hooks")),
61
+ routes=bool(raw.get("routes", False)),
62
+ )
63
+
64
+
65
+ def parse_refinement(raw: dict) -> RefinementConfig | None:
66
+ """Parse [tool.splent.refinement] from pyproject.toml data.
67
+
68
+ Returns None if the feature does not declare any refinement.
69
+ """
70
+ refines = raw.get("refines")
71
+ if not refines:
72
+ return None
73
+
74
+ overrides = raw.get("overrides", {})
75
+ extends = raw.get("extends", {})
76
+
77
+ return RefinementConfig(
78
+ refines=refines,
79
+ overrides_services=[
80
+ RefinementOverride(target=o["target"], replacement=o.get("replacement", ""))
81
+ for o in _list_of_dicts(overrides.get("services"))
82
+ ],
83
+ overrides_templates=[
84
+ RefinementOverride(target=o["target"], replacement=o.get("replacement", ""))
85
+ for o in _list_of_dicts(overrides.get("templates"))
86
+ ],
87
+ overrides_hooks=[
88
+ RefinementOverride(target=o["target"], replacement=o.get("replacement", ""))
89
+ for o in _list_of_dicts(overrides.get("hooks"))
90
+ ],
91
+ extends_models=[
92
+ RefinementModelExtension(target=o["target"], mixin=o["mixin"])
93
+ for o in _list_of_dicts(extends.get("models"))
94
+ ],
95
+ adds_routes=[
96
+ RefinementRouteAddition(blueprint=o["blueprint"], module=o["module"])
97
+ for o in _list_of_dicts(extends.get("routes"))
98
+ ],
99
+ )
100
+
101
+
102
+ def _list(val) -> list[str]:
103
+ if val is None:
104
+ return []
105
+ if isinstance(val, list):
106
+ return [str(x) for x in val]
107
+ return []
108
+
109
+
110
+ def _list_of_dicts(val) -> list[dict]:
111
+ if val is None:
112
+ return []
113
+ if isinstance(val, list):
114
+ return [x for x in val if isinstance(x, dict)]
115
+ return []