gagarin 0.1.1__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.
Binary file
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
gagarin-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: gagarin
3
+ Version: 0.1.1
4
+ Summary: Python client for the gagarin API: run jobs from images already in the gagarin registry.
5
+ Project-URL: Repository, https://github.com/gagarin-cloud/sdk
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # gagarin — Python
11
+
12
+ ```sh
13
+ pip install gagarin
14
+ ```
15
+
16
+ Python 3.9+, standard library only.
17
+
18
+ ```python
19
+ from gagarin import Gagarin, GagarinError
20
+
21
+ gg = Gagarin() # GAGARIN_TOKEN, GAGARIN_API; or Gagarin(token=...)
22
+
23
+ # What `gg run` does: submit, wait, exit with the script's code.
24
+ res = gg.jobs.run_and_wait(
25
+ "shop", "migrate",
26
+ image="migrate:v3", # or registry.gagarin.cloud/<project-id>/migrate:v3
27
+ env={"LOG_LEVEL": "debug"},
28
+ deps=["db"],
29
+ size="m",
30
+ timeout_seconds=900,
31
+ on_update=lambda run: print(run.phase),
32
+ )
33
+ raise SystemExit(res.exit_code)
34
+ ```
35
+
36
+ Fire and forget, then check later:
37
+
38
+ ```python
39
+ sub = gg.jobs.run("shop", "backfill", image="backfill:v1")
40
+ run = gg.jobs.wait("shop", "backfill", sub.revision)
41
+ print(gg.jobs.logs("shop", "backfill", since="30m")["logs"])
42
+ ```
43
+
44
+ A scheduled job (runs nothing now):
45
+
46
+ ```python
47
+ gg.jobs.run("shop", "nightly", image="report:v2", schedule="0 3 * * *", time_zone="Europe/Berlin")
48
+ ```
49
+
50
+ The basics (answers are plain dicts, typed with `TypedDict`):
51
+
52
+ ```python
53
+ me = gg.account.whoami() # me["account"], me["can"], me["registry"]
54
+ bill = gg.account.billing() # bill["balance"], bill["runway_hours"]
55
+ projects = gg.projects.list()
56
+ p = gg.projects.create("shop") # raises project_exists if you have one
57
+ st = gg.projects.status("shop") # st["services"], st["notices"], st["usage_today"]
58
+ web = gg.projects.service_status("shop", "web")
59
+ logs = gg.services.logs("shop", "web", since="1h", contains="error")
60
+ history = gg.services.deployments("shop", "web")
61
+ health = gg.platform.health() # an unhealthy platform is an answer, not an exception
62
+ ```
63
+
64
+ API refusals raise `GagarinError` with `status`, `code`, `message` and `fix_hint`.
65
+
66
+ ```sh
67
+ PYTHONPATH=src python -m unittest discover -s tests
68
+ ```
@@ -0,0 +1,59 @@
1
+ # gagarin — Python
2
+
3
+ ```sh
4
+ pip install gagarin
5
+ ```
6
+
7
+ Python 3.9+, standard library only.
8
+
9
+ ```python
10
+ from gagarin import Gagarin, GagarinError
11
+
12
+ gg = Gagarin() # GAGARIN_TOKEN, GAGARIN_API; or Gagarin(token=...)
13
+
14
+ # What `gg run` does: submit, wait, exit with the script's code.
15
+ res = gg.jobs.run_and_wait(
16
+ "shop", "migrate",
17
+ image="migrate:v3", # or registry.gagarin.cloud/<project-id>/migrate:v3
18
+ env={"LOG_LEVEL": "debug"},
19
+ deps=["db"],
20
+ size="m",
21
+ timeout_seconds=900,
22
+ on_update=lambda run: print(run.phase),
23
+ )
24
+ raise SystemExit(res.exit_code)
25
+ ```
26
+
27
+ Fire and forget, then check later:
28
+
29
+ ```python
30
+ sub = gg.jobs.run("shop", "backfill", image="backfill:v1")
31
+ run = gg.jobs.wait("shop", "backfill", sub.revision)
32
+ print(gg.jobs.logs("shop", "backfill", since="30m")["logs"])
33
+ ```
34
+
35
+ A scheduled job (runs nothing now):
36
+
37
+ ```python
38
+ gg.jobs.run("shop", "nightly", image="report:v2", schedule="0 3 * * *", time_zone="Europe/Berlin")
39
+ ```
40
+
41
+ The basics (answers are plain dicts, typed with `TypedDict`):
42
+
43
+ ```python
44
+ me = gg.account.whoami() # me["account"], me["can"], me["registry"]
45
+ bill = gg.account.billing() # bill["balance"], bill["runway_hours"]
46
+ projects = gg.projects.list()
47
+ p = gg.projects.create("shop") # raises project_exists if you have one
48
+ st = gg.projects.status("shop") # st["services"], st["notices"], st["usage_today"]
49
+ web = gg.projects.service_status("shop", "web")
50
+ logs = gg.services.logs("shop", "web", since="1h", contains="error")
51
+ history = gg.services.deployments("shop", "web")
52
+ health = gg.platform.health() # an unhealthy platform is an answer, not an exception
53
+ ```
54
+
55
+ API refusals raise `GagarinError` with `status`, `code`, `message` and `fix_hint`.
56
+
57
+ ```sh
58
+ PYTHONPATH=src python -m unittest discover -s tests
59
+ ```
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gagarin"
7
+ version = "0.1.1"
8
+ description = "Python client for the gagarin API: run jobs from images already in the gagarin registry."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/gagarin-cloud/sdk"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/gagarin"]
@@ -0,0 +1,58 @@
1
+ """A client for the gagarin API.
2
+
3
+ Its first job is the one ``gg run`` does: start a job from an image that is
4
+ already in gagarin's registry, and optionally wait for it to finish.
5
+
6
+ from gagarin import Gagarin
7
+
8
+ gg = Gagarin() # GAGARIN_TOKEN, GAGARIN_API
9
+ res = gg.jobs.run_and_wait("shop", "migrate", image="migrate:v3", deps=["db"])
10
+ raise SystemExit(res.exit_code)
11
+
12
+ Around it are the basics: ``account``, ``projects`` (with their status),
13
+ ``services`` (logs and history) and ``platform`` (gagarin's own health).
14
+ """
15
+
16
+ from .account import Account
17
+ from .client import DEFAULT_BASE_URL, DEFAULT_REGISTRY, Gagarin, Transport
18
+ from .errors import GagarinError, SupersededError, SuspendedError, WaitTimeout
19
+ from .jobs import (
20
+ DONE,
21
+ FAILED,
22
+ MAX_TIMEOUT_SECONDS,
23
+ PENDING,
24
+ RUNNING,
25
+ SUSPENDED,
26
+ Jobs,
27
+ RunResult,
28
+ RunState,
29
+ Submission,
30
+ )
31
+ from .platform import Platform
32
+ from .projects import Projects
33
+ from .services import Services
34
+
35
+ __all__ = [
36
+ "DEFAULT_BASE_URL",
37
+ "DEFAULT_REGISTRY",
38
+ "DONE",
39
+ "FAILED",
40
+ "MAX_TIMEOUT_SECONDS",
41
+ "PENDING",
42
+ "RUNNING",
43
+ "SUSPENDED",
44
+ "Account",
45
+ "Gagarin",
46
+ "GagarinError",
47
+ "Jobs",
48
+ "Platform",
49
+ "Projects",
50
+ "RunResult",
51
+ "RunState",
52
+ "Services",
53
+ "Submission",
54
+ "SupersededError",
55
+ "SuspendedError",
56
+ "Transport",
57
+ "WaitTimeout",
58
+ ]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .types import Billing, Whoami
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from .client import Gagarin
9
+
10
+
11
+ class Account:
12
+ """The account the credential belongs to."""
13
+
14
+ def __init__(self, client: Gagarin):
15
+ self._c = client
16
+
17
+ def whoami(self) -> Whoami:
18
+ """Which account this credential acts as, and what it may do
19
+ (``can`` holds ``"deploy"`` and/or ``"destroy"``)."""
20
+ return self._c.request("GET", "/v1/whoami")
21
+
22
+ def billing(self) -> Billing:
23
+ """The account's balance, burn and runway, in micro-USD."""
24
+ return self._c.request("GET", "/v1/billing")
@@ -0,0 +1,133 @@
1
+ """The client, and the one place requests are made."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any, Callable, Mapping, Optional, Tuple
11
+
12
+ from .errors import api_error
13
+
14
+ DEFAULT_BASE_URL = "https://api.gagarin.cloud"
15
+ DEFAULT_REGISTRY = "registry.gagarin.cloud"
16
+
17
+ #: (method, url, headers, body) -> (status, body). Replaceable to use a
18
+ #: different HTTP stack.
19
+ Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, bytes]]
20
+
21
+
22
+ def _urllib_transport(timeout: float) -> Transport:
23
+ def send(method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> tuple[int, bytes]:
24
+ req = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
25
+ try:
26
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
27
+ return resp.status, resp.read()
28
+ except urllib.error.HTTPError as e:
29
+ return e.code, e.read()
30
+
31
+ return send
32
+
33
+
34
+ def quote(s: str) -> str:
35
+ return urllib.parse.quote(s, safe="")
36
+
37
+
38
+ def project_path(project: str) -> str:
39
+ return f"/v1/projects/{quote(project)}"
40
+
41
+
42
+ def service_path(project: str, name: str) -> str:
43
+ return f"{project_path(project)}/services/{quote(name)}"
44
+
45
+
46
+ class Gagarin:
47
+ """A client for the gagarin API.
48
+
49
+ - ``jobs``: submit, wait on and read jobs — what ``gg run`` does.
50
+ - ``account``: who the credential is, and the account's balance.
51
+ - ``projects``: list, create and read projects, and their status.
52
+ - ``services``: any service's logs and deploy history.
53
+ - ``platform``: whether gagarin itself is working.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ token: str | None = None,
59
+ base_url: str | None = None,
60
+ registry: str | None = None,
61
+ *,
62
+ timeout: float = 120.0,
63
+ transport: Transport | None = None,
64
+ ):
65
+ """``token`` defaults to ``$GAGARIN_TOKEN``, ``base_url`` to ``$GAGARIN_API``
66
+ and ``registry`` to ``$GAGARIN_REGISTRY``."""
67
+ from .account import Account
68
+ from .jobs import Jobs
69
+ from .platform import Platform
70
+ from .projects import Projects
71
+ from .services import Services
72
+
73
+ token = token or os.environ.get("GAGARIN_TOKEN")
74
+ if not token:
75
+ raise ValueError("gagarin: no token: pass token= or set GAGARIN_TOKEN")
76
+ self._token = token
77
+ self._base_url = (base_url or os.environ.get("GAGARIN_API") or DEFAULT_BASE_URL).rstrip("/")
78
+ self._registry = (registry or os.environ.get("GAGARIN_REGISTRY") or DEFAULT_REGISTRY).rstrip("/")
79
+ self._send = transport or _urllib_transport(timeout)
80
+ self.jobs = Jobs(self)
81
+ self.account = Account(self)
82
+ self.projects = Projects(self)
83
+ self.services = Services(self)
84
+ self.platform = Platform(self)
85
+
86
+ def send(
87
+ self,
88
+ method: str,
89
+ path: str,
90
+ *,
91
+ query: Mapping[str, Any] | None = None,
92
+ body: Any = None,
93
+ ) -> tuple[int, bytes]:
94
+ """Makes one request and hands back the status and body, whatever the status was."""
95
+ url = self._base_url + path
96
+ q = {}
97
+ for k, v in (query or {}).items():
98
+ if v is None or v == "" or v is False:
99
+ continue
100
+ q[k] = "true" if v is True else str(v)
101
+ if q:
102
+ url += "?" + urllib.parse.urlencode(q)
103
+ headers = {
104
+ "Authorization": f"Bearer {self._token}",
105
+ "Accept": "application/json",
106
+ "User-Agent": "gagarin-sdk-python",
107
+ }
108
+ data = None
109
+ if body is not None:
110
+ headers["Content-Type"] = "application/json"
111
+ data = json.dumps(body).encode()
112
+ return self._send(method, url, headers, data)
113
+
114
+ def request(
115
+ self,
116
+ method: str,
117
+ path: str,
118
+ *,
119
+ query: Mapping[str, Any] | None = None,
120
+ body: Any = None,
121
+ ) -> Any:
122
+ """Makes one request, raises :class:`GagarinError` on a refusal, and parses the answer."""
123
+ status, raw = self.send(method, path, query=query, body=body)
124
+ if not 200 <= status < 300:
125
+ raise api_error(status, raw)
126
+ return json.loads(raw) if raw else None
127
+
128
+ def image_ref(self, project: str, image: str) -> str:
129
+ """Expands a bare ``migrate:v3`` into the full reference gagarin runs from.
130
+ A reference that already contains a ``/`` is returned unchanged."""
131
+ if "/" in image:
132
+ return image
133
+ return f"{self._registry}/{self.projects.get(project)['id']}/{image}"
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+
6
+ class GagarinError(Exception):
7
+ """What the API answers when it refuses a request. Branch on ``code``."""
8
+
9
+ def __init__(self, status: int, code: str, message: str, fix_hint: str | None = None):
10
+ self.status = status
11
+ self.code = code
12
+ self.message = message
13
+ self.fix_hint = fix_hint
14
+ text = f"[{code}] {message}"
15
+ if fix_hint:
16
+ text += f"\nhint: {fix_hint}"
17
+ super().__init__(text)
18
+
19
+
20
+ class SupersededError(GagarinError):
21
+ """A newer run of the job started before the awaited one was seen to finish."""
22
+
23
+
24
+ class SuspendedError(GagarinError):
25
+ """The project was suspended, which stops its runs."""
26
+
27
+
28
+ class WaitTimeout(GagarinError):
29
+ """The run had not finished when the wait gave up."""
30
+
31
+
32
+ def api_error(status: int, raw: bytes) -> GagarinError:
33
+ """The error for a refusal, from the API's envelope when there is one."""
34
+ err = None
35
+ try:
36
+ err = json.loads(raw).get("error")
37
+ except (ValueError, AttributeError):
38
+ pass
39
+ if isinstance(err, dict) and err.get("code"):
40
+ return GagarinError(status, err["code"], err.get("message", ""), err.get("fix_hint"))
41
+ return GagarinError(status, f"http_{status}", raw.decode(errors="replace").strip())
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
6
+
7
+ from .client import service_path
8
+ from .errors import GagarinError, SupersededError, SuspendedError, WaitTimeout
9
+ from .types import Logs, ServiceStatus
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from .client import Gagarin
13
+
14
+ #: The longest a run may take, and what a new job gets.
15
+ MAX_TIMEOUT_SECONDS = 3600
16
+
17
+ PENDING, RUNNING, DONE, FAILED, SUSPENDED = "pending", "running", "done", "failed", "suspended"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RunState:
22
+ """What a run did."""
23
+
24
+ revision: int
25
+ phase: str
26
+ exit_code: int | None = None
27
+ started_at: str | None = None
28
+ finished_at: str | None = None
29
+
30
+ @property
31
+ def finished(self) -> bool:
32
+ return self.phase in (DONE, FAILED)
33
+
34
+ @property
35
+ def succeeded(self) -> bool:
36
+ return self.phase == DONE and (self.exit_code is None or self.exit_code == 0)
37
+
38
+ @classmethod
39
+ def from_json(cls, d: Mapping[str, Any]) -> RunState:
40
+ return cls(
41
+ revision=d["revision"],
42
+ phase=d["phase"],
43
+ exit_code=d.get("exit_code"),
44
+ started_at=d.get("started_at"),
45
+ finished_at=d.get("finished_at"),
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Submission:
51
+ """The job as stored, and the revision this run is — the run's name."""
52
+
53
+ project: str
54
+ name: str
55
+ revision: int
56
+ image: str
57
+ kind: str = "job"
58
+ size: str | None = None
59
+ digest: str | None = None
60
+ timeout_seconds: int | None = None
61
+ schedule: str | None = None
62
+ time_zone: str | None = None
63
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
64
+
65
+ @classmethod
66
+ def from_json(cls, d: Mapping[str, Any]) -> Submission:
67
+ return cls(
68
+ project=d["project"],
69
+ name=d["name"],
70
+ revision=d["revision"],
71
+ image=d["image"],
72
+ kind=d.get("kind", "job"),
73
+ size=d.get("size"),
74
+ digest=d.get("digest"),
75
+ timeout_seconds=d.get("timeout_seconds"),
76
+ schedule=d.get("schedule"),
77
+ time_zone=d.get("time_zone"),
78
+ raw=d,
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class RunResult:
84
+ """A submission and how its run ended."""
85
+
86
+ submission: Submission
87
+ run: RunState
88
+
89
+ @property
90
+ def exit_code(self) -> int:
91
+ """The script's own exit code, 0 on success, 1 for a run that failed
92
+ without one — what ``gg run`` exits with."""
93
+ if self.run.exit_code is not None:
94
+ return self.run.exit_code
95
+ return 0 if self.run.phase == DONE else 1
96
+
97
+
98
+ def _retryable(e: Exception) -> bool:
99
+ if isinstance(e, GagarinError):
100
+ return e.status >= 500 or e.status == 429
101
+ return isinstance(e, OSError)
102
+
103
+
104
+ class Jobs:
105
+ """Jobs: images that run to completion and listen on nothing."""
106
+
107
+ def __init__(self, client: Gagarin):
108
+ self._c = client
109
+
110
+ def run(
111
+ self,
112
+ project: str,
113
+ name: str,
114
+ *,
115
+ image: str,
116
+ digest: str | None = None,
117
+ env: Mapping[str, str] | None = None,
118
+ deps: Sequence[str] | None = None,
119
+ size: str | None = None,
120
+ timeout_seconds: int | None = None,
121
+ schedule: str | None = None,
122
+ time_zone: str | None = None,
123
+ ) -> Submission:
124
+ """Submits a run of the named job and returns without waiting.
125
+
126
+ As with ``gg run``, the image must already be in gagarin's registry: a
127
+ full reference, or a bare ``migrate:v3`` expanded against the project.
128
+ Every call is a new run and a new revision.
129
+
130
+ - ``digest``: ``sha256:...`` as docker push reported it, pinning the image.
131
+ - ``env``: the complete environment for this run. Replaces the job's;
132
+ not merged, and ``None`` empties it.
133
+ - ``deps``: resources and services this run may reach, added to what the
134
+ job already declares. A migration must name its database here.
135
+ - ``size``: ``"s"``, ``"m"`` or ``"l"``. ``None`` keeps the current size.
136
+ - ``timeout_seconds``: 1–3600, counted from submission. ``None`` keeps it.
137
+ - ``schedule``: five cron fields or ``@daily``-style. Nothing runs now;
138
+ each firing is a run. Cannot be removed except by destroying the job.
139
+ - ``time_zone``: IANA zone the schedule is read in. ``None`` keeps it.
140
+ """
141
+ if not image:
142
+ raise ValueError("gagarin: image is required")
143
+ # The environment is replaced on every run, so it is always sent.
144
+ body: dict[str, Any] = {"kind": "job", "image": self._c.image_ref(project, image), "env": dict(env or {})}
145
+ if digest:
146
+ body["digest"] = digest
147
+ if deps:
148
+ body["deps"] = list(deps)
149
+ if size:
150
+ body["size"] = size
151
+ if timeout_seconds:
152
+ body["timeout_seconds"] = timeout_seconds
153
+ if schedule:
154
+ body["schedule"] = schedule
155
+ if time_zone:
156
+ body["time_zone"] = time_zone
157
+ return Submission.from_json(self._c.request("PUT", service_path(project, name), body=body))
158
+
159
+ def get(self, project: str, name: str) -> ServiceStatus:
160
+ """The job's row from the project's status, with how its latest run went
161
+ in ``actual.run``. A name that is not a job is refused ``not_a_job``."""
162
+ row = self._c.projects.service_status(project, name)
163
+ if row.get("kind") != "job":
164
+ raise GagarinError(409, "not_a_job", f"{name} is a {row.get('kind')} in this project, not a job")
165
+ return row
166
+
167
+ def wait(
168
+ self,
169
+ project: str,
170
+ name: str,
171
+ revision: int,
172
+ *,
173
+ poll_interval: float = 3.0,
174
+ timeout: float = 65 * 60,
175
+ on_update: Callable[[RunState], None] | None = None,
176
+ ) -> RunState:
177
+ """Blocks until the given revision of the job has finished, and returns
178
+ how it ended. A failed run is not an exception: read ``phase`` and
179
+ ``exit_code``. The default timeout outlasts the platform's 60 minutes.
180
+
181
+ Raises :class:`SupersededError` if a newer run starts first,
182
+ :class:`SuspendedError` if the project is suspended, and
183
+ :class:`WaitTimeout` when ``timeout`` passes."""
184
+ deadline = time.monotonic() + timeout
185
+ last_phase, transient = None, 0
186
+ while True:
187
+ d = None
188
+ try:
189
+ d = (self.get(project, name).get("actual") or {}).get("run")
190
+ transient = 0
191
+ except Exception as e:
192
+ if not _retryable(e) or transient >= 5:
193
+ raise
194
+ transient += 1
195
+ if d:
196
+ run = RunState.from_json(d)
197
+ if run.revision > revision:
198
+ raise SupersededError(
199
+ 409, "superseded", f"run {revision} of {name} was superseded by run {run.revision}"
200
+ )
201
+ if run.revision == revision:
202
+ if run.phase != last_phase and on_update:
203
+ on_update(run)
204
+ last_phase = run.phase
205
+ if run.finished:
206
+ return run
207
+ if run.phase == SUSPENDED:
208
+ raise SuspendedError(
209
+ 409, "suspended", f"the project is suspended and run {revision} of {name} was stopped"
210
+ )
211
+ if time.monotonic() + poll_interval > deadline:
212
+ raise WaitTimeout(408, "wait_timeout", f"run {revision} of {name} had not finished in time")
213
+ time.sleep(poll_interval)
214
+
215
+ def run_and_wait(
216
+ self,
217
+ project: str,
218
+ name: str,
219
+ *,
220
+ poll_interval: float = 3.0,
221
+ timeout: float = 65 * 60,
222
+ on_update: Callable[[RunState], None] | None = None,
223
+ **run_options: Any,
224
+ ) -> RunResult:
225
+ """Submits a run and waits for it to finish — what ``gg run`` does.
226
+ Takes the settings :meth:`run` takes, except ``schedule``."""
227
+ if run_options.get("schedule"):
228
+ raise ValueError("gagarin: a scheduled job does not run now; submit it with run()")
229
+ sub = self.run(project, name, **run_options)
230
+ run = self.wait(project, name, sub.revision, poll_interval=poll_interval, timeout=timeout, on_update=on_update)
231
+ return RunResult(submission=sub, run=run)
232
+
233
+ def logs(self, project: str, name: str, **options: Any) -> Logs:
234
+ """The job's logs; takes the filters :meth:`Services.logs` takes."""
235
+ return self._c.services.logs(project, name, **options)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .errors import api_error
7
+ from .types import Health
8
+
9
+ if TYPE_CHECKING: # pragma: no cover
10
+ from .client import Gagarin
11
+
12
+
13
+ class Platform:
14
+ """Whether gagarin itself is working."""
15
+
16
+ def __init__(self, client: Gagarin):
17
+ self._c = client
18
+
19
+ def health(self) -> Health:
20
+ """The platform's health. An unhealthy platform is an answer, not an
21
+ exception: read ``ok`` and each check's ``detail``. An exception means
22
+ the API could not be asked at all."""
23
+ status, raw = self._c.send("GET", "/healthz/platform")
24
+ if status in (200, 503):
25
+ try:
26
+ return json.loads(raw)
27
+ except ValueError:
28
+ pass
29
+ raise api_error(status, raw)