ffai-cli 0.2.0__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.
ffai/state.py ADDED
@@ -0,0 +1,291 @@
1
+ """Stateful, locally owned conversion task session.
2
+
3
+ A session holds one task per conversion tool plus the currently selected tool.
4
+ Inputs and outputs are shared user-facing files for the active tool, and are
5
+ still changed exclusively through :meth:`SessionState.add_inputs` and
6
+ :meth:`SessionState.set_output`, which validate paths locally. LLM patches can
7
+ only update the technical fields of the tool they target.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from pydantic import ValidationError
18
+
19
+ from ffai.errors import StateError
20
+ from ffai.models import (
21
+ FFmpegPatch,
22
+ FFmpegTask,
23
+ PandocPatch,
24
+ PandocTask,
25
+ ToolName,
26
+ ToolPatch,
27
+ )
28
+ from ffai.tools import TOOL_SPECS, ToolSpec, detect_tool, get_tool
29
+
30
+ _LIST_PATCH_FIELDS = {
31
+ "video_filters",
32
+ "audio_filters",
33
+ "extra_options",
34
+ "maps",
35
+ "metadata",
36
+ "bibliography",
37
+ "resource_path",
38
+ }
39
+ _BOOL_PATCH_FIELDS = {
40
+ "standalone",
41
+ "toc",
42
+ "number_sections",
43
+ "embed_resources",
44
+ "citeproc",
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class AddInputsResult:
50
+ added: tuple[str, ...]
51
+ duplicates: tuple[str, ...]
52
+ tool: ToolName
53
+ switched_tool: ToolName | None = None
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class TaskChange:
58
+ field: str
59
+ before: Any
60
+ after: Any
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class SessionState:
65
+ """Owns the tasks and all filesystem-facing mutations.
66
+
67
+ Each tool keeps its own task, so switching tools preserves the other
68
+ tool's work. When the active task is still empty, adding files whose
69
+ extensions clearly belong to the other tool selects that tool
70
+ automatically.
71
+ """
72
+
73
+ tool: ToolName = "ffmpeg"
74
+ ffmpeg_task: FFmpegTask = field(default_factory=FFmpegTask)
75
+ pandoc_task: PandocTask = field(default_factory=PandocTask)
76
+ revision: int = 0
77
+
78
+ @property
79
+ def task(self) -> FFmpegTask | PandocTask:
80
+ return self._task_for(self.tool)
81
+
82
+ @property
83
+ def spec(self) -> ToolSpec:
84
+ return get_tool(self.tool)
85
+
86
+ def _task_for(self, tool: ToolName) -> FFmpegTask | PandocTask:
87
+ return self.ffmpeg_task if tool == "ffmpeg" else self.pandoc_task
88
+
89
+ def _store_task(self, tool: ToolName, task: FFmpegTask | PandocTask) -> None:
90
+ if tool == "ffmpeg":
91
+ self.ffmpeg_task = task # type: ignore[assignment]
92
+ else:
93
+ self.pandoc_task = task # type: ignore[assignment]
94
+
95
+ def select_tool(self, tool: ToolName) -> bool:
96
+ """Switch the active tool. Returns whether the selection changed."""
97
+
98
+ if tool not in TOOL_SPECS:
99
+ raise StateError(f"Unknown conversion tool: {tool}")
100
+ if tool == self.tool:
101
+ return False
102
+ self.tool = tool
103
+ self.revision += 1
104
+ return True
105
+
106
+ def add_inputs(self, raw_paths: list[str] | tuple[str, ...]) -> AddInputsResult:
107
+ if not raw_paths:
108
+ raise StateError("add requires at least one input file")
109
+
110
+ candidates: list[Path] = []
111
+ seen: list[Path] = []
112
+ for raw in raw_paths:
113
+ if not raw.strip():
114
+ raise StateError("Input filenames must not be empty")
115
+ candidate = self._resolve_existing_file(raw)
116
+ if any(_same_file(candidate, existing) for existing in seen):
117
+ continue
118
+ seen.append(candidate)
119
+ candidates.append(candidate)
120
+
121
+ # Resolving every path above keeps the operation atomic: a missing
122
+ # file never leaves the session partially updated.
123
+ pending = [str(path) for path in candidates]
124
+ switched: ToolName | None = None
125
+ if not self.task.inputs and self.task.output is None:
126
+ detected = detect_tool(pending)
127
+ if detected is not None and detected != self.tool:
128
+ switched = detected
129
+
130
+ activation = switched or self.tool
131
+ task = self._task_for(activation)
132
+
133
+ added: list[str] = []
134
+ duplicates: list[str] = []
135
+ for candidate in candidates:
136
+ candidate_str = str(candidate)
137
+ if candidate_str in task.inputs or any(
138
+ _same_file(candidate, existing) for existing in task.inputs
139
+ ):
140
+ duplicates.append(candidate_str)
141
+ continue
142
+ if task.output is not None and _same_file(candidate, task.output):
143
+ raise StateError(
144
+ f"Input file is also the current output: {candidate_str}. "
145
+ "Choose a different output with setoutput."
146
+ )
147
+ added.append(candidate_str)
148
+
149
+ if not added and switched is None:
150
+ return AddInputsResult(
151
+ added=(),
152
+ duplicates=tuple(duplicates),
153
+ tool=self.tool,
154
+ )
155
+
156
+ self.tool = activation
157
+ task.inputs.extend(added)
158
+ self.revision += 1
159
+ return AddInputsResult(
160
+ added=tuple(added),
161
+ duplicates=tuple(duplicates),
162
+ tool=self.tool,
163
+ switched_tool=switched,
164
+ )
165
+
166
+ def set_output(self, raw_path: str) -> str:
167
+ if not raw_path.strip():
168
+ raise StateError("Output filename must not be empty")
169
+
170
+ path = Path(raw_path).expanduser()
171
+ if not path.is_absolute():
172
+ path = Path.cwd() / path
173
+ if path.name in {"", ".", ".."}:
174
+ raise StateError("Output must be a filename, not a directory")
175
+
176
+ try:
177
+ parent = path.parent.resolve(strict=True)
178
+ except OSError as exc:
179
+ raise StateError(f"Output directory does not exist: {path.parent}") from exc
180
+ if not parent.is_dir():
181
+ raise StateError(f"Output parent is not a directory: {parent}")
182
+ if not os.access(parent, os.W_OK | os.X_OK):
183
+ raise StateError(f"Output directory is not writable: {parent}")
184
+
185
+ target = parent / path.name
186
+ if target.exists() and target.is_dir():
187
+ raise StateError(f"Output path is a directory: {target}")
188
+ for source in self.task.inputs:
189
+ if _same_file(target, source):
190
+ raise StateError(
191
+ f"Output would overwrite an input file: {target}. Choose a different path."
192
+ )
193
+
194
+ self.task.output = str(target)
195
+ self.revision += 1
196
+ return str(target)
197
+
198
+ def apply_patch(self, patch: ToolPatch) -> list[TaskChange]:
199
+ """Apply only explicitly supplied patch fields and return a diff.
200
+
201
+ A patch with no tool is a no-op. Selecting a different tool is itself
202
+ a visible change even when the tool payload is empty.
203
+ """
204
+
205
+ tool = patch.tool
206
+ fields = patch.fields
207
+ if tool is None or fields is None:
208
+ return []
209
+
210
+ target = self._task_for(tool)
211
+ updates = _patch_updates(fields)
212
+ if not updates and tool == self.tool:
213
+ return []
214
+
215
+ candidate_data = target.model_dump()
216
+ candidate_data.update(updates)
217
+ try:
218
+ candidate = type(target).model_validate(candidate_data)
219
+ except ValidationError as exc:
220
+ raise StateError(f"The proposed task is invalid: {exc}") from exc
221
+
222
+ if isinstance(candidate, FFmpegTask) and candidate.filter_complex and (
223
+ candidate.video_filters or candidate.audio_filters
224
+ ):
225
+ raise StateError(
226
+ "filter_complex cannot be combined with simple video_filters or "
227
+ "audio_filters. Clear the simple filters or remove filter_complex."
228
+ )
229
+
230
+ before_data = target.model_dump()
231
+ candidate_snapshot = candidate.model_dump()
232
+ changes: list[TaskChange] = []
233
+ if tool != self.tool:
234
+ changes.append(TaskChange(field="tool", before=self.tool, after=tool))
235
+ changes.extend(
236
+ TaskChange(field=name, before=before_data[name], after=candidate_snapshot[name])
237
+ for name in updates
238
+ if before_data[name] != candidate_snapshot[name]
239
+ )
240
+ if not changes:
241
+ return []
242
+
243
+ self._store_task(tool, candidate)
244
+ self.tool = tool
245
+ self.revision += 1
246
+ return changes
247
+
248
+ def reset(self) -> None:
249
+ self._store_task(self.tool, get_tool(self.tool).task_type())
250
+ self.revision += 1
251
+
252
+ def llm_context(self) -> dict[str, Any]:
253
+ context = self.task.model_dump()
254
+ context["tool"] = self.tool
255
+ context["revision"] = self.revision
256
+ return context
257
+
258
+ @staticmethod
259
+ def _resolve_existing_file(raw_path: str) -> Path:
260
+ try:
261
+ path = Path(raw_path).expanduser().resolve(strict=True)
262
+ except OSError as exc:
263
+ raise StateError(f"Input file does not exist: {raw_path}") from exc
264
+ if not path.is_file():
265
+ raise StateError(f"Input is not a regular file: {path}")
266
+ if not os.access(path, os.R_OK):
267
+ raise StateError(f"Input file is not readable: {path}")
268
+ return path
269
+
270
+
271
+ def _patch_updates(fields: FFmpegPatch | PandocPatch) -> dict[str, Any]:
272
+ updates: dict[str, Any] = {}
273
+ for field_name in fields.model_fields_set:
274
+ value = getattr(fields, field_name)
275
+ if field_name in _LIST_PATCH_FIELDS and value is None:
276
+ value = []
277
+ if field_name in _BOOL_PATCH_FIELDS and value is None:
278
+ value = False
279
+ if field_name == "description" and value is None:
280
+ value = ""
281
+ updates[field_name] = value
282
+ return updates
283
+
284
+
285
+ def _same_file(left: str | Path, right: str | Path) -> bool:
286
+ try:
287
+ return os.path.samefile(left, right)
288
+ except OSError:
289
+ # At least one path does not exist yet; lexical comparison is the only
290
+ # available check and is sufficient for normalized SessionState paths.
291
+ return Path(left) == Path(right)
ffai/tools.py ADDED
@@ -0,0 +1,177 @@
1
+ """Registry of external conversion tools and extension-based routing.
2
+
3
+ Each supported tool exposes one task model, one patch model, and one argv
4
+ builder. The registry is the single place that maps a tool name to its
5
+ capabilities so the REPL, the prompt, and the session state stay tool-agnostic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from ffai import command_builder, pandoc_builder
17
+ from ffai.models import (
18
+ FFMPEG_DISPLAY_FIELDS,
19
+ PANDOC_DISPLAY_FIELDS,
20
+ FFmpegPatch,
21
+ FFmpegTask,
22
+ PandocPatch,
23
+ PandocTask,
24
+ TaskBase,
25
+ ToolName,
26
+ )
27
+
28
+ # Media container/codec extensions routed to FFmpeg.
29
+ MEDIA_EXTENSIONS: frozenset[str] = frozenset(
30
+ {
31
+ ".3g2",
32
+ ".3gp",
33
+ ".aac",
34
+ ".aif",
35
+ ".aiff",
36
+ ".alac",
37
+ ".ape",
38
+ ".avi",
39
+ ".avif",
40
+ ".bmp",
41
+ ".flac",
42
+ ".flv",
43
+ ".gif",
44
+ ".jpeg",
45
+ ".jpg",
46
+ ".m2ts",
47
+ ".m4a",
48
+ ".m4v",
49
+ ".mkv",
50
+ ".mov",
51
+ ".mp3",
52
+ ".mp4",
53
+ ".mpeg",
54
+ ".mpg",
55
+ ".mts",
56
+ ".oga",
57
+ ".ogg",
58
+ ".ogv",
59
+ ".opus",
60
+ ".png",
61
+ ".tif",
62
+ ".tiff",
63
+ ".ts",
64
+ ".vob",
65
+ ".wav",
66
+ ".webm",
67
+ ".webp",
68
+ ".wma",
69
+ ".wmv",
70
+ }
71
+ )
72
+
73
+ # Document extensions routed to Pandoc.
74
+ DOCUMENT_EXTENSIONS: frozenset[str] = frozenset(
75
+ {
76
+ ".adoc",
77
+ ".asciidoc",
78
+ ".docx",
79
+ ".epub",
80
+ ".htm",
81
+ ".html",
82
+ ".ipynb",
83
+ ".latex",
84
+ ".markdown",
85
+ ".md",
86
+ ".mediawiki",
87
+ ".odt",
88
+ ".opml",
89
+ ".org",
90
+ ".pptx",
91
+ ".rst",
92
+ ".rtf",
93
+ ".tex",
94
+ ".text",
95
+ ".textile",
96
+ ".txt",
97
+ }
98
+ )
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class ToolSpec:
103
+ """Everything the application needs to know about one external tool."""
104
+
105
+ name: ToolName
106
+ binary: str
107
+ label: str
108
+ task_type: type[TaskBase]
109
+ patch_type: type[FFmpegPatch] | type[PandocPatch]
110
+ display_fields: tuple[str, ...]
111
+ default_description: str
112
+ supports_progress: bool
113
+ builder: Callable[[Any], list[str]]
114
+
115
+
116
+ TOOL_SPECS: dict[ToolName, ToolSpec] = {
117
+ "ffmpeg": ToolSpec(
118
+ name="ffmpeg",
119
+ binary="ffmpeg",
120
+ label="FFmpeg",
121
+ task_type=FFmpegTask,
122
+ patch_type=FFmpegPatch,
123
+ display_fields=FFMPEG_DISPLAY_FIELDS,
124
+ default_description="New FFmpeg task",
125
+ supports_progress=True,
126
+ builder=command_builder.build_command,
127
+ ),
128
+ "pandoc": ToolSpec(
129
+ name="pandoc",
130
+ binary="pandoc",
131
+ label="Pandoc",
132
+ task_type=PandocTask,
133
+ patch_type=PandocPatch,
134
+ display_fields=PANDOC_DISPLAY_FIELDS,
135
+ default_description="New pandoc task",
136
+ supports_progress=False,
137
+ builder=pandoc_builder.build_command,
138
+ ),
139
+ }
140
+
141
+ TOOL_NAMES: tuple[ToolName, ...] = tuple(TOOL_SPECS)
142
+
143
+ _EXTENSION_TOOL: dict[str, ToolName] = {
144
+ **{extension: "ffmpeg" for extension in MEDIA_EXTENSIONS},
145
+ **{extension: "pandoc" for extension in DOCUMENT_EXTENSIONS},
146
+ }
147
+
148
+
149
+ def get_tool(name: ToolName) -> ToolSpec:
150
+ return TOOL_SPECS[name]
151
+
152
+
153
+ def detect_tool(paths: Sequence[str]) -> ToolName | None:
154
+ """Infer the conversion tool from the extension of *paths*.
155
+
156
+ Returns ``None`` when the extensions are unknown or point at different
157
+ tools, in which case the caller keeps the currently selected tool.
158
+ """
159
+
160
+ detected = {
161
+ _EXTENSION_TOOL[Path(path).suffix.lower()]
162
+ for path in paths
163
+ if Path(path).suffix.lower() in _EXTENSION_TOOL
164
+ }
165
+ return detected.pop() if len(detected) == 1 else None
166
+
167
+
168
+ def tool_available(name: ToolName) -> bool:
169
+ return shutil.which(TOOL_SPECS[name].binary) is not None
170
+
171
+
172
+ def available_tools() -> tuple[ToolName, ...]:
173
+ return tuple(name for name in TOOL_NAMES if tool_available(name))
174
+
175
+
176
+ def missing_tools() -> tuple[ToolName, ...]:
177
+ return tuple(name for name in TOOL_NAMES if not tool_available(name))
ffai/utils.py ADDED
@@ -0,0 +1,91 @@
1
+ """Small, side-effect-light helpers shared by the CLI and executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+
13
+ _SECRET_ENV_NAME = re.compile(r"KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL", re.IGNORECASE)
14
+
15
+
16
+ def format_command(args: Sequence[str]) -> str:
17
+ """Render argv as a copy-pasteable shell command for display only."""
18
+
19
+ return shlex.join(str(arg) for arg in args)
20
+
21
+
22
+ def scrubbed_environment(source: Mapping[str, str] | None = None) -> dict[str, str]:
23
+ """Return an environment without credentials for spawned media tools."""
24
+
25
+ environment = os.environ if source is None else source
26
+ return {name: value for name, value in environment.items() if not _SECRET_ENV_NAME.search(name)}
27
+
28
+
29
+ def binary_available(binary: str) -> bool:
30
+ """Return whether *binary* can be found on PATH."""
31
+
32
+ return shutil.which(binary) is not None
33
+
34
+
35
+ def probe_duration(path: str, timeout_seconds: float = 10.0) -> float | None:
36
+ """Best-effort media duration used only to render a progress bar.
37
+
38
+ The FFmpeg run itself is still reported from its real exit status. Missing
39
+ ffprobe, malformed media, and timeouts therefore degrade to an unknown
40
+ duration rather than masking or changing command execution.
41
+ """
42
+
43
+ if shutil.which("ffprobe") is None:
44
+ return None
45
+ command = [
46
+ "ffprobe",
47
+ "-v",
48
+ "error",
49
+ "-show_entries",
50
+ "format=duration",
51
+ "-of",
52
+ "default=noprint_wrappers=1:nokey=1",
53
+ str(Path(path)),
54
+ ]
55
+ try:
56
+ completed = subprocess.run(
57
+ command,
58
+ stdin=subprocess.DEVNULL,
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=timeout_seconds,
62
+ check=False,
63
+ env=scrubbed_environment(),
64
+ )
65
+ except (OSError, subprocess.TimeoutExpired):
66
+ return None
67
+ if completed.returncode != 0:
68
+ return None
69
+ try:
70
+ duration = float(completed.stdout.strip().splitlines()[0])
71
+ except (IndexError, ValueError):
72
+ return None
73
+ return duration if duration > 0 else None
74
+
75
+
76
+ def format_file_size(path: str) -> str | None:
77
+ try:
78
+ size = Path(path).stat().st_size
79
+ except OSError:
80
+ return None
81
+ units = ("B", "KiB", "MiB", "GiB", "TiB")
82
+ value = float(size)
83
+ for unit in units:
84
+ if value < 1024 or unit == units[-1]:
85
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
86
+ value /= 1024
87
+ return None
88
+
89
+
90
+ def ffmpeg_available() -> bool:
91
+ return binary_available("ffmpeg")