hotframe 0.0.1__py3-none-any.whl
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.
- hotframe/__init__.py +92 -0
- hotframe/apps/__init__.py +48 -0
- hotframe/apps/config.py +307 -0
- hotframe/apps/registry.py +268 -0
- hotframe/apps/service_facade.py +249 -0
- hotframe/asgi.py +10 -0
- hotframe/auth/__init__.py +20 -0
- hotframe/auth/auth.py +117 -0
- hotframe/auth/crypto.py +96 -0
- hotframe/auth/csp.py +57 -0
- hotframe/auth/csrf.py +101 -0
- hotframe/auth/current_user.py +205 -0
- hotframe/auth/jwt.py +124 -0
- hotframe/auth/permissions.py +115 -0
- hotframe/auth/rate_limit.py +186 -0
- hotframe/bootstrap.py +314 -0
- hotframe/config/__init__.py +20 -0
- hotframe/config/database.py +78 -0
- hotframe/config/paths.py +101 -0
- hotframe/config/settings.py +251 -0
- hotframe/db/__init__.py +18 -0
- hotframe/db/singletons.py +49 -0
- hotframe/db/types.py +58 -0
- hotframe/dev/__init__.py +19 -0
- hotframe/dev/autoreload.py +157 -0
- hotframe/discovery/__init__.py +20 -0
- hotframe/discovery/bootstrap.py +110 -0
- hotframe/discovery/conventions.py +90 -0
- hotframe/discovery/scanner.py +274 -0
- hotframe/engine/__init__.py +59 -0
- hotframe/engine/dependency.py +265 -0
- hotframe/engine/import_manager.py +316 -0
- hotframe/engine/lifecycle.py +119 -0
- hotframe/engine/loader.py +581 -0
- hotframe/engine/manager.py +625 -0
- hotframe/engine/models.py +49 -0
- hotframe/engine/module_runtime.py +1381 -0
- hotframe/engine/pipeline.py +264 -0
- hotframe/engine/s3_source.py +309 -0
- hotframe/engine/state.py +174 -0
- hotframe/forms/__init__.py +17 -0
- hotframe/forms/rendering.py +229 -0
- hotframe/management/__init__.py +2 -0
- hotframe/management/cli.py +716 -0
- hotframe/middleware/__init__.py +21 -0
- hotframe/middleware/body_limit.py +48 -0
- hotframe/middleware/csp.py +43 -0
- hotframe/middleware/error_pages.py +120 -0
- hotframe/middleware/htmx.py +56 -0
- hotframe/middleware/htmx_messages.py +84 -0
- hotframe/middleware/i18n_support.py +350 -0
- hotframe/middleware/language.py +149 -0
- hotframe/middleware/module_middleware.py +125 -0
- hotframe/middleware/proxy_fix.py +104 -0
- hotframe/middleware/rate_limit.py +130 -0
- hotframe/middleware/request_id.py +76 -0
- hotframe/middleware/session.py +110 -0
- hotframe/middleware/stack.py +80 -0
- hotframe/middleware/stack_manager.py +187 -0
- hotframe/middleware/timeout.py +52 -0
- hotframe/middleware/trailing_slash.py +55 -0
- hotframe/migrations/__init__.py +19 -0
- hotframe/migrations/multi_namespace.py +132 -0
- hotframe/migrations/runner.py +181 -0
- hotframe/models/__init__.py +2 -0
- hotframe/models/base.py +114 -0
- hotframe/models/mixins.py +69 -0
- hotframe/models/queryset.py +207 -0
- hotframe/orm/__init__.py +21 -0
- hotframe/orm/events.py +310 -0
- hotframe/orm/listeners.py +158 -0
- hotframe/orm/transactions.py +60 -0
- hotframe/repository/__init__.py +1 -0
- hotframe/repository/base.py +177 -0
- hotframe/signals/__init__.py +21 -0
- hotframe/signals/builtins.py +117 -0
- hotframe/signals/catalog.py +216 -0
- hotframe/signals/dispatcher.py +517 -0
- hotframe/signals/hooks.py +352 -0
- hotframe/signals/types.py +207 -0
- hotframe/static/js/hotframe.persist.js +268 -0
- hotframe/static/js/hotframe.realtime.js +323 -0
- hotframe/storage/__init__.py +2 -0
- hotframe/storage/media.py +265 -0
- hotframe/templating/__init__.py +20 -0
- hotframe/templating/alpine_helpers.py +68 -0
- hotframe/templating/engine.py +164 -0
- hotframe/templating/extensions.py +265 -0
- hotframe/templating/frame_extension.py +97 -0
- hotframe/templating/globals.py +111 -0
- hotframe/templating/htmx_helpers.py +158 -0
- hotframe/templating/slots.py +188 -0
- hotframe/testing/__init__.py +173 -0
- hotframe/utils/__init__.py +1 -0
- hotframe/utils/observability_context.py +106 -0
- hotframe/utils/observability_logging.py +149 -0
- hotframe/utils/observability_metrics.py +249 -0
- hotframe/utils/observability_telemetry.py +243 -0
- hotframe/views/__init__.py +23 -0
- hotframe/views/broadcast.py +292 -0
- hotframe/views/responses.py +370 -0
- hotframe/views/streams.py +121 -0
- hotframe-0.0.1.dist-info/METADATA +418 -0
- hotframe-0.0.1.dist-info/RECORD +107 -0
- hotframe-0.0.1.dist-info/WHEEL +4 -0
- hotframe-0.0.1.dist-info/entry_points.txt +3 -0
- hotframe-0.0.1.dist-info/licenses/LICENSE +189 -0
hotframe/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""hotframe — Modular Python web framework with hot-mount dynamic modules."""
|
|
3
|
+
|
|
4
|
+
__version__ = "0.0.1"
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Lazy imports — only loaded when accessed
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
_LAZY_IMPORTS: dict[str, str] = {
|
|
11
|
+
# Bootstrap
|
|
12
|
+
"create_app": "hotframe.bootstrap",
|
|
13
|
+
# Settings
|
|
14
|
+
"HotframeSettings": "hotframe.config.settings",
|
|
15
|
+
"get_settings": "hotframe.config.settings",
|
|
16
|
+
# Apps
|
|
17
|
+
"AppConfig": "hotframe.apps.config",
|
|
18
|
+
"ModuleConfig": "hotframe.apps.config",
|
|
19
|
+
# Models
|
|
20
|
+
"Base": "hotframe.models.base",
|
|
21
|
+
"HubBaseModel": "hotframe.models.base",
|
|
22
|
+
"TimeStampedModel": "hotframe.models.base",
|
|
23
|
+
"ActiveModel": "hotframe.models.base",
|
|
24
|
+
"HubMixin": "hotframe.models.mixins",
|
|
25
|
+
"TimestampMixin": "hotframe.models.mixins",
|
|
26
|
+
"AuditMixin": "hotframe.models.mixins",
|
|
27
|
+
"SoftDeleteMixin": "hotframe.models.mixins",
|
|
28
|
+
"HubQuery": "hotframe.models.queryset",
|
|
29
|
+
# Repository
|
|
30
|
+
"BaseRepository": "hotframe.repository.base",
|
|
31
|
+
# Signals
|
|
32
|
+
"AsyncEventBus": "hotframe.signals.dispatcher",
|
|
33
|
+
"HookRegistry": "hotframe.signals.hooks",
|
|
34
|
+
"BaseEvent": "hotframe.signals.types",
|
|
35
|
+
"register_event": "hotframe.signals.types",
|
|
36
|
+
# ORM
|
|
37
|
+
"setup_orm_events": "hotframe.orm.events",
|
|
38
|
+
# Views
|
|
39
|
+
"htmx_view": "hotframe.views.responses",
|
|
40
|
+
"is_htmx_request": "hotframe.views.responses",
|
|
41
|
+
"htmx_redirect": "hotframe.views.responses",
|
|
42
|
+
"htmx_refresh": "hotframe.views.responses",
|
|
43
|
+
"htmx_trigger": "hotframe.views.responses",
|
|
44
|
+
"add_message": "hotframe.views.responses",
|
|
45
|
+
"sse_stream": "hotframe.views.responses",
|
|
46
|
+
"TurboStream": "hotframe.views.streams",
|
|
47
|
+
"StreamResponse": "hotframe.views.streams",
|
|
48
|
+
"BroadcastHub": "hotframe.views.broadcast",
|
|
49
|
+
# Templating
|
|
50
|
+
"SlotRegistry": "hotframe.templating.slots",
|
|
51
|
+
# Auth
|
|
52
|
+
"get_session_user_id": "hotframe.auth.auth",
|
|
53
|
+
"hash_password": "hotframe.auth.auth",
|
|
54
|
+
"verify_password": "hotframe.auth.auth",
|
|
55
|
+
"has_permission": "hotframe.auth.permissions",
|
|
56
|
+
"require_permission": "hotframe.auth.permissions",
|
|
57
|
+
# Dependencies
|
|
58
|
+
"DbSession": "hotframe.auth.current_user",
|
|
59
|
+
"CurrentUser": "hotframe.auth.current_user",
|
|
60
|
+
"OptionalUser": "hotframe.auth.current_user",
|
|
61
|
+
"EventBus": "hotframe.auth.current_user",
|
|
62
|
+
"Hooks": "hotframe.auth.current_user",
|
|
63
|
+
"Slots": "hotframe.auth.current_user",
|
|
64
|
+
"get_db": "hotframe.auth.current_user",
|
|
65
|
+
"get_current_user": "hotframe.auth.current_user",
|
|
66
|
+
# Services
|
|
67
|
+
"ModuleService": "hotframe.apps.service_facade",
|
|
68
|
+
"action": "hotframe.apps.service_facade",
|
|
69
|
+
# Engine
|
|
70
|
+
"ModuleStateDB": "hotframe.engine.state",
|
|
71
|
+
"HotMountPipeline": "hotframe.engine.pipeline",
|
|
72
|
+
"ImportManager": "hotframe.engine.import_manager",
|
|
73
|
+
# Forms
|
|
74
|
+
"FormRenderer": "hotframe.forms.rendering",
|
|
75
|
+
# Config
|
|
76
|
+
"get_engine": "hotframe.config.database",
|
|
77
|
+
"get_session_factory": "hotframe.config.database",
|
|
78
|
+
# Storage
|
|
79
|
+
"MediaStorage": "hotframe.storage.media",
|
|
80
|
+
"get_media_storage": "hotframe.storage.media",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def __getattr__(name: str):
|
|
85
|
+
if name in _LAZY_IMPORTS:
|
|
86
|
+
import importlib
|
|
87
|
+
module = importlib.import_module(_LAZY_IMPORTS[name])
|
|
88
|
+
return getattr(module, name)
|
|
89
|
+
raise AttributeError(f"module 'hotframe' has no attribute {name!r}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [*list(_LAZY_IMPORTS.keys()), "__version__"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apps — module configuration and registry.
|
|
3
|
+
|
|
4
|
+
Defines the declarative contracts that every Hub module must satisfy
|
|
5
|
+
(``AppConfig``, ``ModuleConfig``, ``ModuleManifest``) and the in-memory
|
|
6
|
+
registries (``AppRegistry``, ``ModuleRegistry``) that the engine uses to
|
|
7
|
+
track installed and active modules at runtime.
|
|
8
|
+
|
|
9
|
+
Key exports::
|
|
10
|
+
|
|
11
|
+
from hotframe.apps import AppConfig, ModuleConfig, AppRegistry, ModuleRegistry
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
registry = AppRegistry()
|
|
16
|
+
registry.register(my_module_config)
|
|
17
|
+
mod = registry.get("sales")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from hotframe.apps.config import (
|
|
21
|
+
AppConfig,
|
|
22
|
+
MenuConfig,
|
|
23
|
+
ModuleConfig,
|
|
24
|
+
ModuleManifest,
|
|
25
|
+
NavigationItem,
|
|
26
|
+
load_manifest,
|
|
27
|
+
manifest_to_dict,
|
|
28
|
+
)
|
|
29
|
+
from hotframe.apps.registry import (
|
|
30
|
+
AppRegistry,
|
|
31
|
+
ModuleRegistry,
|
|
32
|
+
RegisteredModule,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# New contract
|
|
37
|
+
"AppConfig",
|
|
38
|
+
"AppRegistry",
|
|
39
|
+
"MenuConfig",
|
|
40
|
+
"ModuleConfig",
|
|
41
|
+
# Legacy contract (compat durante migracion)
|
|
42
|
+
"ModuleManifest",
|
|
43
|
+
"ModuleRegistry",
|
|
44
|
+
"NavigationItem",
|
|
45
|
+
"RegisteredModule",
|
|
46
|
+
"load_manifest",
|
|
47
|
+
"manifest_to_dict",
|
|
48
|
+
]
|
hotframe/apps/config.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module manifest — Pydantic strict validation of module.py contents.
|
|
3
|
+
|
|
4
|
+
Every module must have a ``module.py`` at its root with specific attributes.
|
|
5
|
+
This module defines the schema (ModuleManifest) and the loader that extracts
|
|
6
|
+
those attributes into a validated Pydantic model.
|
|
7
|
+
|
|
8
|
+
If validation fails, the module CANNOT load and its status becomes ``error``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, Field, field_validator
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------
|
|
25
|
+
# Sub-models
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
class MenuConfig(BaseModel):
|
|
29
|
+
"""Module sidebar menu entry configuration."""
|
|
30
|
+
|
|
31
|
+
label: str
|
|
32
|
+
icon: str = "cube-outline"
|
|
33
|
+
order: int = 50
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NavigationItem(BaseModel):
|
|
37
|
+
"""A single tab/section inside a module's navigation bar."""
|
|
38
|
+
|
|
39
|
+
label: str
|
|
40
|
+
icon: str
|
|
41
|
+
id: str
|
|
42
|
+
view: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# ModuleManifest
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
class ModuleManifest(BaseModel):
|
|
50
|
+
"""
|
|
51
|
+
Strict schema for ``module.py`` attributes.
|
|
52
|
+
|
|
53
|
+
If any required field is missing or fails validation the module
|
|
54
|
+
is rejected and its DB status set to ``error``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
MODULE_ID: str = Field(pattern=r"^[a-z][a-z0-9_]*$")
|
|
58
|
+
MODULE_NAME: str
|
|
59
|
+
MODULE_VERSION: str = Field(pattern=r"^\d+\.\d+\.\d+")
|
|
60
|
+
MODULE_ICON: str = "cube-outline"
|
|
61
|
+
MODULE_DESCRIPTION: str = ""
|
|
62
|
+
MODULE_AUTHOR: str = ""
|
|
63
|
+
HAS_MODELS: bool = False
|
|
64
|
+
|
|
65
|
+
MENU: MenuConfig | None = None
|
|
66
|
+
NAVIGATION: list[NavigationItem] = []
|
|
67
|
+
PERMISSIONS: list[str] = []
|
|
68
|
+
ROLE_PERMISSIONS: dict[str, list[str | tuple]] = {}
|
|
69
|
+
DEPENDENCIES: list[str] = []
|
|
70
|
+
|
|
71
|
+
@field_validator("PERMISSIONS", mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def normalize_permissions(cls, v: Any) -> list[str]:
|
|
74
|
+
"""Accept both 'codename' strings and ('codename', 'description') tuples."""
|
|
75
|
+
result = []
|
|
76
|
+
for item in v:
|
|
77
|
+
if isinstance(item, (tuple, list)):
|
|
78
|
+
result.append(str(item[0]))
|
|
79
|
+
else:
|
|
80
|
+
result.append(str(item))
|
|
81
|
+
return result
|
|
82
|
+
MIDDLEWARE: str | None = None
|
|
83
|
+
SCHEDULED_TASKS: list[dict] = []
|
|
84
|
+
PRICING: dict | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Manifest attributes — the exhaustive list we read from module.py
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
_MANIFEST_FIELDS: set[str] = set(ModuleManifest.model_fields.keys())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Loader
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def load_manifest(module_path: Path) -> ModuleManifest:
|
|
99
|
+
"""
|
|
100
|
+
Import ``module.py`` from *module_path* and build a validated manifest.
|
|
101
|
+
|
|
102
|
+
The file is loaded via ``importlib`` into an isolated spec so it does not
|
|
103
|
+
pollute ``sys.modules`` with a permanent entry. Attributes listed in
|
|
104
|
+
:data:`_MANIFEST_FIELDS` are extracted and passed to :class:`ModuleManifest`.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
module_path: Directory containing ``module.py``.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A validated :class:`ModuleManifest` instance.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
FileNotFoundError: If ``module.py`` does not exist.
|
|
114
|
+
pydantic.ValidationError: If the extracted attributes fail validation.
|
|
115
|
+
"""
|
|
116
|
+
module_py = module_path / "module.py"
|
|
117
|
+
if not module_py.exists():
|
|
118
|
+
raise FileNotFoundError(f"module.py not found in {module_path}")
|
|
119
|
+
|
|
120
|
+
# Use a temporary module name to avoid collisions
|
|
121
|
+
tmp_name = f"_manifest_loader_{module_path.name}"
|
|
122
|
+
|
|
123
|
+
spec = importlib.util.spec_from_file_location(tmp_name, str(module_py))
|
|
124
|
+
if spec is None or spec.loader is None:
|
|
125
|
+
raise ImportError(f"Cannot create import spec for {module_py}")
|
|
126
|
+
|
|
127
|
+
mod = importlib.util.module_from_spec(spec)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
spec.loader.exec_module(mod)
|
|
131
|
+
finally:
|
|
132
|
+
# Clean up the temporary module — never keep it around
|
|
133
|
+
sys.modules.pop(tmp_name, None)
|
|
134
|
+
|
|
135
|
+
# Extract manifest attributes
|
|
136
|
+
data: dict[str, Any] = {}
|
|
137
|
+
for attr in _MANIFEST_FIELDS:
|
|
138
|
+
value = getattr(mod, attr, None)
|
|
139
|
+
if value is not None:
|
|
140
|
+
# MenuConfig may be provided as a plain dict
|
|
141
|
+
data[attr] = value
|
|
142
|
+
|
|
143
|
+
return ModuleManifest(**data)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def manifest_to_dict(manifest: ModuleManifest) -> dict[str, Any]:
|
|
147
|
+
"""Serialize a manifest to a JSON-safe dict for storing in hub_module.manifest.
|
|
148
|
+
|
|
149
|
+
Produces short keys (``name``, ``icon``, ``dependencies``, …) instead of
|
|
150
|
+
the raw Pydantic field names (``MODULE_NAME``, ``MODULE_ICON``, …) so that
|
|
151
|
+
templates, routes, and APIs can access them consistently.
|
|
152
|
+
"""
|
|
153
|
+
raw = manifest.model_dump(mode="json")
|
|
154
|
+
_KEY_MAP: dict[str, str] = {
|
|
155
|
+
"MODULE_ID": "module_id",
|
|
156
|
+
"MODULE_NAME": "name",
|
|
157
|
+
"MODULE_VERSION": "version",
|
|
158
|
+
"MODULE_ICON": "icon",
|
|
159
|
+
"MODULE_DESCRIPTION": "description",
|
|
160
|
+
"MODULE_AUTHOR": "author",
|
|
161
|
+
"HAS_MODELS": "has_models",
|
|
162
|
+
"MENU": "menu",
|
|
163
|
+
"NAVIGATION": "navigation",
|
|
164
|
+
"PERMISSIONS": "permissions",
|
|
165
|
+
"ROLE_PERMISSIONS": "role_permissions",
|
|
166
|
+
"DEPENDENCIES": "dependencies",
|
|
167
|
+
"MIDDLEWARE": "middleware",
|
|
168
|
+
"SCHEDULED_TASKS": "scheduled_tasks",
|
|
169
|
+
"PRICING": "pricing",
|
|
170
|
+
}
|
|
171
|
+
return {_KEY_MAP.get(k, k): v for k, v in raw.items()}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ======================================================================
|
|
175
|
+
# Django-like AppConfig / ModuleConfig (new contract, Fase 3+)
|
|
176
|
+
# ======================================================================
|
|
177
|
+
|
|
178
|
+
class AppConfig:
|
|
179
|
+
"""
|
|
180
|
+
Base class for core app configurations.
|
|
181
|
+
|
|
182
|
+
A dev writes ``apps/<name>/app.py`` with a subclass of ``AppConfig``.
|
|
183
|
+
The discovery scanner (hotframe.discovery.scanner) auto-loads it at
|
|
184
|
+
boot time. Core apps are STATIC: they are registered once and do not
|
|
185
|
+
unmount in runtime (that is for ``ModuleConfig`` — see below).
|
|
186
|
+
|
|
187
|
+
Subclass attributes:
|
|
188
|
+
name: required, identifier for the app (matches the directory name)
|
|
189
|
+
verbose_name: human-readable label
|
|
190
|
+
mount_prefix: where to mount the app's urlpatterns (default ``/<name>/``)
|
|
191
|
+
version: semver string
|
|
192
|
+
depends: list of other app names this one requires at boot
|
|
193
|
+
permissions: list of (code, label) tuples (optional)
|
|
194
|
+
role_permissions: dict[role_name, list[permission_code | "*"]]
|
|
195
|
+
menu: dict with ``label``, ``icon``, ``order`` (optional)
|
|
196
|
+
navigation: list of dicts (optional)
|
|
197
|
+
is_kernel: True if this app is a system app that cannot be disabled
|
|
198
|
+
|
|
199
|
+
Subclass methods:
|
|
200
|
+
async def ready(self) -> None:
|
|
201
|
+
Hook called once after all apps are loaded and mounted.
|
|
202
|
+
Typical use: import signals module to register @receiver
|
|
203
|
+
decorators.
|
|
204
|
+
|
|
205
|
+
Runtime usage (pseudo):
|
|
206
|
+
cfg = MyAppConfig()
|
|
207
|
+
registry.register(cfg)
|
|
208
|
+
await cfg.ready()
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
name: str = ""
|
|
212
|
+
verbose_name: str = ""
|
|
213
|
+
mount_prefix: str = "" # if empty, defaults to f"/{name}/"
|
|
214
|
+
media_path: str = "" # Media subdirectory name. If empty, uses app name.
|
|
215
|
+
version: str = "0.1.0"
|
|
216
|
+
depends: list[str] = []
|
|
217
|
+
permissions: list[tuple[str, str]] = []
|
|
218
|
+
role_permissions: dict[str, list[str]] = {}
|
|
219
|
+
menu: dict | None = None
|
|
220
|
+
navigation: list[dict] = []
|
|
221
|
+
is_kernel: bool = False
|
|
222
|
+
|
|
223
|
+
# Abstract/base subclasses (like ModuleConfig) set this to True to
|
|
224
|
+
# opt out of the required-name check. Concrete subclasses inherit
|
|
225
|
+
# the default False and must declare 'name'.
|
|
226
|
+
_abstract: bool = False
|
|
227
|
+
|
|
228
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
229
|
+
super().__init_subclass__(**kwargs)
|
|
230
|
+
# Reset the abstract flag for every subclass unless explicitly
|
|
231
|
+
# declared in the class body. This prevents the flag from
|
|
232
|
+
# leaking from an abstract base (e.g. ModuleConfig) into concrete
|
|
233
|
+
# subclasses of it.
|
|
234
|
+
if "_abstract" not in cls.__dict__:
|
|
235
|
+
cls._abstract = False
|
|
236
|
+
if cls._abstract:
|
|
237
|
+
return
|
|
238
|
+
if not cls.name:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"{cls.__name__}: AppConfig subclass must define 'name'"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
async def ready(self) -> None:
|
|
244
|
+
"""Hook. Default is no-op. Subclasses override to wire signals."""
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def __repr__(self) -> str:
|
|
248
|
+
return f"<{type(self).__name__} name={self.name!r} version={self.version!r}>"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class ModuleConfig(AppConfig):
|
|
252
|
+
"""
|
|
253
|
+
Base class for dynamic modules downloaded from S3.
|
|
254
|
+
|
|
255
|
+
A dev writes ``modules/<name>/module.py`` with a subclass of
|
|
256
|
+
``ModuleConfig``. The module runtime (hotframe.engine.module_runtime)
|
|
257
|
+
installs/activates/deactivates/uninstalls these at runtime.
|
|
258
|
+
|
|
259
|
+
Additional subclass attributes vs AppConfig:
|
|
260
|
+
requires_restart: if True, changes to this module cannot be
|
|
261
|
+
hot-mounted and trigger a GracefulRestartCoordinator swap.
|
|
262
|
+
is_system: if True, cannot be uninstalled from UI (e.g. assistant).
|
|
263
|
+
s3_key: optional explicit S3 key override; default is computed
|
|
264
|
+
from name+version by S3ModuleSource.
|
|
265
|
+
sha256: optional explicit SHA256 override.
|
|
266
|
+
|
|
267
|
+
Additional subclass methods:
|
|
268
|
+
async def install(self, ctx) -> None:
|
|
269
|
+
Called once when the module is first installed on a hub.
|
|
270
|
+
Use for seeding initial data.
|
|
271
|
+
async def uninstall(self, ctx) -> None:
|
|
272
|
+
Called when the module is uninstalled.
|
|
273
|
+
Use for idempotent cleanup.
|
|
274
|
+
async def activate(self, ctx) -> None:
|
|
275
|
+
Called when the module is activated (after install or when
|
|
276
|
+
re-enabled from the disabled state).
|
|
277
|
+
async def deactivate(self, ctx) -> None:
|
|
278
|
+
Called when the module is deactivated (user disables it).
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
# ModuleConfig itself is an abstract base — only its subclasses are
|
|
282
|
+
# expected to carry a name.
|
|
283
|
+
_abstract: bool = True
|
|
284
|
+
|
|
285
|
+
requires_restart: bool = False
|
|
286
|
+
is_system: bool = False
|
|
287
|
+
has_views: bool = True
|
|
288
|
+
has_api: bool = True
|
|
289
|
+
media_path: str = "" # Media subdirectory name. If empty, uses module name.
|
|
290
|
+
s3_key: str | None = None
|
|
291
|
+
sha256: str | None = None
|
|
292
|
+
|
|
293
|
+
async def install(self, ctx) -> None:
|
|
294
|
+
"""Hook. Called once at first install. Default no-op."""
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
async def uninstall(self, ctx) -> None:
|
|
298
|
+
"""Hook. Idempotent cleanup. Default no-op."""
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
async def activate(self, ctx) -> None:
|
|
302
|
+
"""Hook. Called on activate. Default no-op."""
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
async def deactivate(self, ctx) -> None:
|
|
306
|
+
"""Hook. Called on deactivate. Default no-op."""
|
|
307
|
+
return None
|