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 +1 -0
- stata_cli/__main__.py +5 -0
- stata_cli/daemon.py +367 -0
- stata_cli/engine.py +461 -0
- stata_cli/graph_artifacts.py +95 -0
- stata_cli/main.py +343 -0
- stata_cli/output_filter.py +239 -0
- stata_cli/smcl_parser.py +93 -0
- stata_cli/utils.py +85 -0
- stata_cli-0.2.0.dist-info/METADATA +338 -0
- stata_cli-0.2.0.dist-info/RECORD +14 -0
- stata_cli-0.2.0.dist-info/WHEEL +5 -0
- stata_cli-0.2.0.dist-info/entry_points.txt +2 -0
- stata_cli-0.2.0.dist-info/top_level.txt +1 -0
stata_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
stata_cli/__main__.py
ADDED
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)
|