asyncz 0.14.0__tar.gz → 0.14.2__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 (113) hide show
  1. {asyncz-0.14.0 → asyncz-0.14.2}/.gitignore +1 -0
  2. {asyncz-0.14.0 → asyncz-0.14.2}/PKG-INFO +3 -3
  3. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/__init__.py +1 -1
  4. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/admin/backends.py +4 -4
  5. asyncz-0.14.2/asyncz/contrib/dashboard/admin/core.py +280 -0
  6. asyncz-0.14.2/asyncz/contrib/dashboard/admin/middleware/__init__.py +3 -0
  7. asyncz-0.14.2/asyncz/contrib/dashboard/admin/middleware/forward_root_path.py +70 -0
  8. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/application.py +43 -35
  9. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/config.py +35 -9
  10. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/_helpers.py +91 -3
  11. asyncz-0.14.2/asyncz/contrib/dashboard/controllers/logs.py +108 -0
  12. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/tasks.py +68 -21
  13. asyncz-0.14.2/asyncz/contrib/dashboard/engine.py +42 -0
  14. asyncz-0.14.2/asyncz/contrib/dashboard/logs/handler.py +129 -0
  15. asyncz-0.14.2/asyncz/contrib/dashboard/logs/storage.py +200 -0
  16. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/mixins.py +2 -1
  17. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/404.html +1 -1
  18. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/index.html +3 -3
  19. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/layout.html +23 -6
  20. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/login.html +4 -4
  21. asyncz-0.14.2/asyncz/contrib/dashboard/templates/logs/index.html +45 -0
  22. asyncz-0.14.2/asyncz/contrib/dashboard/templates/logs/partials/table.html +28 -0
  23. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/tasks/_table.html +4 -4
  24. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/tasks/tasks.html +2 -2
  25. asyncz-0.14.2/asyncz/stores/__init__.py +0 -0
  26. {asyncz-0.14.0 → asyncz-0.14.2}/pyproject.toml +2 -2
  27. asyncz-0.14.0/asyncz/contrib/dashboard/admin/core.py +0 -163
  28. asyncz-0.14.0/asyncz/contrib/dashboard/engine.py +0 -6
  29. {asyncz-0.14.0 → asyncz-0.14.2}/LICENSE +0 -0
  30. {asyncz-0.14.0 → asyncz-0.14.2}/README.md +0 -0
  31. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/__init__.py +0 -0
  32. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/_parsers.py +0 -0
  33. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/app.py +0 -0
  34. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/bootstrap_loader.py +0 -0
  35. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/__init__.py +0 -0
  36. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/add.py +0 -0
  37. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/list.py +0 -0
  38. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/pause.py +0 -0
  39. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/remove.py +0 -0
  40. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/resume.py +0 -0
  41. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/run.py +0 -0
  42. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/start.py +0 -0
  43. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/loader.py +0 -0
  44. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/types.py +0 -0
  45. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/utils.py +0 -0
  46. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/conf/__init__.py +0 -0
  47. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/conf/global_settings.py +0 -0
  48. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/__init__.py +0 -0
  49. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/__init__.py +0 -0
  50. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/admin/__init__.py +0 -0
  51. /asyncz-0.14.0/asyncz/contrib/dashboard/admin/middleware.py → /asyncz-0.14.2/asyncz/contrib/dashboard/admin/middleware/auth_gate.py +0 -0
  52. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/admin/protocols.py +0 -0
  53. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/__init__.py +0 -0
  54. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/home.py +0 -0
  55. {asyncz-0.14.0/asyncz/stores → asyncz-0.14.2/asyncz/contrib/dashboard/logs}/__init__.py +0 -0
  56. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/messages.py +0 -0
  57. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/css/asyncz.css +0 -0
  58. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/css/toastify.min.css +0 -0
  59. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/js/asyncz.js +0 -0
  60. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/js/toastify.min.js +0 -0
  61. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/shared/loading.html +0 -0
  62. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/shared/messages.html +0 -0
  63. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/datastructures.py +0 -0
  64. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/enums.py +0 -0
  65. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/events/__init__.py +0 -0
  66. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/events/base.py +0 -0
  67. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/events/constants.py +0 -0
  68. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/exceptions.py +0 -0
  69. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/__init__.py +0 -0
  70. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/asyncio.py +0 -0
  71. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/base.py +0 -0
  72. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/debug.py +0 -0
  73. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/pool.py +0 -0
  74. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/process_pool.py +0 -0
  75. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/types.py +0 -0
  76. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/file_locking.py +0 -0
  77. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/locks.py +0 -0
  78. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/monkay.py +0 -0
  79. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/protocols.py +0 -0
  80. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/py.typed +0 -0
  81. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/__init__.py +0 -0
  82. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/asyncio.py +0 -0
  83. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/base.py +0 -0
  84. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/datastructures.py +0 -0
  85. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/defaults.py +0 -0
  86. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/types.py +0 -0
  87. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/utils.py +0 -0
  88. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/state.py +0 -0
  89. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/base.py +0 -0
  90. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/file.py +0 -0
  91. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/memory.py +0 -0
  92. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/mongo.py +0 -0
  93. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/redis.py +0 -0
  94. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/sqlalchemy.py +0 -0
  95. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/types.py +0 -0
  96. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/tasks/__init__.py +0 -0
  97. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/tasks/base.py +0 -0
  98. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/tasks/types.py +0 -0
  99. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/__init__.py +0 -0
  100. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/base.py +0 -0
  101. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/combination.py +0 -0
  102. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/__init__.py +0 -0
  103. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/constants.py +0 -0
  104. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/expressions.py +0 -0
  105. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/fields.py +0 -0
  106. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/trigger.py +0 -0
  107. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/types.py +0 -0
  108. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/date.py +0 -0
  109. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/interval.py +0 -0
  110. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/shutdown.py +0 -0
  111. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/types.py +0 -0
  112. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/typing.py +0 -0
  113. {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/utils.py +0 -0
@@ -17,6 +17,7 @@ results/
17
17
  site/
18
18
  target/
19
19
 
20
+ *.zip
20
21
  # files
21
22
  **/*.so
22
23
  **/*.sqlite
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncz
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: The scheduler that nobody wants but every application needs.
5
5
  Project-URL: Homepage, https://github.com/dymmond/asyncz
6
6
  Project-URL: Documentation, https://asyncz.dymmond.com/
@@ -41,7 +41,7 @@ Requires-Dist: monkay>=0.5.0
41
41
  Requires-Dist: pydantic<3.0.0,>=2.5.3
42
42
  Requires-Dist: sayer>=0.7.4
43
43
  Provides-Extra: dashboard
44
- Requires-Dist: lilya[standard]>=0.22.11; extra == 'dashboard'
44
+ Requires-Dist: lilya[standard]>=0.22.15; extra == 'dashboard'
45
45
  Provides-Extra: docs
46
46
  Requires-Dist: griffe-typingdoc<1.0,>=0.2.2; extra == 'docs'
47
47
  Requires-Dist: mdx-include>=1.4.2; extra == 'docs'
@@ -61,7 +61,7 @@ Provides-Extra: testing
61
61
  Requires-Dist: freezegun; extra == 'testing'
62
62
  Requires-Dist: httpx; extra == 'testing'
63
63
  Requires-Dist: ipdb; extra == 'testing'
64
- Requires-Dist: lilya[standard]>=0.22.11; extra == 'testing'
64
+ Requires-Dist: lilya[standard]>=0.22.15; extra == 'testing'
65
65
  Requires-Dist: loguru<0.8.0,>=0.7.0; extra == 'testing'
66
66
  Requires-Dist: mypy<2.0.0,>=0.982; extra == 'testing'
67
67
  Requires-Dist: pymongo<5.0.0,>=4.3.3; extra == 'testing'
@@ -2,7 +2,7 @@ from typing import TYPE_CHECKING
2
2
 
3
3
  from .monkay import create_monkay
4
4
 
5
- __version__ = "0.14.0"
5
+ __version__ = "0.14.2"
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from .conf import settings
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from collections.abc import Callable
4
4
  from typing import Any
5
5
 
6
+ from lilya.compat import reverse
6
7
  from lilya.datastructures import FormData
7
8
  from lilya.requests import Request
8
9
  from lilya.responses import RedirectResponse, Response
@@ -103,8 +104,8 @@ class SimpleUsernamePasswordBackend(AuthBackend):
103
104
  }
