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.
Files changed (87) hide show
  1. ferp/__init__.py +3 -0
  2. ferp/__main__.py +4 -0
  3. ferp/__version__.py +1 -0
  4. ferp/app.py +9 -0
  5. ferp/cli.py +160 -0
  6. ferp/core/__init__.py +0 -0
  7. ferp/core/app.py +1312 -0
  8. ferp/core/bundle_installer.py +245 -0
  9. ferp/core/command_provider.py +77 -0
  10. ferp/core/dependency_manager.py +59 -0
  11. ferp/core/fs_controller.py +70 -0
  12. ferp/core/fs_watcher.py +144 -0
  13. ferp/core/messages.py +49 -0
  14. ferp/core/path_actions.py +124 -0
  15. ferp/core/paths.py +3 -0
  16. ferp/core/protocols.py +8 -0
  17. ferp/core/script_controller.py +515 -0
  18. ferp/core/script_protocol.py +35 -0
  19. ferp/core/script_runner.py +421 -0
  20. ferp/core/settings.py +16 -0
  21. ferp/core/settings_store.py +69 -0
  22. ferp/core/state.py +156 -0
  23. ferp/core/task_store.py +164 -0
  24. ferp/core/transcript_logger.py +95 -0
  25. ferp/domain/__init__.py +0 -0
  26. ferp/domain/scripts.py +29 -0
  27. ferp/fscp/host/__init__.py +11 -0
  28. ferp/fscp/host/host.py +439 -0
  29. ferp/fscp/host/managed_process.py +113 -0
  30. ferp/fscp/host/process_registry.py +124 -0
  31. ferp/fscp/protocol/__init__.py +13 -0
  32. ferp/fscp/protocol/errors.py +2 -0
  33. ferp/fscp/protocol/messages.py +55 -0
  34. ferp/fscp/protocol/schemas/__init__.py +0 -0
  35. ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
  36. ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
  37. ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
  38. ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
  39. ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
  40. ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
  41. ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
  42. ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
  43. ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
  44. ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
  45. ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
  46. ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
  47. ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
  48. ferp/fscp/protocol/state.py +16 -0
  49. ferp/fscp/protocol/validator.py +123 -0
  50. ferp/fscp/scripts/__init__.py +0 -0
  51. ferp/fscp/scripts/runtime/__init__.py +4 -0
  52. ferp/fscp/scripts/runtime/__main__.py +40 -0
  53. ferp/fscp/scripts/runtime/errors.py +14 -0
  54. ferp/fscp/scripts/runtime/io.py +64 -0
  55. ferp/fscp/scripts/runtime/script.py +149 -0
  56. ferp/fscp/scripts/runtime/state.py +17 -0
  57. ferp/fscp/scripts/runtime/worker.py +13 -0
  58. ferp/fscp/scripts/sdk.py +548 -0
  59. ferp/fscp/transcript/__init__.py +3 -0
  60. ferp/fscp/transcript/events.py +14 -0
  61. ferp/resources/__init__.py +0 -0
  62. ferp/services/__init__.py +3 -0
  63. ferp/services/file_listing.py +120 -0
  64. ferp/services/monday_sync.py +155 -0
  65. ferp/services/releases.py +214 -0
  66. ferp/services/scripts.py +90 -0
  67. ferp/services/update_check.py +130 -0
  68. ferp/styles/index.tcss +638 -0
  69. ferp/themes/themes.py +238 -0
  70. ferp/widgets/__init__.py +17 -0
  71. ferp/widgets/dialogs.py +167 -0
  72. ferp/widgets/file_tree.py +991 -0
  73. ferp/widgets/forms.py +146 -0
  74. ferp/widgets/output_panel.py +244 -0
  75. ferp/widgets/panels.py +13 -0
  76. ferp/widgets/process_list.py +158 -0
  77. ferp/widgets/readme_modal.py +59 -0
  78. ferp/widgets/scripts.py +192 -0
  79. ferp/widgets/task_capture.py +74 -0
  80. ferp/widgets/task_list.py +493 -0
  81. ferp/widgets/top_bar.py +110 -0
  82. ferp-0.7.1.dist-info/METADATA +128 -0
  83. ferp-0.7.1.dist-info/RECORD +87 -0
  84. ferp-0.7.1.dist-info/WHEEL +5 -0
  85. ferp-0.7.1.dist-info/entry_points.txt +2 -0
  86. ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
  87. ferp-0.7.1.dist-info/top_level.txt +1 -0
@@ -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,3 @@
1
+ from .events import TranscriptEvent
2
+
3
+ __all__ = ["TranscriptEvent"]
@@ -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,3 @@
1
+ from .scripts import ScriptExecutionContext, build_execution_context
2
+
3
+ __all__ = ["ScriptExecutionContext", "build_execution_context"]
@@ -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