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,192 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Sequence
4
+
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal
7
+ from textual.widgets import Label, ListItem, ListView
8
+
9
+ from ferp.core.messages import RunScriptRequest, ShowReadmeRequest
10
+ from ferp.domain.scripts import Script, ScriptConfig
11
+
12
+
13
+ def script_from_config(cfg: ScriptConfig) -> Script:
14
+ return Script(
15
+ id=cfg["id"],
16
+ name=cfg["name"],
17
+ version=cfg["version"],
18
+ script=cfg["script"],
19
+ target=cfg["target"],
20
+ file_extensions=cfg.get("file_extensions"),
21
+ )
22
+
23
+
24
+ def load_scripts_config(path: Path) -> list[Script]:
25
+ data = json.loads(path.read_text())
26
+
27
+ if "scripts" not in data or not isinstance(data["scripts"], list):
28
+ raise ValueError("Invalid config: missing 'scripts' list")
29
+
30
+ scripts: list[Script] = []
31
+
32
+ for raw in data["scripts"]:
33
+ scripts.append(script_from_config(raw)) # type: ignore[arg-type]
34
+
35
+ return sorted(scripts, key=lambda script: script.name.lower())
36
+
37
+
38
+ def load_scripts_configs(paths: Sequence[Path]) -> list[Script]:
39
+ scripts_by_id: dict[str, Script] = {}
40
+ errors: list[str] = []
41
+
42
+ for path in paths:
43
+ try:
44
+ loaded = load_scripts_config(path)
45
+ except ValueError as exc:
46
+ errors.append(f"{path}: {exc}")
47
+ continue
48
+ for script in loaded:
49
+ scripts_by_id.setdefault(script.id, script)
50
+
51
+ if not scripts_by_id and errors:
52
+ raise ValueError("; ".join(errors))
53
+
54
+ return sorted(scripts_by_id.values(), key=lambda script: script.name.lower())
55
+
56
+
57
+ class ScriptItem(ListItem):
58
+ def __init__(self, script: Script) -> None:
59
+ category, name = (
60
+ script.name.split(":", 1)
61
+ if ":" in script.name
62
+ else ("General", script.name)
63
+ )
64
+ super().__init__(
65
+ Horizontal(
66
+ Label(f"{category}:", classes="script_category"),
67
+ Label(name, classes="script_name"),
68
+ Label(f"v{script.version}", classes="script_version"),
69
+ id="script_item",
70
+ )
71
+ )
72
+ self.script = script
73
+
74
+
75
+ class ScriptManager(ListView):
76
+ FAST_CURSOR_STEP = 5
77
+ BINDINGS = [
78
+ Binding("g", "cursor_top", "To top", show=False),
79
+ Binding("G", "cursor_bottom", "To bottom", key_display="G", show=False),
80
+ Binding("k", "cursor_up", "Move cursor up", show=False),
81
+ Binding("K", "cursor_up_fast", "Cursor up (fast)", key_display="K", show=False),
82
+ Binding("j", "cursor_down", "Move cursor down", show=False),
83
+ Binding(
84
+ "J", "cursor_down_fast", "Cursor down (fast)", key_display="J", show=False
85
+ ),
86
+ Binding("R", "run_script", "Run selected script", show=True),
87
+ Binding("enter", "show_readme", "Show readme", show=True),
88
+ ]
89
+
90
+ def __init__(
91
+ self, config_paths: Sequence[Path] | Path, *, scripts_root: Path, id: str
92
+ ) -> None:
93
+ if isinstance(config_paths, Path):
94
+ self.config_paths = [config_paths]
95
+ else:
96
+ self.config_paths = list(config_paths)
97
+ self.scripts_root = scripts_root
98
+ super().__init__(id=id)
99
+
100
+ def _get_selected_script(self) -> Script | None:
101
+ item = self.highlighted_child
102
+ if isinstance(item, ScriptItem):
103
+ return item.script
104
+ return None
105
+
106
+ def on_mount(self) -> None:
107
+ self.border_title = "Scripts"
108
+ self.load_scripts()
109
+
110
+ def load_scripts(self) -> None:
111
+ self.clear()
112
+
113
+ if not any(path.exists() for path in self.config_paths):
114
+ self.append(ListItem(Label("No config.json found")))
115
+ return
116
+
117
+ try:
118
+ scripts = load_scripts_configs(self.config_paths)
119
+ except ValueError as exc:
120
+ self.append(ListItem(Label(f"Invalid config: {exc}")))
121
+ return
122
+
123
+ if not scripts:
124
+ self.append(ListItem(Label("No scripts configured")))
125
+ return
126
+
127
+ for script in scripts:
128
+ self.append(ScriptItem(script))
129
+
130
+ self.call_after_refresh(self._focus_first_script)
131
+
132
+ def _focus_first_script(self) -> None:
133
+ if not self.children:
134
+ return
135
+ self.index = 0
136
+ self.scroll_to(y=0)
137
+
138
+ def action_run_script(self) -> None:
139
+ script = self._get_selected_script()
140
+ if not script:
141
+ return
142
+
143
+ self.post_message(RunScriptRequest(script))
144
+
145
+ def action_show_readme(self) -> None:
146
+ script = self._get_selected_script()
147
+ if not script:
148
+ return
149
+
150
+ readme_path = self.resolve_readme(script)
151
+ self.post_message(ShowReadmeRequest(script, readme_path))
152
+
153
+ def resolve_readme(self, script: Script) -> Path | None:
154
+ script_path = Path(script.script)
155
+ if script_path.is_absolute():
156
+ full_script_path = script_path
157
+ elif script_path.parts and script_path.parts[0] == "scripts":
158
+ full_script_path = (self.scripts_root.parent / script_path).resolve()
159
+ else:
160
+ full_script_path = (self.scripts_root / script_path).resolve()
161
+ script_path = full_script_path
162
+ candidate = script_path.parent / "readme.md"
163
+ return candidate if candidate.exists() else None
164
+
165
+ def action_cursor_down_fast(self) -> None:
166
+ for _ in range(self.FAST_CURSOR_STEP):
167
+ super().action_cursor_down()
168
+
169
+ def action_cursor_up_fast(self) -> None:
170
+ for _ in range(self.FAST_CURSOR_STEP):
171
+ super().action_cursor_up()
172
+
173
+ def action_cursor_top(self) -> None:
174
+ if len(self.children) > 1:
175
+ self.index = 0
176
+ self.scroll_to(y=0)
177
+
178
+ def action_cursor_bottom(self) -> None:
179
+ if len(self.children) > 1:
180
+ self.index = len(self.children) - 1
181
+
182
+ def _visible_item_count(self) -> int:
183
+ if not self.children:
184
+ return 0
185
+
186
+ first = self.children[0]
187
+ row_height = first.size.height
188
+
189
+ if row_height <= 0:
190
+ return 0
191
+
192
+ return (self.size.height // row_height) - 1
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from textual.binding import Binding
6
+ from textual.containers import Container, Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.timer import Timer
9
+ from textual.widgets import Footer, Input, Static
10
+
11
+
12
+ class CaptureInput(Input):
13
+ """Input widget that triggers a callback on submission."""
14
+
15
+ def __init__(self, submit_callback: Callable[[], None]) -> None:
16
+ super().__init__(id="task_capture_input", placeholder="New task…")
17
+ self._submit_callback = submit_callback
18
+
19
+ def on_input_submitted(self, event: Input.Submitted) -> None:
20
+ event.stop()
21
+ self._submit_callback()
22
+
23
+
24
+ class TaskCaptureModal(ModalScreen[None]):
25
+ """Popup used for rapid task entry."""
26
+
27
+ BINDINGS = [
28
+ Binding("escape", "close", "Close modal", show=True),
29
+ Binding("enter", "submit", "Submit new task", show=True),
30
+ ]
31
+
32
+ def __init__(self, on_submit: Callable[[str], None]) -> None:
33
+ super().__init__()
34
+ self._on_submit = on_submit
35
+ self._area: CaptureInput | None = None
36
+ self._status: Static | None = None
37
+ self._clear_timer: Timer | None = None
38
+
39
+ def compose(self):
40
+ self._area = CaptureInput(self.action_submit)
41
+ self._status = Static("", classes="task_capture_status")
42
+ yield Container(
43
+ Vertical(self._area, self._status, Footer()),
44
+ id="task_capture_modal",
45
+ )
46
+
47
+ def on_mount(self) -> None:
48
+ container = self.query_one("#task_capture_modal", Container)
49
+ container.border_title = "Add a New Task"
50
+ if self._area:
51
+ self._area.focus()
52
+
53
+ def action_submit(self) -> None:
54
+ area = self._area or self.query_one(Input)
55
+ text = area.value.strip()
56
+ if not text:
57
+ return
58
+ self._on_submit(text)
59
+ area.value = ""
60
+ if self._status:
61
+ self._status.update("[green]Task saved[/]")
62
+ if self._clear_timer:
63
+ self._clear_timer.stop()
64
+ self._clear_timer = self.set_timer(1.5, self._clear_status)
65
+
66
+ def _clear_status(self) -> None:
67
+ if self._status:
68
+ self._status.update("")
69
+ if self._clear_timer:
70
+ self._clear_timer.stop()
71
+ self._clear_timer = None
72
+
73
+ def action_close(self) -> None:
74
+ self.dismiss(None)