ferp 0.7.1__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.
- ferp/__init__.py +3 -0
- ferp/__main__.py +4 -0
- ferp/__version__.py +1 -0
- ferp/app.py +9 -0
- ferp/cli.py +160 -0
- ferp/core/__init__.py +0 -0
- ferp/core/app.py +1312 -0
- ferp/core/bundle_installer.py +245 -0
- ferp/core/command_provider.py +77 -0
- ferp/core/dependency_manager.py +59 -0
- ferp/core/fs_controller.py +70 -0
- ferp/core/fs_watcher.py +144 -0
- ferp/core/messages.py +49 -0
- ferp/core/path_actions.py +124 -0
- ferp/core/paths.py +3 -0
- ferp/core/protocols.py +8 -0
- ferp/core/script_controller.py +515 -0
- ferp/core/script_protocol.py +35 -0
- ferp/core/script_runner.py +421 -0
- ferp/core/settings.py +16 -0
- ferp/core/settings_store.py +69 -0
- ferp/core/state.py +156 -0
- ferp/core/task_store.py +164 -0
- ferp/core/transcript_logger.py +95 -0
- ferp/domain/__init__.py +0 -0
- ferp/domain/scripts.py +29 -0
- ferp/fscp/host/__init__.py +11 -0
- ferp/fscp/host/host.py +439 -0
- ferp/fscp/host/managed_process.py +113 -0
- ferp/fscp/host/process_registry.py +124 -0
- ferp/fscp/protocol/__init__.py +13 -0
- ferp/fscp/protocol/errors.py +2 -0
- ferp/fscp/protocol/messages.py +55 -0
- ferp/fscp/protocol/schemas/__init__.py +0 -0
- ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
- ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
- ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
- ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
- ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
- ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
- ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
- ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
- ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
- ferp/fscp/protocol/state.py +16 -0
- ferp/fscp/protocol/validator.py +123 -0
- ferp/fscp/scripts/__init__.py +0 -0
- ferp/fscp/scripts/runtime/__init__.py +4 -0
- ferp/fscp/scripts/runtime/__main__.py +40 -0
- ferp/fscp/scripts/runtime/errors.py +14 -0
- ferp/fscp/scripts/runtime/io.py +64 -0
- ferp/fscp/scripts/runtime/script.py +149 -0
- ferp/fscp/scripts/runtime/state.py +17 -0
- ferp/fscp/scripts/runtime/worker.py +13 -0
- ferp/fscp/scripts/sdk.py +548 -0
- ferp/fscp/transcript/__init__.py +3 -0
- ferp/fscp/transcript/events.py +14 -0
- ferp/resources/__init__.py +0 -0
- ferp/services/__init__.py +3 -0
- ferp/services/file_listing.py +120 -0
- ferp/services/monday_sync.py +155 -0
- ferp/services/releases.py +214 -0
- ferp/services/scripts.py +90 -0
- ferp/services/update_check.py +130 -0
- ferp/styles/index.tcss +638 -0
- ferp/themes/themes.py +238 -0
- ferp/widgets/__init__.py +17 -0
- ferp/widgets/dialogs.py +167 -0
- ferp/widgets/file_tree.py +991 -0
- ferp/widgets/forms.py +146 -0
- ferp/widgets/output_panel.py +244 -0
- ferp/widgets/panels.py +13 -0
- ferp/widgets/process_list.py +158 -0
- ferp/widgets/readme_modal.py +59 -0
- ferp/widgets/scripts.py +192 -0
- ferp/widgets/task_capture.py +74 -0
- ferp/widgets/task_list.py +493 -0
- ferp/widgets/top_bar.py +110 -0
- ferp-0.7.1.dist-info/METADATA +128 -0
- ferp-0.7.1.dist-info/RECORD +87 -0
- ferp-0.7.1.dist-info/WHEEL +5 -0
- ferp-0.7.1.dist-info/entry_points.txt +2 -0
- ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
- ferp-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from functools import partial
|
|
10
|
+
from multiprocessing import util as mp_util
|
|
11
|
+
from multiprocessing.connection import Connection
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from runpy import run_path
|
|
14
|
+
from threading import Lock
|
|
15
|
+
from typing import Any, Callable, Literal
|
|
16
|
+
|
|
17
|
+
from ferp.fscp.host import Host
|
|
18
|
+
from ferp.fscp.host.managed_process import WorkerFn
|
|
19
|
+
from ferp.fscp.host.process_registry import (
|
|
20
|
+
ProcessMetadata,
|
|
21
|
+
ProcessRegistry,
|
|
22
|
+
)
|
|
23
|
+
from ferp.fscp.protocol.messages import Message, MessageDirection, MessageType
|
|
24
|
+
from ferp.fscp.protocol.state import HostState
|
|
25
|
+
from ferp.fscp.scripts.runtime.io import configure_connection
|
|
26
|
+
from ferp.fscp.transcript.events import TranscriptEvent
|
|
27
|
+
from ferp.services.scripts import ScriptExecutionContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _patch_spawnv_passfds() -> None:
|
|
31
|
+
"""Work around macOS passing invalid fds to spawnv."""
|
|
32
|
+
original_spawn = mp_util.spawnv_passfds
|
|
33
|
+
|
|
34
|
+
def safe_spawn(exe, args, passfds): # type: ignore[override]
|
|
35
|
+
filtered = tuple(fd for fd in passfds if isinstance(fd, int) and fd >= 0)
|
|
36
|
+
return original_spawn(exe, args, filtered)
|
|
37
|
+
|
|
38
|
+
mp_util.spawnv_passfds = safe_spawn # type: ignore[assignment]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_patch_spawnv_passfds()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_app_version() -> str:
|
|
45
|
+
try:
|
|
46
|
+
from ferp.__version__ import __version__
|
|
47
|
+
except Exception:
|
|
48
|
+
return "unknown"
|
|
49
|
+
return __version__
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_build_label() -> str:
|
|
53
|
+
if os.environ.get("FERP_DEV_CONFIG") == "1":
|
|
54
|
+
return "dev"
|
|
55
|
+
return "release"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_os_version() -> str:
|
|
59
|
+
if sys.platform == "darwin":
|
|
60
|
+
mac_version = platform.mac_ver()[0]
|
|
61
|
+
return mac_version or platform.release()
|
|
62
|
+
if sys.platform.startswith("win"):
|
|
63
|
+
return platform.version()
|
|
64
|
+
return platform.release()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_environment(app_root: Path, cache_dir: Path) -> dict[str, Any]:
|
|
68
|
+
"""Build the SDK environment payload for script initialization."""
|
|
69
|
+
return {
|
|
70
|
+
"app": {
|
|
71
|
+
"name": "ferp",
|
|
72
|
+
"version": _read_app_version(),
|
|
73
|
+
"build": _read_build_label(),
|
|
74
|
+
},
|
|
75
|
+
"host": {
|
|
76
|
+
"platform": sys.platform,
|
|
77
|
+
"os": platform.system(),
|
|
78
|
+
"os_version": _read_os_version(),
|
|
79
|
+
"arch": platform.machine(),
|
|
80
|
+
"python": platform.python_version(),
|
|
81
|
+
},
|
|
82
|
+
"paths": {
|
|
83
|
+
"app_root": str(app_root),
|
|
84
|
+
"cwd": str(Path.cwd()),
|
|
85
|
+
"cache_dir": str(cache_dir),
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ScriptStatus(Enum):
|
|
91
|
+
COMPLETED = "completed"
|
|
92
|
+
FAILED = "failed"
|
|
93
|
+
WAITING_INPUT = "waiting_input"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ScriptInputRequest:
|
|
98
|
+
id: str
|
|
99
|
+
prompt: str
|
|
100
|
+
default: str | None = None
|
|
101
|
+
secret: bool = False
|
|
102
|
+
mode: Literal["input", "confirm"] = "input"
|
|
103
|
+
fields: list[dict[str, Any]] = field(default_factory=list)
|
|
104
|
+
suggestions: list[str] = field(default_factory=list)
|
|
105
|
+
show_text_input: bool = True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ScriptResult:
|
|
110
|
+
status: ScriptStatus
|
|
111
|
+
transcript: list[TranscriptEvent] = field(default_factory=list)
|
|
112
|
+
results: list[dict[str, Any]] = field(default_factory=list)
|
|
113
|
+
exit_code: int | None = None
|
|
114
|
+
error: str | None = None
|
|
115
|
+
input_request: ScriptInputRequest | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class HostSession:
|
|
120
|
+
context: ScriptExecutionContext
|
|
121
|
+
host: Host
|
|
122
|
+
pending_request: ScriptInputRequest | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _script_worker_entry(script_path: str, app_root: str, conn: Connection) -> None:
|
|
126
|
+
os.chdir(app_root)
|
|
127
|
+
configure_connection(conn)
|
|
128
|
+
run_path(script_path, run_name="__main__")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ScriptRunner:
|
|
132
|
+
"""Run FSCP-compatible scripts inside a managed Host."""
|
|
133
|
+
|
|
134
|
+
_TERMINAL_STATES = {
|
|
135
|
+
HostState.TERMINATED,
|
|
136
|
+
HostState.ERR_PROTOCOL,
|
|
137
|
+
HostState.ERR_TRANSPORT,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
app_root: Path,
|
|
143
|
+
cache_dir: Path,
|
|
144
|
+
progress_handler: Callable[[dict[str, Any]], None] | None = None,
|
|
145
|
+
process_registry: ProcessRegistry | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
self.app_root = app_root
|
|
148
|
+
self.cache_dir = cache_dir
|
|
149
|
+
self._session: HostSession | None = None
|
|
150
|
+
self._lock = Lock()
|
|
151
|
+
self._progress_handler = progress_handler
|
|
152
|
+
self.process_registry = process_registry or ProcessRegistry()
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def active_script_name(self) -> str | None:
|
|
156
|
+
session = self._session
|
|
157
|
+
if session is None:
|
|
158
|
+
return None
|
|
159
|
+
return session.context.script.name
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def active_target(self) -> Path | None:
|
|
163
|
+
session = self._session
|
|
164
|
+
if session is None:
|
|
165
|
+
return None
|
|
166
|
+
return session.context.target_path
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def active_process_handle(self) -> str | None:
|
|
170
|
+
session = self._session
|
|
171
|
+
if session is None:
|
|
172
|
+
return None
|
|
173
|
+
return session.host.process_handle
|
|
174
|
+
|
|
175
|
+
def start(self, context: ScriptExecutionContext) -> ScriptResult:
|
|
176
|
+
worker = self._create_worker(context.script_path)
|
|
177
|
+
metadata = ProcessMetadata(
|
|
178
|
+
script_name=context.script.name,
|
|
179
|
+
script_id=context.script.id,
|
|
180
|
+
target_path=context.target_path,
|
|
181
|
+
)
|
|
182
|
+
host = Host(
|
|
183
|
+
worker=worker,
|
|
184
|
+
process_registry=self.process_registry,
|
|
185
|
+
process_metadata=metadata,
|
|
186
|
+
)
|
|
187
|
+
session = HostSession(context=context, host=host)
|
|
188
|
+
|
|
189
|
+
with self._lock:
|
|
190
|
+
if self._session is not None:
|
|
191
|
+
raise RuntimeError("A script is already running.")
|
|
192
|
+
self._session = session
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
host.start()
|
|
196
|
+
environment = _build_environment(self.app_root, self.cache_dir)
|
|
197
|
+
init_payload = {
|
|
198
|
+
"target": {
|
|
199
|
+
"path": str(context.target_path),
|
|
200
|
+
"kind": context.target_kind,
|
|
201
|
+
},
|
|
202
|
+
"params": {
|
|
203
|
+
"script": {
|
|
204
|
+
"id": context.script.id,
|
|
205
|
+
"name": context.script.name,
|
|
206
|
+
"version": context.script.version,
|
|
207
|
+
"path": str(context.script_path),
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
"environment": environment,
|
|
211
|
+
}
|
|
212
|
+
host.send(Message(type=MessageType.INIT, payload=init_payload))
|
|
213
|
+
return self._drive_host(session)
|
|
214
|
+
except Exception:
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._session = None
|
|
217
|
+
host.shutdown(force=True)
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
def provide_input(self, value: str) -> ScriptResult:
|
|
221
|
+
with self._lock:
|
|
222
|
+
session = self._require_session()
|
|
223
|
+
request = session.pending_request
|
|
224
|
+
if request is None:
|
|
225
|
+
raise RuntimeError("No pending input request.")
|
|
226
|
+
payload = {"id": request.id, "value": value}
|
|
227
|
+
session.pending_request = None
|
|
228
|
+
host = session.host
|
|
229
|
+
|
|
230
|
+
host.provide_input(payload)
|
|
231
|
+
return self._drive_host(session)
|
|
232
|
+
|
|
233
|
+
def abort(
|
|
234
|
+
self,
|
|
235
|
+
reason: str | None = None,
|
|
236
|
+
*,
|
|
237
|
+
graceful_timeout: float = 2.0,
|
|
238
|
+
) -> ScriptResult | None:
|
|
239
|
+
with self._lock:
|
|
240
|
+
session = self._session
|
|
241
|
+
if session is None:
|
|
242
|
+
return None
|
|
243
|
+
self._session = None
|
|
244
|
+
|
|
245
|
+
host = session.host
|
|
246
|
+
try:
|
|
247
|
+
if graceful_timeout > 0:
|
|
248
|
+
try:
|
|
249
|
+
host.request_cancel()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
deadline = time.time() + graceful_timeout
|
|
254
|
+
while time.time() < deadline:
|
|
255
|
+
host.poll()
|
|
256
|
+
updates = host.drain_progress_updates()
|
|
257
|
+
if updates:
|
|
258
|
+
for payload in updates:
|
|
259
|
+
self._publish_progress(payload)
|
|
260
|
+
if host.state in self._TERMINAL_STATES:
|
|
261
|
+
break
|
|
262
|
+
time.sleep(0.05)
|
|
263
|
+
|
|
264
|
+
if host.state not in self._TERMINAL_STATES:
|
|
265
|
+
host.shutdown(force=True)
|
|
266
|
+
finally:
|
|
267
|
+
return ScriptResult(
|
|
268
|
+
status=ScriptStatus.FAILED,
|
|
269
|
+
transcript=list(host.transcript),
|
|
270
|
+
results=list(host.results),
|
|
271
|
+
error=reason or "Script canceled.",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _publish_progress(self, payload: dict[str, Any]) -> None:
|
|
275
|
+
handler = self._progress_handler
|
|
276
|
+
if handler:
|
|
277
|
+
handler(payload)
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
# Internal helpers
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def _drive_host(self, session: HostSession) -> ScriptResult:
|
|
284
|
+
host = session.host
|
|
285
|
+
|
|
286
|
+
while True:
|
|
287
|
+
host.poll()
|
|
288
|
+
updates = host.drain_progress_updates()
|
|
289
|
+
if updates:
|
|
290
|
+
for payload in updates:
|
|
291
|
+
self._publish_progress(payload)
|
|
292
|
+
|
|
293
|
+
if host.state is HostState.AWAITING_INPUT:
|
|
294
|
+
request = self._extract_input_request(host)
|
|
295
|
+
session.pending_request = request
|
|
296
|
+
return ScriptResult(
|
|
297
|
+
status=ScriptStatus.WAITING_INPUT,
|
|
298
|
+
transcript=list(host.transcript),
|
|
299
|
+
results=list(host.results),
|
|
300
|
+
input_request=request,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if host.state in self._TERMINAL_STATES:
|
|
304
|
+
return self._finalize(session)
|
|
305
|
+
|
|
306
|
+
time.sleep(0.05)
|
|
307
|
+
|
|
308
|
+
def _finalize(self, session: HostSession) -> ScriptResult:
|
|
309
|
+
host = session.host
|
|
310
|
+
transcript = list(host.transcript)
|
|
311
|
+
results = list(host.results)
|
|
312
|
+
exit_code = self._extract_exit_code(transcript)
|
|
313
|
+
state = host.state
|
|
314
|
+
|
|
315
|
+
success = state is HostState.TERMINATED and (exit_code in (None, 0))
|
|
316
|
+
status = ScriptStatus.COMPLETED if success else ScriptStatus.FAILED
|
|
317
|
+
error = None if success else self._derive_error_message(host, exit_code)
|
|
318
|
+
|
|
319
|
+
self._cleanup_session()
|
|
320
|
+
|
|
321
|
+
return ScriptResult(
|
|
322
|
+
status=status,
|
|
323
|
+
transcript=transcript,
|
|
324
|
+
results=results,
|
|
325
|
+
exit_code=exit_code,
|
|
326
|
+
error=error,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _cleanup_session(self) -> None:
|
|
330
|
+
with self._lock:
|
|
331
|
+
self._session = None
|
|
332
|
+
|
|
333
|
+
def _require_session(self) -> HostSession:
|
|
334
|
+
session = self._session
|
|
335
|
+
if session is None:
|
|
336
|
+
raise RuntimeError("No active FSCP session.")
|
|
337
|
+
return session
|
|
338
|
+
|
|
339
|
+
def _create_worker(self, script_path: Path) -> WorkerFn:
|
|
340
|
+
return partial(
|
|
341
|
+
_script_worker_entry,
|
|
342
|
+
str(script_path),
|
|
343
|
+
str(self.app_root),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _extract_input_request(self, host: Host) -> ScriptInputRequest:
|
|
347
|
+
for event in reversed(host.transcript):
|
|
348
|
+
msg = event.message
|
|
349
|
+
if msg and msg.type is MessageType.REQUEST_INPUT:
|
|
350
|
+
payload = msg.payload or {}
|
|
351
|
+
raw_id = payload.get("id")
|
|
352
|
+
if raw_id is None:
|
|
353
|
+
break
|
|
354
|
+
mode = str(payload.get("mode", "input"))
|
|
355
|
+
if mode not in {"input", "confirm"}:
|
|
356
|
+
mode = "input"
|
|
357
|
+
raw_fields = payload.get("fields")
|
|
358
|
+
fields: list[dict[str, Any]] = []
|
|
359
|
+
if isinstance(raw_fields, list):
|
|
360
|
+
for item in raw_fields:
|
|
361
|
+
if isinstance(item, dict):
|
|
362
|
+
fields.append(dict(item))
|
|
363
|
+
raw_suggestions = payload.get("suggestions")
|
|
364
|
+
suggestions: list[str] = []
|
|
365
|
+
if isinstance(raw_suggestions, list):
|
|
366
|
+
for value in raw_suggestions:
|
|
367
|
+
if isinstance(value, str) and value:
|
|
368
|
+
suggestions.append(value)
|
|
369
|
+
show_text_input = payload.get("show_text_input", True)
|
|
370
|
+
if not isinstance(show_text_input, bool):
|
|
371
|
+
show_text_input = True
|
|
372
|
+
return ScriptInputRequest(
|
|
373
|
+
id=str(raw_id),
|
|
374
|
+
prompt=str(payload.get("prompt", "")),
|
|
375
|
+
default=payload.get("default"),
|
|
376
|
+
secret=bool(payload.get("secret", False)),
|
|
377
|
+
mode=mode, # type: ignore
|
|
378
|
+
fields=fields,
|
|
379
|
+
suggestions=suggestions,
|
|
380
|
+
show_text_input=show_text_input,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
raise RuntimeError("FSCP host entered input state without payload.")
|
|
384
|
+
|
|
385
|
+
def _extract_exit_code(
|
|
386
|
+
self,
|
|
387
|
+
transcript: list[TranscriptEvent],
|
|
388
|
+
) -> int | None:
|
|
389
|
+
for event in reversed(transcript):
|
|
390
|
+
msg = event.message
|
|
391
|
+
if msg and msg.type is MessageType.EXIT:
|
|
392
|
+
payload = msg.payload or {}
|
|
393
|
+
code = payload.get("code")
|
|
394
|
+
if isinstance(code, int):
|
|
395
|
+
return code
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
def _derive_error_message(
|
|
399
|
+
self,
|
|
400
|
+
host: Host,
|
|
401
|
+
exit_code: int | None,
|
|
402
|
+
) -> str:
|
|
403
|
+
detail = self._latest_system_note(host)
|
|
404
|
+
|
|
405
|
+
if host.state is HostState.ERR_PROTOCOL:
|
|
406
|
+
base = "Script failed due to an FSCP protocol violation."
|
|
407
|
+
elif host.state is HostState.ERR_TRANSPORT:
|
|
408
|
+
base = "Script failed due to a transport error."
|
|
409
|
+
else:
|
|
410
|
+
base = "Script exited with errors."
|
|
411
|
+
|
|
412
|
+
if exit_code not in (None, 0):
|
|
413
|
+
base = f"{base} (exit code {exit_code})"
|
|
414
|
+
|
|
415
|
+
return f"{base} {detail}".strip()
|
|
416
|
+
|
|
417
|
+
def _latest_system_note(self, host: Host) -> str:
|
|
418
|
+
for event in reversed(host.transcript):
|
|
419
|
+
if event.direction is MessageDirection.INTERNAL and event.raw:
|
|
420
|
+
return event.raw
|
|
421
|
+
return ""
|
ferp/core/settings.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from platformdirs import user_config_path
|
|
5
|
+
|
|
6
|
+
from ferp.core.paths import APP_AUTHOR, APP_NAME
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_settings(app_root: Path) -> dict:
|
|
10
|
+
path = Path(user_config_path(APP_NAME, APP_AUTHOR)) / "settings.json"
|
|
11
|
+
return json.loads(path.read_text()) if path.exists() else {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def save_settings(app_root: Path, settings: dict) -> None:
|
|
15
|
+
path = Path(user_config_path(APP_NAME, APP_AUTHOR)) / "settings.json"
|
|
16
|
+
path.write_text(json.dumps(settings, indent=4))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SettingsStore:
|
|
9
|
+
"""Load and persist FERP user settings."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, path: Path) -> None:
|
|
12
|
+
self._path = path
|
|
13
|
+
|
|
14
|
+
def load(self) -> dict[str, Any]:
|
|
15
|
+
"""Read settings from disk, injecting expected sections."""
|
|
16
|
+
if self._path.exists():
|
|
17
|
+
try:
|
|
18
|
+
data = json.loads(self._path.read_text())
|
|
19
|
+
except (OSError, json.JSONDecodeError):
|
|
20
|
+
data = {}
|
|
21
|
+
else:
|
|
22
|
+
data = {}
|
|
23
|
+
return self._with_defaults(data)
|
|
24
|
+
|
|
25
|
+
def save(self, settings: dict[str, Any]) -> None:
|
|
26
|
+
"""Persist settings to disk."""
|
|
27
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._path.write_text(json.dumps(settings, indent=4))
|
|
29
|
+
|
|
30
|
+
def update_theme(self, settings: dict[str, Any], theme_name: str) -> None:
|
|
31
|
+
"""Store the active theme."""
|
|
32
|
+
settings.setdefault("userPreferences", {})["theme"] = theme_name
|
|
33
|
+
self.save(settings)
|
|
34
|
+
|
|
35
|
+
def update_startup_path(self, settings: dict[str, Any], path: Path | str) -> None:
|
|
36
|
+
"""Store the startup directory."""
|
|
37
|
+
settings.setdefault("userPreferences", {})["startupPath"] = str(path)
|
|
38
|
+
self.save(settings)
|
|
39
|
+
|
|
40
|
+
def log_preferences(self, settings: dict[str, Any]) -> tuple[int, int]:
|
|
41
|
+
"""Return (max_files, max_age_days) for transcript pruning."""
|
|
42
|
+
logs = settings.setdefault("logs", {})
|
|
43
|
+
max_files = self._coerce_positive_int(
|
|
44
|
+
logs.get("maxFiles"), default=50, min_value=1
|
|
45
|
+
)
|
|
46
|
+
max_age_days = self._coerce_positive_int(
|
|
47
|
+
logs.get("maxAgeDays"), default=14, min_value=0
|
|
48
|
+
)
|
|
49
|
+
return max_files, max_age_days
|
|
50
|
+
|
|
51
|
+
def _with_defaults(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
data.setdefault("userPreferences", {})
|
|
53
|
+
data.setdefault("logs", {})
|
|
54
|
+
integrations = data.setdefault("integrations", {})
|
|
55
|
+
integrations.setdefault("monday", {})
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
def _coerce_positive_int(
|
|
59
|
+
self,
|
|
60
|
+
value: Any,
|
|
61
|
+
*,
|
|
62
|
+
default: int,
|
|
63
|
+
min_value: int,
|
|
64
|
+
) -> int:
|
|
65
|
+
try:
|
|
66
|
+
number = int(value)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
number = default
|
|
69
|
+
return max(min_value, number)
|
ferp/core/state.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from ferp.core.script_runner import ScriptResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ScriptRunState:
|
|
13
|
+
phase: str = "idle"
|
|
14
|
+
script_name: str | None = None
|
|
15
|
+
target_path: Path | None = None
|
|
16
|
+
input_prompt: str | None = None
|
|
17
|
+
progress_message: str = ""
|
|
18
|
+
progress_line: str = ""
|
|
19
|
+
progress_current: float | None = None
|
|
20
|
+
progress_total: float | None = None
|
|
21
|
+
progress_unit: str = ""
|
|
22
|
+
result: ScriptResult | None = None
|
|
23
|
+
transcript_path: Path | None = None
|
|
24
|
+
error: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class FileTreeState:
|
|
29
|
+
filter_query: str = ""
|
|
30
|
+
current_listing_path: Path | None = None
|
|
31
|
+
last_selected_path: Path | None = None
|
|
32
|
+
selection_history: dict[Path, Path] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class TaskListState:
|
|
37
|
+
active_tag_filter: frozenset[str] = frozenset()
|
|
38
|
+
highlighted_task_id: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class AppState:
|
|
43
|
+
current_path: str = ""
|
|
44
|
+
status: str = "Ready"
|
|
45
|
+
cache_updated_at: datetime = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
46
|
+
script_run: ScriptRunState = field(default_factory=ScriptRunState)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AppStateStore:
|
|
50
|
+
def __init__(self, initial: AppState | None = None) -> None:
|
|
51
|
+
self._state = initial or AppState()
|
|
52
|
+
self._listeners: set[Callable[[AppState], None]] = set()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def state(self) -> AppState:
|
|
56
|
+
return self._state
|
|
57
|
+
|
|
58
|
+
def subscribe(self, callback: Callable[[AppState], None]) -> None:
|
|
59
|
+
self._listeners.add(callback)
|
|
60
|
+
callback(self._state)
|
|
61
|
+
|
|
62
|
+
def unsubscribe(self, callback: Callable[[AppState], None]) -> None:
|
|
63
|
+
self._listeners.discard(callback)
|
|
64
|
+
|
|
65
|
+
def set_current_path(self, value: str) -> None:
|
|
66
|
+
self._update_state(current_path=value)
|
|
67
|
+
|
|
68
|
+
def set_status(self, value: str) -> None:
|
|
69
|
+
self._update_state(status=value)
|
|
70
|
+
|
|
71
|
+
def set_cache_updated_at(self, value: datetime) -> None:
|
|
72
|
+
self._update_state(cache_updated_at=value)
|
|
73
|
+
|
|
74
|
+
def update_script_run(self, **changes: object) -> None:
|
|
75
|
+
self._update_state(script_run=replace(self._state.script_run, **changes))
|
|
76
|
+
|
|
77
|
+
def _update_state(self, **changes: object) -> None:
|
|
78
|
+
new_state = replace(self._state, **changes)
|
|
79
|
+
if new_state == self._state:
|
|
80
|
+
return
|
|
81
|
+
self._state = new_state
|
|
82
|
+
for callback in list(self._listeners):
|
|
83
|
+
callback(self._state)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FileTreeStateStore:
|
|
87
|
+
def __init__(self, initial: FileTreeState | None = None) -> None:
|
|
88
|
+
self._state = initial or FileTreeState()
|
|
89
|
+
self._listeners: set[Callable[[FileTreeState], None]] = set()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def state(self) -> FileTreeState:
|
|
93
|
+
return self._state
|
|
94
|
+
|
|
95
|
+
def subscribe(self, callback: Callable[[FileTreeState], None]) -> None:
|
|
96
|
+
self._listeners.add(callback)
|
|
97
|
+
callback(self._state)
|
|
98
|
+
|
|
99
|
+
def unsubscribe(self, callback: Callable[[FileTreeState], None]) -> None:
|
|
100
|
+
self._listeners.discard(callback)
|
|
101
|
+
|
|
102
|
+
def set_filter_query(self, value: str) -> None:
|
|
103
|
+
self._update_state(filter_query=value)
|
|
104
|
+
|
|
105
|
+
def set_current_listing_path(self, value: Path | None) -> None:
|
|
106
|
+
self._update_state(current_listing_path=value)
|
|
107
|
+
|
|
108
|
+
def set_last_selected_path(self, value: Path | None) -> None:
|
|
109
|
+
self._update_state(last_selected_path=value)
|
|
110
|
+
|
|
111
|
+
def clear_selection_history(self) -> None:
|
|
112
|
+
self._update_state(selection_history={})
|
|
113
|
+
|
|
114
|
+
def update_selection_history(self, directory: Path, selected: Path) -> None:
|
|
115
|
+
history = dict(self._state.selection_history)
|
|
116
|
+
history[directory] = selected
|
|
117
|
+
self._update_state(selection_history=history)
|
|
118
|
+
|
|
119
|
+
def _update_state(self, **changes: object) -> None:
|
|
120
|
+
new_state = replace(self._state, **changes)
|
|
121
|
+
if new_state == self._state:
|
|
122
|
+
return
|
|
123
|
+
self._state = new_state
|
|
124
|
+
for callback in list(self._listeners):
|
|
125
|
+
callback(self._state)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TaskListStateStore:
|
|
129
|
+
def __init__(self, initial: TaskListState | None = None) -> None:
|
|
130
|
+
self._state = initial or TaskListState()
|
|
131
|
+
self._listeners: set[Callable[[TaskListState], None]] = set()
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def state(self) -> TaskListState:
|
|
135
|
+
return self._state
|
|
136
|
+
|
|
137
|
+
def subscribe(self, callback: Callable[[TaskListState], None]) -> None:
|
|
138
|
+
self._listeners.add(callback)
|
|
139
|
+
callback(self._state)
|
|
140
|
+
|
|
141
|
+
def unsubscribe(self, callback: Callable[[TaskListState], None]) -> None:
|
|
142
|
+
self._listeners.discard(callback)
|
|
143
|
+
|
|
144
|
+
def set_active_tag_filter(self, value: set[str]) -> None:
|
|
145
|
+
self._update_state(active_tag_filter=frozenset(value))
|
|
146
|
+
|
|
147
|
+
def set_highlighted_task_id(self, value: str | None) -> None:
|
|
148
|
+
self._update_state(highlighted_task_id=value)
|
|
149
|
+
|
|
150
|
+
def _update_state(self, **changes: object) -> None:
|
|
151
|
+
new_state = replace(self._state, **changes)
|
|
152
|
+
if new_state == self._state:
|
|
153
|
+
return
|
|
154
|
+
self._state = new_state
|
|
155
|
+
for callback in list(self._listeners):
|
|
156
|
+
callback(self._state)
|