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.
- dynos_client/__init__.py +17 -0
- dynos_client/__main__.py +4 -0
- dynos_client/action.py +70 -0
- dynos_client/action_context.py +56 -0
- dynos_client/auth_cli.py +128 -0
- dynos_client/call.py +579 -0
- dynos_client/cli.py +149 -0
- dynos_client/domain.py +10 -0
- dynos_client/expr_parser.py +118 -0
- dynos_client/hash_cli.py +116 -0
- dynos_client/mission.py +261 -0
- dynos_client/remote_orchestrator.py +605 -0
- dynos_client/resolve_runnable.py +45 -0
- dynos_client/servant.py +194 -0
- dynos_client/session_cli.py +481 -0
- dynos_client/session_manager.py +303 -0
- dynos_client-0.1.2.dist-info/METADATA +263 -0
- dynos_client-0.1.2.dist-info/RECORD +21 -0
- dynos_client-0.1.2.dist-info/WHEEL +5 -0
- dynos_client-0.1.2.dist-info/entry_points.txt +2 -0
- dynos_client-0.1.2.dist-info/top_level.txt +1 -0
dynos_client/__init__.py
ADDED
|
@@ -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
|
+
]
|
dynos_client/__main__.py
ADDED
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
|
+
)
|
dynos_client/auth_cli.py
ADDED
|
@@ -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)
|