talks-reducer 0.7.1__py3-none-any.whl → 0.8.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.
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, List
8
8
  from ..discovery import discover_servers as core_discover_servers
9
9
 
10
10
  if TYPE_CHECKING: # pragma: no cover - imported for type checking only
11
- from . import TalksReducerGUI
11
+ from .app import TalksReducerGUI
12
12
 
13
13
 
14
14
  def start_discovery(gui: "TalksReducerGUI") -> None:
@@ -3,15 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
- from pathlib import Path
7
6
  from typing import TYPE_CHECKING, Callable
8
7
 
8
+ from ..icons import find_icon_path
9
9
  from ..models import default_temp_folder
10
10
 
11
11
  if TYPE_CHECKING: # pragma: no cover - imported for type checking only
12
12
  import tkinter as tk
13
13
 
14
- from . import TalksReducerGUI
14
+ from .app import TalksReducerGUI
15
15
 
16
16
 
17
17
  def build_layout(gui: "TalksReducerGUI") -> None:
@@ -454,37 +454,24 @@ def reset_basic_defaults(gui: "TalksReducerGUI") -> None:
454
454
  def apply_window_icon(gui: "TalksReducerGUI") -> None:
455
455
  """Configure the application icon when the asset is available."""
456
456
 
457
- base_path = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
458
-
459
- icon_candidates: list[tuple[Path, str]] = []
460
- if sys.platform.startswith("win"):
461
- icon_candidates.append(
462
- (
463
- base_path / "talks_reducer" / "resources" / "icons" / "icon.ico",
464
- "ico",
465
- )
466
- )
467
- icon_candidates.append(
468
- (
469
- base_path / "talks_reducer" / "resources" / "icons" / "icon.png",
470
- "png",
471
- )
457
+ icon_filenames = (
458
+ ("app.ico", "app.png")
459
+ if sys.platform.startswith("win")
460
+ else ("app.png", "app.ico")
472
461
  )
462
+ icon_path = find_icon_path(filenames=icon_filenames)
463
+ if icon_path is None:
464
+ return
473
465
 
474
- for icon_path, icon_type in icon_candidates:
475
- if not icon_path.is_file():
476
- continue
477
-
478
- try:
479
- if icon_type == "ico" and sys.platform.startswith("win"):
480
- # On Windows, iconbitmap works better without the 'default' parameter
481
- gui.root.iconbitmap(str(icon_path))
482
- else:
483
- gui.root.iconphoto(False, gui.tk.PhotoImage(file=str(icon_path)))
484
- return
485
- except (gui.tk.TclError, Exception):
486
- # Missing Tk image support or invalid icon format - try next candidate
487
- continue
466
+ try:
467
+ if icon_path.suffix.lower() == ".ico" and sys.platform.startswith("win"):
468
+ # On Windows, iconbitmap works better without the 'default' parameter.
469
+ gui.root.iconbitmap(str(icon_path))
470
+ else:
471
+ gui.root.iconphoto(False, gui.tk.PhotoImage(file=str(icon_path)))
472
+ except (gui.tk.TclError, Exception):
473
+ # Missing Tk image support or invalid icon format - fail silently.
474
+ return
488
475
 
489
476
 
490
477
  def apply_window_size(gui: "TalksReducerGUI", *, simple: bool) -> None:
@@ -0,0 +1,80 @@
1
+ """Progress helpers that bridge the pipeline with the Tkinter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ from ..progress import ProgressHandle, SignalProgressReporter
8
+
9
+
10
+ class _GuiProgressHandle(ProgressHandle):
11
+ """Simple progress handle that records totals but only logs milestones."""
12
+
13
+ def __init__(self, log_callback: Callable[[str], None], desc: str) -> None:
14
+ self._log_callback = log_callback
15
+ self._desc = desc
16
+ self._current = 0
17
+ self._total: Optional[int] = None
18
+ if desc:
19
+ self._log_callback(f"{desc} started")
20
+
21
+ @property
22
+ def current(self) -> int:
23
+ return self._current
24
+
25
+ def ensure_total(self, total: int) -> None:
26
+ if self._total is None or total > self._total:
27
+ self._total = total
28
+
29
+ def advance(self, amount: int) -> None:
30
+ if amount > 0:
31
+ self._current += amount
32
+
33
+ def finish(self) -> None:
34
+ if self._total is not None:
35
+ self._current = self._total
36
+ if self._desc:
37
+ self._log_callback(f"{self._desc} completed")
38
+
39
+ def __enter__(self) -> "_GuiProgressHandle":
40
+ return self
41
+
42
+ def __exit__(self, exc_type, exc, tb) -> bool:
43
+ if exc_type is None:
44
+ self.finish()
45
+ return False
46
+
47
+
48
+ class _TkProgressReporter(SignalProgressReporter):
49
+ """Progress reporter that forwards updates to the GUI thread."""
50
+
51
+ def __init__(
52
+ self,
53
+ log_callback: Callable[[str], None],
54
+ process_callback: Optional[Callable] = None,
55
+ *,
56
+ stop_callback: Optional[Callable[[], bool]] = None,
57
+ ) -> None:
58
+ self._log_callback = log_callback
59
+ self.process_callback = process_callback
60
+ self._stop_callback = stop_callback
61
+
62
+ def log(self, message: str) -> None:
63
+ self._log_callback(message)
64
+ print(message, flush=True)
65
+
66
+ def task(
67
+ self, *, desc: str = "", total: Optional[int] = None, unit: str = ""
68
+ ) -> _GuiProgressHandle:
69
+ del total, unit
70
+ return _GuiProgressHandle(self._log_callback, desc)
71
+
72
+ def stop_requested(self) -> bool:
73
+ """Return ``True`` when the GUI has asked to cancel processing."""
74
+
75
+ if self._stop_callback is None:
76
+ return False
77
+ return bool(self._stop_callback())
78
+
79
+
80
+ __all__ = ["_GuiProgressHandle", "_TkProgressReporter"]
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
14
14
  from ..pipeline import ProcessingAborted
15
15
 
16
16
  if TYPE_CHECKING: # pragma: no cover - imported for type checking only
17
- from . import TalksReducerGUI
17
+ from .app import TalksReducerGUI
18
18
 
19
19
 
20
20
  def normalize_server_url(server_url: str) -> str:
@@ -219,6 +219,12 @@ def check_remote_server_for_gui(
219
219
  )
220
220
 
221
221
 
222
+ def _load_service_client() -> object:
223
+ """Return the Talks Reducer service client module."""
224
+
225
+ return importlib.import_module("talks_reducer.service_client")
226
+
227
+
222
228
  def process_files_via_server(
223
229
  gui: "TalksReducerGUI",
224
230
  files: List[str],
@@ -228,6 +234,8 @@ def process_files_via_server(
228
234
  open_after_convert: bool,
229
235
  default_remote_destination: Callable[[Path, bool], Path],
230
236
  parse_summary: Callable[[str], tuple[Optional[float], Optional[float]]],
237
+ load_service_client: Callable[[], object] = _load_service_client,
238
+ check_server: Callable[..., bool] = check_remote_server_for_gui,
231
239
  ) -> bool:
232
240
  """Send *files* to the configured server for processing."""
233
241
 
@@ -236,7 +244,7 @@ def process_files_via_server(
236
244
  raise ProcessingAborted("Remote processing cancelled by user.")
237
245
 
238
246
  try:
239
- service_module = importlib.import_module("talks_reducer.service_client")
247
+ service_module = load_service_client()
240
248
  except ModuleNotFoundError as exc:
241
249
  gui._append_log(f"Server client unavailable: {exc}")
242
250
  gui._schedule_on_ui_thread(
@@ -253,7 +261,7 @@ def process_files_via_server(
253
261
  lambda: gui._set_status("waiting", f"Waiting server {host_label}...")
254
262
  )
255
263
 
256
- available = check_remote_server_for_gui(
264
+ available = check_server(
257
265
  gui,
258
266
  server_url,
259
267
  success_status="waiting",
@@ -0,0 +1,202 @@
1
+ """Startup utilities for launching the Talks Reducer GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional, Sequence, Tuple
12
+
13
+ from ..cli import main as cli_main
14
+ from .app import TalksReducerGUI
15
+
16
+
17
+ def _check_tkinter_available() -> Tuple[bool, str]:
18
+ """Check if tkinter can create windows without importing it globally."""
19
+
20
+ # Test in a subprocess to avoid crashing the main process
21
+ test_code = """
22
+ import json
23
+
24
+ def run_check():
25
+ try:
26
+ import tkinter as tk # noqa: F401 - imported for side effect
27
+ except Exception as exc: # pragma: no cover - runs in subprocess
28
+ return {
29
+ "status": "import_error",
30
+ "error": f"{exc.__class__.__name__}: {exc}",
31
+ }
32
+
33
+ try:
34
+ import tkinter as tk
35
+
36
+ root = tk.Tk()
37
+ root.destroy()
38
+ except Exception as exc: # pragma: no cover - runs in subprocess
39
+ return {
40
+ "status": "init_error",
41
+ "error": f"{exc.__class__.__name__}: {exc}",
42
+ }
43
+
44
+ return {"status": "ok"}
45
+
46
+
47
+ if __name__ == "__main__":
48
+ print(json.dumps(run_check()))
49
+ """
50
+
51
+ try:
52
+ result = subprocess.run(
53
+ [sys.executable, "-c", test_code], capture_output=True, text=True, timeout=5
54
+ )
55
+
56
+ output = result.stdout.strip() or result.stderr.strip()
57
+
58
+ if not output:
59
+ return False, "Window creation failed"
60
+
61
+ try:
62
+ payload = json.loads(output)
63
+ except json.JSONDecodeError:
64
+ return False, output
65
+
66
+ status = payload.get("status")
67
+
68
+ if status == "ok":
69
+ return True, ""
70
+
71
+ if status == "import_error":
72
+ return (
73
+ False,
74
+ f"tkinter is not installed ({payload.get('error', 'unknown error')})",
75
+ )
76
+
77
+ if status == "init_error":
78
+ return (
79
+ False,
80
+ f"tkinter could not open a window ({payload.get('error', 'unknown error')})",
81
+ )
82
+
83
+ return False, output
84
+ except Exception as e: # pragma: no cover - defensive fallback
85
+ return False, f"Error testing tkinter: {e}"
86
+
87
+
88
+ def main(argv: Optional[Sequence[str]] = None) -> bool:
89
+ """Launch the GUI when run without arguments, otherwise defer to the CLI."""
90
+
91
+ if argv is None:
92
+ argv = sys.argv[1:]
93
+
94
+ parser = argparse.ArgumentParser(add_help=False)
95
+ parser.add_argument(
96
+ "--server",
97
+ action="store_true",
98
+ help="Launch the Talks Reducer server tray instead of the desktop GUI.",
99
+ )
100
+ parser.add_argument(
101
+ "--no-tray",
102
+ action="store_true",
103
+ help="Deprecated: the GUI no longer starts the server tray automatically.",
104
+ )
105
+
106
+ parsed_args, remaining = parser.parse_known_args(argv)
107
+ if parsed_args.server:
108
+ package_name = __package__ or "talks_reducer"
109
+ module_name = f"{package_name}.server_tray"
110
+ try:
111
+ tray_module = importlib.import_module(module_name)
112
+ except ModuleNotFoundError as exc:
113
+ if exc.name != module_name:
114
+ raise
115
+ root_package = package_name.split(".")[0] or "talks_reducer"
116
+ tray_module = importlib.import_module(f"{root_package}.server_tray")
117
+ tray_main = getattr(tray_module, "main")
118
+ tray_main(remaining)
119
+ return False
120
+ if parsed_args.no_tray:
121
+ sys.stderr.write(
122
+ "Warning: --no-tray is deprecated; the GUI no longer starts the server tray automatically.\n"
123
+ )
124
+ argv = remaining
125
+
126
+ if argv:
127
+ launch_gui = False
128
+ if sys.platform == "win32" and not any(arg.startswith("-") for arg in argv):
129
+ if any(Path(arg).exists() for arg in argv if arg):
130
+ launch_gui = True
131
+
132
+ if launch_gui:
133
+ try:
134
+ app = TalksReducerGUI(argv, auto_run=True)
135
+ app.run()
136
+ return True
137
+ except Exception:
138
+ # Fall back to the CLI if the GUI cannot be started.
139
+ pass
140
+
141
+ cli_main(argv)
142
+ return False
143
+
144
+ is_frozen = getattr(sys, "frozen", False)
145
+
146
+ if not is_frozen:
147
+ tkinter_available, error_msg = _check_tkinter_available()
148
+
149
+ if not tkinter_available:
150
+ try:
151
+ print("Talks Reducer GUI")
152
+ print("=" * 50)
153
+ print("X GUI not available on this system")
154
+ print(f"Error: {error_msg}")
155
+ print()
156
+ print("! Alternative: Use the command-line interface")
157
+ print()
158
+ print("The CLI provides all the same functionality:")
159
+ print(" python3 -m talks_reducer <input_file> [options]")
160
+ print()
161
+ print("Examples:")
162
+ print(" python3 -m talks_reducer video.mp4")
163
+ print(" python3 -m talks_reducer video.mp4 --small")
164
+ print(" python3 -m talks_reducer video.mp4 -o output.mp4")
165
+ print()
166
+ print("Run 'python3 -m talks_reducer --help' for all options.")
167
+ print()
168
+ print("Troubleshooting tips:")
169
+ if sys.platform == "darwin":
170
+ print(
171
+ " - On macOS, install Python from python.org or ensure "
172
+ "Homebrew's python-tk package is present."
173
+ )
174
+ elif sys.platform.startswith("linux"):
175
+ print(
176
+ " - On Linux, install the Tk bindings for Python (for example, "
177
+ "python3-tk)."
178
+ )
179
+ else:
180
+ print(" - Ensure your Python installation includes Tk support.")
181
+ print(" - You can always fall back to the CLI workflow below.")
182
+ print()
183
+ print("The CLI interface works perfectly and is recommended.")
184
+ except UnicodeEncodeError:
185
+ sys.stderr.write("GUI not available. Use CLI mode instead.\n")
186
+ return False
187
+
188
+ try:
189
+ app = TalksReducerGUI()
190
+ app.run()
191
+ return True
192
+ except Exception as e:
193
+ import traceback
194
+
195
+ sys.stderr.write(f"Error starting GUI: {e}\n")
196
+ sys.stderr.write(traceback.format_exc())
197
+ sys.stderr.write("\nPlease use the CLI mode instead:\n")
198
+ sys.stderr.write(" python3 -m talks_reducer <input_file> [options]\n")
199
+ sys.exit(1)
200
+
201
+
202
+ __all__ = ["_check_tkinter_available", "main"]
talks_reducer/icons.py ADDED
@@ -0,0 +1,123 @@
1
+ """Icon discovery helpers shared across Talks Reducer entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+ from typing import Iterator, Optional, Sequence
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+ _ICON_RELATIVE_PATHS: Sequence[Path] = (
14
+ Path("resources") / "icons",
15
+ Path("talks_reducer") / "resources" / "icons",
16
+ Path("docs") / "assets",
17
+ )
18
+ _ICON_PATH_SUFFIXES: Sequence[Path] = (
19
+ Path(""),
20
+ Path("_internal"),
21
+ Path("Contents") / "Resources",
22
+ Path("Resources"),
23
+ )
24
+
25
+
26
+ def _iter_base_roots(module_file: Optional[Path | str] = None) -> Iterator[Path]:
27
+ """Yield base directories where icon assets may live."""
28
+
29
+ module_path = Path(module_file or __file__).resolve()
30
+ package_root = module_path.parent
31
+ project_root = package_root.parent
32
+
33
+ seen: set[Path] = set()
34
+
35
+ def _yield(path: Optional[Path]) -> Iterator[Path]:
36
+ if path is None:
37
+ return iter(())
38
+ resolved = path.resolve()
39
+ if resolved in seen:
40
+ return iter(())
41
+ seen.add(resolved)
42
+ return iter((resolved,))
43
+
44
+ for root in (
45
+ package_root,
46
+ project_root,
47
+ Path.cwd(),
48
+ ):
49
+ yield from _yield(root)
50
+
51
+ frozen_root: Optional[Path] = None
52
+ frozen_value = getattr(sys, "_MEIPASS", None)
53
+ if frozen_value:
54
+ with suppress(Exception):
55
+ frozen_root = Path(str(frozen_value))
56
+ if frozen_root is not None:
57
+ yield from _yield(frozen_root)
58
+
59
+ with suppress(Exception):
60
+ executable_root = Path(sys.executable).resolve().parent
61
+ yield from _yield(executable_root)
62
+
63
+ with suppress(Exception):
64
+ launcher_root = Path(sys.argv[0]).resolve().parent
65
+ yield from _yield(launcher_root)
66
+
67
+
68
+ def iter_icon_candidates(
69
+ *,
70
+ filenames: Sequence[str],
71
+ relative_paths: Sequence[Path] | None = None,
72
+ module_file: Optional[Path | str] = None,
73
+ ) -> Iterator[Path]:
74
+ """Yield possible icon paths ordered from most to least specific."""
75
+
76
+ if relative_paths is None:
77
+ relative_paths = _ICON_RELATIVE_PATHS
78
+
79
+ seen: set[Path] = set()
80
+ for base_root in _iter_base_roots(module_file=module_file):
81
+ for suffix in _ICON_PATH_SUFFIXES:
82
+ candidate_root = (base_root / suffix).resolve()
83
+ if candidate_root in seen:
84
+ continue
85
+ seen.add(candidate_root)
86
+ LOGGER.debug("Considering icon root: %s", candidate_root)
87
+
88
+ if not candidate_root.exists():
89
+ LOGGER.debug("Skipping missing icon root: %s", candidate_root)
90
+ continue
91
+
92
+ for relative in relative_paths:
93
+ candidate_base = (candidate_root / relative).resolve()
94
+ if not candidate_base.exists():
95
+ LOGGER.debug("Skipping missing icon directory: %s", candidate_base)
96
+ continue
97
+ for name in filenames:
98
+ candidate = (candidate_base / name).resolve()
99
+ LOGGER.debug("Checking icon candidate: %s", candidate)
100
+ yield candidate
101
+
102
+
103
+ def find_icon_path(
104
+ *,
105
+ filenames: Sequence[str],
106
+ relative_paths: Sequence[Path] | None = None,
107
+ module_file: Optional[Path | str] = None,
108
+ ) -> Optional[Path]:
109
+ """Return the first existing icon path matching *filenames* or ``None``."""
110
+
111
+ for candidate in iter_icon_candidates(
112
+ filenames=filenames,
113
+ relative_paths=relative_paths,
114
+ module_file=module_file,
115
+ ):
116
+ if candidate.is_file():
117
+ LOGGER.info("Found icon at %s", candidate)
118
+ return candidate
119
+ LOGGER.warning("Unable to locate Talks Reducer icon; checked %s", filenames)
120
+ return None
121
+
122
+
123
+ __all__ = ["find_icon_path", "iter_icon_candidates"]