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,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import zipfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from textual.worker import Worker, WorkerState
|
|
12
|
+
|
|
13
|
+
from ferp.core.dependency_manager import ScriptDependencyManager
|
|
14
|
+
from ferp.widgets.scripts import ScriptManager
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ferp.core.app import Ferp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ScriptBundleManifest:
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
version: str
|
|
25
|
+
target: str
|
|
26
|
+
entrypoint: str
|
|
27
|
+
readme: str | None
|
|
28
|
+
dependencies: list[str]
|
|
29
|
+
file_extensions: list[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class InstalledBundleResult:
|
|
34
|
+
manifest: ScriptBundleManifest
|
|
35
|
+
script_path: Path
|
|
36
|
+
readme_path: Path | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ScriptBundleInstaller:
|
|
40
|
+
"""Handles installing zipped FSCP script bundles."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, app: "Ferp") -> None:
|
|
43
|
+
self._app = app
|
|
44
|
+
self._app_root = app.app_root
|
|
45
|
+
self._scripts_dir = app.scripts_dir
|
|
46
|
+
self._config_file = app._paths.config_file
|
|
47
|
+
|
|
48
|
+
def start_install(self, bundle_path: Path) -> None:
|
|
49
|
+
self._app.notify(f"Installing bundle: {bundle_path}", timeout=3)
|
|
50
|
+
self._app.run_worker(
|
|
51
|
+
lambda: self._process_script_bundle(bundle_path),
|
|
52
|
+
group="bundle_install",
|
|
53
|
+
exclusive=True,
|
|
54
|
+
thread=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def handle_worker_state(self, event: Worker.StateChanged) -> bool:
|
|
58
|
+
worker = event.worker
|
|
59
|
+
if worker.group != "bundle_install":
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if event.state is WorkerState.SUCCESS:
|
|
63
|
+
result = worker.result
|
|
64
|
+
if isinstance(result, InstalledBundleResult):
|
|
65
|
+
self._handle_bundle_install_result(result)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
if event.state is WorkerState.ERROR:
|
|
69
|
+
error = worker.error or RuntimeError("Bundle installation failed.")
|
|
70
|
+
self._app.show_error(error)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Internal helpers
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
def _process_script_bundle(self, bundle_path: Path) -> InstalledBundleResult:
|
|
79
|
+
path = bundle_path.expanduser()
|
|
80
|
+
if not path.exists():
|
|
81
|
+
raise FileNotFoundError(f"No bundle found at {path}")
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
raise ValueError(f"Bundle path must point to a file: {path}")
|
|
84
|
+
if path.suffix.lower() != ".ferp":
|
|
85
|
+
raise ValueError("Bundles must be supplied as .ferp archives.")
|
|
86
|
+
|
|
87
|
+
with zipfile.ZipFile(path) as archive:
|
|
88
|
+
manifest_member = self._find_manifest_member(archive)
|
|
89
|
+
raw_manifest = archive.read(manifest_member).decode("utf-8")
|
|
90
|
+
manifest = self._parse_bundle_manifest(json.loads(raw_manifest))
|
|
91
|
+
|
|
92
|
+
script_member = self._resolve_archive_member(archive, manifest.entrypoint)
|
|
93
|
+
script_bytes = archive.read(script_member)
|
|
94
|
+
|
|
95
|
+
script_dir = self._scripts_dir / manifest.id
|
|
96
|
+
if script_dir.exists():
|
|
97
|
+
shutil.rmtree(script_dir)
|
|
98
|
+
script_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
script_target = script_dir / "script.py"
|
|
100
|
+
script_target.write_bytes(script_bytes)
|
|
101
|
+
|
|
102
|
+
readme_path: Path | None = None
|
|
103
|
+
if manifest.readme:
|
|
104
|
+
readme_member = self._resolve_archive_member(archive, manifest.readme)
|
|
105
|
+
readme_text = archive.read(readme_member).decode("utf-8")
|
|
106
|
+
readme_path = script_dir / "readme.md"
|
|
107
|
+
readme_path.write_text(readme_text, encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
self._update_scripts_config(manifest, script_target)
|
|
110
|
+
dependency_manager = ScriptDependencyManager(
|
|
111
|
+
self._config_file, python_executable=sys.executable
|
|
112
|
+
)
|
|
113
|
+
dependency_manager.install_for_scripts([manifest.id])
|
|
114
|
+
|
|
115
|
+
return InstalledBundleResult(
|
|
116
|
+
manifest=manifest,
|
|
117
|
+
script_path=script_target,
|
|
118
|
+
readme_path=readme_path,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _handle_bundle_install_result(self, result: InstalledBundleResult) -> None:
|
|
122
|
+
message = (
|
|
123
|
+
f"Bundle installed: {result.manifest.name} v{result.manifest.version} "
|
|
124
|
+
f"({result.manifest.id})"
|
|
125
|
+
)
|
|
126
|
+
self._app.notify(message, timeout=4)
|
|
127
|
+
scripts_panel = self._app.query_one(ScriptManager)
|
|
128
|
+
scripts_panel.load_scripts()
|
|
129
|
+
scripts_panel.focus()
|
|
130
|
+
|
|
131
|
+
def _find_manifest_member(self, archive: zipfile.ZipFile) -> str:
|
|
132
|
+
for name in archive.namelist():
|
|
133
|
+
normalized = name.rstrip("/")
|
|
134
|
+
if normalized.endswith("manifest.json"):
|
|
135
|
+
return name
|
|
136
|
+
raise FileNotFoundError("Bundle is missing manifest.json")
|
|
137
|
+
|
|
138
|
+
def _resolve_archive_member(self, archive: zipfile.ZipFile, reference: str) -> str:
|
|
139
|
+
normalized = reference.replace("\\", "/").strip("/")
|
|
140
|
+
if not normalized:
|
|
141
|
+
raise ValueError("Invalid reference inside bundle manifest.")
|
|
142
|
+
|
|
143
|
+
files = [
|
|
144
|
+
info.filename.rstrip("/")
|
|
145
|
+
for info in archive.infolist()
|
|
146
|
+
if not info.is_dir()
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
for name in files:
|
|
150
|
+
if name == normalized:
|
|
151
|
+
return name
|
|
152
|
+
|
|
153
|
+
matches = [name for name in files if name.endswith(normalized)]
|
|
154
|
+
if not matches:
|
|
155
|
+
raise FileNotFoundError(f"Unable to locate '{reference}' in bundle.")
|
|
156
|
+
if len(matches) > 1:
|
|
157
|
+
raise ValueError(f"Reference '{reference}' is ambiguous in bundle.")
|
|
158
|
+
return matches[0]
|
|
159
|
+
|
|
160
|
+
def _parse_bundle_manifest(self, payload: dict[str, Any]) -> ScriptBundleManifest:
|
|
161
|
+
for key in ("id", "name", "version", "entrypoint", "target"):
|
|
162
|
+
if key not in payload:
|
|
163
|
+
raise ValueError(f"Manifest missing required field '{key}'.")
|
|
164
|
+
|
|
165
|
+
target = str(payload["target"])
|
|
166
|
+
if target not in {
|
|
167
|
+
"current_directory",
|
|
168
|
+
"highlighted_file",
|
|
169
|
+
"highlighted_directory",
|
|
170
|
+
}:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"Manifest 'target' must be 'current_directory', "
|
|
173
|
+
"'highlighted_file', or 'highlighted_directory'."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
readme = payload.get("readme")
|
|
177
|
+
if readme is not None:
|
|
178
|
+
readme = str(readme).strip()
|
|
179
|
+
if not readme:
|
|
180
|
+
readme = None
|
|
181
|
+
|
|
182
|
+
deps_raw = payload.get("dependencies", [])
|
|
183
|
+
if deps_raw is None:
|
|
184
|
+
dependencies: list[str] = []
|
|
185
|
+
elif isinstance(deps_raw, list):
|
|
186
|
+
dependencies = [str(dep).strip() for dep in deps_raw if str(dep).strip()]
|
|
187
|
+
else:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"Manifest 'dependencies' must be an array of requirement strings."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
file_ext_raw = payload.get("file_extensions", [])
|
|
193
|
+
if file_ext_raw is None:
|
|
194
|
+
file_extensions: list[str] = []
|
|
195
|
+
elif isinstance(file_ext_raw, list):
|
|
196
|
+
file_extensions = [
|
|
197
|
+
str(ext).strip() for ext in file_ext_raw if str(ext).strip()
|
|
198
|
+
]
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError("Manifest 'file_extensions' must be an array of strings.")
|
|
201
|
+
|
|
202
|
+
return ScriptBundleManifest(
|
|
203
|
+
id=str(payload["id"]),
|
|
204
|
+
name=str(payload["name"]),
|
|
205
|
+
version=str(payload["version"]),
|
|
206
|
+
target=target,
|
|
207
|
+
entrypoint=str(payload["entrypoint"]),
|
|
208
|
+
readme=readme,
|
|
209
|
+
dependencies=dependencies,
|
|
210
|
+
file_extensions=file_extensions,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _update_scripts_config(
|
|
214
|
+
self,
|
|
215
|
+
manifest: ScriptBundleManifest,
|
|
216
|
+
script_path: Path,
|
|
217
|
+
) -> None:
|
|
218
|
+
config_path = self._config_file
|
|
219
|
+
if not config_path.exists():
|
|
220
|
+
raise FileNotFoundError(f"Unable to locate config at {config_path}")
|
|
221
|
+
|
|
222
|
+
data = json.loads(config_path.read_text())
|
|
223
|
+
scripts = data.setdefault("scripts", [])
|
|
224
|
+
|
|
225
|
+
rel_path = script_path.relative_to(self._app_root).as_posix()
|
|
226
|
+
entry: dict[str, Any] = {
|
|
227
|
+
"id": manifest.id,
|
|
228
|
+
"name": manifest.name,
|
|
229
|
+
"version": manifest.version,
|
|
230
|
+
"script": rel_path,
|
|
231
|
+
"target": manifest.target,
|
|
232
|
+
}
|
|
233
|
+
if manifest.file_extensions:
|
|
234
|
+
entry["file_extensions"] = manifest.file_extensions
|
|
235
|
+
if manifest.dependencies:
|
|
236
|
+
entry["dependencies"] = manifest.dependencies
|
|
237
|
+
|
|
238
|
+
for index, existing in enumerate(scripts):
|
|
239
|
+
if existing.get("id") == manifest.id:
|
|
240
|
+
scripts[index] = entry
|
|
241
|
+
break
|
|
242
|
+
else:
|
|
243
|
+
scripts.append(entry)
|
|
244
|
+
|
|
245
|
+
config_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
4
|
+
|
|
5
|
+
from textual.command import CommandListItem, SimpleCommand, SimpleProvider
|
|
6
|
+
from textual.screen import Screen
|
|
7
|
+
from textual.style import Style
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ferp.core.app import Ferp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FerpCommandProvider(SimpleProvider):
|
|
14
|
+
"""Command palette provider for FERP-specific actions."""
|
|
15
|
+
|
|
16
|
+
_COMMAND_DEFS: tuple[tuple[str, str, str], ...] = (
|
|
17
|
+
(
|
|
18
|
+
"Install Script Bundle",
|
|
19
|
+
"Install a zipped FSCP script bundle into FERP.",
|
|
20
|
+
"_command_install_script_bundle",
|
|
21
|
+
),
|
|
22
|
+
(
|
|
23
|
+
"Install/Update Default Scripts",
|
|
24
|
+
"Update scripts from the bundled defaults and overwrite config.json.",
|
|
25
|
+
"_command_install_default_scripts",
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
"Refresh File Tree",
|
|
29
|
+
"Reload the current directory listing.",
|
|
30
|
+
"_command_refresh_file_tree",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"Reload Script Catalog",
|
|
34
|
+
"Re-read script metadata from config/config.json.",
|
|
35
|
+
"_command_reload_scripts",
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"Open Latest Log",
|
|
39
|
+
"Open the most recent transcript log file.",
|
|
40
|
+
"_command_open_latest_log",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"Open User Guide",
|
|
44
|
+
"Open the bundled FERP user guide.",
|
|
45
|
+
"_command_open_user_guide",
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
"Show Processes",
|
|
49
|
+
"View and manage tracked script processes.",
|
|
50
|
+
"_command_show_processes",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
"Set Startup Directory",
|
|
54
|
+
"Update the startup directory stored in settings.json.",
|
|
55
|
+
"_command_set_startup_directory",
|
|
56
|
+
),
|
|
57
|
+
(
|
|
58
|
+
"Sync Monday Board Cache",
|
|
59
|
+
"Fetch the Monday board and refresh publishers_cache.json.",
|
|
60
|
+
"_command_sync_monday_board",
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
"Upgrade FERP",
|
|
64
|
+
"Run pipx upgrade.",
|
|
65
|
+
"_command_upgrade_app",
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __init__(self, screen: Screen[Any], match_style: Style | None = None) -> None:
|
|
70
|
+
app = cast("Ferp", screen.app)
|
|
71
|
+
commands: list[CommandListItem] = [
|
|
72
|
+
SimpleCommand(label, getattr(app, handler_name), description)
|
|
73
|
+
for label, description, handler_name in self._COMMAND_DEFS
|
|
74
|
+
]
|
|
75
|
+
super().__init__(screen, commands)
|
|
76
|
+
if match_style is not None:
|
|
77
|
+
self._SimpleProvider__match_style = match_style # type: ignore[attr-defined]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScriptDependencyManager:
|
|
11
|
+
"""Install script dependencies from the user config."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config_file: Path, python_executable: str | None = None) -> None:
|
|
14
|
+
self._config_file = config_file
|
|
15
|
+
self._python_executable = python_executable or sys.executable
|
|
16
|
+
|
|
17
|
+
def install_for_scripts(self, script_ids: Iterable[str] | None = None) -> None:
|
|
18
|
+
dependencies = self._collect_dependencies(script_ids)
|
|
19
|
+
if not dependencies:
|
|
20
|
+
return
|
|
21
|
+
self._install_dependencies(dependencies)
|
|
22
|
+
|
|
23
|
+
def _collect_dependencies(self, script_ids: Iterable[str] | None) -> list[str]:
|
|
24
|
+
if not self._config_file.exists():
|
|
25
|
+
raise FileNotFoundError(f"Unable to locate config at {self._config_file}")
|
|
26
|
+
|
|
27
|
+
data = json.loads(self._config_file.read_text())
|
|
28
|
+
scripts = data.get("scripts", [])
|
|
29
|
+
selected_ids = {script_id for script_id in script_ids} if script_ids else None
|
|
30
|
+
seen: set[str] = set()
|
|
31
|
+
deps: list[str] = []
|
|
32
|
+
|
|
33
|
+
for script in scripts:
|
|
34
|
+
script_id = str(script.get("id", ""))
|
|
35
|
+
if selected_ids is not None and script_id not in selected_ids:
|
|
36
|
+
continue
|
|
37
|
+
for dep in script.get("dependencies", []) or []:
|
|
38
|
+
dep_text = str(dep).strip()
|
|
39
|
+
if not dep_text or dep_text in seen:
|
|
40
|
+
continue
|
|
41
|
+
seen.add(dep_text)
|
|
42
|
+
deps.append(dep_text)
|
|
43
|
+
|
|
44
|
+
return deps
|
|
45
|
+
|
|
46
|
+
def _install_dependencies(self, dependencies: Sequence[str]) -> None:
|
|
47
|
+
pip_cmd = [self._python_executable, "-m", "pip", "install", *dependencies]
|
|
48
|
+
try:
|
|
49
|
+
subprocess.run(
|
|
50
|
+
pip_cmd,
|
|
51
|
+
check=True,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
)
|
|
55
|
+
except subprocess.CalledProcessError as exc:
|
|
56
|
+
stderr = exc.stderr.strip() if exc.stderr else ""
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"Failed to install dependencies ({', '.join(dependencies)}).\n{stderr}"
|
|
59
|
+
) from exc
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileSystemController:
|
|
10
|
+
"""Encapsulates file-system mutations so the UI stays lean."""
|
|
11
|
+
|
|
12
|
+
def create_path(
|
|
13
|
+
self,
|
|
14
|
+
target: Path,
|
|
15
|
+
*,
|
|
16
|
+
is_directory: bool,
|
|
17
|
+
overwrite: bool = False,
|
|
18
|
+
) -> Path:
|
|
19
|
+
if target.exists():
|
|
20
|
+
if not overwrite:
|
|
21
|
+
raise FileExistsError(f"{target} already exists")
|
|
22
|
+
if target.is_dir():
|
|
23
|
+
shutil.rmtree(target)
|
|
24
|
+
else:
|
|
25
|
+
target.unlink()
|
|
26
|
+
|
|
27
|
+
if is_directory:
|
|
28
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
else:
|
|
30
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
target.touch()
|
|
32
|
+
|
|
33
|
+
return target
|
|
34
|
+
|
|
35
|
+
def delete_path(self, target: Path) -> None:
|
|
36
|
+
if not target.exists():
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if target.is_dir():
|
|
40
|
+
def _handle_remove_error(func, path, exc):
|
|
41
|
+
if isinstance(exc, PermissionError):
|
|
42
|
+
os.chmod(path, stat.S_IWRITE)
|
|
43
|
+
func(path)
|
|
44
|
+
return
|
|
45
|
+
raise exc
|
|
46
|
+
|
|
47
|
+
shutil.rmtree(target, onexc=_handle_remove_error)
|
|
48
|
+
else:
|
|
49
|
+
target.unlink()
|
|
50
|
+
|
|
51
|
+
def rename_path(
|
|
52
|
+
self, source: Path, destination: Path, *, overwrite: bool = False
|
|
53
|
+
) -> Path:
|
|
54
|
+
if not source.exists():
|
|
55
|
+
raise FileNotFoundError(f"{source} does not exist")
|
|
56
|
+
|
|
57
|
+
if destination.exists() and destination != source:
|
|
58
|
+
if not overwrite:
|
|
59
|
+
raise FileExistsError(f"{destination} already exists")
|
|
60
|
+
if destination.is_dir():
|
|
61
|
+
shutil.rmtree(destination)
|
|
62
|
+
else:
|
|
63
|
+
destination.unlink()
|
|
64
|
+
|
|
65
|
+
if source == destination:
|
|
66
|
+
return destination
|
|
67
|
+
|
|
68
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
source.rename(destination)
|
|
70
|
+
return destination
|
ferp/core/fs_watcher.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Callable
|
|
5
|
+
|
|
6
|
+
from textual.timer import Timer
|
|
7
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
8
|
+
from watchdog.observers.api import ObservedWatch
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from watchdog.observers import Observer as WatchdogObserver
|
|
12
|
+
else:
|
|
13
|
+
from watchdog.observers import Observer as WatchdogObserver
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DirectoryChangeHandler(FileSystemEventHandler):
|
|
17
|
+
"""Watchdog handler that forwards filesystem activity to the UI thread."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, notify_change: Callable[[], None]) -> None:
|
|
20
|
+
self._notify_change = notify_change
|
|
21
|
+
|
|
22
|
+
def on_any_event(self, event: FileSystemEvent | None = None) -> None: # type: ignore[override]
|
|
23
|
+
self._notify_change()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FileTreeWatcher:
|
|
27
|
+
"""Manages filesystem watching and debounced refreshes for the FileTree."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
call_from_thread: Callable[[Callable[[], None]], object | None],
|
|
33
|
+
refresh_callback: Callable[[], None],
|
|
34
|
+
missing_callback: Callable[[Path], None] | None = None,
|
|
35
|
+
snapshot_func: Callable[[Path], tuple[str, ...]],
|
|
36
|
+
timer_factory: Callable[[float, Callable[[], None]], Timer],
|
|
37
|
+
debounce_seconds: float = 2.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._call_from_thread = call_from_thread
|
|
40
|
+
self._refresh_callback = refresh_callback
|
|
41
|
+
self._missing_callback = missing_callback
|
|
42
|
+
self._snapshot_func = snapshot_func
|
|
43
|
+
self._timer_factory = timer_factory
|
|
44
|
+
self._debounce_seconds = debounce_seconds
|
|
45
|
+
|
|
46
|
+
self._observer: WatchdogObserver | None = None # type: ignore
|
|
47
|
+
self._watch: ObservedWatch | None = None
|
|
48
|
+
self._handler: DirectoryChangeHandler | None = None
|
|
49
|
+
self._current_directory: Path | None = None
|
|
50
|
+
self._refresh_timer: Timer | None = None
|
|
51
|
+
self._last_snapshot: tuple[str, ...] | None = None
|
|
52
|
+
|
|
53
|
+
def start(self, directory: Path) -> None:
|
|
54
|
+
"""Ensure the watcher is running and observing the provided directory."""
|
|
55
|
+
self._current_directory = directory
|
|
56
|
+
self._ensure_observer()
|
|
57
|
+
self._restart_watch(directory)
|
|
58
|
+
|
|
59
|
+
def stop(self) -> None:
|
|
60
|
+
"""Tear down the watch and stop the observer."""
|
|
61
|
+
if self._refresh_timer is not None:
|
|
62
|
+
self._refresh_timer.stop()
|
|
63
|
+
self._refresh_timer = None
|
|
64
|
+
|
|
65
|
+
observer = self._observer
|
|
66
|
+
if observer is not None:
|
|
67
|
+
if self._watch is not None:
|
|
68
|
+
try:
|
|
69
|
+
observer.unschedule(self._watch)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
self._watch = None
|
|
73
|
+
observer.stop()
|
|
74
|
+
observer.join(timeout=1)
|
|
75
|
+
|
|
76
|
+
self._observer = None
|
|
77
|
+
self._handler = None
|
|
78
|
+
|
|
79
|
+
def update_snapshot(self, directory: Path) -> None:
|
|
80
|
+
"""Record the latest directory signature to prevent redundant refreshes."""
|
|
81
|
+
if not directory.exists():
|
|
82
|
+
self._last_snapshot = None
|
|
83
|
+
return
|
|
84
|
+
self._last_snapshot = self._snapshot_func(directory)
|
|
85
|
+
self._current_directory = directory
|
|
86
|
+
|
|
87
|
+
def _ensure_observer(self) -> None:
|
|
88
|
+
if self._observer is not None:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
handler = DirectoryChangeHandler(
|
|
92
|
+
notify_change=self._notify_from_thread,
|
|
93
|
+
)
|
|
94
|
+
observer = WatchdogObserver()
|
|
95
|
+
observer.daemon = True
|
|
96
|
+
observer.start()
|
|
97
|
+
|
|
98
|
+
self._handler = handler
|
|
99
|
+
self._observer = observer
|
|
100
|
+
|
|
101
|
+
def _restart_watch(self, directory: Path) -> None:
|
|
102
|
+
observer = self._observer
|
|
103
|
+
handler = self._handler
|
|
104
|
+
if observer is None or handler is None:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if self._watch is not None:
|
|
108
|
+
try:
|
|
109
|
+
observer.unschedule(self._watch)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
self._watch = None
|
|
113
|
+
|
|
114
|
+
if not directory.exists():
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
self._watch = observer.schedule(handler, str(directory), recursive=False)
|
|
119
|
+
except Exception:
|
|
120
|
+
self._watch = None
|
|
121
|
+
|
|
122
|
+
def _queue_refresh(self) -> None:
|
|
123
|
+
if self._refresh_timer is not None:
|
|
124
|
+
return
|
|
125
|
+
self._refresh_timer = self._timer_factory(
|
|
126
|
+
self._debounce_seconds, self._complete_refresh
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _complete_refresh(self) -> None:
|
|
130
|
+
self._refresh_timer = None
|
|
131
|
+
directory = self._current_directory
|
|
132
|
+
if directory is None or not directory.exists():
|
|
133
|
+
if directory is not None and self._missing_callback is not None:
|
|
134
|
+
self._missing_callback(directory)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
signature = self._snapshot_func(directory)
|
|
138
|
+
if signature == self._last_snapshot:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
self._refresh_callback()
|
|
142
|
+
|
|
143
|
+
def _notify_from_thread(self) -> None:
|
|
144
|
+
self._call_from_thread(self._queue_refresh)
|
ferp/core/messages.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from textual.message import Message
|
|
4
|
+
|
|
5
|
+
from ferp.domain.scripts import Script
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NavigateRequest(Message):
|
|
9
|
+
def __init__(self, path: Path) -> None:
|
|
10
|
+
self.path = path
|
|
11
|
+
super().__init__()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RunScriptRequest(Message):
|
|
15
|
+
def __init__(self, script: Script) -> None:
|
|
16
|
+
self.script = script
|
|
17
|
+
super().__init__()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DirectorySelectRequest(Message):
|
|
21
|
+
def __init__(self, path: Path) -> None:
|
|
22
|
+
self.path = path
|
|
23
|
+
super().__init__()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ShowReadmeRequest(Message):
|
|
27
|
+
def __init__(self, script: Script, readme_path: Path | None) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.script = script
|
|
30
|
+
self.readme_path = readme_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CreatePathRequest(Message):
|
|
34
|
+
def __init__(self, base: Path, *, is_directory: bool) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.base = base
|
|
37
|
+
self.is_directory = is_directory
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DeletePathRequest(Message):
|
|
41
|
+
def __init__(self, target: Path) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.target = target
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RenamePathRequest(Message):
|
|
47
|
+
def __init__(self, target: Path) -> None:
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.target = target
|