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
ferp/fscp/scripts/sdk.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import (
|
|
11
|
+
Any,
|
|
12
|
+
Callable,
|
|
13
|
+
Dict,
|
|
14
|
+
Literal,
|
|
15
|
+
Mapping,
|
|
16
|
+
Sequence,
|
|
17
|
+
TypedDict,
|
|
18
|
+
TypeVar,
|
|
19
|
+
cast,
|
|
20
|
+
overload,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ferp.fscp.protocol.messages import Message, MessageType
|
|
24
|
+
from ferp.fscp.protocol.validator import Endpoint, ProtocolValidator
|
|
25
|
+
from ferp.fscp.scripts.runtime.errors import FatalScriptError, ProtocolViolation
|
|
26
|
+
from ferp.fscp.scripts.runtime.io import read_message, try_read_message, write_message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScriptCancelled(FatalScriptError):
|
|
30
|
+
"""Raised when the host requests cancellation."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ScriptContext:
|
|
35
|
+
"""Normalized data supplied by the host at script startup."""
|
|
36
|
+
|
|
37
|
+
target_path: Path
|
|
38
|
+
target_kind: Literal["file", "directory"]
|
|
39
|
+
params: Dict[str, Any]
|
|
40
|
+
environment: "ScriptEnvironment"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScriptEnvironmentApp(TypedDict):
|
|
44
|
+
name: str
|
|
45
|
+
version: str
|
|
46
|
+
build: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScriptEnvironmentHost(TypedDict):
|
|
50
|
+
platform: str
|
|
51
|
+
os: str
|
|
52
|
+
os_version: str
|
|
53
|
+
arch: str
|
|
54
|
+
python: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ScriptEnvironmentPaths(TypedDict):
|
|
58
|
+
app_root: str
|
|
59
|
+
cwd: str
|
|
60
|
+
cache_dir: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ScriptEnvironment(TypedDict):
|
|
64
|
+
app: ScriptEnvironmentApp
|
|
65
|
+
host: ScriptEnvironmentHost
|
|
66
|
+
paths: ScriptEnvironmentPaths
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BoolField(TypedDict):
|
|
70
|
+
id: str
|
|
71
|
+
type: Literal["bool"]
|
|
72
|
+
label: str
|
|
73
|
+
default: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MultiSelectField(TypedDict):
|
|
77
|
+
id: str
|
|
78
|
+
type: Literal["multi_select"]
|
|
79
|
+
label: str
|
|
80
|
+
options: Sequence[str]
|
|
81
|
+
default: Sequence[str]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_InputPayloadT = TypeVar("_InputPayloadT", bound=Mapping[str, object])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ScriptAPI:
|
|
88
|
+
"""High-level helpers for authoring FSCP scripts."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, transport: "_Transport") -> None:
|
|
91
|
+
self._transport = transport
|
|
92
|
+
self._request_counter = itertools.count(1)
|
|
93
|
+
self._exited = False
|
|
94
|
+
self._log_level = _normalize_log_level(
|
|
95
|
+
os.environ.get("FERP_SCRIPT_LOG_LEVEL", "info")
|
|
96
|
+
)
|
|
97
|
+
self._cleanup_hooks: list[Callable[[], None]] = []
|
|
98
|
+
self._cleanup_ran = False
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def exited(self) -> bool:
|
|
102
|
+
return self._exited
|
|
103
|
+
|
|
104
|
+
def log(self, level: str, message: str) -> None:
|
|
105
|
+
self._ensure_running()
|
|
106
|
+
if not _should_emit_log(level, self._log_level):
|
|
107
|
+
return
|
|
108
|
+
payload = {"level": level, "message": message}
|
|
109
|
+
self._transport.send(MessageType.LOG, payload)
|
|
110
|
+
|
|
111
|
+
def progress(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
current: float,
|
|
115
|
+
total: float | None = None,
|
|
116
|
+
unit: str | None = None,
|
|
117
|
+
message: str | None = None,
|
|
118
|
+
every: int | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
self._ensure_running()
|
|
121
|
+
if every is not None:
|
|
122
|
+
if every <= 0:
|
|
123
|
+
every = 1
|
|
124
|
+
if not (
|
|
125
|
+
current == 1
|
|
126
|
+
or (total is not None and current == total)
|
|
127
|
+
or current % every == 0
|
|
128
|
+
):
|
|
129
|
+
return
|
|
130
|
+
payload: Dict[str, Any] = {"current": current}
|
|
131
|
+
if total is not None:
|
|
132
|
+
payload["total"] = total
|
|
133
|
+
if unit is not None:
|
|
134
|
+
payload["unit"] = unit
|
|
135
|
+
if message is not None:
|
|
136
|
+
payload["message"] = message
|
|
137
|
+
self._transport.send(MessageType.PROGRESS, payload)
|
|
138
|
+
|
|
139
|
+
def emit_result(self, payload: Mapping[str, Any]) -> None:
|
|
140
|
+
self._ensure_running()
|
|
141
|
+
self._transport.send(MessageType.RESULT, dict(payload))
|
|
142
|
+
|
|
143
|
+
def register_cleanup(self, func: Callable[[], None]) -> None:
|
|
144
|
+
"""Register a cleanup callback to run on cancellation or exit."""
|
|
145
|
+
if not callable(func):
|
|
146
|
+
raise ValueError("Cleanup hook must be callable.")
|
|
147
|
+
self._cleanup_hooks.append(func)
|
|
148
|
+
|
|
149
|
+
def check_cancel(self) -> None:
|
|
150
|
+
"""Raise ScriptCancelled if the host has requested cancellation."""
|
|
151
|
+
self._transport.poll_cancel()
|
|
152
|
+
if self._transport.is_cancelled():
|
|
153
|
+
raise ScriptCancelled("Host cancelled script.")
|
|
154
|
+
|
|
155
|
+
def is_cancelled(self) -> bool:
|
|
156
|
+
"""Return True if a cancellation request has been received."""
|
|
157
|
+
return self._transport.is_cancelled()
|
|
158
|
+
|
|
159
|
+
def request_input(
|
|
160
|
+
self,
|
|
161
|
+
prompt: str,
|
|
162
|
+
*,
|
|
163
|
+
default: str | None = None,
|
|
164
|
+
secret: bool = False,
|
|
165
|
+
id: str | None = None,
|
|
166
|
+
mode: Literal["input", "confirm"] = "input",
|
|
167
|
+
fields: Sequence[Mapping[str, Any]] | None = None,
|
|
168
|
+
suggestions: Sequence[str] | None = None,
|
|
169
|
+
show_text_input: bool | None = None,
|
|
170
|
+
) -> str:
|
|
171
|
+
self._ensure_running()
|
|
172
|
+
request_id = id or f"input-{next(self._request_counter)}"
|
|
173
|
+
request: Dict[str, Any] = {
|
|
174
|
+
"id": request_id,
|
|
175
|
+
"prompt": prompt,
|
|
176
|
+
"mode": mode,
|
|
177
|
+
}
|
|
178
|
+
if default is not None:
|
|
179
|
+
request["default"] = default
|
|
180
|
+
if secret:
|
|
181
|
+
request["secret"] = True
|
|
182
|
+
if fields:
|
|
183
|
+
request["fields"] = fields
|
|
184
|
+
if suggestions:
|
|
185
|
+
request["suggestions"] = list(suggestions)
|
|
186
|
+
if show_text_input is not None:
|
|
187
|
+
request["show_text_input"] = show_text_input
|
|
188
|
+
|
|
189
|
+
self._transport.send(MessageType.REQUEST_INPUT, request)
|
|
190
|
+
return self._transport.wait_for_input(request_id)
|
|
191
|
+
|
|
192
|
+
@overload
|
|
193
|
+
def request_input_json(
|
|
194
|
+
self,
|
|
195
|
+
prompt: str,
|
|
196
|
+
*,
|
|
197
|
+
default: str | None = None,
|
|
198
|
+
secret: bool = False,
|
|
199
|
+
id: str | None = None,
|
|
200
|
+
fields: Sequence[BoolField | MultiSelectField] | None = None,
|
|
201
|
+
suggestions: Sequence[str] | None = None,
|
|
202
|
+
show_text_input: bool | None = None,
|
|
203
|
+
) -> Dict[str, str | bool | list[str]]: ...
|
|
204
|
+
|
|
205
|
+
@overload
|
|
206
|
+
def request_input_json(
|
|
207
|
+
self,
|
|
208
|
+
prompt: str,
|
|
209
|
+
*,
|
|
210
|
+
default: str | None = None,
|
|
211
|
+
secret: bool = False,
|
|
212
|
+
id: str | None = None,
|
|
213
|
+
fields: Sequence[BoolField | MultiSelectField] | None = None,
|
|
214
|
+
suggestions: Sequence[str] | None = None,
|
|
215
|
+
show_text_input: bool | None = None,
|
|
216
|
+
payload_type: type[_InputPayloadT],
|
|
217
|
+
) -> _InputPayloadT: ...
|
|
218
|
+
|
|
219
|
+
def request_input_json(
|
|
220
|
+
self,
|
|
221
|
+
prompt: str,
|
|
222
|
+
*,
|
|
223
|
+
default: str | None = None,
|
|
224
|
+
secret: bool = False,
|
|
225
|
+
id: str | None = None,
|
|
226
|
+
fields: Sequence[BoolField | MultiSelectField] | None = None,
|
|
227
|
+
suggestions: Sequence[str] | None = None,
|
|
228
|
+
show_text_input: bool | None = None,
|
|
229
|
+
payload_type: type[_InputPayloadT] | None = None,
|
|
230
|
+
) -> Dict[str, str | bool | list[str]] | _InputPayloadT:
|
|
231
|
+
if fields:
|
|
232
|
+
self._validate_fields(fields)
|
|
233
|
+
raw = self.request_input(
|
|
234
|
+
prompt,
|
|
235
|
+
default=default,
|
|
236
|
+
secret=secret,
|
|
237
|
+
id=id,
|
|
238
|
+
mode="input",
|
|
239
|
+
fields=fields,
|
|
240
|
+
suggestions=suggestions,
|
|
241
|
+
show_text_input=show_text_input,
|
|
242
|
+
)
|
|
243
|
+
payload = json.loads(raw)
|
|
244
|
+
if not isinstance(payload, dict):
|
|
245
|
+
raise ValueError("Expected JSON object for request_input_json response.")
|
|
246
|
+
if fields:
|
|
247
|
+
self._validate_payload_fields(payload, fields)
|
|
248
|
+
if payload_type is not None:
|
|
249
|
+
return cast(_InputPayloadT, payload)
|
|
250
|
+
return payload
|
|
251
|
+
|
|
252
|
+
def confirm(
|
|
253
|
+
self,
|
|
254
|
+
prompt: str,
|
|
255
|
+
*,
|
|
256
|
+
default: bool = False,
|
|
257
|
+
id: str | None = None,
|
|
258
|
+
) -> bool:
|
|
259
|
+
raw = self.request_input(
|
|
260
|
+
prompt,
|
|
261
|
+
default="true" if default else "false",
|
|
262
|
+
id=id,
|
|
263
|
+
mode="confirm",
|
|
264
|
+
)
|
|
265
|
+
return raw.strip().lower() in {"true", "1", "yes", "y"}
|
|
266
|
+
|
|
267
|
+
def _validate_fields(
|
|
268
|
+
self,
|
|
269
|
+
fields: Sequence[BoolField | MultiSelectField],
|
|
270
|
+
) -> None:
|
|
271
|
+
for field in fields:
|
|
272
|
+
field_id = field.get("id")
|
|
273
|
+
label = field.get("label")
|
|
274
|
+
field_type = field.get("type")
|
|
275
|
+
if not isinstance(field_id, str) or not field_id:
|
|
276
|
+
raise ValueError("Fields must define a non-empty 'id'.")
|
|
277
|
+
if not isinstance(label, str) or not label:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"Field '{field_id}' must define a non-empty 'label'."
|
|
280
|
+
)
|
|
281
|
+
if field_type == "bool":
|
|
282
|
+
default = field.get("default")
|
|
283
|
+
if not isinstance(default, bool):
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"Boolean field '{field_id}' must define a boolean 'default'."
|
|
286
|
+
)
|
|
287
|
+
continue
|
|
288
|
+
if field_type == "multi_select":
|
|
289
|
+
options = field.get("options")
|
|
290
|
+
default = field.get("default", [])
|
|
291
|
+
if not isinstance(options, Sequence) or not options:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"Multi-select field '{field_id}' must define non-empty 'options'."
|
|
294
|
+
)
|
|
295
|
+
if any(not isinstance(item, str) or not item for item in options):
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"Multi-select field '{field_id}' options must be strings."
|
|
298
|
+
)
|
|
299
|
+
if not isinstance(default, Sequence):
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"Multi-select field '{field_id}' must define a list 'default'."
|
|
302
|
+
)
|
|
303
|
+
if any(not isinstance(item, str) for item in default):
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"Multi-select field '{field_id}' default values must be strings."
|
|
306
|
+
)
|
|
307
|
+
continue
|
|
308
|
+
raise ValueError(
|
|
309
|
+
"request_input_json only supports bool or multi_select fields; "
|
|
310
|
+
f"received {field_type!r}."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _validate_payload_fields(
|
|
314
|
+
self,
|
|
315
|
+
payload: Dict[str, Any],
|
|
316
|
+
fields: Sequence[BoolField | MultiSelectField],
|
|
317
|
+
) -> None:
|
|
318
|
+
value = payload.get("value")
|
|
319
|
+
if not isinstance(value, str):
|
|
320
|
+
raise ValueError("request_input_json payload must include string 'value'.")
|
|
321
|
+
for field in fields:
|
|
322
|
+
field_id = field["id"]
|
|
323
|
+
if field_id not in payload:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"request_input_json payload missing field '{field_id}'."
|
|
326
|
+
)
|
|
327
|
+
field_type = field.get("type")
|
|
328
|
+
if field_type == "bool":
|
|
329
|
+
if not isinstance(payload[field_id], bool):
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"request_input_json field '{field_id}' must be a boolean."
|
|
332
|
+
)
|
|
333
|
+
continue
|
|
334
|
+
if field_type == "multi_select":
|
|
335
|
+
values = payload[field_id]
|
|
336
|
+
if not isinstance(values, list):
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"request_input_json field '{field_id}' must be a list."
|
|
339
|
+
)
|
|
340
|
+
if any(not isinstance(item, str) for item in values):
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"request_input_json field '{field_id}' must contain strings."
|
|
343
|
+
)
|
|
344
|
+
continue
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"request_input_json field '{field_id}' has unknown type '{field_type}'."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def exit(self, *, code: int = 0) -> None:
|
|
350
|
+
if self._exited:
|
|
351
|
+
return
|
|
352
|
+
self._transport.send(MessageType.EXIT, {"code": code})
|
|
353
|
+
self._exited = True
|
|
354
|
+
|
|
355
|
+
def _ensure_running(self) -> None:
|
|
356
|
+
if self._exited:
|
|
357
|
+
raise RuntimeError("Cannot interact with host after exit has been sent.")
|
|
358
|
+
|
|
359
|
+
def _run_cleanup_hooks(self) -> None:
|
|
360
|
+
if self._cleanup_ran:
|
|
361
|
+
return
|
|
362
|
+
self._cleanup_ran = True
|
|
363
|
+
for hook in self._cleanup_hooks:
|
|
364
|
+
try:
|
|
365
|
+
hook()
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
if not self._exited:
|
|
368
|
+
self.log("error", f"Cleanup hook failed: {exc}")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
ScriptCallable = Callable[[ScriptContext, ScriptAPI], None]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def run(script_fn: ScriptCallable) -> None:
|
|
375
|
+
"""Entrypoint for running an FSCP script function."""
|
|
376
|
+
session = _ScriptSession(script_fn)
|
|
377
|
+
session.run()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def script(func: ScriptCallable) -> Callable[[], None]:
|
|
381
|
+
"""Decorator that converts a (ctx, api) callable into an executable script."""
|
|
382
|
+
|
|
383
|
+
@wraps(func)
|
|
384
|
+
def wrapper() -> None:
|
|
385
|
+
run(func)
|
|
386
|
+
|
|
387
|
+
return wrapper
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _Transport:
|
|
391
|
+
def __init__(self) -> None:
|
|
392
|
+
self._validator = ProtocolValidator()
|
|
393
|
+
self._cancelled = False
|
|
394
|
+
|
|
395
|
+
def is_cancelled(self) -> bool:
|
|
396
|
+
return self._cancelled
|
|
397
|
+
|
|
398
|
+
def send(self, msg_type: MessageType, payload: Mapping[str, Any]) -> None:
|
|
399
|
+
msg = Message(type=msg_type, payload=dict(payload))
|
|
400
|
+
self._validator.validate(msg, sender=Endpoint.SCRIPT)
|
|
401
|
+
write_message(msg.to_dict())
|
|
402
|
+
|
|
403
|
+
def poll_cancel(self) -> None:
|
|
404
|
+
if self._cancelled:
|
|
405
|
+
return
|
|
406
|
+
msg = self._try_receive()
|
|
407
|
+
if msg is None:
|
|
408
|
+
return
|
|
409
|
+
if msg.type is MessageType.CANCEL:
|
|
410
|
+
self._cancelled = True
|
|
411
|
+
return
|
|
412
|
+
# Ignore unexpected non-cancel messages in polling mode.
|
|
413
|
+
|
|
414
|
+
def expect(self, expected: MessageType) -> Message:
|
|
415
|
+
msg = self.receive()
|
|
416
|
+
if msg.type is expected:
|
|
417
|
+
return msg
|
|
418
|
+
if msg.type is MessageType.CANCEL:
|
|
419
|
+
self._cancelled = True
|
|
420
|
+
raise ScriptCancelled("Host cancelled script before it started.")
|
|
421
|
+
raise ProtocolViolation(
|
|
422
|
+
f"Expected '{expected.value}', received '{msg.type.value}'."
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def receive(self) -> Message:
|
|
426
|
+
raw = read_message()
|
|
427
|
+
msg = Message.from_dict(raw)
|
|
428
|
+
self._validator.validate(msg, sender=Endpoint.HOST)
|
|
429
|
+
if msg.type is MessageType.CANCEL:
|
|
430
|
+
self._cancelled = True
|
|
431
|
+
return msg
|
|
432
|
+
|
|
433
|
+
def _try_receive(self) -> Message | None:
|
|
434
|
+
raw = try_read_message()
|
|
435
|
+
if raw is None:
|
|
436
|
+
return None
|
|
437
|
+
msg = Message.from_dict(raw)
|
|
438
|
+
self._validator.validate(msg, sender=Endpoint.HOST)
|
|
439
|
+
return msg
|
|
440
|
+
|
|
441
|
+
def wait_for_input(self, request_id: str) -> str:
|
|
442
|
+
while True:
|
|
443
|
+
msg = self.receive()
|
|
444
|
+
if msg.type is MessageType.INPUT_RESPONSE:
|
|
445
|
+
payload = msg.payload or {}
|
|
446
|
+
if str(payload.get("id")) != request_id:
|
|
447
|
+
raise ProtocolViolation("Received mismatched input response.")
|
|
448
|
+
value = payload.get("value")
|
|
449
|
+
if not isinstance(value, str):
|
|
450
|
+
raise ProtocolViolation("Input response value must be a string.")
|
|
451
|
+
return value
|
|
452
|
+
|
|
453
|
+
if msg.type is MessageType.CANCEL:
|
|
454
|
+
self._cancelled = True
|
|
455
|
+
raise ScriptCancelled("Host cancelled script while awaiting input.")
|
|
456
|
+
|
|
457
|
+
raise ProtocolViolation(
|
|
458
|
+
f"Unexpected message '{msg.type.value}' while awaiting input."
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class _ScriptSession:
|
|
463
|
+
def __init__(self, script_fn: ScriptCallable) -> None:
|
|
464
|
+
self._script_fn = script_fn
|
|
465
|
+
self._transport = _Transport()
|
|
466
|
+
|
|
467
|
+
def run(self) -> None:
|
|
468
|
+
api = ScriptAPI(self._transport)
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
init_msg = self._transport.expect(MessageType.INIT)
|
|
472
|
+
except EOFError:
|
|
473
|
+
return
|
|
474
|
+
except ScriptCancelled:
|
|
475
|
+
api.exit(code=1)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
context = _build_context(init_msg)
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
self._script_fn(context, api)
|
|
482
|
+
except ScriptCancelled as exc:
|
|
483
|
+
api.log("warn", str(exc))
|
|
484
|
+
api._run_cleanup_hooks()
|
|
485
|
+
api.exit(code=1)
|
|
486
|
+
return
|
|
487
|
+
except Exception:
|
|
488
|
+
tb = traceback.format_exc().rstrip()
|
|
489
|
+
api.log("error", tb)
|
|
490
|
+
api._run_cleanup_hooks()
|
|
491
|
+
api.exit(code=1)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
api._run_cleanup_hooks()
|
|
495
|
+
api.exit(code=0)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _build_context(init_msg: Message) -> ScriptContext:
|
|
499
|
+
payload = init_msg.payload or {}
|
|
500
|
+
target = payload.get("target") or {}
|
|
501
|
+
path = Path(str(target.get("path", ".")))
|
|
502
|
+
kind = str(target.get("kind", "file"))
|
|
503
|
+
if kind not in {"file", "directory"}:
|
|
504
|
+
kind = "file"
|
|
505
|
+
|
|
506
|
+
params = dict(payload.get("params") or {})
|
|
507
|
+
environment = cast(ScriptEnvironment, payload.get("environment") or {})
|
|
508
|
+
|
|
509
|
+
return ScriptContext(
|
|
510
|
+
target_path=path,
|
|
511
|
+
target_kind=cast(Literal["file", "directory"], kind),
|
|
512
|
+
params=params,
|
|
513
|
+
environment=environment,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
_LOG_LEVELS: dict[str, int] = {
|
|
518
|
+
"debug": 10,
|
|
519
|
+
"info": 20,
|
|
520
|
+
"warn": 30,
|
|
521
|
+
"error": 40,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _normalize_log_level(level: str | None) -> int:
|
|
526
|
+
if not level:
|
|
527
|
+
return _LOG_LEVELS["info"]
|
|
528
|
+
return _LOG_LEVELS.get(str(level).strip().lower(), _LOG_LEVELS["info"])
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _should_emit_log(level: str, minimum: int) -> bool:
|
|
532
|
+
return _normalize_log_level(level) >= minimum
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
__all__ = [
|
|
536
|
+
"BoolField",
|
|
537
|
+
"MultiSelectField",
|
|
538
|
+
"ScriptEnvironment",
|
|
539
|
+
"ScriptEnvironmentApp",
|
|
540
|
+
"ScriptEnvironmentHost",
|
|
541
|
+
"ScriptEnvironmentPaths",
|
|
542
|
+
"ScriptAPI",
|
|
543
|
+
"ScriptCallable",
|
|
544
|
+
"ScriptCancelled",
|
|
545
|
+
"ScriptContext",
|
|
546
|
+
"run",
|
|
547
|
+
"script",
|
|
548
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ferp.fscp.protocol.messages import Message, MessageDirection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TranscriptEvent:
|
|
11
|
+
timestamp: float
|
|
12
|
+
direction: MessageDirection
|
|
13
|
+
message: Optional[Message] = None
|
|
14
|
+
raw: Optional[str] = None
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ferp.widgets.file_tree import FileListingEntry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DirectoryListingResult:
|
|
13
|
+
path: Path
|
|
14
|
+
token: int
|
|
15
|
+
entries: list[FileListingEntry]
|
|
16
|
+
error: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collect_directory_listing(directory: Path, token: int) -> DirectoryListingResult:
|
|
20
|
+
try:
|
|
21
|
+
with os.scandir(directory) as scan:
|
|
22
|
+
visible = []
|
|
23
|
+
for entry in scan:
|
|
24
|
+
entry_path = Path(entry.path)
|
|
25
|
+
if _should_skip_entry(entry_path, directory):
|
|
26
|
+
continue
|
|
27
|
+
visible.append(entry)
|
|
28
|
+
entries = sorted(visible, key=_sort_key)
|
|
29
|
+
except OSError as exc:
|
|
30
|
+
return DirectoryListingResult(directory, token, [], str(exc))
|
|
31
|
+
|
|
32
|
+
rows: list[FileListingEntry] = []
|
|
33
|
+
for entry in entries:
|
|
34
|
+
listing_entry = _build_listing_entry(entry)
|
|
35
|
+
if listing_entry is not None:
|
|
36
|
+
rows.append(listing_entry)
|
|
37
|
+
|
|
38
|
+
return DirectoryListingResult(directory, token, rows)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sort_key(entry: os.DirEntry[str]) -> tuple[int, str]:
|
|
42
|
+
try:
|
|
43
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
44
|
+
except OSError:
|
|
45
|
+
is_dir = False
|
|
46
|
+
name = entry.name
|
|
47
|
+
underscore_dir_rank = 0 if is_dir and name.startswith("_") else 1
|
|
48
|
+
return (underscore_dir_rank, name.casefold())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def snapshot_directory(path: Path) -> tuple[str, ...]:
|
|
52
|
+
try:
|
|
53
|
+
stat_result = path.stat()
|
|
54
|
+
except OSError:
|
|
55
|
+
return tuple()
|
|
56
|
+
signature = f"{stat_result.st_mtime_ns}:{stat_result.st_size}:{stat_result.st_ino}"
|
|
57
|
+
return (signature,)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_listing_entry(entry: os.DirEntry[str]) -> FileListingEntry | None:
|
|
61
|
+
entry_path = Path(entry.path)
|
|
62
|
+
try:
|
|
63
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
64
|
+
stat_result = entry.stat(follow_symlinks=False)
|
|
65
|
+
except OSError:
|
|
66
|
+
return None
|
|
67
|
+
name = entry.name
|
|
68
|
+
stem = Path(name).stem
|
|
69
|
+
display_name = f"{name}/" if is_dir else stem
|
|
70
|
+
|
|
71
|
+
type_label = "dir" if is_dir else entry_path.suffix.lstrip(".").lower()
|
|
72
|
+
if not type_label:
|
|
73
|
+
type_label = "file"
|
|
74
|
+
|
|
75
|
+
search_blob = f"{display_name}\n{type_label}\n{entry_path.name}".casefold()
|
|
76
|
+
return FileListingEntry(
|
|
77
|
+
path=entry_path,
|
|
78
|
+
display_name=display_name,
|
|
79
|
+
char_count=len(name) if is_dir else len(stem),
|
|
80
|
+
type_label=type_label,
|
|
81
|
+
modified_ts=stat_result.st_mtime,
|
|
82
|
+
is_dir=is_dir,
|
|
83
|
+
search_blob=search_blob,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _should_skip_entry(entry: Path, directory: Path) -> bool:
|
|
88
|
+
name = entry.name
|
|
89
|
+
if name.startswith("."):
|
|
90
|
+
return True
|
|
91
|
+
if name.casefold() == "desktop.ini":
|
|
92
|
+
return True
|
|
93
|
+
if sys.platform == "win32" and _should_filter_windows_home(directory):
|
|
94
|
+
name_folded = name.casefold()
|
|
95
|
+
if name_folded.startswith("ntuser") or name_folded in _WINDOWS_HIDDEN_NAMES:
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_WINDOWS_HIDDEN_NAMES = {
|
|
101
|
+
"intelgraphicsprofiles",
|
|
102
|
+
"desktop.ini",
|
|
103
|
+
"application data",
|
|
104
|
+
"local settings",
|
|
105
|
+
"cookies",
|
|
106
|
+
"history",
|
|
107
|
+
"recent",
|
|
108
|
+
"sendto",
|
|
109
|
+
"start menu",
|
|
110
|
+
"templates",
|
|
111
|
+
"printhood",
|
|
112
|
+
"nethood",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _should_filter_windows_home(directory: Path) -> bool:
|
|
117
|
+
try:
|
|
118
|
+
return directory.resolve() == Path.home().resolve()
|
|
119
|
+
except OSError:
|
|
120
|
+
return False
|