qodalis-cli-server-plugin-admin 1.0.0b1__tar.gz → 1.0.0b5__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 (21) hide show
  1. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/PKG-INFO +1 -1
  2. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/pyproject.toml +1 -1
  3. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/auth_controller.py +5 -16
  4. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/auth_middleware.py +1 -3
  5. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/jwt_service.py +5 -10
  6. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/cli_admin_builder.py +6 -6
  7. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/config_controller.py +2 -0
  8. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/dashboard_resolver.py +8 -10
  9. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/admin_config.py +1 -0
  10. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/log_ring_buffer.py +1 -4
  11. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/module_registry.py +38 -9
  12. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/spa_static_files.py +1 -1
  13. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/.gitignore +0 -0
  14. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/__init__.py +0 -0
  15. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/__init__.py +0 -0
  16. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/__init__.py +0 -0
  17. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/logs_controller.py +0 -0
  18. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/plugins_controller.py +0 -0
  19. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/status_controller.py +0 -0
  20. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/ws_clients_controller.py +0 -0
  21. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodalis-cli-server-plugin-admin
3
- Version: 1.0.0b1
3
+ Version: 1.0.0b5
4
4
  Summary: Admin dashboard plugin for Qodalis CLI servers
5
5
  Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
6
6
  Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qodalis-cli-server-plugin-admin"
7
- version = "1.0.0b1"
7
+ version = "1.0.0b5"
8
8
  description = "Admin dashboard plugin for Qodalis CLI servers"
9
9
  readme = { text = "", content-type = "text/plain" }
10
10
  license = "MIT"
@@ -13,26 +13,21 @@ from .jwt_service import JwtService
13
13
  from ..services.admin_config import AdminConfig
14
14
 
15
15
 
16
- # ---------------------------------------------------------------------------
17
- # Request / Response models
18
- # ---------------------------------------------------------------------------
19
-
20
-
21
16
  class LoginRequest(BaseModel):
17
+ """Request body for the login endpoint."""
18
+
22
19
  username: str
23
20
  password: str
24
21
 
25
22
 
26
23
  class LoginResponse(BaseModel):
24
+ """Response body returned on successful login."""
25
+
27
26
  token: str
28
27
  expiresIn: int
29
28
  username: str
30
29
 
31
30
 
32
- # ---------------------------------------------------------------------------
33
- # Rate limiting helpers
34
- # ---------------------------------------------------------------------------
35
-
36
31
  _MAX_ATTEMPTS = 5
37
32
  _WINDOW_SECONDS = 60
38
33
 
@@ -47,20 +42,15 @@ class _RateLimiter:
47
42
  """Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
48
43
  now = time.time()
49
44
  cutoff = now - _WINDOW_SECONDS
50
- # Clean old entries
51
45
  self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
52
46
  if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
53
47
  raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
54
48
 
55
49
  def record_failure(self, ip: str) -> None:
50
+ """Record a failed login attempt for the given IP."""
56
51
  self._attempts[ip].append(time.time())
57
52
 
58
53
 
59
- # ---------------------------------------------------------------------------
60
- # Router factory
61
- # ---------------------------------------------------------------------------
62
-
63
-
64
54
  def create_auth_router(
65
55
  admin_config: AdminConfig,
66
56
  jwt_service: JwtService,
@@ -72,7 +62,6 @@ def create_auth_router(
72
62
 
73
63
  @router.post("/login", response_model=LoginResponse)
74
64
  async def login(body: LoginRequest, request: Request) -> Any:
75
- # Check X-Forwarded-For header for real client IP behind proxy
76
65
  forwarded_for = request.headers.get("x-forwarded-for")
77
66
  if forwarded_for:
78
67
  client_ip = forwarded_for.split(",")[0].strip()
@@ -36,8 +36,6 @@ def create_auth_dependency(jwt_service: JwtService):
36
36
  return require_auth
37
37
 
38
38
 
39
- # Convenience alias — requires a JwtService instance to be set externally.
40
- # Prefer ``create_auth_dependency(jwt_service)`` for production use.
41
39
  async def require_auth(request: Request) -> dict[str, Any]: # noqa: F811
42
- """Placeholder — use :func:`create_auth_dependency` to create a real dependency."""
40
+ """No-op fallback; use ``create_auth_dependency`` to create a real dependency."""
43
41
  raise HTTPException(status_code=401, detail="Auth not configured")
@@ -33,12 +33,9 @@ class JwtService:
33
33
  ) -> str:
34
34
  """Create a signed JWT token.
