polytool 0.1.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.
- polytool/__init__.py +4 -0
- polytool/__main__.py +6 -0
- polytool/cli/__init__.py +82 -0
- polytool/cli/clip.py +71 -0
- polytool/cli/color.py +125 -0
- polytool/cli/convert.py +151 -0
- polytool/cli/cron.py +60 -0
- polytool/cli/data.py +188 -0
- polytool/cli/dl.py +102 -0
- polytool/cli/enc.py +216 -0
- polytool/cli/file.py +275 -0
- polytool/cli/gen.py +163 -0
- polytool/cli/img.py +439 -0
- polytool/cli/net.py +147 -0
- polytool/cli/pdf.py +306 -0
- polytool/cli/qr.py +122 -0
- polytool/cli/shot.py +128 -0
- polytool/cli/text.py +176 -0
- polytool/cli/vid.py +159 -0
- polytool/core/__init__.py +1 -0
- polytool/core/console.py +8 -0
- polytool/core/errors.py +56 -0
- polytool/core/ffmpeg.py +35 -0
- polytool/core/io.py +71 -0
- polytool/core/lazy.py +35 -0
- polytool/core/progress.py +47 -0
- polytool-0.1.0.dist-info/METADATA +258 -0
- polytool-0.1.0.dist-info/RECORD +31 -0
- polytool-0.1.0.dist-info/WHEEL +4 -0
- polytool-0.1.0.dist-info/entry_points.txt +3 -0
- polytool-0.1.0.dist-info/licenses/LICENSE +21 -0
polytool/__init__.py
ADDED
polytool/__main__.py
ADDED
polytool/cli/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Root Typer app — subcommand groups are added by their own modules.
|
|
2
|
+
|
|
3
|
+
Subcommand modules must keep top-level imports light (typer/rich/stdlib only).
|
|
4
|
+
Heavy imports (Pillow, ffmpeg, rembg) happen inside command bodies via
|
|
5
|
+
`polytool.core.lazy.require_extra`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from polytool import __version__
|
|
13
|
+
from polytool.cli import (
|
|
14
|
+
clip,
|
|
15
|
+
color,
|
|
16
|
+
convert,
|
|
17
|
+
cron,
|
|
18
|
+
data,
|
|
19
|
+
dl,
|
|
20
|
+
enc,
|
|
21
|
+
file,
|
|
22
|
+
gen,
|
|
23
|
+
img,
|
|
24
|
+
net,
|
|
25
|
+
pdf,
|
|
26
|
+
qr,
|
|
27
|
+
shot,
|
|
28
|
+
text,
|
|
29
|
+
vid,
|
|
30
|
+
)
|
|
31
|
+
from polytool.core.errors import install_excepthook
|
|
32
|
+
|
|
33
|
+
install_excepthook()
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="polytool",
|
|
37
|
+
help="polytool — one-binary CLI bundling 26 everyday utilities (pt for short).",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
add_completion=False,
|
|
40
|
+
rich_markup_mode="rich",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version_callback(value: bool) -> None:
|
|
45
|
+
if value:
|
|
46
|
+
typer.echo(f"polytool {__version__}")
|
|
47
|
+
raise typer.Exit
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback()
|
|
51
|
+
def main(
|
|
52
|
+
version: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--version",
|
|
55
|
+
"-V",
|
|
56
|
+
callback=_version_callback,
|
|
57
|
+
is_eager=True,
|
|
58
|
+
help="Show version and exit.",
|
|
59
|
+
),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""polytool — one-binary CLI bundling 26 everyday utilities."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
app.add_typer(enc.app, name="enc")
|
|
65
|
+
app.add_typer(gen.app, name="gen")
|
|
66
|
+
app.add_typer(color.app, name="color")
|
|
67
|
+
app.add_typer(convert.app, name="convert")
|
|
68
|
+
app.add_typer(text.app, name="text")
|
|
69
|
+
app.add_typer(data.app, name="data")
|
|
70
|
+
app.add_typer(qr.app, name="qr")
|
|
71
|
+
app.add_typer(clip.app, name="clip")
|
|
72
|
+
app.add_typer(cron.app, name="cron")
|
|
73
|
+
app.add_typer(net.app, name="net")
|
|
74
|
+
app.add_typer(file.app, name="file")
|
|
75
|
+
app.add_typer(img.app, name="img")
|
|
76
|
+
app.add_typer(pdf.app, name="pdf")
|
|
77
|
+
app.add_typer(vid.app, name="vid")
|
|
78
|
+
app.add_typer(dl.app, name="dl")
|
|
79
|
+
app.add_typer(shot.app, name="shot")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["app"]
|
polytool/cli/clip.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Clipboard read/write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from polytool.core.errors import PolytoolError
|
|
10
|
+
from polytool.core.io import read_text
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="clip",
|
|
14
|
+
help="Clipboard read/write.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _pyperclip():
|
|
20
|
+
try:
|
|
21
|
+
import pyperclip # type: ignore
|
|
22
|
+
|
|
23
|
+
return pyperclip
|
|
24
|
+
except ImportError as exc: # pragma: no cover
|
|
25
|
+
raise PolytoolError(
|
|
26
|
+
"pyperclip is missing.",
|
|
27
|
+
hint="Reinstall polytool to restore base deps.",
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("copy")
|
|
32
|
+
def cmd_copy(
|
|
33
|
+
text: Annotated[str | None, typer.Argument(help="Text (or omit to read stdin).")] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Copy text to the system clipboard.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
|
|
39
|
+
pt clip copy "hello"
|
|
40
|
+
cat secret.txt | pt clip copy
|
|
41
|
+
"""
|
|
42
|
+
payload = text if text is not None else read_text(None).rstrip("\n")
|
|
43
|
+
pc = _pyperclip()
|
|
44
|
+
try:
|
|
45
|
+
pc.copy(payload)
|
|
46
|
+
except pc.PyperclipException as exc:
|
|
47
|
+
raise PolytoolError(
|
|
48
|
+
f"Clipboard not available: {exc}",
|
|
49
|
+
hint="On Linux, install xclip or xsel: 'apt install xclip'.",
|
|
50
|
+
) from exc
|
|
51
|
+
typer.echo(f"Copied {len(payload)} chars.", err=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("paste")
|
|
55
|
+
def cmd_paste() -> None:
|
|
56
|
+
"""Print the clipboard contents to stdout.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
|
|
60
|
+
pt clip paste
|
|
61
|
+
pt clip paste > out.txt
|
|
62
|
+
"""
|
|
63
|
+
pc = _pyperclip()
|
|
64
|
+
try:
|
|
65
|
+
text = pc.paste()
|
|
66
|
+
except pc.PyperclipException as exc:
|
|
67
|
+
raise PolytoolError(
|
|
68
|
+
f"Clipboard not available: {exc}",
|
|
69
|
+
hint="On Linux, install xclip or xsel: 'apt install xclip'.",
|
|
70
|
+
) from exc
|
|
71
|
+
typer.echo(text, nl=False)
|
polytool/cli/color.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Color converter (hex / rgb / hsl / hsv / cmyk)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
import re
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from polytool.core.errors import PolytoolError
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="color",
|
|
15
|
+
help="Convert colors between hex, rgb, hsl, hsv, cmyk.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
20
|
+
RGB_RE = re.compile(r"^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)")
|
|
21
|
+
HSL_RE = re.compile(
|
|
22
|
+
r"^hsla?\s*\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*%\s*,\s*(\d+(?:\.\d+)?)\s*%"
|
|
23
|
+
)
|
|
24
|
+
HSV_RE = re.compile(
|
|
25
|
+
r"^hsva?\s*\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*%\s*,\s*(\d+(?:\.\d+)?)\s*%"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse(spec: str) -> tuple[float, float, float]:
|
|
30
|
+
"""Parse any supported color spec into normalized 0..1 RGB."""
|
|
31
|
+
s = spec.strip()
|
|
32
|
+
m = HEX_RE.match(s)
|
|
33
|
+
if m:
|
|
34
|
+
h = m.group(1)
|
|
35
|
+
if len(h) == 3:
|
|
36
|
+
h = "".join(c * 2 for c in h)
|
|
37
|
+
r = int(h[0:2], 16) / 255
|
|
38
|
+
g = int(h[2:4], 16) / 255
|
|
39
|
+
b = int(h[4:6], 16) / 255
|
|
40
|
+
return r, g, b
|
|
41
|
+
m = RGB_RE.match(s)
|
|
42
|
+
if m:
|
|
43
|
+
return int(m.group(1)) / 255, int(m.group(2)) / 255, int(m.group(3)) / 255
|
|
44
|
+
m = HSL_RE.match(s)
|
|
45
|
+
if m:
|
|
46
|
+
h = float(m.group(1)) / 360
|
|
47
|
+
l_ = float(m.group(3)) / 100
|
|
48
|
+
sat = float(m.group(2)) / 100
|
|
49
|
+
r, g, b = colorsys.hls_to_rgb(h, l_, sat)
|
|
50
|
+
return r, g, b
|
|
51
|
+
m = HSV_RE.match(s)
|
|
52
|
+
if m:
|
|
53
|
+
h = float(m.group(1)) / 360
|
|
54
|
+
sat = float(m.group(2)) / 100
|
|
55
|
+
v = float(m.group(3)) / 100
|
|
56
|
+
r, g, b = colorsys.hsv_to_rgb(h, sat, v)
|
|
57
|
+
return r, g, b
|
|
58
|
+
raise PolytoolError(
|
|
59
|
+
f"Could not parse color {spec!r}",
|
|
60
|
+
hint="Try forms like #3366ff, rgb(51,102,255), hsl(220,100%,60%).",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_hex(r: float, g: float, b: float) -> str:
|
|
65
|
+
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _to_rgb(r: float, g: float, b: float) -> str:
|
|
69
|
+
return f"rgb({int(r * 255)}, {int(g * 255)}, {int(b * 255)})"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _to_hsl(r: float, g: float, b: float) -> str:
|
|
73
|
+
h, l_, s = colorsys.rgb_to_hls(r, g, b)
|
|
74
|
+
return f"hsl({h * 360:.0f}, {s * 100:.0f}%, {l_ * 100:.0f}%)"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _to_hsv(r: float, g: float, b: float) -> str:
|
|
78
|
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
79
|
+
return f"hsv({h * 360:.0f}, {s * 100:.0f}%, {v * 100:.0f}%)"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _to_cmyk(r: float, g: float, b: float) -> str:
|
|
83
|
+
k = 1 - max(r, g, b)
|
|
84
|
+
if k >= 1:
|
|
85
|
+
return "cmyk(0%, 0%, 0%, 100%)"
|
|
86
|
+
c = (1 - r - k) / (1 - k)
|
|
87
|
+
m = (1 - g - k) / (1 - k)
|
|
88
|
+
y = (1 - b - k) / (1 - k)
|
|
89
|
+
return f"cmyk({c * 100:.0f}%, {m * 100:.0f}%, {y * 100:.0f}%, {k * 100:.0f}%)"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("convert")
|
|
93
|
+
def cmd_convert(
|
|
94
|
+
spec: Annotated[str, typer.Argument(help="Color in hex, rgb(...), hsl(...), or hsv(...).")],
|
|
95
|
+
to: Annotated[
|
|
96
|
+
str, typer.Option("--to", "-t", help="hex | rgb | hsl | hsv | cmyk | all")
|
|
97
|
+
] = "all",
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Convert a color between formats.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
|
|
103
|
+
pt color convert "#3366ff"
|
|
104
|
+
pt color convert "rgb(51,102,255)" --to hsl
|
|
105
|
+
pt color convert "hsl(220,100%,60%)" --to cmyk
|
|
106
|
+
"""
|
|
107
|
+
r, g, b = _parse(spec)
|
|
108
|
+
converters = {
|
|
109
|
+
"hex": _to_hex,
|
|
110
|
+
"rgb": _to_rgb,
|
|
111
|
+
"hsl": _to_hsl,
|
|
112
|
+
"hsv": _to_hsv,
|
|
113
|
+
"cmyk": _to_cmyk,
|
|
114
|
+
}
|
|
115
|
+
if to == "all":
|
|
116
|
+
for name, fn in converters.items():
|
|
117
|
+
typer.echo(f"{name:5} {fn(r, g, b)}")
|
|
118
|
+
else:
|
|
119
|
+
fn = converters.get(to.lower())
|
|
120
|
+
if fn is None:
|
|
121
|
+
raise PolytoolError(
|
|
122
|
+
f"Unknown target {to!r}",
|
|
123
|
+
hint=f"One of: {', '.join(converters)}",
|
|
124
|
+
)
|
|
125
|
+
typer.echo(fn(r, g, b))
|
polytool/cli/convert.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Unit / timestamp / number-base converter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from polytool.core.errors import PolytoolError
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="convert",
|
|
13
|
+
help="Unit, timestamp, number-base converters.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("unit")
|
|
19
|
+
def cmd_unit(
|
|
20
|
+
value: Annotated[str, typer.Argument(help="Value with unit, e.g. '100 km' or '3 hours'.")],
|
|
21
|
+
to: Annotated[str, typer.Option("--to", "-t", help="Target unit, e.g. 'mi'.")],
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Convert physical units (length, mass, time, temperature, ...).
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
|
|
27
|
+
pt convert unit "100 km" --to mi
|
|
28
|
+
pt convert unit "20 degC" --to degF
|
|
29
|
+
pt convert unit "5 hours" --to seconds
|
|
30
|
+
"""
|
|
31
|
+
import pint
|
|
32
|
+
|
|
33
|
+
ureg = pint.UnitRegistry()
|
|
34
|
+
ureg.autoconvert_offset_to_baseunit = True
|
|
35
|
+
try:
|
|
36
|
+
q = ureg.Quantity(value)
|
|
37
|
+
result = q.to(to)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise PolytoolError(
|
|
40
|
+
f"Could not convert {value!r} to {to!r}: {exc}",
|
|
41
|
+
hint="Try units like 'km', 'mi', 'kg', 'lb', 'celsius', 'fahrenheit'.",
|
|
42
|
+
) from exc
|
|
43
|
+
typer.echo(f"{result:.4f~P}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("timestamp")
|
|
47
|
+
def cmd_timestamp(
|
|
48
|
+
value: Annotated[str, typer.Argument(help="Unix epoch (sec or ms) or ISO datetime.")],
|
|
49
|
+
to: Annotated[
|
|
50
|
+
str, typer.Option("--to", "-t", help="Target: 'iso', 'epoch', 'epoch-ms', 'rfc822'")
|
|
51
|
+
] = "iso",
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Convert between epoch seconds/ms and ISO 8601 / RFC 822.
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
|
|
57
|
+
pt convert timestamp 1715200000
|
|
58
|
+
pt convert timestamp 1715200000000 --to iso
|
|
59
|
+
pt convert timestamp "2025-04-08T20:26:40Z" --to epoch
|
|
60
|
+
"""
|
|
61
|
+
from datetime import UTC, datetime
|
|
62
|
+
|
|
63
|
+
from dateutil import parser as dt_parser
|
|
64
|
+
|
|
65
|
+
dt: datetime
|
|
66
|
+
s = value.strip()
|
|
67
|
+
if s.lstrip("-").isdigit():
|
|
68
|
+
n = int(s)
|
|
69
|
+
if abs(n) > 10**12: # ms heuristic
|
|
70
|
+
dt = datetime.fromtimestamp(n / 1000, tz=UTC)
|
|
71
|
+
else:
|
|
72
|
+
dt = datetime.fromtimestamp(n, tz=UTC)
|
|
73
|
+
else:
|
|
74
|
+
try:
|
|
75
|
+
dt = dt_parser.parse(s)
|
|
76
|
+
if dt.tzinfo is None:
|
|
77
|
+
dt = dt.replace(tzinfo=UTC)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise PolytoolError(f"Could not parse {value!r}") from exc
|
|
80
|
+
|
|
81
|
+
out = to.lower()
|
|
82
|
+
if out == "iso":
|
|
83
|
+
typer.echo(dt.isoformat())
|
|
84
|
+
elif out == "epoch":
|
|
85
|
+
typer.echo(str(int(dt.timestamp())))
|
|
86
|
+
elif out == "epoch-ms":
|
|
87
|
+
typer.echo(str(int(dt.timestamp() * 1000)))
|
|
88
|
+
elif out == "rfc822":
|
|
89
|
+
typer.echo(dt.strftime("%a, %d %b %Y %H:%M:%S %z"))
|
|
90
|
+
else:
|
|
91
|
+
raise PolytoolError(
|
|
92
|
+
f"Unknown target {to!r}",
|
|
93
|
+
hint="One of: iso, epoch, epoch-ms, rfc822",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("base")
|
|
98
|
+
def cmd_base(
|
|
99
|
+
value: Annotated[
|
|
100
|
+
str,
|
|
101
|
+
typer.Argument(help="Number in current base (use 0x/0b/0o prefix or --from)."),
|
|
102
|
+
],
|
|
103
|
+
to: Annotated[
|
|
104
|
+
str,
|
|
105
|
+
typer.Option("--to", "-t", help="Target base: 2, 8, 10, 16 (or bin/oct/dec/hex)"),
|
|
106
|
+
] = "10",
|
|
107
|
+
from_: Annotated[
|
|
108
|
+
str | None,
|
|
109
|
+
typer.Option("--from", "-f", help="Source base (default: auto-detect)"),
|
|
110
|
+
] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Convert numbers between bases (binary, octal, decimal, hex).
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
|
|
116
|
+
pt convert base 0xff --to 10
|
|
117
|
+
pt convert base 255 --to hex
|
|
118
|
+
pt convert base 11111111 --from 2 --to dec
|
|
119
|
+
"""
|
|
120
|
+
bases = {"2": 2, "bin": 2, "8": 8, "oct": 8, "10": 10, "dec": 10, "16": 16, "hex": 16}
|
|
121
|
+
target = bases.get(to.lower())
|
|
122
|
+
if target is None:
|
|
123
|
+
raise PolytoolError(
|
|
124
|
+
f"Unknown target base {to!r}", hint="One of: 2/bin, 8/oct, 10/dec, 16/hex"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if from_ is None:
|
|
128
|
+
try:
|
|
129
|
+
n = int(value, 0)
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
raise PolytoolError(
|
|
132
|
+
f"Could not parse {value!r}",
|
|
133
|
+
hint="Pass --from or use 0x/0b/0o prefix.",
|
|
134
|
+
) from exc
|
|
135
|
+
else:
|
|
136
|
+
src = bases.get(from_.lower())
|
|
137
|
+
if src is None:
|
|
138
|
+
raise PolytoolError(f"Unknown source base {from_!r}")
|
|
139
|
+
try:
|
|
140
|
+
n = int(value, src)
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise PolytoolError(f"{value!r} is not a base-{src} number") from exc
|
|
143
|
+
|
|
144
|
+
if target == 2:
|
|
145
|
+
typer.echo(bin(n)[2:] if n >= 0 else "-" + bin(-n)[2:])
|
|
146
|
+
elif target == 8:
|
|
147
|
+
typer.echo(oct(n)[2:] if n >= 0 else "-" + oct(-n)[2:])
|
|
148
|
+
elif target == 10:
|
|
149
|
+
typer.echo(str(n))
|
|
150
|
+
elif target == 16:
|
|
151
|
+
typer.echo(hex(n)[2:] if n >= 0 else "-" + hex(-n)[2:])
|
polytool/cli/cron.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Cron expression explainer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from polytool.core.errors import PolytoolError
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="cron",
|
|
13
|
+
help="Cron expression explainer.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("explain")
|
|
19
|
+
def cmd_explain(
|
|
20
|
+
expression: Annotated[str, typer.Argument(help="Cron expression, e.g. '0 9 * * MON'")],
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Render a cron expression in plain English.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
|
|
26
|
+
pt cron explain "0 9 * * MON"
|
|
27
|
+
pt cron explain "*/5 * * * *"
|
|
28
|
+
"""
|
|
29
|
+
from cron_descriptor import ExpressionDescriptor
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
descr = str(ExpressionDescriptor(expression))
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
raise PolytoolError(f"Invalid cron expression: {exc}") from exc
|
|
35
|
+
typer.echo(descr)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("next")
|
|
39
|
+
def cmd_next(
|
|
40
|
+
expression: Annotated[str, typer.Argument(help="Cron expression")],
|
|
41
|
+
count: Annotated[int, typer.Option("--count", "-n", help="How many runs to show")] = 5,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Show the next N firing times of a cron schedule (UTC).
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
|
|
47
|
+
pt cron next "0 9 * * MON" --count 3
|
|
48
|
+
"""
|
|
49
|
+
from datetime import UTC, datetime
|
|
50
|
+
|
|
51
|
+
from croniter import croniter
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
it = croniter(expression, datetime.now(UTC))
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise PolytoolError(f"Invalid cron expression: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
for _ in range(count):
|
|
59
|
+
nxt = it.get_next(datetime)
|
|
60
|
+
typer.echo(nxt.isoformat())
|
polytool/cli/data.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Data format converter (json / yaml / toml / csv / xml)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv as _csv
|
|
6
|
+
import io
|
|
7
|
+
import json as _json
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from polytool.core.errors import PolytoolError
|
|
15
|
+
from polytool.core.io import read_text, write_text
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="data",
|
|
19
|
+
help="Data format converter (json/yaml/toml/csv/xml).",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
FORMATS = ("json", "yaml", "yml", "toml", "csv", "xml")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _detect_format(path: Path | None, hint: str | None) -> str:
|
|
27
|
+
if hint:
|
|
28
|
+
h = hint.lower()
|
|
29
|
+
if h not in FORMATS:
|
|
30
|
+
raise PolytoolError(
|
|
31
|
+
f"Unknown format {hint!r}",
|
|
32
|
+
hint=f"One of: {', '.join(FORMATS)}",
|
|
33
|
+
)
|
|
34
|
+
return "yaml" if h == "yml" else h
|
|
35
|
+
if path is None:
|
|
36
|
+
raise PolytoolError(
|
|
37
|
+
"Cannot detect format from stdin",
|
|
38
|
+
hint="Pass --from json|yaml|toml|csv|xml.",
|
|
39
|
+
)
|
|
40
|
+
suffix = path.suffix.lstrip(".").lower()
|
|
41
|
+
if suffix in {"yml"}:
|
|
42
|
+
return "yaml"
|
|
43
|
+
if suffix in FORMATS:
|
|
44
|
+
return suffix
|
|
45
|
+
raise PolytoolError(
|
|
46
|
+
f"Cannot detect format from extension {suffix!r}",
|
|
47
|
+
hint="Pass --from explicitly.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load(text: str, fmt: str) -> Any:
|
|
52
|
+
if fmt == "json":
|
|
53
|
+
return _json.loads(text)
|
|
54
|
+
if fmt == "yaml":
|
|
55
|
+
from ruamel.yaml import YAML
|
|
56
|
+
|
|
57
|
+
return YAML(typ="safe").load(io.StringIO(text))
|
|
58
|
+
if fmt == "toml":
|
|
59
|
+
return tomllib.loads(text)
|
|
60
|
+
if fmt == "csv":
|
|
61
|
+
reader = _csv.DictReader(io.StringIO(text))
|
|
62
|
+
return list(reader)
|
|
63
|
+
if fmt == "xml":
|
|
64
|
+
import xmltodict
|
|
65
|
+
|
|
66
|
+
return xmltodict.parse(text)
|
|
67
|
+
raise PolytoolError(f"Unsupported format: {fmt}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dump(data: Any, fmt: str, *, pretty: bool = True) -> str:
|
|
71
|
+
if fmt == "json":
|
|
72
|
+
return _json.dumps(data, indent=2 if pretty else None, ensure_ascii=False) + "\n"
|
|
73
|
+
if fmt == "yaml":
|
|
74
|
+
from ruamel.yaml import YAML
|
|
75
|
+
|
|
76
|
+
y = YAML()
|
|
77
|
+
y.default_flow_style = False
|
|
78
|
+
buf = io.StringIO()
|
|
79
|
+
y.dump(data, buf)
|
|
80
|
+
return buf.getvalue()
|
|
81
|
+
if fmt == "toml":
|
|
82
|
+
import tomli_w
|
|
83
|
+
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise PolytoolError(
|
|
86
|
+
"TOML output requires a top-level mapping",
|
|
87
|
+
hint='Wrap your list in a key, e.g. {"items": [...]}.',
|
|
88
|
+
)
|
|
89
|
+
return tomli_w.dumps(data)
|
|
90
|
+
if fmt == "csv":
|
|
91
|
+
if not isinstance(data, list):
|
|
92
|
+
raise PolytoolError(
|
|
93
|
+
"CSV output requires a list of records",
|
|
94
|
+
hint="Wrap your value in a JSON array of objects.",
|
|
95
|
+
)
|
|
96
|
+
if not data:
|
|
97
|
+
return ""
|
|
98
|
+
if not isinstance(data[0], dict):
|
|
99
|
+
raise PolytoolError("CSV output requires a list of dicts")
|
|
100
|
+
buf = io.StringIO(newline="")
|
|
101
|
+
writer = _csv.DictWriter(buf, fieldnames=list(data[0].keys()))
|
|
102
|
+
writer.writeheader()
|
|
103
|
+
writer.writerows(data)
|
|
104
|
+
return buf.getvalue()
|
|
105
|
+
if fmt == "xml":
|
|
106
|
+
import xmltodict
|
|
107
|
+
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise PolytoolError(
|
|
110
|
+
"XML output requires a top-level mapping",
|
|
111
|
+
hint='Wrap data: {"root": {...}}',
|
|
112
|
+
)
|
|
113
|
+
return xmltodict.unparse(data, pretty=pretty)
|
|
114
|
+
raise PolytoolError(f"Unsupported format: {fmt}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command("convert")
|
|
118
|
+
def cmd_convert(
|
|
119
|
+
source: Annotated[
|
|
120
|
+
Path | None, typer.Argument(help="Source file (or '-' / omit for stdin)")
|
|
121
|
+
] = None,
|
|
122
|
+
to: Annotated[str, typer.Option("--to", "-t", help="Target: json|yaml|toml|csv|xml")] = "json",
|
|
123
|
+
from_: Annotated[
|
|
124
|
+
str | None,
|
|
125
|
+
typer.Option("--from", "-f", help="Source format (default: from extension)"),
|
|
126
|
+
] = None,
|
|
127
|
+
output: Annotated[
|
|
128
|
+
Path | None, typer.Option("--output", "-o", help="Output file (default: stdout)")
|
|
129
|
+
] = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Convert between JSON / YAML / TOML / CSV / XML.
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
|
|
135
|
+
pt data convert config.yaml --to json
|
|
136
|
+
pt data convert data.csv --to json --output data.json
|
|
137
|
+
cat x.toml | pt data convert --from toml --to yaml
|
|
138
|
+
"""
|
|
139
|
+
src_fmt = _detect_format(source, from_)
|
|
140
|
+
tgt_fmt = to.lower()
|
|
141
|
+
if tgt_fmt == "yml":
|
|
142
|
+
tgt_fmt = "yaml"
|
|
143
|
+
if tgt_fmt not in FORMATS:
|
|
144
|
+
raise PolytoolError(f"Unknown target {to!r}", hint=f"One of: {', '.join(FORMATS)}")
|
|
145
|
+
text = read_text(source)
|
|
146
|
+
data = _load(text, src_fmt)
|
|
147
|
+
write_text(output, _dump(data, tgt_fmt))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("pretty")
|
|
151
|
+
def cmd_pretty(
|
|
152
|
+
source: Annotated[Path | None, typer.Argument(help="File (or '-' / omit for stdin)")] = None,
|
|
153
|
+
from_: Annotated[
|
|
154
|
+
str | None, typer.Option("--from", "-f", help="Format (default: from extension)")
|
|
155
|
+
] = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Pretty-print structured data in its current format.
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
|
|
161
|
+
pt data pretty messy.json
|
|
162
|
+
cat x.yaml | pt data pretty --from yaml
|
|
163
|
+
"""
|
|
164
|
+
fmt = _detect_format(source, from_)
|
|
165
|
+
data = _load(read_text(source), fmt)
|
|
166
|
+
typer.echo(_dump(data, fmt, pretty=True), nl=False)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("validate")
|
|
170
|
+
def cmd_validate(
|
|
171
|
+
source: Annotated[Path | None, typer.Argument(help="File (or '-' / omit for stdin)")] = None,
|
|
172
|
+
from_: Annotated[
|
|
173
|
+
str | None, typer.Option("--from", "-f", help="Format (default: from extension)")
|
|
174
|
+
] = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Validate that a file parses cleanly. Exit 0 = valid, 1 = invalid.
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
|
|
180
|
+
pt data validate config.yaml
|
|
181
|
+
cat suspect.json | pt data validate --from json
|
|
182
|
+
"""
|
|
183
|
+
fmt = _detect_format(source, from_)
|
|
184
|
+
try:
|
|
185
|
+
_load(read_text(source), fmt)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
raise PolytoolError(f"Invalid {fmt}: {exc}") from exc
|
|
188
|
+
typer.echo(f"OK ({fmt})")
|