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 ADDED
@@ -0,0 +1,4 @@
1
+ """polytool — one-binary CLI bundling 26 everyday utilities."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
polytool/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m polytool` invocation."""
2
+
3
+ from polytool.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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))
@@ -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})")