35
35
 
36
- Parameters
37
- ----------
38
- payload:
39
- Claims to embed in the token.
40
- expires_in:
41
- Lifetime in seconds. Defaults to 24 hours.
36
+ Args:
37
+ payload: Claims to embed in the token.
38
+ expires_in: Lifetime in seconds. Defaults to 24 hours.
42
39
  """
43
40
  exp = int(time.time()) + (self.DEFAULT_EXPIRY_SECONDS if expires_in is None else expires_in)
44
41
  data = {**payload, "exp": exp, "iat": int(time.time())}
@@ -47,9 +44,7 @@ class JwtService:
47
44
  def verify_token(self, token: str) -> dict[str, Any]:
48
45
  """Decode and verify a JWT token.
49
46
 
50
- Raises
51
- ------
52
- jwt.InvalidTokenError
53
- If the token is invalid or expired.
47
+ Raises:
48
+ jwt.InvalidTokenError: If the token is invalid or expired.
54
49
  """
55
50
  return jwt.decode(token, self._secret, algorithms=["HS256"])
@@ -42,10 +42,13 @@ class AdminBuildDeps:
42
42
  class CliAdminPlugin:
43
43
  """Result of building the admin plugin."""
44
44
 
45
+ prefix: str
46
+ dashboard_prefix: str
45
47
  router: APIRouter
46
48
  auth_dependency: Any
47
49
  log_buffer: LogRingBuffer
48
50
  dashboard_app: Any # ASGI app (SPA fallback) or None
51
+ module_registry: ModuleRegistry | None = None
49
52
 
50
53
 
51
54
  class CliAdminBuilder:
@@ -77,7 +80,6 @@ class CliAdminBuilder:
77
80
  """Build the plugin, returning the router, auth dependency, and log buffer."""
78
81
  start_time = time.time()
79
82
 
80
- # Services
81
83
  config = AdminConfig(
82
84
  username=self._username,
83
85
  password=self._password,
@@ -89,14 +91,11 @@ class CliAdminBuilder:
89
91
  module_registry = ModuleRegistry(deps.builder)
90
92
  auth_dep = create_auth_dependency(jwt_service)
91
93
 
92
- # Build the top-level router
93
94
  router = APIRouter()
94
95
 
95
- # Auth routes (login is public, /me is protected)
96
96
  auth_router = create_auth_router(config, jwt_service, auth_dependency=auth_dep)
97
97
  router.include_router(auth_router, prefix="/auth")
98
98
 
99
- # Detect enabled features
100
99
  enabled_features: list[str] = list(deps.enabled_features or [])
101
100
 
102
101
  if "filesystem" not in enabled_features and (
@@ -112,7 +111,6 @@ class CliAdminBuilder:
112
111
  enabled_features.append("jobs")
113
112
  break
114
113
 
115
- # Protected routes
116
114
  status_router = create_status_router(
117
115
  start_time, deps.event_socket_manager, auth_dep, enabled_features,
118
116
  registry=deps.registry,
@@ -131,7 +129,6 @@ class CliAdminBuilder:
131
129
  ws_clients_router = create_ws_clients_router(deps.event_socket_manager, auth_dep)
132
130
  router.include_router(ws_clients_router)
133
131
 
134
- # Resolve dashboard SPA
135
132
  dashboard_dir = resolve_dashboard_dir(self._dashboard_path)
136
133
  dashboard_app = None
137
134
 
@@ -139,8 +136,11 @@ class CliAdminBuilder:
139
136
  dashboard_app = SPAStaticFiles(directory=dashboard_dir, html=True)
140
137
 
141
138
  return CliAdminPlugin(
139
+ prefix="/api/v1/qcli",
140
+ dashboard_prefix="/qcli/admin",
142
141
  router=router,
143
142
  auth_dependency=auth_dep,
144
143
  log_buffer=log_buffer,
145
144
  dashboard_app=dashboard_app,
145
+ module_registry=module_registry,
146
146
  )
@@ -11,6 +11,8 @@ from ..services.admin_config import AdminConfig
11
11
 
12
12
 
13
13
  class UpdateConfigRequest(BaseModel):
14
+ """Request body for updating admin configuration settings."""
15
+
14
16
  settings: dict[str, Any]
15
17
 
16
18
 
@@ -5,23 +5,22 @@ from pathlib import Path
5
5
 
6
6
 
7
7
  def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
8
+ """Resolve the dashboard dist directory.
9
+
10
+ Searches in order: explicit path, bundled directory, npm package
11
+ (``node_modules/@qodalis/cli-server-dashboard/dist``), and relative
12
+ development paths.
13
+
14
+ Returns:
15
+ The absolute path to the dashboard directory, or ``None``.
8
16
  """
9
- Resolve the dashboard dist directory. Looks for:
10
- 1. Explicitly configured path
11
- 2. Bundled dashboard directory (embedded in this package)
12
- 3. node_modules/@qodalis/cli-server-dashboard/dist (npm package)
13
- 4. Relative development paths (sibling repo)
14
- """
15
- # 1. Explicit override
16
17
  if explicit_path and os.path.isdir(explicit_path):