104
105
 
105
106
  # Redirect to the requested 'next' URL or default to root
106
- nxt: str = form.get("next") or "/"
107
- return RedirectResponse(nxt, status_code=303)
107
+ next: str = form.get("next") or reverse("dashboard:index")
108
+ return RedirectResponse(next, status_code=303)
108
109
 
109
110
  async def logout(self, request: Request) -> Response:
110
111
  """
@@ -118,5 +119,4 @@ class SimpleUsernamePasswordBackend(AuthBackend):
118
119
  """
119
120
  # Remove user data from the session
120
121
  request.session.pop(self.session_key, None)
121
-
122
- return RedirectResponse("/login", status_code=303)
122
+ return RedirectResponse(reverse("login"), status_code=303)
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import Any, cast
5
+
6
+ from lilya.apps import ChildLilya, Lilya
7
+ from lilya.middleware.base import DefineMiddleware
8
+ from lilya.middleware.cors import CORSMiddleware
9
+ from lilya.requests import Request
10
+ from lilya.responses import Response
11
+ from lilya.routing import Include, Path, Router
12
+ from lilya.types import ASGIApp
13
+
14
+ from asyncz import monkay
15
+ from asyncz.contrib.dashboard import create_dashboard_app
16
+ from asyncz.contrib.dashboard.admin.middleware import AuthGateMiddleware
17
+ from asyncz.contrib.dashboard.admin.middleware.forward_root_path import (
18
+ ForwardedPrefixMiddleware,
19
+ )
20
+ from asyncz.contrib.dashboard.admin.protocols import AuthBackend
21
+ from asyncz.contrib.dashboard.engine import templates
22
+ from asyncz.contrib.dashboard.logs.storage import LogStorage
23
+ from asyncz.schedulers.asyncio import AsyncIOScheduler
24
+
25
+
26
+ async def not_found(request: Request, exc: Exception) -> Any:
27
+ return templates.get_template_response(
28
+ request,
29
+ "404.html",
30
+ context={
31
+ "title": "Not Found",
32
+ "url_prefix": monkay.settings.dashboard_config.dashboard_url_prefix,
33
+ },
34
+ status_code=404,
35
+ )
36
+
37
+
38
+ class AsynczAdmin:
39
+ """
40
+ A configurable wrapper for the Asyncz web dashboard and management API.
41
+
42
+ This class handles the creation of a private `ChildLilya` application, configures
43
+ CORS, session management, and optional authentication (via AuthGateMiddleware),
44
+ and exposes a method to mount itself onto a parent Lilya application.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ enable_login: bool = False,
50
+ backend: AuthBackend | None = None,
51
+ enable_forward_middleware: bool = False,
52
+ url_prefix: str | None = None,
53
+ scheduler: AsyncIOScheduler | None = None,
54
+ include_session: bool = True,
55
+ include_cors: bool = True,
56
+ login_path: str = "/login",
57
+ allowlist: tuple[str, ...] = ("/login", "/logout", "/static", "/assets"),
58
+ log_storage: LogStorage | None = None,
59
+ ) -> None:
60
+ """
61
+ Initializes the Asyncz Admin dashboard instance.
62
+
63
+ Args:
64
+ enable_login: If True, enables session and authentication middleware, requiring a `backend`.
65
+ backend: The authentication backend implementing `AuthBackend` methods (required if `enable_login` is True).
66
+ url_prefix: The base URL path where the dashboard should be mounted (e.g., "/asyncz").
67
+ Defaults to the value from `monkay.settings.dashboard_config`.
68
+ scheduler: The active `AsyncIOScheduler` instance to manage. If None, a new
69
+ default scheduler is created (in-memory store).
70
+
71
+ Raises:
72
+ ValueError: If `enable_login` is True but no `backend` is provided.
73
+ """
74
+ if enable_login and not backend:
75
+ raise ValueError("`backend` must not be `None` when enable login is True")
76
+
77
+ # Resolve defaults
78
+ self.config = monkay.settings.dashboard_config
79
+ self.url_prefix: str = (url_prefix or self.config.dashboard_url_prefix).rstrip("/")
80
+ self.scheduler: AsyncIOScheduler = scheduler or AsyncIOScheduler(
81
+ stores={"default": {"type": "memory"}}
82
+ )
83
+
84
+ assert self.url_prefix.startswith("/"), "The dashboard url prefix must start with /."
85
+
86
+ self.enable_login: bool = enable_login
87
+ self.backend: AuthBackend = backend # type: ignore[assignment]
88
+ self.enable_forward_middleware = enable_forward_middleware
89
+
90
+ # Extras
91
+ self.include_session = include_session
92
+ self.include_cors = include_cors
93
+ self.login_path = login_path
94
+ self.allowlist = allowlist
95
+
96
+ # Build the internal dashboard routing application immediately
97
+ self.log_storage: LogStorage | None = log_storage
98
+ self.child_app: Router = self.assemble_dashboard_router()
99
+
100
+ def add_sign_in_pages(self) -> list[Path | Include]:
101
+ """
102
+ Defines and registers the routes required for user login and logout pages.
103
+
104
+ This function delegates the core logic for rendering the form (GET) and processing
105
+ credentials/clearing sessions (POST) to the configured authentication backend (`self.backend`).
106
+
107
+ Returns:
108
+ A list of `Path` objects for the `/login` and `/logout` endpoints, ready
109
+ to be included in the application's routing table.
110
+ """
111
+ # The list of routes to be returned
112
+ routes: list[Path | Include] = []
113
+
114
+ # Assert self has backend attribute (required by logic)
115
+ backend: AuthBackend = self.backend
116
+
117
+ async def login(request: Request) -> Response:
118
+ """
119
+ Handler for the `/login` route. Delegates GET (form rendering) and
120
+ POST (credential processing) to the authentication backend.
121
+ """
122
+ return await backend.login(request)
123
+
124
+ async def logout(request: Request) -> Response:
125
+ """
126
+ Handler for the `/logout` route. Delegates the session clearing logic
127
+ to the authentication backend.
128
+ """
129
+ return await backend.logout(request)
130
+
131
+ # 1. Add /login route
132
+ routes.append(Path("/login", login, methods=["GET", "POST"], name="login"))
133
+
134
+ # 2. Add /logout route
135
+ routes.append(Path("/logout", logout, methods=["GET", "POST"], name="logout"))
136
+
137
+ return routes
138
+
139
+ @cached_property
140
+ def cors_middleware(self) -> DefineMiddleware:
141
+ """
142
+ Creates and caches the necessary middleware definition for handling HTTP requests,
143
+ specifically configuring **Cross-Origin Resource Sharing (CORS)**.
144
+
145
+ This property returns a single `DefineMiddleware` instance configured to be highly
146
+ permissive for development environments (`allow_origins=["*"]`, etc.).
147
+
148
+ The result is cached using `@cached_property`, meaning the configuration dictionary
149
+ is generated only once per object instance.
150
+
151
+ Returns:
152
+ DefineMiddleware: The fully configured middleware definition for CORS.
153
+ (Though the type hint is often simplified to list[DefineMiddleware]
154
+ in the consuming context, the property returns a single instance).
155
+ """
156
+ return DefineMiddleware(
157
+ CORSMiddleware,
158
+ allow_origins=["*"],
159
+ allow_methods=["*"],
160
+ allow_headers=["*"],
161
+ allow_credentials=True,
162
+ )
163
+
164
+ @cached_property
165
+ def auth_gate_middleware(self) -> DefineMiddleware:
166
+ """
167
+ Creates and caches the necessary middleware definition for enforcing authentication
168
+ across the dashboard application.
169
+
170
+ This middleware uses the configured `self.backend`'s `authenticate` method to
171
+ verify the user's credentials on every request, redirecting unauthorized users
172
+ to `self.login_path`, unless the path is in `self.allowlist`.
173
+
174
+ The result is cached using `@cached_property`, ensuring the middleware definition
175
+ is constructed only once per object instance.
176
+
177
+ Returns:
178
+ DefineMiddleware: The fully configured authentication gate middleware definition.
179
+ """
180
+ return DefineMiddleware(
181
+ AuthGateMiddleware,
182
+ authenticate=self.backend.authenticate,
183
+ login_path=self.login_path,
184
+ allowlist=self.allowlist,
185
+ )
186
+
187
+ def assemble_dashboard_router(self) -> Router:
188
+ """
189
+ Constructs the internal `ChildLilya` application with all necessary middlewares
190
+ and routes (dashboard, login/logout).
191
+
192
+ Returns:
193
+ The fully configured `ChildLilya` application instance.
194
+ """
195
+ # 1. Create the router app
196
+ app: Router = Router(
197
+ routes=[
198
+ Include(
199
+ "/",
200
+ app=create_dashboard_app(
201
+ scheduler=self.scheduler, log_storage=self.log_storage
202
+ ),
203
+ ),
204
+ ],
205
+ )
206
+
207
+ return app
208
+
209
+ def include_in(self, app: Lilya) -> None:
210
+ """
211
+ Mount the composed dashboard app at the host root.
212
+
213
+ The composed app (built with `with_url_prefix=True`) already:
214
+ - mounts the dashboard router under `self.url_prefix` (e.g., /dashboard)
215
+ - exposes root-level `/login` and `/logout` routes
216
+ So mounting it at "/" yields the desired final URLs:
217
+ - /login, /logout
218
+ - {self.url_prefix}/...
219
+ """
220
+ composed: ASGIApp = self.get_asgi_app(with_url_prefix=True)
221
+ app.add_child_lilya("/", cast(ChildLilya, composed))
222
+
223
+ def get_asgi_app(self, with_url_prefix: bool = False) -> ASGIApp | Lilya | ChildLilya:
224
+ """
225
+ Constructs and returns the final, self-contained ASGI application for the dashboard,
226
+ complete with nested routing, middleware, and authentication gates.
227
+
228
+ Args:
229
+ self: The instance of the class (e.g., AsynczAdmin) holding the configuration.
230
+ with_url_prefix: If `True`, mounts the application under `self.url_prefix`.
231
+ If `False` (default), mounts it directly under the root `/`.
232
+
233
+ Returns:
234
+ ASGIApp | Lilya | ChildLilya: The top-level `Lilya` application instance, ready to be served.
235
+ """
236
+ # Determine the base path for mounting the application's internal routes
237
+ base: str = self.url_prefix if with_url_prefix else "/"
238
+ base_norm = "/" if base == "/" else base.rstrip("/")
239
+
240
+ # 1. Build Inner Routes (Dashboard Core + Auth Pages)
241
+ inner_routes: list[Path | Include] = [
242
+ Include("/", app=self.child_app) # Mount the core dashboard routes
243
+ ]
244
+
245
+ # 2. Build Top-Level Routes (Mounting everything under the final prefix)
246
+ routes: list[Path | Include] = [
247
+ Include(path=base_norm, routes=inner_routes, name="dashboard")
248
+ ]
249
+ if self.enable_login:
250
+ # Add /login and /logout handlers if authentication is enabled at root level
251
+ routes.extend(self.add_sign_in_pages())
252
+
253
+ # 3. Build Middleware Stack (Order is critical)
254
+ middlewares: list[DefineMiddleware] = []
255
+
256
+ if self.enable_forward_middleware:
257
+ # X-Forwarded-Prefix handling (useful if mounted via proxy)
258
+ middlewares.append(DefineMiddleware(ForwardedPrefixMiddleware))
259
+
260
+ if self.include_cors:
261
+ # CORS handling
262
+ middlewares.append(self.cors_middleware)
263
+
264
+ if self.include_session:
265
+ # Session handling (required for SimpleUsernamePasswordBackend)
266
+ middlewares.append(self.config.session_middleware)
267
+
268
+ if self.enable_login:
269
+ # Authentication enforcement
270
+ middlewares.append(self.auth_gate_middleware)
271
+
272
+ # 4. Create Final Lilya Application
273
+ return cast(
274
+ ASGIApp,
275
+ Lilya(
276
+ routes=routes,
277
+ middleware=middlewares,
278
+ exception_handlers={404: not_found},
279
+ ),
280
+ )
@@ -0,0 +1,3 @@
1
+ from .auth_gate import AuthGateMiddleware
2
+
3
+ __all__ = ["AuthGateMiddleware"]
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from lilya.types import ASGIApp, Receive, Scope, Send
4
+
5
+
6
+ class ForwardedPrefixMiddleware:
7
+ """
8
+ ASGI middleware that automatically updates the `scope['root_path']` based on the
9
+ `X-Forwarded-Prefix` header provided by a reverse proxy (like Nginx).
10
+
11
+ This ensures that internal framework components, such as `url_for()` and `StaticFiles`,
12
+ generate correct absolute URLs that include the proxy's mount prefix (e.g., /base/static).
13
+ """
14
+
15
+ def __init__(self, app: ASGIApp, header_name: bytes = b"x-forwarded-prefix"):
16
+ """
17
+ Initializes the middleware.
18
+
19
+ Args:
20
+ app: The next ASGI application in the stack.
21
+ header_name: The byte string name of the HTTP header to check for the prefix.
22
+ Defaults to `b"x-forwarded-prefix"`.
23
+ """
24
+ self.app: ASGIApp = app
25
+ self.header_name: bytes = header_name
26
+
27
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
28
+ """
29
+ The ASGI entry point. Processes headers and updates the scope's root_path if necessary.
30
+
31
+ Args:
32
+ scope: The ASGI scope dictionary.
33
+ receive: The ASGI receive callable.
34
+ send: The ASGI send callable.
35
+ """
36
+ if scope["type"] in ("http", "websocket"):
37
+ # Normalize headers to a dictionary for easy, case-insensitive lookup
38
+ headers: dict[bytes, bytes] = {k.lower(): v for k, v in scope.get("headers", [])}
39
+
40
+ prefix_bytes: bytes | None = headers.get(self.header_name.lower())
41
+
42
+ if prefix_bytes:
43
+ # Decode and strip the prefix value
44
+ p: str = prefix_bytes.decode("latin-1").strip()
45
+
46
+ if p and p != "/":
47
+ # 1. Ensure leading slash (e.g., 'base' -> '/base')
48
+ if not p.startswith("/"):
49
+ p = "/" + p
50
+
51
+ # 2. Strip trailing slash unless it's just '/'
52
+ if p != "/":
53
+ p = p.rstrip("/")
54
+
55
+ # 3. Combine with any existing root_path
56
+ existing: str = scope.get("root_path") or ""
57
+
58
+ # Ensure existing root_path also has a leading slash
59
+ if existing and not existing.startswith("/"):
60
+ existing = "/" + existing
61
+
62
+ # Concatenate and clean up trailing slash (result is either '/base' or '/')
63
+ combined: str = (p + existing).rstrip("/") or "/"
64
+
65
+ # 4. Create a copy of the scope and update root_path
66
+ scope = dict(scope)
67
+ scope["root_path"] = combined
68
+
69
+ # Pass control to the next application
70
+ await self.app(scope, receive, send)
@@ -1,12 +1,15 @@
1
+ import logging
1
2
  from typing import Any
2
3
 
3
- from lilya.apps import Lilya
4
- from lilya.requests import Request
5
- from lilya.routing import Include, RoutePath
4
+ from lilya.routing import Include, RoutePath, Router
6
5
  from lilya.staticfiles import StaticFiles
7
6
 
8
- from asyncz import monkay
9
7
  from asyncz.contrib.dashboard.controllers import home
8
+ from asyncz.contrib.dashboard.controllers.logs import (
9
+ LogsPageController,
10
+ LogsTablePartialController,
11
+ get_log_storage,
12
+ )
10
13
  from asyncz.contrib.dashboard.controllers.tasks import (
11
14
  TaskBulkPauseController,
12
15
  TaskBulkRemoveController,
@@ -20,32 +23,32 @@ from asyncz.contrib.dashboard.controllers.tasks import (
20
23
  TasklistController,
21
24
  TaskTablePartialController,
22
25
  )
23
- from asyncz.contrib.dashboard.engine import templates
24
-
25
-
26
- async def not_found(request: Request, exc: Exception) -> Any:
27
- return templates.get_template_response(
28
- request,
29
- "404.html",
30
- context={
31
- "title": "Not Found",
32
- "url_prefix": monkay.settings.dashboard_config.dashboard_url_prefix,
33
- },
34
- status_code=404,
35
- )
26
+ from asyncz.contrib.dashboard.logs.handler import install_task_log_handler
27
+ from asyncz.contrib.dashboard.logs.storage import LogStorage
36
28
 
37
29
 
38
- def create_dashboard_app(scheduler: Any) -> Lilya:
30
+ def create_dashboard_app(scheduler: Any, log_storage: LogStorage | None = None) -> Router:
39
31
  """
