Coverage for src/dev_shell/cli.py: 0%
99 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-03 09:02 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-03 09:02 +0000
1"""dev_shell - run Python against a live Home Assistant.
3 dev_shell interactive API client shell (default)
4 `obj` only, read-only, no HACS component needed
5 dev_shell custom interactive custom-component shell
6 `hass` + `obj` (same `obj` API, live not cached);
7 needs the dev_shell_server component
8 dev_shell exec 'hass.states.get("sun.sun")' run a snippet (custom mode only)
9 dev_shell exec -f snippet.py run a file
10 dev_shell exec - <<'EOF' ... EOF read the snippet from stdin
11 dev_shell reset / dev_shell sessions manage custom-mode server-side sessions
13Connection: HASS_SERVER (default http://homeassistant.local:8123), HASS_TOKEN, HASS_SESSION.
14API client mode: --ttl seconds before the cached snapshot is refreshed (default 30).
15Exit status of exec is 1 when the snippet raised, 2 on connection/usage errors.
16"""
18from __future__ import annotations
20import argparse
21import asyncio
22import json
23import os
24import shutil
25import sys
26from typing import Any
28from .client import Client, DevShellError, resolve_token, resolve_url
31def main() -> None:
32 args = _parser().parse_args()
33 try:
34 sys.exit(asyncio.run(_dispatch(args)))
35 except DevShellError as err:
36 print(f"dev_shell: {err}", file=sys.stderr)
37 sys.exit(2)
38 except KeyboardInterrupt:
39 sys.exit(130)
42def _parser() -> argparse.ArgumentParser:
43 parser = argparse.ArgumentParser(
44 prog="dev_shell",
45 description=__doc__,
46 formatter_class=argparse.RawDescriptionHelpFormatter,
47 )
48 parser.add_argument("--url", default=os.environ.get("HASS_SERVER"))
49 parser.add_argument("--token", default=None, help="defaults to $HASS_TOKEN")
50 parser.add_argument(
51 "-s", "--session", default=os.environ.get("HASS_SESSION", "default")
52 )
53 parser.add_argument("--json", action="store_true", help="print raw JSON results")
54 parser.add_argument(
55 "--ttl",
56 type=float,
57 default=30.0,
58 help="API client mode: seconds before the cached snapshot is refreshed (default: 30)",
59 )
60 sub = parser.add_subparsers(dest="command")
62 exec_ = sub.add_parser(
63 "exec", help="run code and print output and result (custom mode only)"
64 )
65 exec_.add_argument("code", nargs="?", help="code to run, or - for stdin")
66 exec_.add_argument("-f", "--file", help="run the contents of a file")
67 exec_.add_argument("-t", "--timeout", type=float, help="cancel after N seconds")
68 exec_.add_argument(
69 "--reset", action="store_true", help="reset the session before running"
70 )
72 sub.add_parser(
73 "api",
74 help="interactive API client shell (the default): obj only, no HACS component needed",
75 )
76 sub.add_parser(
77 "custom",
78 help="interactive custom-component shell: hass + obj, needs the dev_shell_server component",
79 )
80 sub.add_parser("reset", help="discard the custom-mode session's variables")
81 sub.add_parser("sessions", help="list custom-mode sessions on the server")
82 return parser
85async def _dispatch(args: argparse.Namespace) -> int:
86 url = resolve_url(args.url)
87 async with Client(url, resolve_token(args.token)) as client:
88 match args.command:
89 case "exec":
90 return await _exec(client, args)
91 case "reset":
92 result = await client.call(
93 "dev_shell_server/reset", session=args.session
94 )
95 _emit(args, result, "reset" if result["reset"] else "no such session")
96 return 0
97 case "sessions":
98 result = await client.call("dev_shell_server/sessions")
99 _emit(args, result, _format_sessions(result["sessions"]))
100 return 0
101 case "custom":
102 from .repl import run_repl
104 return await run_repl(client, args.session)
105 case _: # "api", or no subcommand at all - API client mode is the default
106 from .apirepl import run_api_repl
108 return await run_api_repl(client, args.ttl)
111async def _exec(client: Client, args: argparse.Namespace) -> int:
112 if args.file:
113 # One-shot CLI read, not a hot path - a threaded read would be overkill.
114 with open(args.file, encoding="utf-8") as fp: # noqa: ASYNC230
115 code = fp.read()
116 elif args.code in (None, "-"):
117 if args.code is None and sys.stdin.isatty():
118 raise DevShellError("exec needs code, -f FILE, or - to read stdin")
119 code = sys.stdin.read()
120 else:
121 code = args.code
122 if args.reset:
123 await client.call("dev_shell_server/reset", session=args.session)
124 payload: dict[str, Any] = {
125 "code": code,
126 "session": args.session,
127 **display_options(),
128 }
129 if args.timeout:
130 payload["timeout"] = args.timeout
131 if args.json:
132 # Escape codes embedded in a JSON string are just noise for a consumer
133 # that asked for machine-readable output.
134 payload["color"] = False
135 result = await client.call("dev_shell_server/exec", **payload)
136 if args.json:
137 print(json.dumps(result, indent=2))
138 else:
139 print_result(result)
140 return 1 if result["error"] else 0
143def display_options() -> dict[str, Any]:
144 """Color/width hints for the server to render values and tracebacks with.
146 Decided here, not there: the server only sees a websocket, not a terminal,
147 and stdout here might be piped (a script, a redirected log) rather than a
148 person watching it live.
149 """
150 color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
151 width = shutil.get_terminal_size((88, 24)).columns
152 return {"color": color, "width": width}
155def print_result(result: dict[str, Any]) -> None:
156 if result["stdout"]:
157 sys.stdout.write(result["stdout"])
158 if not result["stdout"].endswith("\n"):
159 sys.stdout.write("\n")
160 if result["value"] is not None:
161 print(result["value"])
162 if result["error"]:
163 sys.stderr.write(result["error"]["traceback"])
164 if result.get("truncated"):
165 print("[dev_shell: output truncated]", file=sys.stderr)
166 sys.stdout.flush()
169def _emit(args: argparse.Namespace, result: Any, text: str) -> None:
170 print(json.dumps(result, indent=2) if args.json else text)
173def _format_sessions(sessions: list[dict[str, Any]]) -> str:
174 if not sessions:
175 return "no sessions"
176 return "\n".join(
177 f"{s['name']}: {s['executions']} runs, vars: {', '.join(s['variables'])}"
178 for s in sessions
179 )