17
18
  return os.path.abspath(explicit_path)
18
19
 
19
- # 2. Bundled dashboard directory (embedded in this package)
20
20
  bundled = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard')
21
21
  if os.path.isdir(bundled):
22
22
  return bundled
23
23
 
24
- # 3. npm package — search upwards from cwd for node_modules
25
24
  current = os.getcwd()
26
25
  while current != os.path.dirname(current):
27
26
  candidate = os.path.join(
@@ -31,7 +30,6 @@ def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
31
30
  return os.path.abspath(candidate)
32
31
  current = os.path.dirname(current)
33
32
 
34
- # 4. Relative development paths
35
33
  this_dir = os.path.dirname(os.path.abspath(__file__))
36
34
  dev_paths = [
37
35
  os.path.join(this_dir, "..", "..", "..", "..", "cli-server-dashboard", "dist"),
@@ -105,6 +105,7 @@ class AdminConfig:
105
105
 
106
106
 
107
107
  def _infer_type(val: Any) -> str:
108
+ """Infer a JSON-schema-style type string from a Python value."""
108
109
  if isinstance(val, bool):
109
110
  return "boolean"
110
111
  if isinstance(val, (int, float)):
@@ -47,8 +47,6 @@ class LogRingBuffer:
47
47
  self._broadcast_fn = broadcast_fn
48
48
  self._handler: _RingBufferHandler | None = None
49
49
 
50
- # -- public API -----------------------------------------------------------
51
-
52
50
  def add(self, entry: LogEntry) -> None:
53
51
  """Append a log entry to the buffer."""
54
52
  entry.level = (entry.level or "INFO").upper()
@@ -93,8 +91,6 @@ class LogRingBuffer:
93
91
  total,
94
92
  )
95
93
 
96
- # -- logging integration --------------------------------------------------
97
-
98
94
  def install_handler(self, logger_name: str = "") -> None:
99
95
  """Install a :class:`logging.Handler` that feeds into this buffer."""
100
96
  if self._handler is not None:
@@ -118,6 +114,7 @@ class _RingBufferHandler(logging.Handler):
118
114
  self._ring = ring
119
115
 
120
116
  def emit(self, record: logging.LogRecord) -> None:
117
+ """Convert a log record to a LogEntry and append it to the ring buffer."""
121
118
  entry = LogEntry(
122
119
  timestamp=datetime.now(timezone.utc).isoformat(),
123
120
  level=record.levelname,
@@ -1,19 +1,54 @@
1
- """Module registry — reads modules from CliBuilder and tracks enabled state."""
1
+ """Module registry — reads modules from CliBuilder and tracks enabled state.
2
+
3
+ Implements :class:`ICliProcessorFilter` so that processors belonging to
4
+ disabled modules are blocked at execution time.
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
5
9
  from typing import Any, TYPE_CHECKING
6
10
 
11
+ from qodalis_cli_server_abstractions import ICliProcessorFilter
12
+
7
13
  if TYPE_CHECKING:
14
+ from qodalis_cli_server_abstractions import ICliCommandProcessor
8
15
  from qodalis_cli.extensions.cli_builder import CliBuilder
9
16
 
10
17
 
11
- class ModuleRegistry:
12
- """Provides a read/toggle view over modules registered in :class:`CliBuilder`."""
18
+ class ModuleRegistry(ICliProcessorFilter):
19
+ """Provides a read/toggle view over modules registered in :class:`CliBuilder`.
20
+
21
+ Also acts as a processor filter: processors that belong to a disabled
22
+ module are reported as *not allowed*, causing the executor to reject
23
+ the command with a user-friendly error.
24
+ """
13
25
 
14
26
  def __init__(self, builder: CliBuilder) -> None:
15
27
  self._builder = builder
16
28
  self._disabled: set[str] = set()
29
+ # Map each processor instance to its module id for fast lookup.
30
+ self._processor_to_module: dict[int, str] = {}
31
+ self._rebuild_processor_map()
32
+
33
+ def _rebuild_processor_map(self) -> None:
34
+ """(Re-)build the processor-to-module-id mapping."""
35
+ self._processor_to_module.clear()
36
+ for idx, module in enumerate(self._builder.modules):
37
+ module_id = str(idx)
38
+ for processor in module.processors:
39
+ self._processor_to_module[id(processor)] = module_id
40
+
41
+ # -- ICliProcessorFilter ------------------------------------------------
42
+
43
+ def is_allowed(self, processor: ICliCommandProcessor) -> bool:
44
+ """Return ``False`` if the processor belongs to a disabled module."""
45
+ module_id = self._processor_to_module.get(id(processor))
46
+ if module_id is None:
47
+ # Processor not registered via a module — always allowed.
48
+ return True
49
+ return module_id not in self._disabled
50
+
51
+ # -- Public API ---------------------------------------------------------
17
52
 
18
53
  def list(self) -> list[dict[str, Any]]:
19
54
  """Return a list of all modules with their enabled/disabled state."""
@@ -55,10 +90,4 @@ class ModuleRegistry:
55
90
  "enabled": enabled,
56
91
  }
57
92
 
58
- if not enabled:
59
- result["warning"] = (
60
- "Module state tracked but command unregistration is not yet"
61
- " supported. Processors remain active."
62
- )
63
-
64
93
  return result
@@ -24,12 +24,12 @@ class SPAStaticFiles(StaticFiles):
24
24
  """
25
25
 
26
26
  async def get_response(self, path: str, scope: Scope) -> Response:
27
+ """Return the static file or fall back to ``index.html`` for SPA routes."""
27
28
  try:
28
29
  return await super().get_response(path, scope)
29
30
  except StarletteHTTPException as exc:
30
31
  if exc.status_code != 404:
31
32
  raise
32
- # File not found — serve index.html for SPA client-side routing
33
33
  index = os.path.join(self.directory, "index.html") # type: ignore[arg-type]
34
34
  if os.path.isfile(index):
35
35
  return FileResponse(index, media_type="text/html")