jump2-client 0.1.0__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.
- jump2_client/__init__.py +49 -0
- jump2_client/_pretty.py +44 -0
- jump2_client/admin/__init__.py +57 -0
- jump2_client/admin/ingest.py +193 -0
- jump2_client/admin/schedules.py +164 -0
- jump2_client/admin/users.py +88 -0
- jump2_client/client.py +299 -0
- jump2_client/data.py +245 -0
- jump2_client/discovery.py +71 -0
- jump2_client/errors.py +136 -0
- jump2_client/health.py +129 -0
- jump2_client/jobs.py +124 -0
- jump2_client/query.py +318 -0
- jump2_client/session.py +228 -0
- jump2_client-0.1.0.dist-info/METADATA +102 -0
- jump2_client-0.1.0.dist-info/RECORD +18 -0
- jump2_client-0.1.0.dist-info/WHEEL +4 -0
- jump2_client-0.1.0.dist-info/licenses/LICENSE +21 -0
jump2_client/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jump2_client — Jump 2.0 Python client.
|
|
3
|
+
|
|
4
|
+
Lightweight, standalone, pip-installable. Works in JupyterHub (picks up
|
|
5
|
+
credentials from the environment automatically) or locally (provide
|
|
6
|
+
``base_url`` and ``api_key``, or use :meth:`~jump2_client.client.Client.login`).
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
from jump2_client import Client
|
|
11
|
+
|
|
12
|
+
# In JupyterHub — zero config needed, credentials injected at spawn:
|
|
13
|
+
c = Client()
|
|
14
|
+
c.status() # connectivity + ACL check
|
|
15
|
+
c.discover() # browse the API
|
|
16
|
+
|
|
17
|
+
# Locally:
|
|
18
|
+
c = Client(base_url="https://jump2.example.org", api_key="...")
|
|
19
|
+
# or:
|
|
20
|
+
c = Client(base_url="https://jump2.example.org").login("user", "pass")
|
|
21
|
+
|
|
22
|
+
# Data access (RLS invisible — server handles authorisation):
|
|
23
|
+
df = c.data.query("SELECT url_slug, vmag FROM core_star LIMIT 20")
|
|
24
|
+
files = c.data.files("hd-209458")
|
|
25
|
+
|
|
26
|
+
# Jobs:
|
|
27
|
+
result = c.jobs.poll(42)
|
|
28
|
+
|
|
29
|
+
# Admin (requires elevated permissions):
|
|
30
|
+
job = c.admin.ingest.launch(instruments=["apf"])
|
|
31
|
+
c.admin.schedules.list()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from .client import Client
|
|
35
|
+
from .errors import AuthError, Jump2Error, NotFoundError, ServerError, ValidationError
|
|
36
|
+
from .query import QueryBuilder
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Client",
|
|
42
|
+
"Jump2Error",
|
|
43
|
+
"AuthError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"ServerError",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"QueryBuilder",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
jump2_client/_pretty.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jump2_client._pretty — rich rendering helpers.
|
|
3
|
+
|
|
4
|
+
Kept in a private module so the public API stays clean. The ``rich``
|
|
5
|
+
library is always available (it is a declared dependency), but callers
|
|
6
|
+
never need to import it themselves — just call the public methods on the
|
|
7
|
+
client objects and they print formatted tables automatically.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _rich() -> Any:
|
|
15
|
+
"""Lazy import of rich.console.Console to avoid import-time overhead."""
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
return Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_table(title: str, columns: list[str], rows: list[list[Any]]) -> None:
|
|
21
|
+
"""Print a labelled rich table to stdout."""
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
console = _rich()
|
|
25
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
26
|
+
for col in columns:
|
|
27
|
+
table.add_column(col, overflow="fold")
|
|
28
|
+
for row in rows:
|
|
29
|
+
table.add_row(*[str(v) if v is not None else "" for v in row])
|
|
30
|
+
console.print(table)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def print_dict(title: str, data: dict[str, Any]) -> None:
|
|
34
|
+
"""Print a two-column key/value rich table."""
|
|
35
|
+
print_table(title, ["Key", "Value"], [[k, v] for k, v in data.items()])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_bytes(n: int | float) -> str:
|
|
39
|
+
"""Human-readable byte size (e.g. ``"3.2 GiB"``)."""
|
|
40
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
41
|
+
if abs(n) < 1024.0:
|
|
42
|
+
return f"{n:.1f} {unit}"
|
|
43
|
+
n /= 1024.0
|
|
44
|
+
return f"{n:.1f} PiB"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jump2_client.admin — admin-only operations.
|
|
3
|
+
|
|
4
|
+
All actions in this submodule require elevated permissions (``run_jobs``,
|
|
5
|
+
``manage_schedules``, ``manage_users`` etc.). Authorisation is enforced
|
|
6
|
+
server-side — calling these without the required tokens raises
|
|
7
|
+
:exc:`~jump2_client.errors.AuthError`.
|
|
8
|
+
|
|
9
|
+
Submodules
|
|
10
|
+
----------
|
|
11
|
+
``ingest``
|
|
12
|
+
Preview and launch ad-hoc ingest coordinator jobs.
|
|
13
|
+
|
|
14
|
+
``schedules``
|
|
15
|
+
CRUD for Celery-Beat ingest schedules.
|
|
16
|
+
|
|
17
|
+
``users``
|
|
18
|
+
Admin user and resource-usage views.
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
from jump2_client import Client
|
|
23
|
+
c = Client()
|
|
24
|
+
|
|
25
|
+
# Preview what would be ingested
|
|
26
|
+
preview = c.admin.ingest.preview()
|
|
27
|
+
|
|
28
|
+
# Launch a manual ingest
|
|
29
|
+
job = c.admin.ingest.launch()
|
|
30
|
+
result = c.jobs.poll(job["id"])
|
|
31
|
+
|
|
32
|
+
# Manage schedules
|
|
33
|
+
c.admin.schedules.list()
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from .ingest import AdminIngest
|
|
40
|
+
from .schedules import AdminSchedules
|
|
41
|
+
from .users import AdminUsers
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from ..session import Session
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Admin:
|
|
48
|
+
"""
|
|
49
|
+
Admin-only client namespace.
|
|
50
|
+
|
|
51
|
+
Attached to :class:`~jump2_client.client.Client` as ``client.admin``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, session: Session) -> None:
|
|
55
|
+
self.ingest = AdminIngest(session)
|
|
56
|
+
self.schedules = AdminSchedules(session)
|
|
57
|
+
self.users = AdminUsers(session)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jump2_client.admin.ingest — ad-hoc ingest job management.
|
|
3
|
+
|
|
4
|
+
Wraps the ingest endpoints:
|
|
5
|
+
``GET /api/v1/jobs/ingest/preview/`` — dry-run manifest walk
|
|
6
|
+
``POST /api/v1/jobs/ingest/`` — launch an ingest coordinator
|
|
7
|
+
``GET /api/v1/jobs/node-status/`` — remote node health
|
|
8
|
+
|
|
9
|
+
Requires the ``run_jobs`` permission token.
|
|
10
|
+
|
|
11
|
+
Backend contract note
|
|
12
|
+
---------------------
|
|
13
|
+
The backend endpoint accepts a *single* ``instrument`` (one job per instrument
|
|
14
|
+
category). Pass ``instrument=`` (singular) to :meth:`preview` and
|
|
15
|
+
:meth:`launch`. Valid choices: ``apf``, ``hires``, ``plots``, ``kpf_cal``,
|
|
16
|
+
``kpf_l0``, ``kpf_qlp``. To process multiple instruments, call
|
|
17
|
+
:meth:`launch` in a loop or use :meth:`launch_and_poll` with a list (it
|
|
18
|
+
iterates automatically).
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
from jump2_client import Client
|
|
23
|
+
c = Client()
|
|
24
|
+
|
|
25
|
+
# Dry-run: see what would be ingested (no DB writes)
|
|
26
|
+
preview = c.admin.ingest.preview(instrument="kpf_l0")
|
|
27
|
+
print(f"{preview['file_count']} files ready to ingest")
|
|
28
|
+
|
|
29
|
+
# Launch one instrument
|
|
30
|
+
job = c.admin.ingest.launch(instrument="kpf_l0")
|
|
31
|
+
result = c.jobs.poll(job["id"]) # wait for completion
|
|
32
|
+
print(result["status"])
|
|
33
|
+
|
|
34
|
+
# Launch multiple instruments sequentially (one job each)
|
|
35
|
+
results = c.admin.ingest.launch_and_poll(instruments=["kpf_l0", "apf"])
|
|
36
|
+
|
|
37
|
+
# Check remote node health
|
|
38
|
+
c.admin.ingest.print_node_status()
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import TYPE_CHECKING, Any
|
|
43
|
+
|
|
44
|
+
from .._pretty import print_dict, print_table
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from ..session import Session
|
|
48
|
+
|
|
49
|
+
_VALID_INSTRUMENTS = frozenset({"apf", "hires", "plots", "kpf_cal", "kpf_l0", "kpf_qlp"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AdminIngest:
|
|
53
|
+
"""
|
|
54
|
+
Ad-hoc ingest job management.
|
|
55
|
+
|
|
56
|
+
Attached to :class:`~jump2_client.admin.Admin` as ``client.admin.ingest``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, session: Session) -> None:
|
|
60
|
+
self._s = session
|
|
61
|
+
|
|
62
|
+
def preview(self, instrument: str, max_scan: int | None = None, **params: Any) -> dict[str, Any]:
|
|
63
|
+
"""
|
|
64
|
+
Dry-run manifest walk — returns what *would* be ingested without
|
|
65
|
+
creating any DB records or dispatching Celery tasks.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
instrument: Instrument category to preview. Must be one of:
|
|
69
|
+
``apf``, ``hires``, ``plots``, ``kpf_cal``, ``kpf_l0``,
|
|
70
|
+
``kpf_qlp``.
|
|
71
|
+
max_scan: Limit the number of files scanned (optional).
|
|
72
|
+
**params: Additional query parameters forwarded to the endpoint.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Preview dict with ``file_count``, ``instrument``, etc.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If *instrument* is not a recognised instrument code.
|
|
79
|
+
"""
|
|
80
|
+
if instrument not in _VALID_INSTRUMENTS:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Unknown instrument {instrument!r}. "
|
|
83
|
+
f"Valid choices: {', '.join(sorted(_VALID_INSTRUMENTS))}"
|
|
84
|
+
)
|
|
85
|
+
query: dict[str, Any] = {"instrument": instrument}
|
|
86
|
+
if max_scan is not None:
|
|
87
|
+
query["max_scan"] = max_scan
|
|
88
|
+
query.update(params)
|
|
89
|
+
return self._s.get("/jobs/ingest/preview/", params=query)
|
|
90
|
+
|
|
91
|
+
def launch(
|
|
92
|
+
self,
|
|
93
|
+
instrument: str,
|
|
94
|
+
max_files: int | None = None,
|
|
95
|
+
**extra: Any,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""
|
|
98
|
+
Launch a manual ingest coordinator job for one instrument category.
|
|
99
|
+
|
|
100
|
+
The backend creates one job per instrument; call :meth:`launch` in a
|
|
101
|
+
loop (or use :meth:`launch_and_poll` with a list) to ingest multiple
|
|
102
|
+
instruments.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
instrument: Instrument category to ingest. Must be one of:
|
|
106
|
+
``apf``, ``hires``, ``plots``, ``kpf_cal``, ``kpf_l0``,
|
|
107
|
+
``kpf_qlp``.
|
|
108
|
+
max_files: Cap the number of files processed in this run.
|
|
109
|
+
**extra: Additional payload fields forwarded verbatim.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Job creation response dict including ``id`` (use with
|
|
113
|
+
:meth:`~jump2_client.jobs.Jobs.poll`).
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ValueError: If *instrument* is not a recognised instrument code.
|
|
117
|
+
"""
|
|
118
|
+
if instrument not in _VALID_INSTRUMENTS:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Unknown instrument {instrument!r}. "
|
|
121
|
+
f"Valid choices: {', '.join(sorted(_VALID_INSTRUMENTS))}"
|
|
122
|
+
)
|
|
123
|
+
payload: dict[str, Any] = {"instrument": instrument}
|
|
124
|
+
if max_files is not None:
|
|
125
|
+
payload["max_files"] = max_files
|
|
126
|
+
payload.update(extra)
|
|
127
|
+
return self._s.post("/jobs/ingest/", json=payload)
|
|
128
|
+
|
|
129
|
+
def node_status(self) -> list[dict[str, Any]]:
|
|
130
|
+
"""
|
|
131
|
+
Return remote ingest-node health status.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of node dicts, each with keys ``node``, ``reachable``,
|
|
135
|
+
``latency_ms``, ``http_status``, ``error``.
|
|
136
|
+
"""
|
|
137
|
+
resp = self._s.get("/jobs/node-status/")
|
|
138
|
+
raw: list[dict[str, Any]] = resp if isinstance(resp, list) else resp.get("nodes", [])
|
|
139
|
+
return raw
|
|
140
|
+
|
|
141
|
+
def print_node_status(self) -> None:
|
|
142
|
+
"""Print remote node status as a rich table."""
|
|
143
|
+
nodes = self.node_status()
|
|
144
|
+
if not nodes:
|
|
145
|
+
print_dict("Node Status", {})
|
|
146
|
+
return
|
|
147
|
+
rows = [
|
|
148
|
+
[
|
|
149
|
+
str(n.get("node", "")),
|
|
150
|
+
"✓" if n.get("reachable") else "✗",
|
|
151
|
+
str(n.get("latency_ms", "")),
|
|
152
|
+
str(n.get("http_status", "")),
|
|
153
|
+
str(n.get("error", "") or ""),
|
|
154
|
+
]
|
|
155
|
+
for n in nodes
|
|
156
|
+
]
|
|
157
|
+
print_table(
|
|
158
|
+
"Remote Node Status",
|
|
159
|
+
["Node", "Reachable", "Latency (ms)", "HTTP", "Error"],
|
|
160
|
+
rows,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def launch_and_poll(
|
|
164
|
+
self,
|
|
165
|
+
instruments: list[str],
|
|
166
|
+
max_files: int | None = None,
|
|
167
|
+
interval: float = 5.0,
|
|
168
|
+
timeout: float = 3600.0,
|
|
169
|
+
**extra: Any,
|
|
170
|
+
) -> list[dict[str, Any]]:
|
|
171
|
+
"""
|
|
172
|
+
Launch ingest jobs for multiple instruments and block until all complete.
|
|
173
|
+
|
|
174
|
+
Iterates over *instruments*, calling :meth:`launch` for each and then
|
|
175
|
+
waiting for completion. Jobs run sequentially.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
instruments: List of instrument codes (e.g. ``["kpf_l0", "apf"]``).
|
|
179
|
+
max_files: Cap applied to every job in the list.
|
|
180
|
+
interval: Polling interval in seconds (default 5).
|
|
181
|
+
timeout: Maximum wait per job in seconds (default 3600).
|
|
182
|
+
**extra: Forwarded to every :meth:`launch` call.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of final job detail dicts (one per instrument, in order).
|
|
186
|
+
"""
|
|
187
|
+
from ..jobs import Jobs
|
|
188
|
+
results = []
|
|
189
|
+
for instrument in instruments:
|
|
190
|
+
job = self.launch(instrument=instrument, max_files=max_files, **extra)
|
|
191
|
+
final = Jobs(self._s).poll(job["id"], interval=interval, timeout=timeout)
|
|
192
|
+
results.append(final)
|
|
193
|
+
return results
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jump2_client.admin.schedules — Celery-Beat ingest schedule management.
|
|
3
|
+
|
|
4
|
+
Wraps ``/api/v1/ingest-schedules/`` CRUD endpoints.
|
|
5
|
+
Requires the ``manage_schedules`` permission token.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from jump2_client import Client
|
|
10
|
+
c = Client()
|
|
11
|
+
|
|
12
|
+
# List existing schedules
|
|
13
|
+
for s in c.admin.schedules.list():
|
|
14
|
+
print(s["instrument"], s.get("cron") or s.get("interval"), s["enabled"])
|
|
15
|
+
|
|
16
|
+
# Create a new cron schedule (daily at midnight UTC)
|
|
17
|
+
sched = c.admin.schedules.create(
|
|
18
|
+
instrument="apf",
|
|
19
|
+
cron="0 0 * * *",
|
|
20
|
+
max_files=500,
|
|
21
|
+
cooldown_minutes=60,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Disable a schedule
|
|
25
|
+
c.admin.schedules.update(sched["id"], enabled=False)
|
|
26
|
+
|
|
27
|
+
# Delete a schedule
|
|
28
|
+
c.admin.schedules.delete(sched["id"])
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Iterator
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
from .._pretty import print_table
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from ..session import Session
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AdminSchedules:
|
|
42
|
+
"""
|
|
43
|
+
Ingest schedule management (Celery Beat).
|
|
44
|
+
|
|
45
|
+
Attached to :class:`~jump2_client.admin.Admin` as ``client.admin.schedules``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, session: Session) -> None:
|
|
49
|
+
self._s = session
|
|
50
|
+
|
|
51
|
+
def list(self) -> Iterator[dict[str, Any]]:
|
|
52
|
+
"""
|
|
53
|
+
Iterate over all ingest schedules.
|
|
54
|
+
|
|
55
|
+
Yields:
|
|
56
|
+
Schedule dicts with ``id``, ``instrument``, ``cron`` or
|
|
57
|
+
``interval``, ``enabled``, ``cooldown_minutes``, ``max_files``,
|
|
58
|
+
``last_run_at``.
|
|
59
|
+
"""
|
|
60
|
+
yield from self._s.paginate("/ingest-schedules/")
|
|
61
|
+
|
|
62
|
+
def get(self, schedule_id: int) -> dict[str, Any]:
|
|
63
|
+
"""
|
|
64
|
+
Return a single schedule by ID.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
schedule_id: Integer schedule primary key.
|
|
68
|
+
"""
|
|
69
|
+
return self._s.get(f"/ingest-schedules/{schedule_id}/")
|
|
70
|
+
|
|
71
|
+
def create(
|
|
72
|
+
self,
|
|
73
|
+
instrument: str,
|
|
74
|
+
cron: str | None = None,
|
|
75
|
+
interval: int | None = None,
|
|
76
|
+
max_files: int | None = None,
|
|
77
|
+
cooldown_minutes: int = 30,
|
|
78
|
+
enabled: bool = True,
|
|
79
|
+
**extra: Any,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""
|
|
82
|
+
Create a new ingest schedule.
|
|
83
|
+
|
|
84
|
+
Exactly one of *cron* or *interval* must be supplied.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
instrument: Instrument code (e.g. ``"kpf_l0"``).
|
|
88
|
+
cron: 5-field cron expression (e.g. ``"0 * * * *"``
|
|
89
|
+
for hourly).
|
|
90
|
+
interval: Interval in minutes (mutually exclusive with
|
|
91
|
+
*cron*).
|
|
92
|
+
max_files: Maximum files to process per run (0 = unlimited).
|
|
93
|
+
cooldown_minutes: Minimum minutes between consecutive runs.
|
|
94
|
+
enabled: Whether the schedule is active immediately.
|
|
95
|
+
**extra: Additional payload fields.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Created schedule dict.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
:exc:`~jump2_client.errors.ValidationError`: If both or neither of
|
|
102
|
+
*cron*/*interval* are supplied, or the instrument is unknown.
|
|
103
|
+
"""
|
|
104
|
+
if (cron is None) == (interval is None):
|
|
105
|
+
from ..errors import ValidationError
|
|
106
|
+
raise ValidationError(
|
|
107
|
+
"Exactly one of 'cron' or 'interval' must be supplied."
|
|
108
|
+
)
|
|
109
|
+
payload: dict[str, Any] = {
|
|
110
|
+
"instrument": instrument,
|
|
111
|
+
"cooldown_minutes": cooldown_minutes,
|
|
112
|
+
"enabled": enabled,
|
|
113
|
+
**extra,
|
|
114
|
+
}
|
|
115
|
+
if cron is not None:
|
|
116
|
+
payload["cron"] = cron
|
|
117
|
+
if interval is not None:
|
|
118
|
+
payload["interval"] = interval
|
|
119
|
+
if max_files is not None:
|
|
120
|
+
payload["max_files"] = max_files
|
|
121
|
+
return self._s.post("/ingest-schedules/", json=payload)
|
|
122
|
+
|
|
123
|
+
def update(self, schedule_id: int, **fields: Any) -> dict[str, Any]:
|
|
124
|
+
"""
|
|
125
|
+
Partially update a schedule.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
schedule_id: Integer schedule primary key.
|
|
129
|
+
**fields: Fields to update (e.g. ``enabled=False``,
|
|
130
|
+
``cron="0 */6 * * *"``).
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Updated schedule dict.
|
|
134
|
+
"""
|
|
135
|
+
return self._s.patch(f"/ingest-schedules/{schedule_id}/", json=fields)
|
|
136
|
+
|
|
137
|
+
def delete(self, schedule_id: int) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Delete a schedule by ID.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
schedule_id: Integer schedule primary key.
|
|
143
|
+
"""
|
|
144
|
+
self._s.delete(f"/ingest-schedules/{schedule_id}/")
|
|
145
|
+
|
|
146
|
+
def print_list(self) -> None:
|
|
147
|
+
"""Print all schedules as a rich table to stdout."""
|
|
148
|
+
rows = []
|
|
149
|
+
for s in self.list():
|
|
150
|
+
schedule_val = s.get("cron") or f"{s.get('interval')}m"
|
|
151
|
+
rows.append([
|
|
152
|
+
str(s.get("id", "")),
|
|
153
|
+
s.get("instrument", ""),
|
|
154
|
+
schedule_val or "—",
|
|
155
|
+
"✓" if s.get("enabled") else "✗",
|
|
156
|
+
str(s.get("cooldown_minutes", "")),
|
|
157
|
+
str(s.get("max_files", "")),
|
|
158
|
+
(s.get("last_run_at") or "")[:19],
|
|
159
|
+
])
|
|
160
|
+
print_table(
|
|
161
|
+
"Ingest Schedules",
|
|
162
|
+
["ID", "Instrument", "Schedule", "Enabled", "Cooldown (min)", "Max files", "Last run"],
|
|
163
|
+
rows,
|
|
164
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jump2_client.admin.users — admin user and resource-usage views.
|
|
3
|
+
|
|
4
|
+
Wraps the IAM admin endpoints:
|
|
5
|
+
``GET /api/v1/users/`` — paginated user list
|
|
6
|
+
``GET /api/v1/users/<id>/`` — single user detail (with resource limits)
|
|
7
|
+
``GET /api/v1/me/`` — own profile (for display)
|
|
8
|
+
|
|
9
|
+
Requires the ``manage_users`` permission token for the user-list endpoint.
|
|
10
|
+
|
|
11
|
+
Example::
|
|
12
|
+
|
|
13
|
+
from jump2_client import Client
|
|
14
|
+
c = Client()
|
|
15
|
+
|
|
16
|
+
for user in c.admin.users.list():
|
|
17
|
+
u = user
|
|
18
|
+
resources = u.get("resources", {})
|
|
19
|
+
print(u["username"], resources.get("storage_used_gb", 0), "GB used")
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from .._pretty import format_bytes, print_table
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ..session import Session
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AdminUsers:
|
|
33
|
+
"""
|
|
34
|
+
Admin user management.
|
|
35
|
+
|
|
36
|
+
Attached to :class:`~jump2_client.admin.Admin` as ``client.admin.users``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, session: Session) -> None:
|
|
40
|
+
self._s = session
|
|
41
|
+
|
|
42
|
+
def list(self, **params: Any) -> Iterator[dict[str, Any]]:
|
|
43
|
+
"""
|
|
44
|
+
Iterate over all users (admin view).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
**params: Query parameters for ``GET /api/v1/users/``.
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
User dicts including ``username``, ``email``, ``resources`` block.
|
|
51
|
+
"""
|
|
52
|
+
yield from self._s.paginate("/users/", params=params)
|
|
53
|
+
|
|
54
|
+
def get(self, user_id: int) -> dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Return the admin detail dict for a single user.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
user_id: Integer user primary key.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
User detail dict.
|
|
63
|
+
"""
|
|
64
|
+
return self._s.get(f"/users/{user_id}/")
|
|
65
|
+
|
|
66
|
+
def print_usage(self, limit: int = 50) -> None:
|
|
67
|
+
"""Print a resource-usage summary table for all users."""
|
|
68
|
+
rows = []
|
|
69
|
+
for i, user in enumerate(self.list()):
|
|
70
|
+
if i >= limit:
|
|
71
|
+
break
|
|
72
|
+
r = user.get("resources", {})
|
|
73
|
+
nb = user.get("notebook", {})
|
|
74
|
+
used = nb.get("used_bytes", 0)
|
|
75
|
+
quota = nb.get("quota_bytes", 0)
|
|
76
|
+
storage = f"{format_bytes(used)} / {format_bytes(quota)}" if quota else "n/a"
|
|
77
|
+
rows.append([
|
|
78
|
+
user.get("username", ""),
|
|
79
|
+
storage,
|
|
80
|
+
f"{r.get('cpu_limit', '?')} CPU",
|
|
81
|
+
str(r.get("mem_limit", "?")),
|
|
82
|
+
"✓" if user.get("is_staff") else "",
|
|
83
|
+
])
|
|
84
|
+
print_table(
|
|
85
|
+
"User Resource Usage",
|
|
86
|
+
["Username", "Storage", "CPU Limit", "Mem Limit", "Staff"],
|
|
87
|
+
rows,
|
|
88
|
+
)
|