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,164 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Callable, Iterable, Sequence
9
+
10
+
11
+ def _utcnow() -> datetime:
12
+ return datetime.now(timezone.utc)
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class Task:
17
+ id: str
18
+ text: str
19
+ completed: bool = False
20
+ created_at: datetime = field(default_factory=_utcnow)
21
+ completed_at: datetime | None = None
22
+
23
+ def to_json(self) -> dict[str, object]:
24
+ return {
25
+ "id": self.id,
26
+ "text": self.text,
27
+ "completed": self.completed,
28
+ "created_at": self.created_at.isoformat(),
29
+ "completed_at": self.completed_at.isoformat()
30
+ if self.completed_at
31
+ else None,
32
+ }
33
+
34
+ @classmethod
35
+ def from_json(cls, payload: dict[str, object]) -> Task:
36
+ created_at = payload.get("created_at")
37
+ completed_at = payload.get("completed_at")
38
+ return cls(
39
+ id=str(payload.get("id") or uuid.uuid4()),
40
+ text=str(payload.get("text") or "").strip(),
41
+ completed=bool(payload.get("completed", False)),
42
+ created_at=datetime.fromisoformat(created_at)
43
+ if isinstance(created_at, str)
44
+ else _utcnow(),
45
+ completed_at=datetime.fromisoformat(completed_at)
46
+ if isinstance(completed_at, str)
47
+ else None,
48
+ )
49
+
50
+
51
+ class TaskStore:
52
+ """File-backed store for lightweight tasks."""
53
+
54
+ def __init__(self, storage_path: Path) -> None:
55
+ self.storage_path = storage_path
56
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
57
+ self._tasks: list[Task] = []
58
+ self._listeners: set[Callable[[Sequence[Task]], None]] = set()
59
+ self.load()
60
+
61
+ def load(self) -> list[Task]:
62
+ if not self.storage_path.exists():
63
+ self._tasks = []
64
+ return []
65
+
66
+ try:
67
+ data = json.loads(self.storage_path.read_text(encoding="utf-8"))
68
+ except (OSError, json.JSONDecodeError):
69
+ self._tasks = []
70
+ return []
71
+
72
+ if not isinstance(data, list):
73
+ self._tasks = []
74
+ return []
75
+
76
+ tasks: list[Task] = []
77
+ for raw in data:
78
+ if isinstance(raw, dict):
79
+ tasks.append(Task.from_json(raw))
80
+ self._tasks = tasks
81
+ return list(self._tasks)
82
+
83
+ def save(self) -> None:
84
+ payload = [task.to_json() for task in self._tasks]
85
+ self.storage_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
86
+
87
+ def subscribe(self, callback: Callable[[Sequence[Task]], None]) -> None:
88
+ self._listeners.add(callback)
89
+ callback(tuple(self._tasks))
90
+
91
+ def unsubscribe(self, callback: Callable[[Sequence[Task]], None]) -> None:
92
+ self._listeners.discard(callback)
93
+
94
+ def _emit(self) -> None:
95
+ snapshot = tuple(self._tasks)
96
+ for callback in list(self._listeners):
97
+ callback(snapshot)
98
+
99
+ def all(self) -> list[Task]:
100
+ return list(self._tasks)
101
+
102
+ def sorted(self) -> list[Task]:
103
+ indexed = list(enumerate(self._tasks))
104
+ indexed.sort(key=lambda pair: (pair[1].completed, pair[0]))
105
+ return [task for _, task in indexed]
106
+
107
+ def add(self, text: str) -> Task:
108
+ normalized = text.strip()
109
+ if not normalized:
110
+ raise ValueError("Task text is required.")
111
+ new_task = Task(
112
+ id=str(uuid.uuid4()),
113
+ text=normalized,
114
+ completed=False,
115
+ created_at=_utcnow(),
116
+ completed_at=None,
117
+ )
118
+ self._tasks.append(new_task)
119
+ self.save()
120
+ self._emit()
121
+ return new_task
122
+
123
+ def delete(self, task_id: str) -> None:
124
+ before = len(self._tasks)
125
+ self._tasks = [task for task in self._tasks if task.id != task_id]
126
+ if len(self._tasks) != before:
127
+ self.save()
128
+ self._emit()
129
+
130
+ def update_text(self, task_id: str, new_text: str) -> Task | None:
131
+ normalized = new_text.strip()
132
+ if not normalized:
133
+ return None
134
+ for task in self._tasks:
135
+ if task.id == task_id:
136
+ task.text = normalized
137
+ self.save()
138
+ self._emit()
139
+ return task
140
+ return None
141
+
142
+ def toggle(self, task_id: str) -> Task | None:
143
+ for task in self._tasks:
144
+ if task.id == task_id:
145
+ task.completed = not task.completed
146
+ task.completed_at = _utcnow() if task.completed else None
147
+ self.save()
148
+ self._emit()
149
+ return task
150
+ return None
151
+
152
+ def clear_completed(self) -> None:
153
+ any_removed = any(task.completed for task in self._tasks)
154
+ if not any_removed:
155
+ return
156
+ self._tasks = [task for task in self._tasks if not task.completed]
157
+ self.save()
158
+ self._emit()
159
+
160
+ def import_tasks(self, tasks: Iterable[Task]) -> None:
161
+ """Replace the current list with provided tasks (used for testing)."""
162
+ self._tasks = list(tasks)
163
+ self.save()
164
+ self._emit()
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from ferp.core.script_runner import ScriptResult
9
+
10
+
11
+ class TranscriptLogger:
12
+ """Writes script transcripts and prunes historical logs."""
13
+
14
+ def __init__(
15
+ self,
16
+ logs_dir: Path,
17
+ log_preferences: Callable[[], tuple[int, int]],
18
+ ) -> None:
19
+ self._logs_dir = logs_dir
20
+ self._logs_dir.mkdir(parents=True, exist_ok=True)
21
+ self._log_preferences = log_preferences
22
+
23
+ def write(
24
+ self,
25
+ script_name: str,
26
+ target_path: Path,
27
+ result: ScriptResult,
28
+ ) -> Path:
29
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
30
+ slug = (
31
+ "".join(
32
+ ch.lower() if ch.isalnum() else "_" for ch in script_name.strip()
33
+ ).strip("_")
34
+ or "script"
35
+ )
36
+ filename = f"{timestamp}_{slug}.log"
37
+ path = self._logs_dir / filename
38
+
39
+ lines = [
40
+ f"Script: {script_name}",
41
+ f"Target: {target_path}",
42
+ f"Status: {result.status.value}",
43
+ f"Exit Code: {result.exit_code}",
44
+ "",
45
+ "Transcript:",
46
+ ]
47
+
48
+ for event in result.transcript:
49
+ if event.message:
50
+ payload = json.dumps(
51
+ event.message.payload,
52
+ ensure_ascii=False,
53
+ indent=2,
54
+ )
55
+ lines.append(
56
+ f"{event.direction.value.upper()} "
57
+ f"{event.message.type.value}: {payload}"
58
+ )
59
+ elif event.raw:
60
+ lines.append(f"{event.direction.value.upper()}: {event.raw}")
61
+
62
+ path.write_text("\n".join(lines), encoding="utf-8")
63
+ self._prune()
64
+ return path
65
+
66
+ def _prune(self) -> None:
67
+ max_files, max_age_days = self._log_preferences()
68
+ entries = sorted(
69
+ self._logs_dir.glob("*.log"),
70
+ key=lambda item: item.stat().st_mtime,
71
+ reverse=True,
72
+ )
73
+
74
+ cutoff = None
75
+ if max_age_days > 0:
76
+ cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
77
+
78
+ for index, entry in enumerate(entries):
79
+ remove = index >= max_files
80
+ if not remove and cutoff is not None:
81
+ try:
82
+ mtime = datetime.fromtimestamp(
83
+ entry.stat().st_mtime,
84
+ tz=timezone.utc,
85
+ )
86
+ except (OSError, ValueError):
87
+ mtime = None
88
+ if mtime is not None and mtime < cutoff:
89
+ remove = True
90
+
91
+ if remove:
92
+ try:
93
+ entry.unlink()
94
+ except OSError:
95
+ pass
File without changes
ferp/domain/scripts.py ADDED
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Literal, Optional, TypedDict
3
+
4
+ from typing_extensions import NotRequired
5
+
6
+ TargetType = Literal[
7
+ "current_directory",
8
+ "highlighted_file",
9
+ "highlighted_directory",
10
+ ]
11
+
12
+
13
+ class ScriptConfig(TypedDict):
14
+ id: str
15
+ name: str
16
+ version: str
17
+ script: str
18
+ target: TargetType
19
+ file_extensions: NotRequired[List[str]]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Script:
24
+ id: str
25
+ name: str
26
+ version: str
27
+ script: str
28
+ target: TargetType
29
+ file_extensions: Optional[List[str]] = None
@@ -0,0 +1,11 @@
1
+ from .host import Host
2
+ from .managed_process import ManagedProcess
3
+ from .process_registry import ProcessMetadata, ProcessRecord, ProcessRegistry
4
+
5
+ __all__ = [
6
+ "Host",
7
+ "ManagedProcess",
8
+ "ProcessMetadata",
9
+ "ProcessRecord",
10
+ "ProcessRegistry",
11
+ ]