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.
- {asyncz-0.14.0 → asyncz-0.14.2}/.gitignore +1 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/PKG-INFO +3 -3
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/__init__.py +1 -1
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/admin/backends.py +4 -4
- asyncz-0.14.2/asyncz/contrib/dashboard/admin/core.py +280 -0
- asyncz-0.14.2/asyncz/contrib/dashboard/admin/middleware/__init__.py +3 -0
- asyncz-0.14.2/asyncz/contrib/dashboard/admin/middleware/forward_root_path.py +70 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/application.py +43 -35
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/config.py +35 -9
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/_helpers.py +91 -3
- asyncz-0.14.2/asyncz/contrib/dashboard/controllers/logs.py +108 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/tasks.py +68 -21
- asyncz-0.14.2/asyncz/contrib/dashboard/engine.py +42 -0
- asyncz-0.14.2/asyncz/contrib/dashboard/logs/handler.py +129 -0
- asyncz-0.14.2/asyncz/contrib/dashboard/logs/storage.py +200 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/mixins.py +2 -1
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/404.html +1 -1
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/index.html +3 -3
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/layout.html +23 -6
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/login.html +4 -4
- asyncz-0.14.2/asyncz/contrib/dashboard/templates/logs/index.html +45 -0
- asyncz-0.14.2/asyncz/contrib/dashboard/templates/logs/partials/table.html +28 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/tasks/_table.html +4 -4
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/tasks/tasks.html +2 -2
- asyncz-0.14.2/asyncz/stores/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/pyproject.toml +2 -2
- asyncz-0.14.0/asyncz/contrib/dashboard/admin/core.py +0 -163
- asyncz-0.14.0/asyncz/contrib/dashboard/engine.py +0 -6
- {asyncz-0.14.0 → asyncz-0.14.2}/LICENSE +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/README.md +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/_parsers.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/app.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/bootstrap_loader.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/add.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/list.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/pause.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/remove.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/resume.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/run.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/commands/start.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/loader.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/cli/utils.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/conf/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/conf/global_settings.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/admin/__init__.py +0 -0
- /asyncz-0.14.0/asyncz/contrib/dashboard/admin/middleware.py → /asyncz-0.14.2/asyncz/contrib/dashboard/admin/middleware/auth_gate.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/admin/protocols.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/controllers/home.py +0 -0
- {asyncz-0.14.0/asyncz/stores → asyncz-0.14.2/asyncz/contrib/dashboard/logs}/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/messages.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/css/asyncz.css +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/css/toastify.min.css +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/js/asyncz.js +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/statics/js/toastify.min.js +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/shared/loading.html +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/contrib/dashboard/templates/shared/messages.html +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/datastructures.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/enums.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/events/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/events/base.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/events/constants.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/exceptions.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/asyncio.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/base.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/debug.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/pool.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/process_pool.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/executors/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/file_locking.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/locks.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/monkay.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/protocols.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/py.typed +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/asyncio.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/base.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/datastructures.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/defaults.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/schedulers/utils.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/state.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/base.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/file.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/memory.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/mongo.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/redis.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/sqlalchemy.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/stores/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/tasks/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/tasks/base.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/tasks/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/base.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/combination.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/__init__.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/constants.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/expressions.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/fields.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/trigger.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/cron/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/date.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/interval.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/shutdown.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/triggers/types.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/typing.py +0 -0
- {asyncz-0.14.0 → asyncz-0.14.2}/asyncz/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: asyncz
|
|
3
|
-
Version: 0.14.
|
|
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.
|
|
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.
|
|
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'
|
|
@@ -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
|
-
|
|
107
|
-
return RedirectResponse(
|
|
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,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.
|
|
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.
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
"/",
|
|
47
|
-
|
|
48
|
-
|
|
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="
|
|
59
|
+
name="index",
|
|
57
60
|
),
|
|
58
61
|
RoutePath(
|
|
59
62
|
"/partials/table",
|
|
60
63
|
TaskTablePartialController.with_init(scheduler=scheduler),
|
|
61
|
-
name="
|
|
64
|
+
name="table",
|
|
62
65
|
),
|
|
63
66
|
RoutePath(
|
|
64
67
|
"/create",
|
|
65
68
|
TaskCreateController.with_init(scheduler=scheduler),
|
|
66
|
-
name="
|
|
69
|
+
name="create",
|
|
67
70
|
),
|
|
68
71
|
RoutePath(
|
|
69
72
|
"/hx/bulk/pause",
|
|
70
73
|
TaskBulkPauseController.with_init(scheduler=scheduler),
|
|
71
|
-
name="
|
|
74
|
+
name="bulk_pause",
|
|
72
75
|
),
|
|
73
76
|
RoutePath(
|
|
74
77
|
"/hx/bulk/resume",
|
|
75
78
|
TaskBulkResumeController.with_init(scheduler=scheduler),
|
|
76
|
-
name="
|
|
79
|
+
name="bulk_resume",
|
|
77
80
|
),
|
|
78
81
|
RoutePath(
|
|
79
82
|
"/hx/bulk/remove",
|
|
80
83
|
TaskBulkRemoveController.with_init(scheduler=scheduler),
|
|
81
|
-
name="
|
|
84
|
+
name="bulk_remove",
|
|
82
85
|
),
|
|
83
86
|
RoutePath(
|
|
84
87
|
"/hx/bulk/run",
|
|
85
88
|
TaskBulkRunController.with_init(scheduler=scheduler),
|
|
86
|
-
name="
|
|
89
|
+
name="bulk_run",
|
|
87
90
|
),
|
|
88
91
|
RoutePath(
|
|
89
92
|
"/{task_id:str}/run",
|
|
90
93
|
TaskHXRunController.with_init(scheduler=scheduler),
|
|
91
|
-
name="
|
|
94
|
+
name="hx_run",
|
|
92
95
|
),
|
|
93
96
|
RoutePath(
|
|
94
97
|
"/{task_id:str}/pause",
|
|
95
98
|
TaskHXPauseController.with_init(scheduler=scheduler),
|
|
96
|
-
name="
|
|
99
|
+
name="hx_pause",
|
|
97
100
|
),
|
|
98
101
|
RoutePath(
|
|
99
102
|
"/{task_id:str}/resume",
|
|
100
103
|
TaskHXResumeController.with_init(scheduler=scheduler),
|
|
101
|
-
name="
|
|
104
|
+
name="hx_resume",
|
|
102
105
|
),
|
|
103
106
|
RoutePath(
|
|
104
107
|
"/{task_id:str}/remove",
|
|
105
108
|
TaskHXRemoveController.with_init(scheduler=scheduler),
|
|
106
|
-
name="
|
|
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
|
|
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
|
|
99
|
-
"""
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
-
|
|
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
|
-
|
|
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 "/"
|