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.
- {polytool-0.2.8 → polytool-0.2.9}/PKG-INFO +1 -1
- {polytool-0.2.8 → polytool-0.2.9}/pyproject.toml +1 -1
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/__init__.py +1 -1
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/__init__.py +13 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/dl.py +103 -18
- {polytool-0.2.8 → polytool-0.2.9}/.gitignore +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/LICENSE +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/README.md +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/docs/README.md +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/__main__.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/clip.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/color.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/convert.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/cron.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/data.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/enc.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/file.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/gen.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/img.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/net.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/pdf.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/qr.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/shot.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/text.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/cli/vid.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/__init__.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/browsers.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/config.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/console.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/errors.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/ffmpeg.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/io.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/lazy.py +0 -0
- {polytool-0.2.8 → polytool-0.2.9}/src/polytool/core/progress.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|