stata-cli 0.2.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.
stata_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
stata_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m stata_cli``."""
2
+
3
+ from .main import cli
4
+
5
+ cli()
stata_cli/daemon.py ADDED
@@ -0,0 +1,367 @@
1
+ """Stata CLI daemon — keeps PyStata alive across invocations.
2
+
3
+ Server: long-running background process with a StataEngine.
4
+ Client: thin JSON-over-socket connector used by CLI commands.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import platform
10
+ import selectors
11
+ import signal
12
+ import socket
13
+ import struct
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional
19
+
20
+ _STATE_DIR = os.path.join(os.path.expanduser("~"), ".stata-cli")
21
+ _PID_FILE = os.path.join(_STATE_DIR, "daemon.pid")
22
+ _SOCK_FILE = os.path.join(_STATE_DIR, "daemon.sock")
23
+ _IS_WINDOWS = platform.system() == "Windows"
24
+ _DEFAULT_PORT = 4718
25
+ _IDLE_TIMEOUT = 3600 # 1 hour
26
+
27
+
28
+ # ── wire protocol: 4-byte big-endian length prefix + JSON ────────────────
29
+
30
+ def _send_msg(sock: socket.socket, obj: Any) -> None:
31
+ data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
32
+ sock.sendall(struct.pack(">I", len(data)) + data)
33
+
34
+
35
+ def _recv_msg(sock: socket.socket) -> Optional[Dict]:
36
+ header = b""
37
+ while len(header) < 4:
38
+ chunk = sock.recv(4 - len(header))
39
+ if not chunk:
40
+ return None
41
+ header += chunk
42
+ length = struct.unpack(">I", header)[0]
43
+ buf = b""
44
+ while len(buf) < length:
45
+ chunk = sock.recv(min(length - len(buf), 65536))
46
+ if not chunk:
47
+ return None
48
+ buf += chunk
49
+ return json.loads(buf.decode("utf-8"))
50
+
51
+
52
+ # ═══════════════════════════════════════════════════════════════════════════
53
+ # SERVER
54
+ # ═══════════════════════════════════════════════════════════════════════════
55
+
56
+ class DaemonServer:
57
+ """Single-threaded daemon that wraps a StataEngine."""
58
+
59
+ def __init__(self, stata_path: str, edition: str = "mp",
60
+ graphs_dir: Optional[str] = None, idle_timeout: float = _IDLE_TIMEOUT):
61
+ self.stata_path = stata_path
62
+ self.edition = edition
63
+ self.graphs_dir = graphs_dir
64
+ self.idle_timeout = idle_timeout
65
+ self._engine = None
66
+ self._running = False
67
+ self._start_time = time.time()
68
+ self._last_activity = time.time()
69
+
70
+ def serve(self) -> None:
71
+ """Start listening and processing commands."""
72
+ from .engine import StataEngine
73
+
74
+ self._engine = StataEngine(self.stata_path, self.edition, graphs_dir=self.graphs_dir)
75
+ self._engine._ensure_initialized()
76
+
77
+ os.makedirs(_STATE_DIR, exist_ok=True)
78
+
79
+ sock = self._create_listener()
80
+ self._write_pid()
81
+ self._running = True
82
+
83
+ signal.signal(signal.SIGTERM, lambda *_: self._shutdown())
84
+ signal.signal(signal.SIGINT, lambda *_: self._shutdown())
85
+
86
+ sel = selectors.DefaultSelector()
87
+ sel.register(sock, selectors.EVENT_READ)
88
+
89
+ try:
90
+ while self._running:
91
+ events = sel.select(timeout=30.0)
92
+ if not events:
93
+ if self.idle_timeout > 0 and (time.time() - self._last_activity) > self.idle_timeout:
94
+ break
95
+ continue
96
+ for key, _ in events:
97
+ conn, _ = sock.accept()
98
+ try:
99
+ self._handle_connection(conn)
100
+ except Exception:
101
+ try:
102
+ _send_msg(conn, {"status": "error", "error": "Internal daemon error"})
103
+ except Exception:
104
+ pass
105
+ finally:
106
+ conn.close()
107
+ finally:
108
+ sel.close()
109
+ sock.close()
110
+ self._cleanup()
111
+
112
+ def _create_listener(self) -> socket.socket:
113
+ if _IS_WINDOWS:
114
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
116
+ sock.bind(("127.0.0.1", _DEFAULT_PORT))
117
+ else:
118
+ if os.path.exists(_SOCK_FILE):
119
+ os.unlink(_SOCK_FILE)
120
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
121
+ sock.bind(_SOCK_FILE)
122
+ sock.listen(4)
123
+ sock.setblocking(False)
124
+ return sock
125
+
126
+ def _handle_connection(self, conn: socket.socket) -> None:
127
+ conn.settimeout(600.0)
128
+ msg = _recv_msg(conn)
129
+ if not msg:
130
+ return
131
+
132
+ self._last_activity = time.time()
133
+ cmd_type = msg.get("type", "")
134
+ payload = msg.get("payload", {})
135
+
136
+ if cmd_type == "execute":
137
+ result = self._engine.run(
138
+ payload.get("code", ""),
139
+ timeout=payload.get("timeout", 600.0),
140
+ )
141
+ _send_msg(conn, self._result_to_dict(result))
142
+
143
+ elif cmd_type == "execute_file":
144
+ result = self._engine.run_file(
145
+ payload.get("path", ""),
146
+ timeout=payload.get("timeout", 600.0),
147
+ )
148
+ _send_msg(conn, self._result_to_dict(result))
149
+
150
+ elif cmd_type == "get_data":
151
+ data = self._engine.get_data(
152
+ if_condition=payload.get("if_condition"),
153
+ max_rows=payload.get("max_rows", 10000),
154
+ )
155
+ _send_msg(conn, data)
156
+
157
+ elif cmd_type == "help":
158
+ result = self._engine.help(payload.get("topic", ""))
159
+ _send_msg(conn, self._result_to_dict(result))
160
+
161
+ elif cmd_type == "stop":
162
+ ok = self._engine.stop()
163
+ _send_msg(conn, {"status": "ok" if ok else "no_op"})
164
+
165
+ elif cmd_type == "status":
166
+ _send_msg(conn, {
167
+ "status": "ok",
168
+ "pid": os.getpid(),
169
+ "uptime": time.time() - self._start_time,
170
+ "idle": time.time() - self._last_activity,
171
+ "stata_path": self.stata_path,
172
+ "edition": self.edition,
173
+ })
174
+
175
+ elif cmd_type == "shutdown":
176
+ _send_msg(conn, {"status": "ok"})
177
+ self._running = False
178
+
179
+ else:
180
+ _send_msg(conn, {"status": "error", "error": f"Unknown command: {cmd_type}"})
181
+
182
+ @staticmethod
183
+ def _result_to_dict(result) -> Dict[str, Any]:
184
+ from dataclasses import asdict
185
+ return asdict(result)
186
+
187
+ def _write_pid(self) -> None:
188
+ addr = f"127.0.0.1:{_DEFAULT_PORT}" if _IS_WINDOWS else _SOCK_FILE
189
+ with open(_PID_FILE, "w") as fh:
190
+ json.dump({"pid": os.getpid(), "address": addr, "started": time.time()}, fh)
191
+
192
+ def _cleanup(self) -> None:
193
+ try:
194
+ os.unlink(_PID_FILE)
195
+ except OSError:
196
+ pass
197
+ if not _IS_WINDOWS:
198
+ try:
199
+ os.unlink(_SOCK_FILE)
200
+ except OSError:
201
+ pass
202
+
203
+ def _shutdown(self) -> None:
204
+ self._running = False
205
+
206
+
207
+ def run_daemon_server(stata_path: str, edition: str = "mp",
208
+ graphs_dir: Optional[str] = None, idle_timeout: float = _IDLE_TIMEOUT) -> None:
209
+ """Entry point called in the daemon subprocess."""
210
+ server = DaemonServer(stata_path, edition, graphs_dir=graphs_dir, idle_timeout=idle_timeout)
211
+ server.serve()
212
+
213
+
214
+ # ═══════════════════════════════════════════════════════════════════════════
215
+ # CLIENT
216
+ # ═══════════════════════════════════════════════════════════════════════════
217
+
218
+ class DaemonClient:
219
+ """Connects to a running daemon over the Unix socket / TCP port."""
220
+
221
+ def __init__(self) -> None:
222
+ self._sock: Optional[socket.socket] = None
223
+
224
+ def is_running(self) -> bool:
225
+ if not os.path.isfile(_PID_FILE):
226
+ return False
227
+ try:
228
+ with open(_PID_FILE) as fh:
229
+ info = json.load(fh)
230
+ pid = info["pid"]
231
+ os.kill(pid, 0)
232
+ return True
233
+ except (OSError, KeyError, json.JSONDecodeError):
234
+ return False
235
+
236
+ def connect(self) -> bool:
237
+ try:
238
+ info = self._read_pid()
239
+ if not info:
240
+ return False
241
+ if _IS_WINDOWS:
242
+ parts = info["address"].split(":")
243
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
244
+ self._sock.connect((parts[0], int(parts[1])))
245
+ else:
246
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
247
+ self._sock.connect(info["address"])
248
+ self._sock.settimeout(600.0)
249
+ return True
250
+ except (OSError, KeyError):
251
+ self._sock = None
252
+ return False
253
+
254
+ def send(self, cmd_type: str, payload: Optional[Dict] = None) -> Dict[str, Any]:
255
+ if self._sock is None:
256
+ raise RuntimeError("Not connected")
257
+ _send_msg(self._sock, {"type": cmd_type, "payload": payload or {}})
258
+ resp = _recv_msg(self._sock)
259
+ return resp or {"status": "error", "error": "No response from daemon"}
260
+
261
+ def close(self) -> None:
262
+ if self._sock:
263
+ try:
264
+ self._sock.close()
265
+ except OSError:
266
+ pass
267
+ self._sock = None
268
+
269
+ def _read_pid(self) -> Optional[Dict]:
270
+ if not os.path.isfile(_PID_FILE):
271
+ return None
272
+ try:
273
+ with open(_PID_FILE) as fh:
274
+ return json.load(fh)
275
+ except (json.JSONDecodeError, OSError):
276
+ return None
277
+
278
+
279
+ def start_daemon(stata_path: str, edition: str = "mp",
280
+ graphs_dir: Optional[str] = None, idle_timeout: float = _IDLE_TIMEOUT) -> bool:
281
+ """Launch daemon as a detached background process. Returns True on success."""
282
+ client = DaemonClient()
283
+ if client.is_running():
284
+ return True
285
+
286
+ os.makedirs(_STATE_DIR, exist_ok=True)
287
+ log_file = os.path.join(_STATE_DIR, "daemon.log")
288
+
289
+ args = [
290
+ sys.executable, "-m", "stata_cli.daemon",
291
+ "--stata-path", stata_path,
292
+ "--edition", edition,
293
+ "--idle-timeout", str(int(idle_timeout)),
294
+ ]
295
+ if graphs_dir:
296
+ args += ["--graphs-dir", graphs_dir]
297
+
298
+ with open(log_file, "a") as log:
299
+ if _IS_WINDOWS:
300
+ subprocess.Popen(
301
+ args, stdout=log, stderr=log,
302
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
303
+ )
304
+ else:
305
+ subprocess.Popen(
306
+ args, stdout=log, stderr=log,
307
+ start_new_session=True,
308
+ )
309
+
310
+ for _ in range(60):
311
+ time.sleep(0.5)
312
+ if client.is_running():
313
+ return True
314
+ return False
315
+
316
+
317
+ def stop_daemon() -> bool:
318
+ """Ask the daemon to shut down gracefully."""
319
+ client = DaemonClient()
320
+ if not client.is_running():
321
+ return True
322
+ if client.connect():
323
+ try:
324
+ client.send("shutdown")
325
+ except Exception:
326
+ pass
327
+ client.close()
328
+ for _ in range(20):
329
+ time.sleep(0.25)
330
+ if not client.is_running():
331
+ return True
332
+ # Force kill
333
+ try:
334
+ with open(_PID_FILE) as fh:
335
+ pid = json.load(fh)["pid"]
336
+ os.kill(pid, signal.SIGKILL if not _IS_WINDOWS else signal.SIGTERM)
337
+ except Exception:
338
+ pass
339
+ return True
340
+
341
+
342
+ def daemon_status() -> Optional[Dict[str, Any]]:
343
+ """Query the running daemon's status. Returns None if not running."""
344
+ client = DaemonClient()
345
+ if not client.is_running():
346
+ return None
347
+ if not client.connect():
348
+ return None
349
+ try:
350
+ return client.send("status")
351
+ except Exception:
352
+ return None
353
+ finally:
354
+ client.close()
355
+
356
+
357
+ # ── Allow running as ``python -m stata_cli.daemon`` ──────────────────────
358
+
359
+ if __name__ == "__main__":
360
+ import argparse
361
+ parser = argparse.ArgumentParser()
362
+ parser.add_argument("--stata-path", required=True)
363
+ parser.add_argument("--edition", default="mp")
364
+ parser.add_argument("--graphs-dir", default=None)
365
+ parser.add_argument("--idle-timeout", type=float, default=_IDLE_TIMEOUT)
366
+ args = parser.parse_args()
367
+ run_daemon_server(args.stata_path, args.edition, args.graphs_dir, args.idle_timeout)