polytool 0.2.0__tar.gz → 0.2.2__tar.gz

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 (34) hide show
  1. {polytool-0.2.0 → polytool-0.2.2}/PKG-INFO +1 -1
  2. {polytool-0.2.0 → polytool-0.2.2}/docs/README.md +1 -0
  3. {polytool-0.2.0 → polytool-0.2.2}/pyproject.toml +3 -3
  4. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/__init__.py +1 -1
  5. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/__main__.py +2 -2
  6. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/__init__.py +19 -3
  7. polytool-0.2.2/src/polytool/cli/dl.py +400 -0
  8. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/img.py +2 -2
  9. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/shot.py +1 -1
  10. polytool-0.2.2/src/polytool/core/config.py +85 -0
  11. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/errors.py +27 -17
  12. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/ffmpeg.py +3 -1
  13. polytool-0.2.0/src/polytool/cli/dl.py +0 -102
  14. {polytool-0.2.0 → polytool-0.2.2}/.gitignore +0 -0
  15. {polytool-0.2.0 → polytool-0.2.2}/LICENSE +0 -0
  16. {polytool-0.2.0 → polytool-0.2.2}/README.md +0 -0
  17. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/clip.py +0 -0
  18. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/color.py +0 -0
  19. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/convert.py +0 -0
  20. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/cron.py +0 -0
  21. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/data.py +0 -0
  22. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/enc.py +0 -0
  23. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/file.py +0 -0
  24. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/gen.py +0 -0
  25. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/net.py +0 -0
  26. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/pdf.py +0 -0
  27. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/qr.py +0 -0
  28. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/text.py +0 -0
  29. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/cli/vid.py +0 -0
  30. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/__init__.py +0 -0
  31. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/console.py +0 -0
  32. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/io.py +0 -0
  33. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/lazy.py +0 -0
  34. {polytool-0.2.0 → polytool-0.2.2}/src/polytool/core/progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytool
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: One-binary CLI bundling 26 everyday utilities — image/video/PDF conversion, background removal, OCR, QR codes, hashing, downloads, and more
5
5
  Project-URL: Homepage, https://github.com/k6w/polytool
6
6
  Project-URL: Repository, https://github.com/k6w/polytool
@@ -7,6 +7,7 @@ Detailed reference for every command in `polytool` (binary `pt`).
7
7
  ## Quick links
8
8
 
9
9
  - **[Install](install.md)** — `uv tool install`, slim vs full, extras, Windows quoting
10
+ - **[Auth & external services](auth.md)** — which verbs touch the network and how to authenticate
10
11
  - **[Troubleshooting](troubleshooting.md)** — every common gotcha with a fix
11
12
  - **[Architecture](architecture.md)** — package layout, lazy imports, error model
12
13
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polytool"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "One-binary CLI bundling 26 everyday utilities — image/video/PDF conversion, background removal, OCR, QR codes, hashing, downloads, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -76,8 +76,8 @@ dev = [
76
76
  ]
77
77
 
78
78
  [project.scripts]
79
- polytool = "polytool.cli:app"
80
- pt = "polytool.cli:app"
79
+ polytool = "polytool.cli:run"
80
+ pt = "polytool.cli:run"
81
81
 
82
82
  [project.urls]
83
83
  Homepage = "https://github.com/k6w/polytool"
