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.
- {splent_framework-1.6.2/src/splent_framework.egg-info → splent_framework-1.7.1}/PKG-INFO +1 -1
- {splent_framework-1.6.2 → splent_framework-1.7.1}/pyproject.toml +1 -1
- splent_framework-1.7.1/src/splent_framework/__init__.py +3 -0
- splent_framework-1.7.1/src/splent_framework/admin/__init__.py +29 -0
- splent_framework-1.7.1/src/splent_framework/admin/registry.py +149 -0
- splent_framework-1.7.1/src/splent_framework/assets/asset_registry.py +68 -0
- splent_framework-1.7.1/src/splent_framework/bootstraps/locustfile_bootstrap.py +157 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/environment/host.py +10 -0
- splent_framework-1.7.1/src/splent_framework/hooks/template_hooks.py +43 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_loader.py +14 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_manager.py +35 -12
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/jinja_manager.py +2 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/locale_manager.py +10 -4
- splent_framework-1.7.1/src/splent_framework/nav/nav_registry.py +50 -0
- splent_framework-1.7.1/src/splent_framework/selenium/common.py +86 -0
- splent_framework-1.7.1/src/splent_framework/settings/settings_schema.py +103 -0
- splent_framework-1.7.1/src/splent_framework/signals/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/signals/signal_utils.py +16 -4
- splent_framework-1.7.1/src/splent_framework/utils/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1/src/splent_framework.egg-info}/PKG-INFO +1 -1
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/SOURCES.txt +9 -1
- splent_framework-1.6.2/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -53
- splent_framework-1.6.2/src/splent_framework/hooks/template_hooks.py +0 -36
- splent_framework-1.6.2/src/splent_framework/selenium/common.py +0 -17
- {splent_framework-1.6.2 → splent_framework-1.7.1}/LICENSE +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/README.md +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/setup.cfg +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/app_factory.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/blueprints → splent_framework-1.7.1/src/splent_framework/assets}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/bootstraps → splent_framework-1.7.1/src/splent_framework/blueprints}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/blueprints/base_blueprint.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/configuration → splent_framework-1.7.1/src/splent_framework/bootstraps}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/context → splent_framework-1.7.1/src/splent_framework/configuration}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/configuration/configuration.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/configuration/default_config.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/decorators → splent_framework-1.7.1/src/splent_framework/context}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/context/context_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/db.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/environment → splent_framework-1.7.1/src/splent_framework/decorators}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/decorators/decorators.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/fixtures → splent_framework-1.7.1/src/splent_framework/environment}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/helpers → splent_framework-1.7.1/src/splent_framework/fixtures}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/fixtures/fixtures.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/hooks → splent_framework-1.7.1/src/splent_framework/helpers}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/locust → splent_framework-1.7.1/src/splent_framework/hooks}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/managers → splent_framework-1.7.1/src/splent_framework/locust}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/locust/common.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/migrations → splent_framework-1.7.1/src/splent_framework/managers}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/config_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/db_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/error_handler_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_order.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/logging_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/migration_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/namespace_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/session_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/task_queue_manager.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/namespaces → splent_framework-1.7.1/src/splent_framework/migrations}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/migrations/feature_env.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/repositories → splent_framework-1.7.1/src/splent_framework/namespaces}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/resources → splent_framework-1.7.1/src/splent_framework/nav}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/model_extender.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/parser.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/registry.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/refinement/validator.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/repositories/BaseRepository.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/seeders → splent_framework-1.7.1/src/splent_framework/repositories}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/selenium → splent_framework-1.7.1/src/splent_framework/resources}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/resources/generic_resource.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/seeders/BaseSeeder.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/serialisers → splent_framework-1.7.1/src/splent_framework/seeders}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/services → splent_framework-1.7.1/src/splent_framework/selenium}/__init__.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/signals → splent_framework-1.7.1/src/splent_framework/serialisers}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/serialisers/serializer.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/services/BaseService.py +0 -0
- {splent_framework-1.6.2/src/splent_framework/utils → splent_framework-1.7.1/src/splent_framework/services}/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/services/service_locator.py +0 -0
- /splent_framework-1.6.2/src/splent_framework/_init__.py → /splent_framework-1.7.1/src/splent_framework/settings/__init__.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/signals/registry.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/app_loader.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/feature_utils.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/form_helpers.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/path_utils.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/utils/pyproject_reader.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/dependency_links.txt +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/requires.txt +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework.egg-info/top_level.txt +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_base_blueprint_security.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_base_repository.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_config_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_config_manager_return.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_context_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_default_config.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_error_handler_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_feature_manager_parse.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_feature_order.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_feature_utils.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_generic_resource.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_migration_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_namespace_manager.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_pyproject_reader.py +0 -0
- {splent_framework-1.6.2 → splent_framework-1.7.1}/tests/test_pyproject_reader_env.py +0 -0
- {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.
|
|
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.
|
|
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,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()
|
{splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_loader.py
RENAMED
|
@@ -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(
|
{splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/feature_manager.py
RENAMED
|
@@ -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
|
-
"""
|
|
136
|
+
"""Make refiner templates win over the base feature's same-path templates.
|
|
125
137
|
|
|
126
|
-
Flask's DispatchingJinjaLoader searches blueprints in
|
|
127
|
-
|
|
128
|
-
We
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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."""
|
{splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/jinja_manager.py
RENAMED
|
@@ -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
|
{splent_framework-1.6.2 → splent_framework-1.7.1}/src/splent_framework/managers/locale_manager.py
RENAMED
|
@@ -72,9 +72,15 @@ class LocaleManager:
|
|
|
72
72
|
if translations_dir not in dirs:
|
|
73
73
|
dirs.append(translations_dir)
|
|
74
74
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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)
|