dynos-client 0.1.2__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,17 @@
1
+ """dynos-client: distributable Python client for DYNOS.
2
+
3
+ Infrastructure package: HTTP client, mission builder, CLI entry point.
4
+ No domain vocabulary (that lives in per-platform packages like dynos-sentry-domain).
5
+ """
6
+
7
+ from dynos_client.remote_orchestrator import RemoteAuthExpired, RemoteOrchestrator
8
+ from dynos_client.mission import Mission, AnyActionBlock, ActionBlock, PlanBlock
9
+
10
+ __all__ = [
11
+ "RemoteAuthExpired",
12
+ "RemoteOrchestrator",
13
+ "Mission",
14
+ "AnyActionBlock",
15
+ "ActionBlock",
16
+ "PlanBlock",
17
+ ]
@@ -0,0 +1,4 @@
1
+ """Allow ``python -m dynos_client`` to run the CLI."""
2
+ from dynos_client.cli import main
3
+
4
+ main()
dynos_client/action.py ADDED
@@ -0,0 +1,70 @@
1
+ """Client-side @Action and @Script decorators.
2
+
3
+ Lightweight fallbacks that store transition metadata on decorated
4
+ methods so ``dynos connect`` can discover transitions without the
5
+ backend. When the backend IS installed, its @Action takes precedence.
6
+ """
7
+
8
+ import inspect
9
+
10
+
11
+ def Action(transition_or_func=None, *, transition=None, **_):
12
+ """Decorate a method as an action bound to a transition."""
13
+ if transition_or_func is None or not callable(transition_or_func):
14
+ t = transition_or_func or transition
15
+
16
+ def decorator(func):
17
+ func._client_transition_name = getattr(t, "name", None)
18
+ func._client_params_type = getattr(t, "params_type", None)
19
+ return func
20
+
21
+ return decorator
22
+ transition_or_func._client_transition_name = None
23
+ transition_or_func._client_params_type = None
24
+ return transition_or_func
25
+
26
+
27
+ Script = Action
28
+
29
+
30
+ def get_transition_names(cls):
31
+ """Scan a class for @Action-decorated methods and return transition names.
32
+
33
+ Works with both client-side and backend decorators.
34
+ """
35
+ names = []
36
+ for _, member in inspect.getmembers(cls):
37
+ t_name = getattr(member, "_client_transition_name", None)
38
+ if t_name is not None:
39
+ names.append(t_name)
40
+ continue
41
+ # Backend ActionCallback objects
42
+ if hasattr(member, "transition") and hasattr(member, "func"):
43
+ t = getattr(member, "transition", None)
44
+ if t is not None and hasattr(t, "name"):
45
+ names.append(t.name)
46
+ return names
47
+
48
+
49
+ def get_action_methods(cls):
50
+ """Return ``[(transition_name, method_name, params_type), ...]`` for *cls*.
51
+
52
+ Used by ``RemoteOrchestrator.register_class`` to build the local
53
+ dispatch table. Handles both the client decorator
54
+ (``_client_transition_name`` / ``_client_params_type`` attrs) and
55
+ the backend ``ActionCallback`` shape (``.transition`` / ``.func``).
56
+ """
57
+ out = []
58
+ for member_name, member in inspect.getmembers(cls):
59
+ # Client decorator: attribute markers on the function itself.
60
+ t_name = getattr(member, "_client_transition_name", None)
61
+ if t_name is not None:
62
+ params_type = getattr(member, "_client_params_type", None)
63
+ out.append((t_name, member_name, params_type))
64
+ continue
65
+ # Backend ActionCallback: holds a Transition object and the func.
66
+ if hasattr(member, "transition") and hasattr(member, "func"):
67
+ t = getattr(member, "transition", None)
68
+ if t is not None and hasattr(t, "name"):
69
+ out.append((t.name, member_name, getattr(t, "params_type", None)))
70
+ return out
@@ -0,0 +1,56 @@
1
+ """Minimal client-side ``ActionContext`` for servant-dispatched actions.
2
+
3
+ The backend's ``ActionContext`` (``src/dynos/symbols/types.py::ActionContext``)
4
+ is a richer Protocol with hardware/sensor/cancellation hooks. Those rely on
5
+ backend infrastructure that doesn't exist on a remote servant machine, so
6
+ this module only implements the slice user ``@Action`` methods actually need
7
+ when dispatched via ``dynos connect``: a structured logger, the audit ID, and
8
+ the transition name.
9
+
10
+ If a user's ``@Action`` reaches for ``ctx.report_progress`` /
11
+ ``ctx.check_cancelled`` / ``ctx.read_sensor`` / ``ctx.call_hardware`` from
12
+ the servant side, an ``AttributeError`` is the right outcome; the action
13
+ is using backend-only machinery that isn't available remotely.
14
+ """
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+
19
+
20
+ @dataclass
21
+ class _ClientActionLogger:
22
+ """Adapter wrapping a stdlib ``logging.Logger`` to match the backend's
23
+ ``ActionLogger`` Protocol surface (``info``/``debug``/``warn``/``error``,
24
+ arbitrary kwargs accepted and dropped)."""
25
+
26
+ _log: logging.Logger
27
+
28
+ def info(self, msg: str, **_kw: object) -> None:
29
+ self._log.info(msg)
30
+
31
+ def debug(self, msg: str, **_kw: object) -> None:
32
+ self._log.debug(msg)
33
+
34
+ def warn(self, msg: str, **_kw: object) -> None:
35
+ self._log.warning(msg)
36
+
37
+ warning = warn
38
+
39
+ def error(self, msg: str, **_kw: object) -> None:
40
+ self._log.error(msg)
41
+
42
+
43
+ @dataclass
44
+ class ClientActionContext:
45
+ """Concrete ``ActionContext`` for client-side ``@Action`` dispatch.
46
+
47
+ Implements the ``log``, ``audit_id``, ``transition_name`` slice of the
48
+ backend ``ActionContext`` Protocol. Constructed per-call by
49
+ ``RemoteOrchestrator.trigger_action``.
50
+ """
51
+
52
+ transition_name: str
53
+ audit_id: str
54
+ log: _ClientActionLogger = field(
55
+ default_factory=lambda: _ClientActionLogger(logging.getLogger("dynos.servant"))
56
+ )
@@ -0,0 +1,128 @@
1
+ """``dynos login`` / ``dynos logout`` / ``dynos whoami`` subcommands.
2
+
3
+ Authenticates against a DYNOS session gateway and caches the bearer token
4
+ in ``~/.dynos/config.json`` (the same file that ``dynos session`` reads
5
+ and writes). Subsequent ``dynos call``, ``dynos session``, and
6
+ ``dynos connect`` commands use the cached values automatically.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ from typing import Optional
13
+
14
+ import requests
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table, box
18
+
19
+ from dynos_client.call import DEFAULT_BACKEND, _normalize_backend_url
20
+ from dynos_client.session_cli import _read_cache, _write_cache
21
+
22
+ console = Console()
23
+
24
+
25
+ def login(
26
+ to: str = typer.Option(
27
+ DEFAULT_BACKEND,
28
+ "--to",
29
+ help=f"Backend URL (defaults to the hosted DYNOS gateway at {DEFAULT_BACKEND})",
30
+ ),
31
+ username: Optional[str] = typer.Option(
32
+ None, "--user", "-u", help="Username (prompts if omitted)"
33
+ ),
34
+ password: Optional[str] = typer.Option(
35
+ None,
36
+ "--password",
37
+ help="Password (prompts if omitted; prefer the prompt to keep it out of shell history)",
38
+ ),
39
+ ) -> None:
40
+ """Authenticate to a DYNOS session gateway and cache the token.
41
+
42
+ Writes ``~/.dynos/config.json`` with ``backend_url``, ``token``,
43
+ ``username``, and ``role``. Subsequent ``dynos call`` and ``dynos session``
44
+ commands use the cached values automatically.
45
+ """
46
+ url = _normalize_backend_url(to)
47
+ if username is None:
48
+ username = typer.prompt("Username")
49
+ if password is None:
50
+ password = getpass.getpass("Password: ")
51
+
52
+ try:
53
+ resp = requests.post(
54
+ f"{url}/auth/login",
55
+ json={"username": username, "password": password},
56
+ timeout=10,
57
+ )
58
+ except requests.RequestException as exc:
59
+ console.print(f"[red]Cannot reach {url}: {exc}[/red]")
60
+ raise typer.Exit(1)
61
+ if resp.status_code != 200:
62
+ detail = resp.text
63
+ try:
64
+ detail = resp.json().get("detail", detail)
65
+ except Exception:
66
+ pass
67
+ console.print(f"[red]Login failed ({resp.status_code}): {detail}[/red]")
68
+ raise typer.Exit(1)
69
+ body = resp.json()
70
+
71
+ cache = _read_cache()
72
+ cache["backend_url"] = url
73
+ cache["token"] = body["token"]
74
+ cache["username"] = body["username"]
75
+ cache["role"] = body.get("role", "user")
76
+ _write_cache(cache)
77
+ console.print(
78
+ f"[green]Logged in as {body['username']} "
79
+ f"({body.get('role', 'user')}) at {url}[/green]"
80
+ )
81
+
82
+
83
+ def logout() -> None:
84
+ """Revoke the cached token and clear it from ``~/.dynos/config.json``."""
85
+ cache = _read_cache()
86
+ if not cache.get("token") or not cache.get("backend_url"):
87
+ console.print("[yellow]Not logged in.[/yellow]")
88
+ return
89
+ backend_url = cache["backend_url"]
90
+ token = cache["token"]
91
+ try:
92
+ resp = requests.post(
93
+ f"{backend_url.rstrip('/')}/auth/logout",
94
+ headers={"Authorization": f"Bearer {token}"},
95
+ timeout=10,
96
+ )
97
+ if resp.status_code not in (200, 204, 401):
98
+ console.print(
99
+ f"[yellow]Server returned {resp.status_code} on logout; "
100
+ "clearing local token anyway[/yellow]"
101
+ )
102
+ except requests.RequestException as exc:
103
+ console.print(
104
+ f"[yellow]Could not reach {backend_url} ({exc}); "
105
+ "clearing local token anyway[/yellow]"
106
+ )
107
+ # backend_url left in place so a subsequent `dynos login` can reuse it.
108
+ for key in ("token", "username", "role", "default_session"):
109
+ cache.pop(key, None)
110
+ _write_cache(cache)
111
+ console.print("[green]Logged out.[/green]")
112
+
113
+
114
+ def whoami() -> None:
115
+ """Print the currently cached auth identity, masking the token."""
116
+ cache = _read_cache()
117
+ token = cache.get("token")
118
+ if not token:
119
+ console.print("[yellow]Not logged in. Run `dynos login --to <url>`.[/yellow]")
120
+ raise typer.Exit(1)
121
+ masked = (token[:6] + "..." + token[-4:]) if len(token) >= 10 else "***"
122
+ table = Table(box=box.MINIMAL, show_header=False)
123
+ table.add_row("backend_url", cache.get("backend_url") or "(unset)")
124
+ table.add_row("username", cache.get("username") or "(unset)")
125
+ table.add_row("role", cache.get("role") or "(unset)")
126
+ table.add_row("token", masked)
127
+ table.add_row("default_session", cache.get("default_session") or "(none)")
128
+ console.print(table)