polytool 0.2.8__tar.gz → 0.2.9__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 (35) hide show
  1. {polytool-0.2.8 → polytool-0.2.9}/PKG-INFO +1 -1
  2. {polytool-0.2.8 → polytool-0.2.9}/pyproject.toml +1 -1
  3. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/__init__.py +1 -1
  4. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/__init__.py +13 -0
  5. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/dl.py +103 -18
  6. {polytool-0.2.8 → polytool-0.2.9}/.gitignore +0 -0
  7. {polytool-0.2.8 → polytool-0.2.9}/LICENSE +0 -0
  8. {polytool-0.2.8 → polytool-0.2.9}/README.md +0 -0
  9. {polytool-0.2.8 → polytool-0.2.9}/docs/README.md +0 -0
  10. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/__main__.py +0 -0
  11. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/clip.py +0 -0
  12. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/color.py +0 -0
  13. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/convert.py +0 -0
  14. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/cron.py +0 -0
  15. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/data.py +0 -0
  16. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/enc.py +0 -0
  17. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/file.py +0 -0
  18. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/gen.py +0 -0
  19. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/img.py +0 -0
  20. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/net.py +0 -0
  21. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/pdf.py +0 -0
  22. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/qr.py +0 -0
  23. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/shot.py +0 -0
  24. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/text.py +0 -0
  25. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/vid.py +0 -0
  26. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/__init__.py +0 -0
  27. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/browsers.py +0 -0
  28. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/config.py +0 -0
  29. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/console.py +0 -0
  30. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/errors.py +0 -0
  31. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/ffmpeg.py +0 -0
  32. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/io.py +0 -0
  33. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/lazy.py +0 -0
  34. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/progress.py +0 -0
  35. {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/runtime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytool
3
- Version: 0.2.8
3
+ Version: 0.2.9
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polytool"
7
- version = "0.2.8"
7
+ version = "0.2.9"
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"
@@ -1,4 +1,4 @@
1
1
  """polytool — one-binary CLI bundling 26 everyday utilities."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.9"
4
4
  __all__ = ["__version__"]
@@ -48,12 +48,25 @@ app = typer.Typer(
48
48
  )
49
49
 
50
50
 
51
+ def _force_utf8_io() -> None:
52
+ """On Windows the default code page is often cp1252 and unicode chars
53
+ in Rich output trip ``UnicodeEncodeError``. Reconfigure stdout/stderr to
54
+ UTF-8 with a safe fallback before any Rich output happens.
55
+ """
56
+ import contextlib
57
+
58
+ for stream in (sys.stdout, sys.stderr):
59
+ with contextlib.suppress(AttributeError, OSError):
60
+ stream.reconfigure(encoding="utf-8", errors="replace")
61
+
62
+
51
63
  def run() -> None:
52
64
  """Console-script entry point — runs the Typer app and renders PolytoolError nicely.
53
65
 
54
66
  (Defined with a unique name to avoid clashing with the ``@app.callback()``
55
67
  function below — both would otherwise be named ``main`` in this module.)
56
68
  """
69
+ _force_utf8_io()
57
70
  try:
58
71
  app()
59
72
  except PolytoolError as exc:
@@ -65,11 +65,30 @@ DRM_PATTERNS = (
65
65
 
66
66
 
67
67
  _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
68
- _NOISE_RE = re.compile(
68
+
69
+ # Lines we *never* show — pure status output from yt-dlp's discovery pipeline.
70
+ _PROGRESS_NOISE_RE = re.compile(
69
71
  r"^(?:Extracting URL|Downloading\s\S+|Extracting cookies|Extracted\s|"
70
72
  r"\[\w+\]\s|Sleeping|Skipping|Deleting original file)"
71
73
  )
72
74
 
75
+ # Per-client warnings yt-dlp emits while it tries multiple YouTube extractors.
76
+ # These are informational — when at least one client succeeds, the user
77
+ # doesn't need to know that the others were skipped. We capture them in the
78
+ # logger so the n-challenge hint can still see them if the download ultimately
79
+ # fails, but we don't print them to the terminal during a successful run.
80
+ _CLIENT_NOISE_PATTERNS = (
81
+ "n challenge solving failed",
82
+ "some formats may be missing",
83
+ "client https formats require a gvs po token",
84
+ "client hls formats require a gvs po token",
85
+ "client formats require a gvs po token",
86
+ "have been skipped as they are drm protected",
87
+ "experiment that applies drm",
88
+ "skipped as they are drm",
89
+ "ensure you have a supported javascript runtime",
90
+ )
91
+
73
92
 
74
93
  def _strip(msg: object) -> str:
75
94
  text = _ANSI_RE.sub("", str(msg)).strip()
@@ -79,12 +98,22 @@ def _strip(msg: object) -> str:
79
98
  return text
80
99
 
81
100
 
101
+ def _is_noise(text: str) -> bool:
102
+ if _PROGRESS_NOISE_RE.match(text):
103
+ return True
104
+ low = text.lower()
105
+ return any(p in low for p in _CLIENT_NOISE_PATTERNS)
106
+
107
+
82
108
  class _DlLogger:
83
- """Capture yt-dlp warnings/errors so we can filter noise and surface hints."""
109
+ """Capture yt-dlp warnings/errors. Filter the noisy per-client status
110
+ warnings out of terminal output (still recorded for hint generation).
111
+ """
84
112
 
85
- def __init__(self) -> None:
113
+ def __init__(self, verbose: bool = False) -> None:
86
114
  self.warnings: list[str] = []
87
115
  self.errors: list[str] = []
116
+ self.verbose = verbose
88
117
 
89
118
  def debug(self, msg: object) -> None:
90
119
  pass
@@ -97,7 +126,7 @@ class _DlLogger:
97
126
  if not clean:
98
127
  return
99
128
  self.warnings.append(clean)
100
- if not _NOISE_RE.match(clean):
129
+ if self.verbose or not _is_noise(clean):
101
130
  err_console.print(f"[yellow]warning:[/yellow] {clean}")
102
131
 
103
132
  def error(self, msg: object) -> None:
@@ -122,7 +151,11 @@ def _make_progress() -> Progress:
122
151
 
123
152
 
124
153
  def _make_progress_hook(progress: Progress, state: dict):
125
- """Build a yt-dlp progress hook that drives the Rich progress bar."""
154
+ """Build a yt-dlp progress hook that drives the Rich progress bar.
155
+
156
+ Also stashes the final filename/size/title in *state* so the success
157
+ summary can read them after the download completes.
158
+ """
126
159
 
127
160
  def hook(d: dict) -> None:
128
161
  status = d.get("status")
@@ -132,12 +165,16 @@ def _make_progress_hook(progress: Progress, state: dict):
132
165
  info = d.get("info_dict") or {}
133
166
  title = info.get("title") or Path(d.get("filename", "download")).name
134
167
  disp = title if len(title) <= 50 else title[:47] + "..."
168
+ state["title"] = title
135
169
  tid = state.get("task_id")
136
170
  if tid is None:
137
171
  state["task_id"] = progress.add_task(disp, total=total)
138
172
  else:
139
173
  progress.update(tid, completed=downloaded, total=total, description=disp)
140
174
  elif status == "finished":
175
+ info = d.get("info_dict") or {}
176
+ state["filename"] = d.get("filename") or info.get("_filename") or ""
177
+ state["total_bytes"] = d.get("total_bytes") or d.get("total_bytes_estimate")
141
178
  tid = state.pop("task_id", None)
142
179
  if tid is not None and progress.tasks[tid].total:
143
180
  progress.update(tid, completed=progress.tasks[tid].total)
@@ -145,6 +182,29 @@ def _make_progress_hook(progress: Progress, state: dict):
145
182
  return hook
146
183
 
147
184
 
185
+ def _human_size(n: int | None) -> str:
186
+ if not n:
187
+ return "—"
188
+ f = float(n)
189
+ for unit in ("B", "KB", "MB", "GB", "TB"):
190
+ if f < 1024:
191
+ return f"{f:.1f} {unit}"
192
+ f /= 1024
193
+ return f"{f:.1f} PB"
194
+
195
+
196
+ def _human_duration(seconds: float) -> str:
197
+ if seconds < 1:
198
+ return f"{seconds * 1000:.0f}ms"
199
+ if seconds < 60:
200
+ return f"{seconds:.1f}s"
201
+ m, s = divmod(int(seconds), 60)
202
+ if m < 60:
203
+ return f"{m}m {s}s"
204
+ h, m = divmod(m, 60)
205
+ return f"{h}h {m}m {s}s"
206
+
207
+
148
208
  def _n_challenge_hint(messages: list[str]) -> str | None:
149
209
  text = " ".join(messages).lower()
150
210
  if not any(p in text for p in N_CHALLENGE_PATTERNS):
@@ -537,6 +597,14 @@ def cmd_get(
537
597
  str | None,
538
598
  typer.Option("--video-password", help="Per-video password (Vimeo etc.)."),
539
599
  ] = None,
600
+ verbose: Annotated[
601
+ bool,
602
+ typer.Option(
603
+ "--verbose",
604
+ "-v",
605
+ help="Show every yt-dlp warning (not just the user-actionable ones).",
606
+ ),
607
+ ] = False,
540
608
  ) -> None:
541
609
  """Download a video or audio file.
542
610
 
@@ -551,7 +619,10 @@ def cmd_get(
551
619
  pt dl get URL --cookies-from-browser firefox # one-off override
552
620
  pt dl get URL --username alice --password 'hunter2'
553
621
  pt dl get URL --video-password 'secret' # Vimeo-style per-video pwd
622
+ pt dl get URL --verbose # show all warnings
554
623
  """
624
+ import time
625
+
555
626
  from polytool.core.lazy import require_extra
556
627
 
557
628
  yt_dlp = require_extra("yt_dlp", extra="dl")
@@ -561,11 +632,7 @@ def cmd_get(
561
632
  # installed, fetch Deno automatically (one-time ~50 MB) — this is what the
562
633
  # user expects from `polytool[full]`: it just works.
563
634
  if runtime.ensure_runtime_in_path() is None:
564
- err_console.print(
565
- "[dim]No JS runtime found — polytool will fetch Deno once "
566
- "(~50 MB into ~/.polytool/runtime/) so YouTube's n-challenge "
567
- "can be solved.[/dim]"
568
- )
635
+ err_console.print("[dim]no JS runtime found — fetching Deno (~50 MB, one-time)...[/dim]")
569
636
  try:
570
637
  runtime.install_deno()
571
638
  runtime.ensure_runtime_in_path()
@@ -575,7 +642,7 @@ def cmd_get(
575
642
  "[dim]Run [cyan]pt dl runtime install[/cyan] manually, "
576
643
  "or install Deno/Node yourself.[/dim]"
577
644
  )
578
- logger = _DlLogger()
645
+ logger = _DlLogger(verbose=verbose)
579
646
  progress = _make_progress()
580
647
  state: dict = {}
581
648
  opts: dict = {
@@ -616,10 +683,9 @@ def cmd_get(
616
683
  if video_password is not None:
617
684
  opts["videopassword"] = video_password
618
685
  if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
619
- err_console.print(
620
- f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
621
- )
686
+ err_console.print(f"[dim]cookies: {opts['cookiesfrombrowser'][0]}[/dim]")
622
687
 
688
+ started = time.monotonic()
623
689
  try:
624
690
  with progress, yt_dlp.YoutubeDL(opts) as ydl:
625
691
  ydl.download([url])
@@ -631,7 +697,28 @@ def cmd_get(
631
697
  raise PolytoolError(
632
698
  f"Download failed: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
633
699
  ) from exc
634
- console.print("[green]Done.[/green]")
700
+
701
+ elapsed = time.monotonic() - started
702
+ title = state.get("title") or "download"
703
+ filename = state.get("filename")
704
+ size = state.get("total_bytes")
705
+ out_path = Path(filename) if filename else None
706
+
707
+ console.print()
708
+ console.print(f" [green bold]done[/green bold] [bold]{title}[/bold]")
709
+ if out_path is not None:
710
+ try:
711
+ display_path = out_path.resolve().relative_to(Path.cwd())
712
+ except (ValueError, OSError):
713
+ display_path = out_path
714
+ console.print(
715
+ f" [dim]{display_path} "
716
+ f"({_human_size(size)} in {_human_duration(elapsed)})[/dim]"
717
+ )
718
+ else:
719
+ console.print(
720
+ f" [dim]({_human_size(size)} in {_human_duration(elapsed)})[/dim]"
721
+ )
635
722
 
636
723
 
637
724
  @app.command("info")
@@ -702,9 +789,7 @@ def cmd_info(
702
789
  if video_password is not None:
703
790
  opts["videopassword"] = video_password
704
791
  if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
705
- err_console.print(
706
- f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
707
- )
792
+ err_console.print(f"[dim]cookies: {opts['cookiesfrombrowser'][0]}[/dim]")
708
793
 
709
794
  # process=False skips yt-dlp's format-selection step. We only want metadata,
710
795
  # and on some sites (YouTube especially) the default format selector
File without changes
File without changes
File without changes
File without changes