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.
@@ -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
+ ]
@@ -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
+ )