splent-framework 1.6.2__tar.gz → 1.7.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 (105) hide show
  1. {splent_framework-1.6.2/src/splent_framework.egg-info → splent_framework-1.7.1}/PKG-INFO +1 -1
  2. {splent_framework-1.6.2 → splent_framework-1.7.1}/pyproject.toml +1 -1
  3. splent_framework-1.7.1/src/splent_framework/__init__.py +3 -0
  4. splent_framework-1.7.1/src/splent_framework/admin/__init__.py +29 -0
  5. splent_framework-1.7.1/src/splent_framework/admin/registry.py +149 -0
  6. splent_framework-1.7.1/src/splent_framework/assets/asset_registry.py +68 -0
  7. splent_framework-1.7.1/src/splent_framework/bootstraps/locustfile_bootstrap.py +157 -0
  8. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/environment/host.py +10 -0
  9. splent_framework-1.7.1/src/splent_framework/hooks/template_hooks.py +43 -0
  10. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_loader.py +14 -0
  11. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_manager.py +35 -12
  12. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/jinja_manager.py +2 -0
  13. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/locale_manager.py +10 -4
  14. splent_framework-1.7.1/src/splent_framework/nav/nav_registry.py +50 -0
  15. splent_framework-1.7.1/src/splent_framework/selenium/common.py +86 -0
  16. splent_framework-1.7.1/src/splent_framework/settings/settings_schema.py +103 -0
  17. splent_framework-1.7.1/src/splent_framework/signals/__init__.py +0 -0
  18. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/signals/signal_utils.py +16 -4
  19. splent_framework-1.7.1/src/splent_framework/utils/__init__.py +0 -0
  20. {splent_framework-1.6.2 → splent_framework-1.7.1/src/splent_framework.egg-info}/PKG-INFO +1 -1
  21. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/SOURCES.txt +9 -1
  22. splent_framework-1.6.2/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -53
  23. splent_framework-1.6.2/src/splent_framework/hooks/template_hooks.py +0 -36
  24. splent_framework-1.6.2/src/splent_framework/selenium/common.py +0 -17
  25. {splent_framework-1.6.2 → splent_framework-1.7.1}/LICENSE +0 -0
  26. {splent_framework-1.6.2 → splent_framework-1.7.1}/README.md +0 -0
  27. {splent_framework-1.6.2 → splent_framework-1.7.1}/setup.cfg +0 -0
  28. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/app_factory.py +0 -0
  29. {splent_framework-1.6.2/src/splent_framework/blueprints → splent_framework-1.7.1/src/splent_framework/assets}/__init__.py +0 -0
  30. {splent_framework-1.6.2/src/splent_framework/bootstraps → splent_framework-1.7.1/src/splent_framework/blueprints}/__init__.py +0 -0
  31. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/blueprints/base_blueprint.py +0 -0
  32. {splent_framework-1.6.2/src/splent_framework/configuration → splent_framework-1.7.1/src/splent_framework/bootstraps}/__init__.py +0 -0
  33. {splent_framework-1.6.2/src/splent_framework/context → splent_framework-1.7.1/src/splent_framework/configuration}/__init__.py +0 -0
  34. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/configuration/configuration.py +0 -0
  35. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/configuration/default_config.py +0 -0
  36. {splent_framework-1.6.2/src/splent_framework/decorators → splent_framework-1.7.1/src/splent_framework/context}/__init__.py +0 -0
  37. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/context/context_manager.py +0 -0
  38. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/db.py +0 -0
  39. {splent_framework-1.6.2/src/splent_framework/environment → splent_framework-1.7.1/src/splent_framework/decorators}/__init__.py +0 -0
  40. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/decorators/decorators.py +0 -0
  41. {splent_framework-1.6.2/src/splent_framework/fixtures → splent_framework-1.7.1/src/splent_framework/environment}/__init__.py +0 -0
  42. {splent_framework-1.6.2/src/splent_framework/helpers → splent_framework-1.7.1/src/splent_framework/fixtures}/__init__.py +0 -0
  43. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/fixtures/fixtures.py +0 -0
  44. {splent_framework-1.6.2/src/splent_framework/hooks → splent_framework-1.7.1/src/splent_framework/helpers}/__init__.py +0 -0
  45. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
  46. {splent_framework-1.6.2/src/splent_framework/locust → splent_framework-1.7.1/src/splent_framework/hooks}/__init__.py +0 -0
  47. {splent_framework-1.6.2/src/splent_framework/managers → splent_framework-1.7.1/src/splent_framework/locust}/__init__.py +0 -0
  48. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/locust/common.py +0 -0
  49. {splent_framework-1.6.2/src/splent_framework/migrations → splent_framework-1.7.1/src/splent_framework/managers}/__init__.py +0 -0
  50. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/config_manager.py +0 -0
  51. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/db_manager.py +0 -0
  52. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/error_handler_manager.py +0 -0
  53. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_order.py +0 -0
  54. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/logging_manager.py +0 -0
  55. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/migration_manager.py +0 -0
  56. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/namespace_manager.py +0 -0
  57. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/session_manager.py +0 -0
  58. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/task_queue_manager.py +0 -0
  59. {splent_framework-1.6.2/src/splent_framework/namespaces → splent_framework-1.7.1/src/splent_framework/migrations}/__init__.py +0 -0
  60. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/migrations/feature_env.py +0 -0
  61. {splent_framework-1.6.2/src/splent_framework/repositories → splent_framework-1.7.1/src/splent_framework/namespaces}/__init__.py +0 -0
  62. {splent_framework-1.6.2/src/splent_framework/resources → splent_framework-1.7.1/src/splent_framework/nav}/__init__.py +0 -0
  63. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/__init__.py +0 -0
  64. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/model_extender.py +0 -0
  65. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/parser.py +0 -0
  66. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/registry.py +0 -0
  67. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/validator.py +0 -0
  68. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/repositories/BaseRepository.py +0 -0
  69. {splent_framework-1.6.2/src/splent_framework/seeders → splent_framework-1.7.1/src/splent_framework/repositories}/__init__.py +0 -0
  70. {splent_framework-1.6.2/src/splent_framework/selenium → splent_framework-1.7.1/src/splent_framework/resources}/__init__.py +0 -0
  71. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/resources/generic_resource.py +0 -0
  72. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/seeders/BaseSeeder.py +0 -0
  73. {splent_framework-1.6.2/src/splent_framework/serialisers → splent_framework-1.7.1/src/splent_framework/seeders}/__init__.py +0 -0
  74. {splent_framework-1.6.2/src/splent_framework/services → splent_framework-1.7.1/src/splent_framework/selenium}/__init__.py +0 -0
  75. {splent_framework-1.6.2/src/splent_framework/signals → splent_framework-1.7.1/src/splent_framework/serialisers}/__init__.py +0 -0
  76. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/serialisers/serializer.py +0 -0
  77. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/services/BaseService.py +0 -0
  78. {splent_framework-1.6.2/src/splent_framework/utils → splent_framework-1.7.1/src/splent_framework/services}/__init__.py +0 -0
  79. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/services/service_locator.py +0 -0
  80. /splent_framework-1.6.2/src/splent_framework/_init__.py → /splent_framework-1.7.1/src/splent_framework/settings/__init__.py +0 -0
  81. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/signals/registry.py +0 -0
  82. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/app_loader.py +0 -0
  83. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/feature_utils.py +0 -0
  84. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/form_helpers.py +0 -0
  85. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/path_utils.py +0 -0
  86. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/pyproject_reader.py +0 -0
  87. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/dependency_links.txt +0 -0
  88. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/requires.txt +0 -0
  89. {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/top_level.txt +0 -0
  90. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_base_blueprint_security.py +0 -0
  91. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_base_repository.py +0 -0
  92. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_config_manager.py +0 -0
  93. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_config_manager_return.py +0 -0
  94. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_context_manager.py +0 -0
  95. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_default_config.py +0 -0
  96. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_error_handler_manager.py +0 -0
  97. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_feature_manager_parse.py +0 -0
  98. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_feature_order.py +0 -0
  99. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_feature_utils.py +0 -0
  100. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_generic_resource.py +0 -0
  101. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_migration_manager.py +0 -0
  102. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_namespace_manager.py +0 -0
  103. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_pyproject_reader.py +0 -0
  104. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_pyproject_reader_env.py +0 -0
  105. {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_refinement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_framework
3
- Version: 1.6.2
3
+ Version: 1.7.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_framework"
7
- version = "1.6.2"
7
+ version = "1.7.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"
@@ -0,0 +1,3 @@
1
+ from splent_framework.app_factory import create_splent_app
2
+
3
+ __all__ = ["create_splent_app"]
@@ -0,0 +1,29 @@
1
+ """Admin resource registry — declare how models surface in the admin panel.
2
+
3
+ A content feature registers its model with :func:`register_admin_resource`
4
+ (typically from ``init_feature``); the ``admin`` feature reads the registry to
5
+ build a grouped, wp-admin-style menu and CRUD screens. Mirrors the singleton
6
+ pattern of ``splent_framework.hooks.template_hooks``.
7
+ """
8
+
9
+ from splent_framework.admin.registry import (
10
+ WIDGETS,
11
+ AdminResource,
12
+ clear_admin_resources,
13
+ get_admin_groups,
14
+ get_admin_resource,
15
+ get_admin_resource_for_model,
16
+ get_admin_resources,
17
+ register_admin_resource,
18
+ )
19
+
20
+ __all__ = [
21
+ "WIDGETS",
22
+ "AdminResource",
23
+ "register_admin_resource",
24
+ "get_admin_resource",
25
+ "get_admin_resource_for_model",
26
+ "get_admin_resources",
27
+ "get_admin_groups",
28
+ "clear_admin_resources",
29
+ ]
@@ -0,0 +1,149 @@
1
+ # splent_framework/admin/registry.py
2
+ #
3
+ # Admin resource registry — the analogue of WordPress `register_post_type`.
4
+ #
5
+ # A content feature registers its model as an *admin resource*, declaring how it
6
+ # should appear in the admin panel (label, icon, menu group, list columns,
7
+ # per-field widgets). The `admin` feature reads this registry to build a curated,
8
+ # grouped, wp-admin-style menu and CRUD screens — instead of dumping every
9
+ # `db.Model` subclass in a flat list.
10
+ #
11
+ # NOTE: _resources is a module-level registry populated once at app startup
12
+ # (during feature registration / init_feature) and read-only during request
13
+ # handling — same contract as template_hooks.py. Not thread-safe for concurrent
14
+ # writes; do not call register_admin_resource() from request handlers.
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ # Widget hints the admin form renderer understands. Features may pass any of
19
+ # these per column via `field_widgets`; unknown columns fall back to an
20
+ # introspection-derived default chosen by the admin feature.
21
+ WIDGETS = (
22
+ "text", # single-line string
23
+ "textarea", # multi-line plain text
24
+ "richtext", # WYSIWYG / HTML body
25
+ "number",
26
+ "bool",
27
+ "date",
28
+ "datetime",
29
+ "select", # enum / fixed choices
30
+ "fk", # foreign key -> related record picker
31
+ "image", # media id / upload
32
+ "url",
33
+ "slug",
34
+ "color",
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class AdminResource:
40
+ """Metadata describing how a model is administered in the admin panel."""
41
+
42
+ model: type
43
+ name: str # url-safe key, e.g. "project"
44
+ label: str # singular human label, e.g. "Project"
45
+ label_plural: str # e.g. "Projects"
46
+ icon: str = "file" # feather icon name used in the menu
47
+ group: str = "Content" # menu section this resource is filed under
48
+ order: int = 100 # sort order within the group
49
+ list_columns: list[str] | None = None # None -> admin auto-picks
50
+ search_fields: list[str] = field(default_factory=list)
51
+ field_widgets: dict[str, str] = field(default_factory=dict) # column -> WIDGETS
52
+ readonly_fields: list[str] = field(default_factory=list)
53
+ scope: dict = field(default_factory=dict) # query filter — lets one shared
54
+ # table back several menu entries
55
+ # (e.g. {"collection": "event"})
56
+ create_defaults: dict = field(
57
+ default_factory=dict
58
+ ) # column values forced on create
59
+ feature: str | None = None # owning feature short name (provenance)
60
+
61
+ @property
62
+ def model_name(self) -> str:
63
+ """The SQLAlchemy class name — the key the CRUD routes resolve by."""
64
+ return self.model.__name__
65
+
66
+
67
+ # Module-level singleton registry (keyed by resource `name`).
68
+ _resources: dict[str, AdminResource] = {}
69
+
70
+
71
+ def register_admin_resource(
72
+ model: type,
73
+ *,
74
+ name: str | None = None,
75
+ label: str | None = None,
76
+ label_plural: str | None = None,
77
+ icon: str = "file",
78
+ group: str = "Content",
79
+ order: int = 100,
80
+ list_columns: list[str] | None = None,
81
+ search_fields: list[str] | None = None,
82
+ field_widgets: dict[str, str] | None = None,
83
+ readonly_fields: list[str] | None = None,
84
+ scope: dict | None = None,
85
+ create_defaults: dict | None = None,
86
+ feature: str | None = None,
87
+ ) -> AdminResource:
88
+ """Register a model as an administered resource (additive, idempotent by name).
89
+
90
+ Call from a feature's ``init_feature(app)``. The owning content feature thus
91
+ declares its admin surface once; the admin panel renders it. Re-registering
92
+ the same ``name`` overrides the previous entry (last registration wins), which
93
+ lets a refinement feature curate another feature's resource.
94
+ """
95
+ key = name or model.__name__.lower()
96
+ resolved_label = label or model.__name__
97
+ res = AdminResource(
98
+ model=model,
99
+ name=key,
100
+ label=resolved_label,
101
+ label_plural=label_plural or f"{resolved_label}s",
102
+ icon=icon,
103
+ group=group,
104
+ order=order,
105
+ list_columns=list_columns,
106
+ search_fields=search_fields or [],
107
+ field_widgets=field_widgets or {},
108
+ readonly_fields=readonly_fields or [],
109
+ scope=scope or {},
110
+ create_defaults=create_defaults or {},
111
+ feature=feature,
112
+ )
113
+ _resources[key] = res
114
+ return res
115
+
116
+
117
+ def get_admin_resource(name: str) -> AdminResource | None:
118
+ """Return a single registered resource by its url-safe name, or None."""
119
+ return _resources.get(name)
120
+
121
+
122
+ def get_admin_resource_for_model(model_name: str) -> AdminResource | None:
123
+ """Return the resource whose model class name matches ``model_name``."""
124
+ for res in _resources.values():
125
+ if res.model_name == model_name:
126
+ return res
127
+ return None
128
+
129
+
130
+ def get_admin_resources() -> dict[str, AdminResource]:
131
+ """Return all registered resources keyed by name (insertion order)."""
132
+ return dict(_resources)
133
+
134
+
135
+ def get_admin_groups() -> dict[str, list[AdminResource]]:
136
+ """Return resources bucketed by menu group, each sorted by (order, label).
137
+
138
+ This is what the admin sidebar iterates to render a grouped, wp-admin-style
139
+ menu (a section per group, content types listed underneath).
140
+ """
141
+ groups: dict[str, list[AdminResource]] = {}
142
+ for res in sorted(_resources.values(), key=lambda r: (r.order, r.label)):
143
+ groups.setdefault(res.group, []).append(res)
144
+ return groups
145
+
146
+
147
+ def clear_admin_resources() -> None:
148
+ """Remove all registered resources. Intended for test teardown."""
149
+ _resources.clear()
@@ -0,0 +1,68 @@
1
+ # splent_framework/assets/asset_registry.py
2
+ #
3
+ # SPL asset registry. Features DECLARE their stylesheets/scripts here (from
4
+ # init_feature) instead of hand-writing <link>/<script> tags in hooks or
5
+ # inlining JS into templates. The public shell renders get_assets("css") in
6
+ # <head> and get_assets("js") before </body>, DEDUPLICATED and ORDERED — so the
7
+ # cascade is deterministic and asset injection is consistent across features.
8
+ # No ad-hoc tags, no inline scripts: a feature ships a file under its blueprint's
9
+ # assets/ folder (served by the blueprint asset route) and registers it here.
10
+ #
11
+ # Cleared per app at feature-load (like the nav registry) so each product only
12
+ # emits the assets of ITS installed features. Registration is idempotent.
13
+
14
+ _assets: list = []
15
+
16
+
17
+ def register_asset(kind: str, endpoint: str, order: int = 100, **params) -> None:
18
+ """Declare a static asset served by a feature blueprint's asset route.
19
+
20
+ Args:
21
+ kind: "css" or "js".
22
+ endpoint: the blueprint asset endpoint, e.g. "slider.assets".
23
+ order: load order (lower first) — governs the cascade deterministically.
24
+ Convention: theme base ~0, features ~100, skins ~200 (win last).
25
+ **params: url_for params for the asset route, e.g.
26
+ subfolder="css", filename="slider.css".
27
+ """
28
+ key = (kind, endpoint, tuple(sorted(params.items())))
29
+ for a in _assets:
30
+ if a["key"] == key:
31
+ return
32
+ _assets.append(
33
+ {
34
+ "key": key,
35
+ "kind": kind,
36
+ "endpoint": endpoint,
37
+ "params": params,
38
+ "order": order,
39
+ }
40
+ )
41
+
42
+
43
+ def get_assets(kind: str) -> list:
44
+ """Resolved asset URLs of a kind, ordered by (order, endpoint) and deduped.
45
+
46
+ url_for is resolved at call time, so invoke this from the template (request
47
+ context). Assets whose route can't be resolved are skipped, not raised.
48
+ """
49
+ from flask import url_for
50
+
51
+ seen = set()
52
+ out = []
53
+ for a in sorted(_assets, key=lambda x: (x["order"], x["endpoint"])):
54
+ if a["kind"] != kind:
55
+ continue
56
+ try:
57
+ href = url_for(a["endpoint"], **a["params"])
58
+ except Exception:
59
+ continue
60
+ if href not in seen:
61
+ seen.add(href)
62
+ out.append(href)
63
+ return out
64
+
65
+
66
+ def clear_assets() -> None:
67
+ """Drop all registered assets. Called per app before feature load."""
68
+ _assets.clear()
@@ -0,0 +1,157 @@
1
+ """Default Locust entry point: load every feature's locustfile at once.
2
+
3
+ Locust is pointed at this module when no ``-f`` names a specific file. It
4
+ discovers the locustfiles a product ships, imports them, and re-exports the
5
+ ``HttpUser`` subclasses it finds so Locust can offer them all.
6
+
7
+ Discovery covers the layouts SPLENT products actually use:
8
+
9
+ * ``app/features/<feature>/tests/locustfile.py`` — a product that keeps its
10
+ features inside the application package.
11
+ * ``features/<namespace>/<feature>/**/tests/load/locustfile.py`` — a product
12
+ composed from installed feature packages. These entries are symlinks into
13
+ the workspace or the feature cache, so discovery follows symlinks.
14
+
15
+ Set ``SPLENT_LOCUSTFILES`` to override discovery entirely: a comma-separated
16
+ list of glob patterns, relative to ``WORKING_DIR`` unless absolute. Use it when
17
+ a product keeps its load tests somewhere else.
18
+ """
19
+
20
+ import glob
21
+ import importlib.util
22
+ import inspect
23
+ import logging
24
+ import os
25
+
26
+ from dotenv import load_dotenv
27
+ from locust import HttpUser
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Relative to WORKING_DIR. Ordered from cheapest to widest.
32
+ DEFAULT_PATTERNS = (
33
+ "app/features/*/tests/locustfile.py",
34
+ "features/*/*/tests/load/locustfile.py",
35
+ "features/*/*/tests/locustfile.py",
36
+ "features/*/*/src/*/*/tests/load/locustfile.py",
37
+ )
38
+
39
+ # Never descend into these while following symlinked feature directories.
40
+ PRUNED_DIRS = frozenset(
41
+ {
42
+ ".git",
43
+ ".venv",
44
+ "venv",
45
+ "node_modules",
46
+ "__pycache__",
47
+ ".splent_cache",
48
+ "dist",
49
+ "build",
50
+ }
51
+ )
52
+
53
+
54
+ def _configured_patterns() -> list[str] | None:
55
+ raw = os.getenv("SPLENT_LOCUSTFILES", "").strip()
56
+ if not raw:
57
+ return None
58
+ return [p.strip() for p in raw.split(",") if p.strip()]
59
+
60
+
61
+ def _walk_for_locustfiles(root: str) -> list[str]:
62
+ """Find locustfile.py under root, following symlinks.
63
+
64
+ glob's ``**`` does not descend into symlinked directories, and a composed
65
+ product links every feature in, so the recursive search is done by hand.
66
+ """
67
+ found = []
68
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
69
+ dirnames[:] = [d for d in dirnames if d not in PRUNED_DIRS]
70
+ if "locustfile.py" in filenames:
71
+ found.append(os.path.join(dirpath, "locustfile.py"))
72
+ return found
73
+
74
+
75
+ def discover_locustfiles() -> list[str]:
76
+ """Return the locustfile paths for this product, deduplicated and sorted."""
77
+ working_dir = os.getenv("WORKING_DIR", "")
78
+
79
+ patterns = _configured_patterns()
80
+ if patterns is not None:
81
+ logger.debug("SPLENT_LOCUSTFILES set, using %s", patterns)
82
+ else:
83
+ patterns = list(DEFAULT_PATTERNS)
84
+
85
+ paths: set[str] = set()
86
+ for pattern in patterns:
87
+ absolute = (
88
+ pattern if os.path.isabs(pattern) else os.path.join(working_dir, pattern)
89
+ )
90
+ paths.update(glob.glob(absolute))
91
+
92
+ # A composed product links its features in, and the depth of the locustfile
93
+ # inside each one varies with how the feature was installed, so walk them.
94
+ features_root = os.path.join(working_dir, "features")
95
+ if _configured_patterns() is None and os.path.isdir(features_root):
96
+ paths.update(_walk_for_locustfiles(features_root))
97
+
98
+ resolved = sorted({os.path.realpath(p) for p in paths})
99
+ logger.debug("Discovered locustfiles: %s", resolved)
100
+ return resolved
101
+
102
+
103
+ def _module_name_for(path: str) -> str:
104
+ """A name unique per feature, so two locustfiles cannot shadow each other.
105
+
106
+ Walks up past the test directories, so both
107
+ ``<feature>/tests/locustfile.py`` and ``<feature>/tests/load/locustfile.py``
108
+ resolve to the feature's own name.
109
+ """
110
+ parts = os.path.dirname(path).split(os.sep)
111
+ while parts and parts[-1] in ("load", "tests", "test", "src"):
112
+ parts.pop()
113
+ name = parts[-1] if parts else "feature"
114
+ return f"splent_locustfile_{name}"
115
+
116
+
117
+ def load_locustfiles():
118
+ load_dotenv()
119
+
120
+ locustfile_paths = discover_locustfiles()
121
+ found_user_classes = []
122
+
123
+ for path in locustfile_paths:
124
+ logger.debug("Loading locustfile: %s", path)
125
+ spec = importlib.util.spec_from_file_location(_module_name_for(path), path)
126
+ locustfile = importlib.util.module_from_spec(spec)
127
+ try:
128
+ spec.loader.exec_module(locustfile)
129
+ except Exception:
130
+ logger.exception("Skipping locustfile that failed to import: %s", path)
131
+ continue
132
+
133
+ for name, obj in vars(locustfile).items():
134
+ if (
135
+ inspect.isclass(obj)
136
+ and issubclass(obj, HttpUser)
137
+ and obj is not HttpUser
138
+ ):
139
+ unique_name = f"{name}_{_module_name_for(path)}"
140
+ globals()[unique_name] = obj
141
+ found_user_classes.append((unique_name, obj))
142
+ logger.debug("Loaded user class: %s", unique_name)
143
+
144
+ if not found_user_classes:
145
+ raise ValueError(
146
+ "No User class found. Looked for locustfiles under "
147
+ f"{os.getenv('WORKING_DIR', '') or '.'} using patterns "
148
+ f"{_configured_patterns() or list(DEFAULT_PATTERNS)}. "
149
+ "Set SPLENT_LOCUSTFILES to a comma-separated list of glob patterns "
150
+ "if this product keeps them somewhere else."
151
+ )
152
+
153
+ return found_user_classes
154
+
155
+
156
+ found_user_classes = load_locustfiles()
157
+ logger.info("Total user classes loaded: %d", len(found_user_classes))
@@ -59,7 +59,17 @@ def get_host_for_selenium_testing() -> str:
59
59
  """
60
60
  Get the host URL for selenium testing.
61
61
 
62
+ ``SELENIUM_TARGET_URL`` wins when set. It is needed whenever the browser
63
+ runs somewhere other than the machine running the tests, typically a
64
+ selenium grid node: the URL is resolved by the browser, so the default
65
+ ``localhost`` would point the browser at itself instead of at the
66
+ application. Set it to a host the browser container can reach, such as the
67
+ web or proxy container name.
68
+
62
69
  Returns:
63
70
  str: The host URL for selenium testing.
64
71
  """
72
+ override = os.getenv("SELENIUM_TARGET_URL")
73
+ if override:
74
+ return override
65
75
  return get_host_for_testing("selenium")
@@ -0,0 +1,43 @@
1
+ # splent_framework/template_hooks.py
2
+ #
3
+ # NOTE: _hooks is a module-level registry populated once at app startup
4
+ # (during feature registration) and read-only during request handling.
5
+ # It is NOT thread-safe for concurrent writes; do not call
6
+ # register_template_hook() from request handlers or background threads.
7
+
8
+ # Each slot holds (order, func) pairs; get_template_hooks returns the funcs
9
+ # sorted by order (lower first), so slots like "home.section" compose features
10
+ # in a deterministic order regardless of feature load order.
11
+ _hooks: dict[str, list] = {}
12
+
13
+
14
+ def register_template_hook(name: str, func, order: int = 100) -> None:
15
+ """Append a callback to a named hook slot (additive).
16
+
17
+ order: lower renders first within the slot (default 100).
18
+ """
19
+ _hooks.setdefault(name, []).append((order, func))
20
+
21
+
22
+ def replace_template_hook(name: str, func, order: int = 100) -> None:
23
+ """Replace ALL existing callbacks for a named hook slot.
24
+
25
+ Used by refinement features to override a base feature's hook content.
26
+ """
27
+ _hooks[name] = [(order, func)]
28
+
29
+
30
+ def remove_template_hook(name: str, func) -> None:
31
+ """Remove a specific callback from a hook slot."""
32
+ if name in _hooks:
33
+ _hooks[name] = [(o, f) for (o, f) in _hooks[name] if f is not func]
34
+
35
+
36
+ def get_template_hooks(name: str) -> list:
37
+ """Callbacks for a slot, ordered by their registration `order`."""
38
+ return [f for _, f in sorted(_hooks.get(name, []), key=lambda pair: pair[0])]
39
+
40
+
41
+ def clear_hooks() -> None:
42
+ """Remove all registered hooks. Intended for use in test teardown."""
43
+ _hooks.clear()
@@ -248,6 +248,7 @@ class FeatureIntegrator:
248
248
  self._call_init(module, import_name)
249
249
  self._apply_service_overrides(import_name)
250
250
  self._register_blueprints(module, import_name)
251
+ self._register_context_vars(module, import_name)
251
252
  self._register_translations(module, import_name)
252
253
  self._register_commands(import_name)
253
254
 
@@ -358,6 +359,19 @@ class FeatureIntegrator:
358
359
  elif self._strict:
359
360
  raise FeatureError(f"{import_name} lacks init_feature(app)")
360
361
 
362
+ def _register_context_vars(self, module, import_name: str) -> None:
363
+ """Collect the feature's ``inject_context_vars`` so the JinjaManager
364
+ context processor merges its returned dict into every template's context
365
+ on each request (see docs: framework/template-system, framework/
366
+ feature-loading). Without this the function is never called.
367
+ """
368
+ fn = getattr(module, "inject_context_vars", None)
369
+ if fn is None:
370
+ return
371
+ if not hasattr(self._app, "context_processors"):
372
+ self._app.context_processors = []
373
+ self._app.context_processors.append(fn)
374
+
361
375
  def _register_blueprints(self, module, import_name: str) -> None:
362
376
  candidates = self._collect_candidate_modules(module, import_name)
363
377
  registered = sum(
@@ -62,6 +62,18 @@ class FeatureManager:
62
62
  return
63
63
  self._registered = True
64
64
 
65
+ # The nav and asset registries are process-global; clear them before
66
+ # loading so the composed menu / injected assets reflect ONLY this
67
+ # product's installed features (no leakage when several products'
68
+ # create_app() run in one interpreter).
69
+ from splent_framework.assets.asset_registry import clear_assets
70
+ from splent_framework.nav.nav_registry import clear_nav_items
71
+ from splent_framework.settings.settings_schema import clear_settings_schemas
72
+
73
+ clear_nav_items()
74
+ clear_assets()
75
+ clear_settings_schemas()
76
+
65
77
  splent_app = self._require_splent_app()
66
78
  product_dir = os.path.join(PathUtils.get_working_dir(), splent_app)
67
79
 
@@ -121,27 +133,33 @@ class FeatureManager:
121
133
  # ------------------------------------------------------------------
122
134
 
123
135
  def _apply_template_overrides(self, registry) -> None:
124
- """Reorder Jinja blueprint template loaders so refiner templates win.
136
+ """Make refiner templates win over the base feature's same-path templates.
125
137
 
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.
138
+ Flask's DispatchingJinjaLoader searches blueprints in REGISTRATION order
139
+ (first registered wins), and a refiner loads AFTER its base — so the base
140
+ template would win. We prepend each refiner's template folder to the
141
+ app's Jinja loader so its override is resolved first.
129
142
  """
143
+ from jinja2 import ChoiceLoader, FileSystemLoader
144
+
130
145
  template_overrides = [
131
146
  e for e in registry.all_entries() if e.category == "template"
132
147
  ]
133
148
  if not template_overrides:
134
149
  return
135
150
 
151
+ app = self._app
152
+ folders: list[str] = []
153
+ seen: set[str] = set()
136
154
  for entry in template_overrides:
137
- # Find the refiner's blueprint (convention: refiner registers its own bp)
138
- # The refiner's templates are served from its own blueprint template folder.
139
- # We need its blueprint to appear BEFORE the base's in the search order.
140
- # Flask 3.x uses app.blueprints (ordered dict), and the
141
- # DispatchingJinjaLoader iterates app.iter_blueprints() which
142
- # returns them in reverse registration order (last registered = first searched).
143
- # So the refiner, being loaded after the base, is already searched first.
144
- # This means template override works out of the box for same-path templates.
155
+ short = entry.refiner.replace("splent_feature_", "")
156
+ bp = app.blueprints.get(short)
157
+ if bp is None or not bp.template_folder:
158
+ continue
159
+ folder = os.path.join(bp.root_path, bp.template_folder)
160
+ if folder not in seen and os.path.isdir(folder):
161
+ seen.add(folder)
162
+ folders.append(folder)
145
163
  logger.info(
146
164
  "Template override: %s (from %s, by %s)",
147
165
  entry.target,
@@ -149,6 +167,11 @@ class FeatureManager:
149
167
  entry.refiner,
150
168
  )
151
169
 
170
+ if folders:
171
+ app.jinja_env.loader = ChoiceLoader(
172
+ [FileSystemLoader(folders), app.jinja_env.loader]
173
+ )
174
+
152
175
  def _setup_refinement_registry(self, product_dir: str, ordered: list[str]) -> None:
153
176
  """Read extensible/refinement declarations from feature pyproject files,
154
177
  validate them, and populate the global RefinementRegistry."""
@@ -1,4 +1,5 @@
1
1
  from flask import Flask
2
+ from splent_framework.assets.asset_registry import get_assets
2
3
  from splent_framework.context.context_manager import build_jinja_context
3
4
  from splent_framework.hooks.template_hooks import get_template_hooks
4
5
 
@@ -17,3 +18,4 @@ class JinjaManager:
17
18
 
18
19
  def _register_globals(self):
19
20
  self.app.jinja_env.globals["get_template_hooks"] = get_template_hooks
21
+ self.app.jinja_env.globals["get_assets"] = get_assets
@@ -72,9 +72,15 @@ class LocaleManager:
72
72
  if translations_dir not in dirs:
73
73
  dirs.append(translations_dir)
74
74
 
75
- # Babel supports multiple translation directories
76
- babel = app.extensions.get("splent_babel")
77
- if babel and hasattr(babel, "translation_directories"):
78
- babel.translation_directories.append(translations_dir)
75
+ # flask-babel computes its translation directories ONCE in init_app
76
+ # (BabelConfiguration.translation_directories) and never re-reads the
77
+ # config. Features load AFTER Babel is initialised, so we (1) keep the
78
+ # config in sync and (2) mutate the live BabelConfiguration list
79
+ # (app.extensions["babel"]) so the new directory actually takes effect.
80
+ app.config["BABEL_TRANSLATION_DIRECTORIES"] = ";".join(dirs)
81
+ babel_cfg = app.extensions.get("babel")
82
+ live = getattr(babel_cfg, "translation_directories", None)
83
+ if isinstance(live, list) and translations_dir not in live:
84
+ live.append(translations_dir)
79
85
 
80
86
  logger.debug("Registered translations: %s", translations_dir)