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.
Files changed (107) hide show
  1. hotframe/__init__.py +92 -0
  2. hotframe/apps/__init__.py +48 -0
  3. hotframe/apps/config.py +307 -0
  4. hotframe/apps/registry.py +268 -0
  5. hotframe/apps/service_facade.py +249 -0
  6. hotframe/asgi.py +10 -0
  7. hotframe/auth/__init__.py +20 -0
  8. hotframe/auth/auth.py +117 -0
  9. hotframe/auth/crypto.py +96 -0
  10. hotframe/auth/csp.py +57 -0
  11. hotframe/auth/csrf.py +101 -0
  12. hotframe/auth/current_user.py +205 -0
  13. hotframe/auth/jwt.py +124 -0
  14. hotframe/auth/permissions.py +115 -0
  15. hotframe/auth/rate_limit.py +186 -0
  16. hotframe/bootstrap.py +314 -0
  17. hotframe/config/__init__.py +20 -0
  18. hotframe/config/database.py +78 -0
  19. hotframe/config/paths.py +101 -0
  20. hotframe/config/settings.py +251 -0
  21. hotframe/db/__init__.py +18 -0
  22. hotframe/db/singletons.py +49 -0
  23. hotframe/db/types.py +58 -0
  24. hotframe/dev/__init__.py +19 -0
  25. hotframe/dev/autoreload.py +157 -0
  26. hotframe/discovery/__init__.py +20 -0
  27. hotframe/discovery/bootstrap.py +110 -0
  28. hotframe/discovery/conventions.py +90 -0
  29. hotframe/discovery/scanner.py +274 -0
  30. hotframe/engine/__init__.py +59 -0
  31. hotframe/engine/dependency.py +265 -0
  32. hotframe/engine/import_manager.py +316 -0
  33. hotframe/engine/lifecycle.py +119 -0
  34. hotframe/engine/loader.py +581 -0
  35. hotframe/engine/manager.py +625 -0
  36. hotframe/engine/models.py +49 -0
  37. hotframe/engine/module_runtime.py +1381 -0
  38. hotframe/engine/pipeline.py +264 -0
  39. hotframe/engine/s3_source.py +309 -0
  40. hotframe/engine/state.py +174 -0
  41. hotframe/forms/__init__.py +17 -0
  42. hotframe/forms/rendering.py +229 -0
  43. hotframe/management/__init__.py +2 -0
  44. hotframe/management/cli.py +716 -0
  45. hotframe/middleware/__init__.py +21 -0
  46. hotframe/middleware/body_limit.py +48 -0
  47. hotframe/middleware/csp.py +43 -0
  48. hotframe/middleware/error_pages.py +120 -0
  49. hotframe/middleware/htmx.py +56 -0
  50. hotframe/middleware/htmx_messages.py +84 -0
  51. hotframe/middleware/i18n_support.py +350 -0
  52. hotframe/middleware/language.py +149 -0
  53. hotframe/middleware/module_middleware.py +125 -0
  54. hotframe/middleware/proxy_fix.py +104 -0
  55. hotframe/middleware/rate_limit.py +130 -0
  56. hotframe/middleware/request_id.py +76 -0
  57. hotframe/middleware/session.py +110 -0
  58. hotframe/middleware/stack.py +80 -0
  59. hotframe/middleware/stack_manager.py +187 -0
  60. hotframe/middleware/timeout.py +52 -0
  61. hotframe/middleware/trailing_slash.py +55 -0
  62. hotframe/migrations/__init__.py +19 -0
  63. hotframe/migrations/multi_namespace.py +132 -0
  64. hotframe/migrations/runner.py +181 -0
  65. hotframe/models/__init__.py +2 -0
  66. hotframe/models/base.py +114 -0
  67. hotframe/models/mixins.py +69 -0
  68. hotframe/models/queryset.py +207 -0
  69. hotframe/orm/__init__.py +21 -0
  70. hotframe/orm/events.py +310 -0
  71. hotframe/orm/listeners.py +158 -0
  72. hotframe/orm/transactions.py +60 -0
  73. hotframe/repository/__init__.py +1 -0
  74. hotframe/repository/base.py +177 -0
  75. hotframe/signals/__init__.py +21 -0
  76. hotframe/signals/builtins.py +117 -0
  77. hotframe/signals/catalog.py +216 -0
  78. hotframe/signals/dispatcher.py +517 -0
  79. hotframe/signals/hooks.py +352 -0
  80. hotframe/signals/types.py +207 -0
  81. hotframe/static/js/hotframe.persist.js +268 -0
  82. hotframe/static/js/hotframe.realtime.js +323 -0
  83. hotframe/storage/__init__.py +2 -0
  84. hotframe/storage/media.py +265 -0
  85. hotframe/templating/__init__.py +20 -0
  86. hotframe/templating/alpine_helpers.py +68 -0
  87. hotframe/templating/engine.py +164 -0
  88. hotframe/templating/extensions.py +265 -0
  89. hotframe/templating/frame_extension.py +97 -0
  90. hotframe/templating/globals.py +111 -0
  91. hotframe/templating/htmx_helpers.py +158 -0
  92. hotframe/templating/slots.py +188 -0
  93. hotframe/testing/__init__.py +173 -0
  94. hotframe/utils/__init__.py +1 -0
  95. hotframe/utils/observability_context.py +106 -0
  96. hotframe/utils/observability_logging.py +149 -0
  97. hotframe/utils/observability_metrics.py +249 -0
  98. hotframe/utils/observability_telemetry.py +243 -0
  99. hotframe/views/__init__.py +23 -0
  100. hotframe/views/broadcast.py +292 -0
  101. hotframe/views/responses.py +370 -0
  102. hotframe/views/streams.py +121 -0
  103. hotframe-0.0.1.dist-info/METADATA +418 -0
  104. hotframe-0.0.1.dist-info/RECORD +107 -0
  105. hotframe-0.0.1.dist-info/WHEEL +4 -0
  106. hotframe-0.0.1.dist-info/entry_points.txt +3 -0
  107. 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
+ ]
@@ -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