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/core/task_store.py
ADDED
|
@@ -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
|
ferp/domain/__init__.py
ADDED
|
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
|
+
]
|