@@ -1,4 +1,4 @@
1
1
  """polytool — one-binary CLI bundling 26 everyday utilities."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
4
4
  __all__ = ["__version__"]
@@ -1,6 +1,6 @@
1
1
  """Allow `python -m polytool` invocation."""
2
2
 
3
- from polytool.cli import app
3
+ from polytool.cli import run
4
4
 
5
5
  if __name__ == "__main__":
6
- app()
6
+ run()
@@ -7,6 +7,8 @@ Heavy imports (Pillow, ffmpeg, rembg) happen inside command bodies via
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import sys
11
+
10
12
  import typer
11
13
 
12
14
  from polytool import __version__
@@ -28,9 +30,7 @@ from polytool.cli import (
28
30
  text,
29
31
  vid,
30
32
  )
31
- from polytool.core.errors import install_excepthook
32
-
33
- install_excepthook()
33
+ from polytool.core.errors import PolytoolError, render_panel
34
34
 
35
35
  app = typer.Typer(
36
36
  name="polytool",
@@ -38,9 +38,25 @@ app = typer.Typer(
38
38
  no_args_is_help=True,
39
39
  add_completion=False,
40
40
  rich_markup_mode="rich",
41
+ # We handle PolytoolError ourselves via the `main()` wrapper below; disable
42
+ # Typer's pretty-traceback so it doesn't squash our hints.
43
+ pretty_exceptions_enable=False,
41
44
  )
42
45
 
43
46
 
47
+ def run() -> None:
48
+ """Console-script entry point — runs the Typer app and renders PolytoolError nicely.
49
+
50
+ (Defined with a unique name to avoid clashing with the ``@app.callback()``
51
+ function below — both would otherwise be named ``main`` in this module.)
52
+ """
53
+ try:
54
+ app()
55
+ except PolytoolError as exc:
56
+ render_panel(exc.message, exc.hint)
57
+ sys.exit(1)
58
+
59
+
44
60
  def _version_callback(value: bool) -> None:
45
61
  if value:
46
62
  typer.echo(f"polytool {__version__}")
@@ -0,0 +1,400 @@
1
+ """Download media from YouTube and 1000+ sites via yt-dlp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from polytool.core import config
11
+ from polytool.core.console import console, err_console
12
+ from polytool.core.errors import PolytoolError
13
+
14
+ app = typer.Typer(
15
+ name="dl",
16
+ help="Download media from YouTube and 1000+ sites (yt-dlp).",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ # Browsers yt-dlp's `cookiesfrombrowser` knows about. Used by `pt dl setup`.
21
+ SUPPORTED_BROWSERS = (
22
+ "chrome",
23
+ "firefox",
24
+ "edge",
25
+ "brave",
26
+ "chromium",
27
+ "opera",
28
+ "safari",
29
+ "vivaldi",
30
+ "whale",
31
+ )
32
+
33
+ BOT_CHECK_HINTS = (
34
+ "sign in",
35
+ "confirm you",
36
+ "not a bot",
37
+ "captcha",
38
+ "private video",
39
+ "members-only",
40
+ "login required",
41
+ "rate limit",
42
+ )
43
+
44
+
45
+ def _parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | None]:
46
+ """Parse a yt-dlp ``--cookies-from-browser`` spec.
47
+
48
+ Format: ``browser[+keyring][:profile][::container]``.
49
+
50
+ >>> _parse_browser_spec("chrome")
51
+ ('chrome', None, None, None)
52
+ >>> _parse_browser_spec("firefox:default-release")
53
+ ('firefox', 'default-release', None, None)
54
+ >>> _parse_browser_spec("firefox+gnomekeyring:default::personal")
55
+ ('firefox', 'default', 'gnomekeyring', 'personal')
56
+ """
57
+ rest = spec
58
+ container: str | None = None
59
+ if "::" in rest:
60
+ rest, _, container = rest.partition("::")
61
+ profile: str | None = None
62
+ if ":" in rest:
63
+ rest, _, profile = rest.partition(":")
64
+ keyring: str | None = None
65
+ if "+" in rest:
66
+ rest, _, keyring = rest.partition("+")
67
+ browser = rest.strip().lower()
68
+ if not browser:
69
+ raise PolytoolError(
70
+ f"Invalid --cookies-from-browser spec: {spec!r}",
71
+ hint="Format: browser[+keyring][:profile][::container].",
72
+ )
73
+ return (browser, profile or None, keyring or None, container or None)
74
+
75
+
76
+ def _resolve_cookies(
77
+ cookies_from_browser: str | None,
78
+ cookies_file: Path | None,
79
+ ) -> tuple[str | None, Path | None]:
80
+ """Pick the active cookie source: explicit flags first, then saved config."""
81
+ if cookies_from_browser and cookies_file:
82
+ raise PolytoolError("Use --cookies-from-browser OR --cookies, not both.")
83
+ if cookies_from_browser:
84
+ return cookies_from_browser, None
85
+ if cookies_file:
86
+ return None, cookies_file
87
+ saved_browser = config.get("dl", "cookies_from_browser")
88
+ saved_file = config.get("dl", "cookies_file")
89
+ if saved_browser:
90
+ return saved_browser, None
91
+ if saved_file:
92
+ return None, Path(saved_file)
93
+ return None, None
94
+
95
+
96
+ def _apply_cookie_opts(
97
+ opts: dict,
98
+ cookies_from_browser: str | None,
99
+ cookies_file: Path | None,
100
+ ) -> None:
101
+ """Mutate *opts* in-place to add yt-dlp cookie options."""
102
+ browser_spec, file_path = _resolve_cookies(cookies_from_browser, cookies_file)
103
+ if browser_spec:
104
+ opts["cookiesfrombrowser"] = _parse_browser_spec(browser_spec)
105
+ elif file_path:
106
+ if not file_path.exists():
107
+ raise PolytoolError(f"Cookie file not found: {file_path}")
108
+ opts["cookiefile"] = str(file_path)
109
+
110
+
111
+ def _bot_check_hint(message: str) -> str | None:
112
+ low = message.lower()
113
+ if not any(s in low for s in BOT_CHECK_HINTS):
114
+ return None
115
+ if config.get("dl", "cookies_from_browser") or config.get("dl", "cookies_file"):
116
+ return (
117
+ "Saved cookies didn't satisfy the site. "
118
+ "Re-run [cyan]pt dl setup[/cyan] (maybe pick a different browser/profile)."
119
+ )
120
+ return (
121
+ "This site is blocking unauthenticated requests. "
122
+ "Run [cyan]pt dl setup[/cyan] once to configure cookies, "
123
+ "or pass [cyan]--cookies-from-browser BROWSER[/cyan] per call."
124
+ )
125
+
126
+
127
+ @app.command("setup")
128
+ def cmd_setup(
129
+ browser: Annotated[
130
+ str | None,
131
+ typer.Option(
132
+ "--browser",
133
+ "-b",
134
+ help=f"Save a default browser ({', '.join(SUPPORTED_BROWSERS)}). "
135
+ "Accepts the same browser[+keyring][:profile][::container] format as yt-dlp.",
136
+ ),
137
+ ] = None,
138
+ cookies_file: Annotated[
139
+ Path | None,
140
+ typer.Option("--cookies", help="Save the path to a Netscape-format cookies.txt file."),
141
+ ] = None,
142
+ clear: Annotated[bool, typer.Option("--clear", help="Remove the saved cookie config.")] = False,
143
+ show: Annotated[bool, typer.Option("--show", help="Print the saved cookie config.")] = False,
144
+ ) -> None:
145
+ """One-time setup: tell `pt dl` where to fetch cookies from.
146
+
147
+ After running this, [cyan]pt dl get[/cyan] and [cyan]pt dl info[/cyan] will
148
+ automatically use your saved cookies — no need to pass flags every time.
149
+
150
+ Examples:
151
+
152
+ pt dl setup # interactive prompt
153
+ pt dl setup --browser firefox # save firefox as the default
154
+ pt dl setup --browser chrome:Default
155
+ pt dl setup --cookies cookies.txt
156
+ pt dl setup --show # print current saved config
157
+ pt dl setup --clear # forget saved cookies
158
+ """
159
+ from polytool.core.config import CONFIG_PATH
160
+
161
+ if show:
162
+ b = config.get("dl", "cookies_from_browser")
163
+ f = config.get("dl", "cookies_file")
164
+ if not b and not f:
165
+ console.print("[dim]No saved dl cookie config.[/dim]")
166
+ return
167
+ if b:
168
+ console.print(f"[cyan]cookies-from-browser[/cyan]: {b}")
169
+ if f:
170
+ console.print(f"[cyan]cookies-file[/cyan]: {f}")
171
+ console.print(f"[dim](stored at {CONFIG_PATH})[/dim]")
172
+ return
173
+
174
+ if clear:
175
+ config.unset("dl", "cookies_from_browser")
176
+ config.unset("dl", "cookies_file")
177
+ console.print("[green]Cleared saved dl cookie config.[/green]")
178
+ return
179
+
180
+ if browser is not None and cookies_file is not None:
181
+ raise PolytoolError("Use --browser OR --cookies, not both.")
182
+
183
+ if browser is None and cookies_file is None:
184
+ # Interactive prompt.
185
+ console.print("Which browser should [cyan]pt dl[/cyan] pull cookies from?\n")
186
+ for i, name in enumerate(SUPPORTED_BROWSERS, 1):
187
+ console.print(f" [cyan]{i}[/cyan]) {name}")
188
+ console.print(f" [cyan]{len(SUPPORTED_BROWSERS) + 1}[/cyan]) [dim]none / not now[/dim]")
189
+ choice_s = typer.prompt("\nPick a number", type=str)
190
+ try:
191
+ choice = int(choice_s.strip())
192
+ except ValueError as exc:
193
+ raise PolytoolError(f"Not a number: {choice_s!r}") from exc
194
+ if choice == len(SUPPORTED_BROWSERS) + 1:
195
+ console.print("[dim]Skipped. Run pt dl setup any time to configure.[/dim]")
196
+ return
197
+ if not 1 <= choice <= len(SUPPORTED_BROWSERS):
198
+ raise PolytoolError("Out of range.")
199
+ browser = SUPPORTED_BROWSERS[choice - 1]
200
+
201
+ if browser is not None:
202
+ # Validate the spec parses (catches typos before the user hits a real error later).
203
+ _parse_browser_spec(browser)
204
+ config.set_(browser, "dl", "cookies_from_browser")
205
+ config.unset("dl", "cookies_file")
206
+ console.print(
207
+ f"[green]Saved.[/green] [cyan]pt dl[/cyan] will use cookies from "
208
+ f"[bold]{browser}[/bold] from now on."
209
+ )
210
+ console.print(f"[dim](config at {CONFIG_PATH})[/dim]")
211
+ return
212
+
213
+ assert cookies_file is not None
214
+ if not cookies_file.exists():
215
+ raise PolytoolError(f"Cookie file not found: {cookies_file}")
216
+ resolved = cookies_file.resolve()
217
+ config.set_(str(resolved), "dl", "cookies_file")
218
+ config.unset("dl", "cookies_from_browser")
219
+ console.print(f"[green]Saved.[/green] [cyan]pt dl[/cyan] will use cookies from {resolved}.")
220
+ console.print(f"[dim](config at {CONFIG_PATH})[/dim]")
221
+
222
+
223
+ @app.command("get")
224
+ def cmd_get(
225
+ url: Annotated[str, typer.Argument(help="Media URL")],
226
+ output_dir: Annotated[Path, typer.Option("--output", "-o", help="Output directory")] = Path(),
227
+ audio_only: Annotated[
228
+ bool,
229
+ typer.Option("--audio-only", "-a", help="Download audio (mp3) only"),
230
+ ] = False,
231
+ format_: Annotated[
232
+ str | None,
233
+ typer.Option("--format", "-f", help="yt-dlp format selector (e.g. 'best', '720p')"),
234
+ ] = None,
235
+ template: Annotated[
236
+ str,
237
+ typer.Option(
238
+ "--template",
239
+ "-t",
240
+ help="Output filename template (yt-dlp syntax)",
241
+ ),
242
+ ] = "%(title)s.%(ext)s",
243
+ cookies_from_browser: Annotated[
244
+ str | None,
245
+ typer.Option(
246
+ "--cookies-from-browser",
247
+ help="Override saved config: load cookies from BROWSER[+keyring][:profile][::container].",
248
+ ),
249
+ ] = None,
250
+ cookies_file: Annotated[
251
+ Path | None,
252
+ typer.Option(
253
+ "--cookies",
254
+ help="Override saved config: path to a Netscape-format cookies.txt file.",
255
+ ),
256
+ ] = None,
257
+ username: Annotated[
258
+ str | None,
259
+ typer.Option("--username", "-u", help="Account username (for sites with login auth)."),
260
+ ] = None,
261
+ password: Annotated[
262
+ str | None,
263
+ typer.Option(
264
+ "--password",
265
+ "-p",
266
+ help="Account password. Tip: omit and yt-dlp will prompt securely.",
267
+ ),
268
+ ] = None,
269
+ video_password: Annotated[
270
+ str | None,
271
+ typer.Option("--video-password", help="Per-video password (Vimeo etc.)."),
272
+ ] = None,
273
+ ) -> None:
274
+ """Download a video or audio file.
275
+
276
+ Cookies are taken from your [cyan]pt dl setup[/cyan] config by default;
277
+ override per-call with --cookies-from-browser / --cookies.
278
+
279
+ Examples:
280
+
281
+ pt dl get 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
282
+ pt dl get URL --audio-only
283
+ pt dl get URL --format 720p -o ./downloads/
284
+ pt dl get URL --cookies-from-browser firefox # one-off override
285
+ pt dl get URL --username alice --password 'hunter2'
286
+ pt dl get URL --video-password 'secret' # Vimeo-style per-video pwd
287
+ """
288
+ from polytool.core.lazy import require_extra
289
+
290
+ yt_dlp = require_extra("yt_dlp", extra="dl")
291
+
292
+ output_dir.mkdir(parents=True, exist_ok=True)
293
+ opts: dict = {
294
+ "outtmpl": str(output_dir / template),
295
+ "noplaylist": False,
296
+ "quiet": False,
297
+ "no_warnings": False,
298
+ "progress": True,
299
+ }
300
+ if audio_only:
301
+ opts["format"] = "bestaudio/best"
302
+ opts["postprocessors"] = [
303
+ {"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}
304
+ ]
305
+ elif format_:
306
+ opts["format"] = format_
307
+
308
+ _apply_cookie_opts(opts, cookies_from_browser, cookies_file)
309
+ if username is not None:
310
+ opts["username"] = username
311
+ if password is not None:
312
+ opts["password"] = password
313
+ if video_password is not None:
314
+ opts["videopassword"] = video_password
315
+ if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
316
+ err_console.print(
317
+ f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
318
+ )
319
+
320
+ try:
321
+ with yt_dlp.YoutubeDL(opts) as ydl:
322
+ ydl.download([url])
323
+ except yt_dlp.utils.DownloadError as exc:
324
+ raise PolytoolError(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
325
+ except Exception as exc:
326
+ raise PolytoolError(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
327
+ console.print("[green]Done.[/green]")
328
+
329
+
330
+ @app.command("info")
331
+ def cmd_info(
332
+ url: Annotated[str, typer.Argument(help="Media URL")],
333
+ cookies_from_browser: Annotated[
334
+ str | None,
335
+ typer.Option(
336
+ "--cookies-from-browser",
337
+ help="Override saved config: load cookies from BROWSER[+keyring][:profile][::container].",
338
+ ),
339
+ ] = None,
340
+ cookies_file: Annotated[
341
+ Path | None,
342
+ typer.Option(
343
+ "--cookies",
344
+ help="Override saved config: path to a Netscape-format cookies.txt file.",
345
+ ),
346
+ ] = None,
347
+ username: Annotated[
348
+ str | None,
349
+ typer.Option("--username", "-u", help="Account username (for sites with login auth)."),
350
+ ] = None,
351
+ password: Annotated[
352
+ str | None,
353
+ typer.Option(
354
+ "--password",
355
+ "-p",
356
+ help="Account password. Tip: omit and yt-dlp will prompt securely.",
357
+ ),
358
+ ] = None,
359
+ video_password: Annotated[
360
+ str | None,
361
+ typer.Option("--video-password", help="Per-video password (Vimeo etc.)."),
362
+ ] = None,
363
+ ) -> None:
364
+ """Show metadata about a URL without downloading.
365
+
366
+ Cookies are taken from your [cyan]pt dl setup[/cyan] config by default.
367
+
368
+ Examples:
369
+
370
+ pt dl info 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
371
+ pt dl info URL --cookies-from-browser chrome
372
+ pt dl info URL --username alice --password hunter2
373
+ """
374
+ from polytool.core.lazy import require_extra
375
+
376
+ yt_dlp = require_extra("yt_dlp", extra="dl")
377
+
378
+ opts: dict = {"quiet": True, "no_warnings": True}
379
+ _apply_cookie_opts(opts, cookies_from_browser, cookies_file)
380
+ if username is not None:
381
+ opts["username"] = username
382
+ if password is not None:
383
+ opts["password"] = password
384
+ if video_password is not None:
385
+ opts["videopassword"] = video_password
386
+ if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
387
+ err_console.print(
388
+ f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
389
+ )
390
+
391
+ try:
392
+ with yt_dlp.YoutubeDL(opts) as ydl:
393
+ info = ydl.extract_info(url, download=False)
394
+ except Exception as exc:
395
+ raise PolytoolError(f"Could not fetch info: {exc}", hint=_bot_check_hint(str(exc))) from exc
396
+
397
+ interesting = ("title", "uploader", "duration", "view_count", "upload_date", "webpage_url")
398
+ for key in interesting:
399
+ if key in info and info[key] is not None:
400
+ console.print(f"[cyan]{key}[/cyan]: {info[key]}")
@@ -41,7 +41,7 @@ def _open_image(source: Path):
41
41
  except ImportError as exc:
42
42
  raise PolytoolError(
43
43
  "HEIC support missing.",
44
- hint="Install: [cyan]uv tool install 'polytool[img]'[/cyan]",
44
+ hint="Install: [cyan]uv tool install 'polytool\\[img]'[/cyan]",
45
45
  ) from exc
46
46
  if suffix == ".avif":
47
47
  try:
@@ -49,7 +49,7 @@ def _open_image(source: Path):
49
49
  except ImportError as exc:
50
50
  raise PolytoolError(
51
51
  "AVIF support missing.",
52
- hint="Install: [cyan]uv tool install 'polytool[img]'[/cyan]",
52
+ hint="Install: [cyan]uv tool install 'polytool\\[img]'[/cyan]",
53
53
  ) from exc
54
54
 
55
55
  try:
@@ -121,7 +121,7 @@ def cmd_install() -> None:
121
121
  except FileNotFoundError as exc:
122
122
  raise PolytoolError(
123
123
  "Playwright not found.",
124
- hint="Install: [cyan]uv tool install 'polytool[shot]'[/cyan]",
124
+ hint="Install: [cyan]uv tool install 'polytool\\[shot]'[/cyan]",
125
125
  ) from exc
126
126
  except subprocess.CalledProcessError as exc:
127
127
  raise PolytoolError(f"playwright install failed (exit {exc.returncode})") from exc
@@ -0,0 +1,85 @@
1
+ """Persistent user config for polytool, stored as TOML at ``~/.polytool/config.toml``.
2
+
3
+ Tiny stdlib-only key-value store (read with ``tomllib``, write with ``tomli_w``).
4
+ Used so per-user preferences survive across invocations — e.g. which browser
5
+ ``pt dl`` should pull cookies from.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import tomli_w
15
+
16
+ CONFIG_DIR = Path.home() / ".polytool"
17
+ CONFIG_PATH = CONFIG_DIR / "config.toml"
18
+
19
+
20
+ def _resolve() -> tuple[Path, Path]:
21
+ """Return the (dir, file) pair currently in use.
22
+
23
+ Indirection so tests can monkeypatch CONFIG_PATH and have ``save()`` honor it.
24
+ """
25
+ return CONFIG_PATH.parent, CONFIG_PATH
26
+
27
+
28
+ def load() -> dict[str, Any]:
29
+ """Read the config file. Returns ``{}`` if missing or unreadable."""
30
+ _, path = _resolve()
31
+ if not path.exists():
32
+ return {}
33
+ try:
34
+ with path.open("rb") as f:
35
+ return tomllib.load(f)
36
+ except (OSError, tomllib.TOMLDecodeError):
37
+ return {}
38
+
39
+
40
+ def save(data: dict[str, Any]) -> None:
41
+ """Write the full config dict (creates parent dir if needed)."""
42
+ parent, path = _resolve()
43
+ parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_text(tomli_w.dumps(data), encoding="utf-8")
45
+
46
+
47
+ def get(*keys: str, default: Any = None) -> Any:
48
+ """Read a nested key. ``get("dl", "cookies_from_browser")`` returns
49
+ ``data['dl']['cookies_from_browser']`` or ``default``.
50
+ """
51
+ cur: Any = load()
52
+ for k in keys:
53
+ if not isinstance(cur, dict) or k not in cur:
54
+ return default
55
+ cur = cur[k]
56
+ return cur
57
+
58
+
59
+ def set_(value: Any, *keys: str) -> None:
60
+ """Set a nested key, creating intermediate tables as needed."""
61
+ if not keys:
62
+ raise ValueError("At least one key required")
63
+ data = load()
64
+ cur: dict[str, Any] = data
65
+ for k in keys[:-1]:
66
+ if k not in cur or not isinstance(cur[k], dict):
67
+ cur[k] = {}
68
+ cur = cur[k]
69
+ cur[keys[-1]] = value
70
+ save(data)
71
+
72
+
73
+ def unset(*keys: str) -> None:
74
+ """Delete a nested key. Silent no-op if the key is missing."""
75
+ if not keys:
76
+ return
77
+ data = load()
78
+ cur: Any = data
79
+ for k in keys[:-1]:
80
+ if not isinstance(cur, dict) or k not in cur:
81
+ return
82
+ cur = cur[k]
83
+ if isinstance(cur, dict) and keys[-1] in cur:
84
+ del cur[keys[-1]]
85
+ save(data)
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
5
  from typing import NoReturn
7
6
 
8
7
  import typer
@@ -11,8 +10,20 @@ from rich.panel import Panel
11
10
  from polytool.core.console import err_console
12
11
 
13
12
 
13
+ def render_panel(message: str, hint: str | None) -> None:
14
+ """Render the standard red error panel to stderr."""
15
+ body = f"[red bold]{message}[/red bold]"
16
+ if hint:
17
+ body += f"\n\n[dim]Hint:[/dim] {hint}"
18
+ err_console.print(Panel(body, border_style="red", title="Error"))
19
+
20
+
14
21
  class PolytoolError(Exception):
15
- """A user-facing error with an optional fix hint."""
22
+ """A user-facing error with an optional fix hint.
23
+
24
+ The CLI entry point (``polytool.cli.main``) catches this and renders
25
+ ``render_panel`` instead of letting Python or Typer print a stack trace.
26
+ """
16
27
 
17
28
  def __init__(self, message: str, hint: str | None = None) -> None:
18
29
  super().__init__(message)
@@ -24,10 +35,16 @@ class MissingExtraError(PolytoolError):
24
35
  """Raised when an optional dependency group isn't installed."""
25
36
 
26
37
  def __init__(self, module: str, extra: str) -> None:
38
+ from rich.markup import escape
39
+
27
40
  message = f"Missing optional dependency: '{module}' (from the '{extra}' extra)."
41
+ # rich.markup.escape only escapes `[` — `]` is left literal because
42
+ # Rich only treats `[` as the markup-tag opener.
43
+ spec = escape(f"polytool[{extra}]")
44
+ full = escape("polytool[full]")
28
45
  hint = (
29
- f"Install with: [cyan]uv tool install 'polytool[{extra}]'[/cyan]\n"
30
- f"Or for everything: [cyan]uv tool install 'polytool[full]'[/cyan]"
46
+ f"Install with: [cyan]uv tool install '{spec}'[/cyan]\n"
47
+ f"Or for everything: [cyan]uv tool install '{full}'[/cyan]"
31
48
  )
32
49
  super().__init__(message, hint)
33
50
  self.module = module
@@ -36,21 +53,14 @@ class MissingExtraError(PolytoolError):
36
53
 
37
54
  def fail(message: str, hint: str | None = None) -> NoReturn:
38
55
  """Print a red error panel and exit with code 1."""
39
- body = f"[red bold]{message}[/red bold]"
40
- if hint:
41
- body += f"\n\n[dim]Hint:[/dim] {hint}"
42
- err_console.print(Panel(body, border_style="red", title="Error"))
56
+ render_panel(message, hint)
43
57
  raise typer.Exit(code=1)
44
58
 
45
59
 
46
60
  def install_excepthook() -> None:
47
- """Install a sys.excepthook that renders PolytoolError nicely."""
48
- original = sys.excepthook
49
-
50
- def hook(exc_type, exc_value, tb):
51
- if isinstance(exc_value, PolytoolError):
52
- fail(exc_value.message, exc_value.hint)
53
- return
54
- original(exc_type, exc_value, tb)
61
+ """No-op kept for backwards compatibility.
55
62
 
56
- sys.excepthook = hook
63
+ Earlier versions installed a ``sys.excepthook``. The CLI entry-point now
64
+ handles ``PolytoolError`` directly via a try/except wrapper.
65
+ """
66
+ return
@@ -26,10 +26,12 @@ def ffmpeg_path() -> str:
26
26
  if iio is not None:
27
27
  return iio.get_ffmpeg_exe()
28
28
 
29
+ from rich.markup import escape
30
+
29
31
  from polytool.core.errors import PolytoolError
30
32
 
31
33
  raise PolytoolError(
32
34
  "ffmpeg not found.",
33
35
  hint="Install ffmpeg system-wide, or install the 'vid' extra: "
34
- "[cyan]uv tool install 'polytool[vid]'[/cyan]",
36
+ f"[cyan]uv tool install '{escape('polytool[vid]')}'[/cyan]",
35
37
  )
@@ -1,102 +0,0 @@
1
- """Download media from YouTube and 1000+ sites via yt-dlp."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Annotated
7
-
8
- import typer
9
-
10
- from polytool.core.console import console
11
- from polytool.core.errors import PolytoolError
12
-
13
- app = typer.Typer(
14
- name="dl",
15
- help="Download media from YouTube and 1000+ sites (yt-dlp).",
16
- no_args_is_help=True,
17
- )
18
-
19
-
20
- @app.command("get")
21
- def cmd_get(
22
- url: Annotated[str, typer.Argument(help="Media URL")],
23
- output_dir: Annotated[Path, typer.Option("--output", "-o", help="Output directory")] = Path(),
24
- audio_only: Annotated[
25
- bool,
26
- typer.Option("--audio-only", "-a", help="Download audio (mp3) only"),
27
- ] = False,
28
- format_: Annotated[
29
- str | None,
30
- typer.Option("--format", "-f", help="yt-dlp format selector (e.g. 'best', '720p')"),
31
- ] = None,
32
- template: Annotated[
33
- str,
34
- typer.Option(
35
- "--template",
36
- "-t",
37
- help="Output filename template (yt-dlp syntax)",
38
- ),
39
- ] = "%(title)s.%(ext)s",
40
- ) -> None:
41
- """Download a video or audio file.
42
-
43
- Examples:
44
-
45
- pt dl get 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
46
- pt dl get URL --audio-only
47
- pt dl get URL --format 720p -o ./downloads/
48
- """
49
- from polytool.core.lazy import require_extra
50
-
51
- yt_dlp = require_extra("yt_dlp", extra="dl")
52
-
53
- output_dir.mkdir(parents=True, exist_ok=True)
54
- opts: dict = {
55
- "outtmpl": str(output_dir / template),
56
- "noplaylist": False,
57
- "quiet": False,
58
- "no_warnings": False,
59
- "progress": True,
60
- }
61
- if audio_only:
62
- opts["format"] = "bestaudio/best"
63
- opts["postprocessors"] = [
64
- {"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}
65
- ]
66
- elif format_:
67
- opts["format"] = format_
68
-
69
- try:
70
- with yt_dlp.YoutubeDL(opts) as ydl:
71
- ydl.download([url])
72
- except yt_dlp.utils.DownloadError as exc:
73
- raise PolytoolError(f"Download failed: {exc}") from exc
74
- except Exception as exc:
75
- raise PolytoolError(f"Download failed: {exc}") from exc
76
- console.print("[green]Done.[/green]")
77
-
78
-
79
- @app.command("info")
80
- def cmd_info(
81
- url: Annotated[str, typer.Argument(help="Media URL")],
82
- ) -> None:
83
- """Show metadata about a URL without downloading.
84
-
85
- Examples:
86
-
87
- pt dl info 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
88
- """
89
- from polytool.core.lazy import require_extra
90
-
91
- yt_dlp = require_extra("yt_dlp", extra="dl")
92
-
93
- try:
94
- with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl:
95
- info = ydl.extract_info(url, download=False)
96
- except Exception as exc:
97
- raise PolytoolError(f"Could not fetch info: {exc}") from exc
98
-
99
- interesting = ("title", "uploader", "duration", "view_count", "upload_date", "webpage_url")
100
- for key in interesting:
101
- if key in info and info[key] is not None:
102
- console.print(f"[cyan]{key}[/cyan]: {info[key]}")
File without changes
File without changes
File without changes