40
32
  Build a Lilya sub-application wired to an AsyncZ AsyncIOScheduler.
41
33
  The scheduler must be a live scheduler instance owned by the host app.
42
34
  """
43
- app = Lilya(
35
+ install_task_log_handler(storage=get_log_storage(storage=log_storage))
36
+
37
+ # Ensure stdlib logs on the namespaced logger bubble up to our handler.
38
+ # Our TaskLogHandler is installed by install_task_log_handler() on a parent logger.
39
+ # Make sure the child logger used by tests (`asyncz.task`) propagates upward.
40
+ logging.getLogger("asyncz").setLevel(logging.INFO)
41
+ logging.getLogger("asyncz").propagate = True
42
+
43
+ app = Router(
44
44
  routes=[
45
- RoutePath(
46
- "/",
47
- home.DashboardController.with_init(scheduler=scheduler),
48
- name="dashboard:index",
45
+ Include(
46
+ path="/logs",
47
+ routes=[
48
+ RoutePath("/", LogsPageController, name="index"),
49
+ RoutePath("/partials/table", LogsTablePartialController, name="table"),
50
+ ],
51
+ name="logs",
49
52
  ),
50
53
  Include(
51
54
  path="/tasks",
@@ -53,66 +56,71 @@ def create_dashboard_app(scheduler: Any) -> Lilya:
53
56
  RoutePath(
54
57
  "/",
55
58
  TasklistController.with_init(scheduler=scheduler),
56
- name="tasks:index",
59
+ name="index",
57
60
  ),
58
61
  RoutePath(
59
62
  "/partials/table",
60
63
  TaskTablePartialController.with_init(scheduler=scheduler),
61
- name="tasks:table",
64
+ name="table",
62
65
  ),
63
66
  RoutePath(
64
67
  "/create",
65
68
  TaskCreateController.with_init(scheduler=scheduler),
66
- name="tasks:create",
69
+ name="create",
67
70
  ),
68
71
  RoutePath(
69
72
  "/hx/bulk/pause",
70
73
  TaskBulkPauseController.with_init(scheduler=scheduler),
71
- name="tasks:bulk_pause",
74
+ name="bulk_pause",
72
75
  ),
73
76
  RoutePath(
74
77
  "/hx/bulk/resume",
75
78
  TaskBulkResumeController.with_init(scheduler=scheduler),
76
- name="tasks:bulk_resume",
79
+ name="bulk_resume",
77
80
  ),
78
81
  RoutePath(
79
82
  "/hx/bulk/remove",
80
83
  TaskBulkRemoveController.with_init(scheduler=scheduler),
81
- name="tasks:bulk_remove",
84
+ name="bulk_remove",
82
85
  ),
83
86
  RoutePath(
84
87
  "/hx/bulk/run",
85
88
  TaskBulkRunController.with_init(scheduler=scheduler),
86
- name="tasks:bulk_run",
89
+ name="bulk_run",
87
90
  ),
88
91
  RoutePath(
89
92
  "/{task_id:str}/run",
90
93
  TaskHXRunController.with_init(scheduler=scheduler),
91
- name="tasks:hx_run",
94
+ name="hx_run",
92
95
  ),
93
96
  RoutePath(
94
97
  "/{task_id:str}/pause",
95
98
  TaskHXPauseController.with_init(scheduler=scheduler),
96
- name="tasks:hx_pause",
99
+ name="hx_pause",
97
100
  ),
98
101
  RoutePath(
99
102
  "/{task_id:str}/resume",
100
103
  TaskHXResumeController.with_init(scheduler=scheduler),
101
- name="tasks:hx_resume",
104
+ name="hx_resume",
102
105
  ),
103
106
  RoutePath(
104
107
  "/{task_id:str}/remove",
105
108
  TaskHXRemoveController.with_init(scheduler=scheduler),
106
- name="tasks:hx_remove",
109
+ name="hx_remove",
107
110
  ),
108
111
  ],
112
+ name="tasks",
109
113
  ),
110
114
  Include(
111
115
  "/static",
112
116
  app=StaticFiles(packages=["asyncz.contrib.dashboard"], html=True),
113
117
  name="statics",
114
118
  ),
119
+ RoutePath(
120
+ "/",
121
+ home.DashboardController.with_init(scheduler=scheduler),
122
+ name="index",
123
+ ),
115
124
  ],
116
- exception_handlers={404: not_found},
117
125
  )
118
126
  return app
@@ -1,6 +1,8 @@
1
1
  import secrets
2
2
  from dataclasses import dataclass
3
- from typing import Literal, cast
3
+ from typing import Literal
4
+
5
+ from lilya.requests import Request
4
6
 
5
7
  try:
6
8
  from lilya.middleware import DefineMiddleware
@@ -95,13 +97,37 @@ class DashboardConfig:
95
97
  )
96
98
 
97
99
 
98
- def get_effective_prefix() -> str:
99
- """Compute an absolute dashboard base path, combining ASGI root_path and the
100
- configured dashboard URL prefix.
100
+ def _normalize_prefix(value: str | None) -> str:
101
+ """Ensure a leading slash and remove trailing slash (except for root)."""
102
+ if not value:
103
+ return "/"
104
+ v = value.strip()
105
+ if not v.startswith("/"):
106
+ v = "/" + v
107
+ return v if v == "/" else v.rstrip("/")
108
+
101
109
 
