splent-framework 1.2.7__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.
- {splent_framework-1.2.7/src/splent_framework.egg-info → splent_framework-1.2.10}/PKG-INFO +1 -1
- {splent_framework-1.2.7 → splent_framework-1.2.10}/pyproject.toml +1 -1
- splent_framework-1.2.10/src/splent_framework/app_factory.py +90 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/hooks/template_hooks.py +15 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/feature_loader.py +72 -12
- splent_framework-1.2.10/src/splent_framework/managers/feature_manager.py +277 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/namespace_manager.py +19 -11
- splent_framework-1.2.10/src/splent_framework/refinement/model_extender.py +84 -0
- splent_framework-1.2.10/src/splent_framework/refinement/parser.py +115 -0
- splent_framework-1.2.10/src/splent_framework/refinement/registry.py +75 -0
- splent_framework-1.2.10/src/splent_framework/refinement/validator.py +122 -0
- splent_framework-1.2.10/src/splent_framework/services/service_locator.py +61 -0
- splent_framework-1.2.10/src/splent_framework/utils/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/utils/pyproject_reader.py +15 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10/src/splent_framework.egg-info}/PKG-INFO +1 -1
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework.egg-info/SOURCES.txt +9 -1
- splent_framework-1.2.10/tests/test_refinement.py +290 -0
- splent_framework-1.2.7/src/splent_framework/managers/feature_manager.py +0 -126
- {splent_framework-1.2.7 → splent_framework-1.2.10}/LICENSE +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/README.md +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/setup.cfg +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/_init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/blueprints/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/blueprints/base_blueprint.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/bootstraps/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/configuration/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/configuration/configuration.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/configuration/default_config.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/context/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/context/context_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/db.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/decorators/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/decorators/decorators.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/environment/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/environment/host.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/fixtures/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/fixtures/fixtures.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/helpers/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/helpers/test_helpers_db.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/hooks/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/locust/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/locust/common.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/config_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/db_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/error_handler_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/feature_order.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/jinja_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/logging_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/migration_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/session_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/managers/task_queue_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/migrations/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/migrations/feature_env.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/namespaces/__init__.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/repositories → splent_framework-1.2.10/src/splent_framework/refinement}/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/repositories/BaseRepository.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/resources → splent_framework-1.2.10/src/splent_framework/repositories}/__init__.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/seeders → splent_framework-1.2.10/src/splent_framework/resources}/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/resources/generic_resource.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/seeders/BaseSeeder.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/selenium → splent_framework-1.2.10/src/splent_framework/seeders}/__init__.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/serialisers → splent_framework-1.2.10/src/splent_framework/selenium}/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/selenium/common.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/services → splent_framework-1.2.10/src/splent_framework/serialisers}/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/serialisers/serializer.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/services/BaseService.py +0 -0
- {splent_framework-1.2.7/src/splent_framework/utils → splent_framework-1.2.10/src/splent_framework/services}/__init__.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/utils/app_loader.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/utils/feature_utils.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework/utils/path_utils.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework.egg-info/dependency_links.txt +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework.egg-info/requires.txt +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/src/splent_framework.egg-info/top_level.txt +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_base_blueprint_security.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_base_repository.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_config_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_config_manager_return.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_context_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_default_config.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_error_handler_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_feature_manager_parse.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_feature_order.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_feature_utils.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_generic_resource.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_migration_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_namespace_manager.py +0 -0
- {splent_framework-1.2.7 → splent_framework-1.2.10}/tests/test_pyproject_reader.py +0 -0
- {splent_framework-1.2.7 → 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.
|
|
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.
|
|
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
|
{splent_framework-1.2.7 → splent_framework-1.2.10}/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.7 → splent_framework-1.2.10}/src/splent_framework/managers/feature_loader.py
RENAMED
|
@@ -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.
|
|
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")
|
|
@@ -356,17 +409,24 @@ class FeatureLoader:
|
|
|
356
409
|
def load(self, ref: FeatureRef) -> None:
|
|
357
410
|
"""Fully load and integrate one feature.
|
|
358
411
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
2. Validate the expected internal structure
|
|
362
|
-
3. Add src/ to sys.path
|
|
363
|
-
4. Import the root package and conventional submodules
|
|
364
|
-
5. Run Flask integration hooks via FeatureIntegrator
|
|
365
|
-
"""
|
|
366
|
-
feature_dir = self._resolver.resolve(self._features_dir, ref)
|
|
367
|
-
src_root, _, _ = self._validator.validate(feature_dir, ref)
|
|
412
|
+
In development, features live on disk (symlinks → cache or workspace
|
|
413
|
+
root) so we resolve, validate, and add src/ to sys.path.
|
|
368
414
|
|
|
369
|
-
|
|
415
|
+
In production, features are pip-installed from PyPI — there are no
|
|
416
|
+
symlinks. When the resolver cannot find a directory we fall back to
|
|
417
|
+
a direct import, which works because pip already placed the package
|
|
418
|
+
in site-packages.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
feature_dir = self._resolver.resolve(self._features_dir, ref)
|
|
422
|
+
src_root, _, _ = self._validator.validate(feature_dir, ref)
|
|
423
|
+
self._importer.add_to_syspath(src_root)
|
|
424
|
+
except FeatureError:
|
|
425
|
+
# No local directory found — the feature must be pip-installed.
|
|
426
|
+
logger.debug(
|
|
427
|
+
"No local directory for %s — assuming pip-installed package.",
|
|
428
|
+
ref.import_name(),
|
|
429
|
+
)
|
|
370
430
|
|
|
371
431
|
import_name = ref.import_name()
|
|
372
432
|
module = self._importer.import_package(import_name)
|
|
@@ -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)
|
|
@@ -14,23 +14,31 @@ logger = logging.getLogger(__name__)
|
|
|
14
14
|
class NamespaceManager:
|
|
15
15
|
@staticmethod
|
|
16
16
|
def init_app(app=None):
|
|
17
|
-
"""Register namespace packages for all organizations
|
|
18
|
-
|
|
17
|
+
"""Register namespace packages for all organizations.
|
|
18
|
+
|
|
19
|
+
In development: scans .splent_cache/features and workspace root.
|
|
20
|
+
In production: features are pip-installed, so we just ensure
|
|
21
|
+
the namespace packages are importable.
|
|
22
|
+
"""
|
|
19
23
|
working_dir = PathUtils.get_working_dir()
|
|
20
24
|
base_cache_dir = os.path.join(working_dir, ".splent_cache", "features")
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
orgs: list[str] = []
|
|
27
|
+
|
|
28
|
+
if os.path.exists(base_cache_dir):
|
|
29
|
+
# Development: resolve from cache + workspace root
|
|
30
|
+
orgs = NamespaceManager._detect_orgs(base_cache_dir)
|
|
31
|
+
if orgs:
|
|
32
|
+
NamespaceManager._ensure_init_files(orgs, base_cache_dir)
|
|
33
|
+
NamespaceManager._add_to_syspath(base_cache_dir)
|
|
34
|
+
NamespaceManager._add_workspace_root_features(working_dir)
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
# Always try to import known namespace packages.
|
|
37
|
+
# In production, pip-installed features make splent_io available
|
|
38
|
+
# without any cache or workspace root directories.
|
|
27
39
|
if not orgs:
|
|
28
|
-
|
|
29
|
-
return
|
|
40
|
+
orgs = ["splent_io"]
|
|
30
41
|
|
|
31
|
-
NamespaceManager._ensure_init_files(orgs, base_cache_dir)
|
|
32
|
-
NamespaceManager._add_to_syspath(base_cache_dir)
|
|
33
|
-
NamespaceManager._add_workspace_root_features(working_dir)
|
|
34
42
|
NamespaceManager._import_namespaces(orgs)
|
|
35
43
|
|
|
36
44
|
# ------------------------------------------------------------------
|
|
@@ -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
|