102
- Guarantees:
103
- - Always starts with '/'
104
- - No trailing slash (except when the result is exactly '/')
105
- - Never double-appends the configured prefix if it's already in root_path
110
+ def get_effective_prefix(request: Request | None = None) -> str:
111
+ """Compute the effective base URL prefix for the dashboard.
112
+
113
+ - If *request* is **None**, return the configured dashboard prefix exactly as before
114
+ (leading '/', no trailing '/', except when it is '/'). This preserves
115
+ backward compatibility with tests that called the old zero-arg function.
116
+ - If *request* is provided, combine ASGI mount ``root_path`` (if any) with the
117
+ configured prefix, avoiding double-prefixing and double slashes.
106
118
  """
107
- return cast(str, monkay.settings.dashboard_config.dashboard_url_prefix)
119
+ configured_prefix = _normalize_prefix(
120
+ getattr(monkay.settings.dashboard_config, "dashboard_url_prefix", "/")
121
+ )
122
+
123
+ # Prefer the configured prefix when it's meaningful (not '/')
124
+ if configured_prefix != "/":
125
+ return configured_prefix
126
+
127
+ # Only when configured is '/' do we consider the mount root
128
+ if request is not None:
129
+ scope = getattr(request, "scope", {}) or {}
130
+ mount_prefix = _normalize_prefix(scope.get("root_path") or "/")
131
+ return mount_prefix
132
+
133
+ return "/"