html-previewer 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agony
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: html-previewer
3
+ Version: 0.1.1
4
+ Summary: Cross-platform client (Windows/Linux/macOS) for html-previewer: publish short-lived HTML previews behind unguessable URLs
5
+ Author: agony
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Agony5757/html-previewer
8
+ Project-URL: Source, https://github.com/Agony5757/html-previewer
9
+ Project-URL: Issues, https://github.com/Agony5757/html-previewer/issues
10
+ Keywords: html,preview,publish,static-site,ephemeral,hosting
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ # html-previewer (Python client)
34
+
35
+ Cross-platform command-line client and library for
36
+ [html-previewer](https://github.com/Agony5757/html-previewer) — a self-hosted
37
+ service that publishes short-lived HTML previews behind **unguessable
38
+ capability URLs**, with automatic expiry and disk reclaim.
39
+
40
+ Works on **Linux, macOS and Windows**. Pure **stdlib** — zero runtime
41
+ dependencies. It is a faithful, feature-complete port of the classic
42
+ `preview-publish.sh`, so existing habits and scripts carry over.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install html-previewer
48
+ ```
49
+
50
+ This installs the `preview-publish` command (alias: `html-previewer`).
51
+
52
+ ## Configure
53
+
54
+ Point the client at your server once:
55
+
56
+ ```bash
57
+ preview-publish --base https://preview.example.com --key YOUR_API_KEY --save-config
58
+ ```
59
+
60
+ Or just export the variables: `PREVIEW_API_BASE` and `PREVIEW_API_KEY`
61
+ (equivalently, put `PREVIEW_API_KEY=…` lines in the config file —
62
+ `~/.config/preview/config` on Linux/macOS, `%APPDATA%\preview\config` on
63
+ Windows; the same shell-style file the bash client reads).
64
+
65
+ ## Use
66
+
67
+ ```bash
68
+ preview-publish ./site/ my-site 14 # publish a folder, 14-day expiry
69
+ preview-publish ./report.zip # publish a zip (topic from filename)
70
+ preview-publish ./page.html # publish a single html page
71
+ preview-publish ./site/ my-site 2m # short-lived: 2 minutes
72
+ preview-publish --dry-run ./site/ my # predict: update in place, or new URL?
73
+ preview-publish --new ./site/ my-site # force a brand-new URL
74
+ preview-publish --list # list live previews (--list-all: everything)
75
+ preview-publish --info <hash> # one preview's record
76
+ preview-publish --delete <hash> # reclaim now
77
+ preview-publish --expiry <hash> --action extend --days 30
78
+ preview-publish --open ./site/ my-site # publish, then open in a browser
79
+ preview-publish --health # is the server alive?
80
+ ```
81
+
82
+ Re-publishing under a `topic` that is still live **updates it in place and
83
+ keeps the same URL** (expiry resets) — share a link once, keep refreshing the
84
+ content. A topic whose preview already expired gets a brand-new URL. Add
85
+ `--json` to any command for machine-readable output.
86
+
87
+ ## Library
88
+
89
+ ```python
90
+ from html_previewer import PreviewClient
91
+
92
+ c = PreviewClient("https://preview.example.com", api_key="…")
93
+ res = c.publish("./site", topic="my-site", expiry="14") # expiry: "14" or "2m"
94
+ print(res["url"])
95
+
96
+ for p in c.list_previews():
97
+ print(p["topic"], p["url"], p["seconds_remaining"])
98
+
99
+ c.check("my-site") # dry-run: update in place vs new URL
100
+ c.info(res["hash"])
101
+ c.expiry(res["hash"], "extend", days=30) # extend | expire | renew
102
+ c.delete(res["hash"])
103
+ ```
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ cd client
109
+ python -m pip install -e .[test]
110
+ python -m pytest tests -v
111
+ ```
112
+
113
+ The test suite includes an end-to-end test that boots the real server from
114
+ the repository checkout; it is skipped automatically when the server sources
115
+ are absent (e.g. in the sdist).
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,87 @@
1
+ # html-previewer (Python client)
2
+
3
+ Cross-platform command-line client and library for
4
+ [html-previewer](https://github.com/Agony5757/html-previewer) — a self-hosted
5
+ service that publishes short-lived HTML previews behind **unguessable
6
+ capability URLs**, with automatic expiry and disk reclaim.
7
+
8
+ Works on **Linux, macOS and Windows**. Pure **stdlib** — zero runtime
9
+ dependencies. It is a faithful, feature-complete port of the classic
10
+ `preview-publish.sh`, so existing habits and scripts carry over.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install html-previewer
16
+ ```
17
+
18
+ This installs the `preview-publish` command (alias: `html-previewer`).
19
+
20
+ ## Configure
21
+
22
+ Point the client at your server once:
23
+
24
+ ```bash
25
+ preview-publish --base https://preview.example.com --key YOUR_API_KEY --save-config
26
+ ```
27
+
28
+ Or just export the variables: `PREVIEW_API_BASE` and `PREVIEW_API_KEY`
29
+ (equivalently, put `PREVIEW_API_KEY=…` lines in the config file —
30
+ `~/.config/preview/config` on Linux/macOS, `%APPDATA%\preview\config` on
31
+ Windows; the same shell-style file the bash client reads).
32
+
33
+ ## Use
34
+
35
+ ```bash
36
+ preview-publish ./site/ my-site 14 # publish a folder, 14-day expiry
37
+ preview-publish ./report.zip # publish a zip (topic from filename)
38
+ preview-publish ./page.html # publish a single html page
39
+ preview-publish ./site/ my-site 2m # short-lived: 2 minutes
40
+ preview-publish --dry-run ./site/ my # predict: update in place, or new URL?
41
+ preview-publish --new ./site/ my-site # force a brand-new URL
42
+ preview-publish --list # list live previews (--list-all: everything)
43
+ preview-publish --info <hash> # one preview's record
44
+ preview-publish --delete <hash> # reclaim now
45
+ preview-publish --expiry <hash> --action extend --days 30
46
+ preview-publish --open ./site/ my-site # publish, then open in a browser
47
+ preview-publish --health # is the server alive?
48
+ ```
49
+
50
+ Re-publishing under a `topic` that is still live **updates it in place and
51
+ keeps the same URL** (expiry resets) — share a link once, keep refreshing the
52
+ content. A topic whose preview already expired gets a brand-new URL. Add
53
+ `--json` to any command for machine-readable output.
54
+
55
+ ## Library
56
+
57
+ ```python
58
+ from html_previewer import PreviewClient
59
+
60
+ c = PreviewClient("https://preview.example.com", api_key="…")
61
+ res = c.publish("./site", topic="my-site", expiry="14") # expiry: "14" or "2m"
62
+ print(res["url"])
63
+
64
+ for p in c.list_previews():
65
+ print(p["topic"], p["url"], p["seconds_remaining"])
66
+
67
+ c.check("my-site") # dry-run: update in place vs new URL
68
+ c.info(res["hash"])
69
+ c.expiry(res["hash"], "extend", days=30) # extend | expire | renew
70
+ c.delete(res["hash"])
71
+ ```
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ cd client
77
+ python -m pip install -e .[test]
78
+ python -m pytest tests -v
79
+ ```
80
+
81
+ The test suite includes an end-to-end test that boots the real server from
82
+ the repository checkout; it is skipped automatically when the server sources
83
+ are absent (e.g. in the sdist).
84
+
85
+ ## License
86
+
87
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "html-previewer"
7
+ description = "Cross-platform client (Windows/Linux/macOS) for html-previewer: publish short-lived HTML previews behind unguessable URLs"
8
+ readme = "README.md"
9
+ requires-python = ">=3.9"
10
+ dynamic = ["version"]
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "agony" }]
13
+ keywords = ["html", "preview", "publish", "static-site", "ephemeral", "hosting"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
29
+ "Topic :: Utilities",
30
+ ]
31
+ dependencies = []
32
+
33
+ [project.optional-dependencies]
34
+ test = ["pytest"]
35
+
36
+ [project.scripts]
37
+ preview-publish = "html_previewer.cli:main"
38
+ html-previewer = "html_previewer.cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/Agony5757/html-previewer"
42
+ Source = "https://github.com/Agony5757/html-previewer"
43
+ Issues = "https://github.com/Agony5757/html-previewer/issues"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.setuptools.dynamic]
49
+ version = { attr = "html_previewer.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ """Cross-platform client for the html-previewer ephemeral preview service."""
2
+ from .client import (PreviewClient, PreviewError, build_zip, default_topic,
3
+ parse_expiry, slugify_topic)
4
+
5
+ __version__ = "0.1.1"
6
+
7
+ __all__ = [
8
+ "PreviewClient",
9
+ "PreviewError",
10
+ "build_zip",
11
+ "default_topic",
12
+ "parse_expiry",
13
+ "slugify_topic",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,290 @@
1
+ """Command-line interface: the ``preview-publish`` command.
2
+
3
+ Mirrors the interface of the classic ``client/preview-publish.sh`` so muscle
4
+ memory carries over, and adds the remaining API endpoints (--info, --expiry,
5
+ --health, --save-config) plus --open and --json.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import webbrowser
15
+ from pathlib import Path
16
+
17
+ from . import __version__
18
+ from .client import PreviewClient, PreviewError, parse_expiry
19
+ from .config import resolve, save_config
20
+
21
+ PROG = "preview-publish"
22
+
23
+
24
+ def _print_json(payload) -> None:
25
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
26
+
27
+
28
+ def _fmt_size(n) -> str:
29
+ try:
30
+ n = float(n)
31
+ except (TypeError, ValueError):
32
+ return "?"
33
+ for unit in ("B", "KB", "MB", "GB", "TB"):
34
+ if n < 1024 or unit == "TB":
35
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
36
+ n /= 1024
37
+ return "?"
38
+
39
+
40
+ def _fmt_span(seconds) -> str:
41
+ try:
42
+ seconds = int(seconds)
43
+ except (TypeError, ValueError):
44
+ return "?"
45
+ for size, name in ((86400, "day"), (3600, "hour"), (60, "minute")):
46
+ if seconds >= size:
47
+ n = seconds // size
48
+ return f"{n} {name}{'s' if n != 1 else ''}"
49
+ return f"{seconds} seconds"
50
+
51
+
52
+ def _fmt_remaining(seconds) -> str:
53
+ try:
54
+ seconds = int(seconds)
55
+ except (TypeError, ValueError):
56
+ return "?"
57
+ if seconds <= 0:
58
+ return "expired"
59
+ days, rem = divmod(seconds, 86400)
60
+ hours, rem = divmod(rem, 3600)
61
+ minutes = rem // 60
62
+ if days:
63
+ return f"{days}d {hours}h left"
64
+ if hours:
65
+ return f"{hours}h {minutes}m left"
66
+ return f"{minutes}m left"
67
+
68
+
69
+ def _fmt_ts(ts) -> str:
70
+ try:
71
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts)))
72
+ except (TypeError, ValueError, OSError):
73
+ return "?"
74
+
75
+
76
+ def _build_parser() -> argparse.ArgumentParser:
77
+ epilog = """\
78
+ expiry formats:
79
+ 7 7 days
80
+ 2m 2 minutes (also Ns / Nh / Nd)
81
+
82
+ examples:
83
+ preview-publish ./site/ my-site 14
84
+ preview-publish ./page.html
85
+ preview-publish --dry-run ./site/ my-site
86
+ preview-publish --list
87
+ preview-publish --delete <hash>
88
+ preview-publish --expiry <hash> --action extend --days 30
89
+ """
90
+ ap = argparse.ArgumentParser(
91
+ prog=PROG,
92
+ description="Publish short-lived HTML previews to a html-previewer service.",
93
+ epilog=epilog,
94
+ formatter_class=argparse.RawDescriptionHelpFormatter,
95
+ )
96
+ ap.add_argument("path", nargs="?", help="folder, .zip, or single .html to publish")
97
+ ap.add_argument("topic", nargs="?", help="URL label (default: derived from path)")
98
+ ap.add_argument("expiry_spec", nargs="?", metavar="EXPIRY",
99
+ help="N days, or N{s,m,h,d} e.g. 2m (default: server default)")
100
+ ap.add_argument("--version", action="version", version=f"{PROG} {__version__}")
101
+
102
+ g = ap.add_argument_group("actions (instead of publishing)")
103
+ g.add_argument("--dry-run", action="store_true",
104
+ help="predict update-in-place vs new URL (no upload)")
105
+ g.add_argument("--list", action="store_true", help="list live previews")
106
+ g.add_argument("--list-all", action="store_true",
107
+ help="list all previews incl. expired/deleted")
108
+ g.add_argument("--info", metavar="HASH", help="show one preview's record")
109
+ g.add_argument("--delete", metavar="HASH", help="reclaim a preview now")
110
+ g.add_argument("--expiry", metavar="HASH",
111
+ help="admin expiry control (requires --action)")
112
+ g.add_argument("--action", choices=("extend", "expire", "renew"),
113
+ help="expiry action for --expiry (days via --days)")
114
+ g.add_argument("--days", type=int, metavar="N",
115
+ help="days for --action extend/renew (default: 30/7)")
116
+ g.add_argument("--health", action="store_true", help="check server liveness")
117
+ g.add_argument("--save-config", action="store_true",
118
+ help="save --base/--key (or env) to the config file and exit")
119
+
120
+ g = ap.add_argument_group("options")
121
+ g.add_argument("--new", action="store_true",
122
+ default=os.environ.get("PREVIEW_NEW") == "1",
123
+ help="force a brand-new URL instead of same-name update "
124
+ "(env: PREVIEW_NEW=1)")
125
+ g.add_argument("--open", action="store_true",
126
+ help="open the published URL in a browser")
127
+ g.add_argument("--json", action="store_true", dest="as_json",
128
+ help="print raw JSON responses")
129
+ g.add_argument("--base", metavar="URL", help="API base URL (env: PREVIEW_API_BASE)")
130
+ g.add_argument("--key", metavar="KEY", help="API key (env: PREVIEW_API_KEY)")
131
+ g.add_argument("--config", metavar="FILE",
132
+ help="config file path (default: platform config dir)")
133
+ return ap
134
+
135
+
136
+ # ---- output -------------------------------------------------------------
137
+
138
+ def _print_publish(res: dict) -> None:
139
+ action = "updated in place" if res.get("updated") else "created"
140
+ print(f"published topic={res.get('topic')} ({action})")
141
+ print(f" url: {res.get('url')}")
142
+ print(f" expires: {_fmt_ts(res.get('expires_at'))} "
143
+ f"(in {_fmt_span(res.get('ttl_seconds'))})")
144
+ print(f" size: {_fmt_size(res.get('size_bytes'))}, "
145
+ f"{res.get('file_count', '?')} files")
146
+ if not res.get("has_index_html"):
147
+ note = ("auto-generated directory index" if res.get("auto_index")
148
+ else "WARNING: no index.html")
149
+ print(f" index: {note}")
150
+
151
+
152
+ def _print_check(res: dict) -> None:
153
+ print(f"dry-run: topic={res.get('topic')}")
154
+ if res.get("action") == "update":
155
+ print(f" would UPDATE in place (same URL): {res.get('url')}")
156
+ else:
157
+ print(" would CREATE a new URL")
158
+
159
+
160
+ def _print_list(previews: list) -> None:
161
+ if not previews:
162
+ print("no previews")
163
+ return
164
+ for p in previews:
165
+ print(f"{p.get('hash')} {p.get('topic')} {p.get('status')}"
166
+ f" ({_fmt_remaining(p.get('seconds_remaining'))},"
167
+ f" {_fmt_size(p.get('size_bytes'))},"
168
+ f" {p.get('file_count', '?')} files)")
169
+ print(f" {p.get('url')}")
170
+
171
+
172
+ def _print_preview(p: dict, title: str) -> None:
173
+ print(title)
174
+ for k in ("hash", "topic", "url", "status", "days", "size_bytes",
175
+ "file_count", "archived"):
176
+ if k in p:
177
+ print(f" {k + ':':12} {p[k]}")
178
+ print(f" expires: {_fmt_ts(p.get('expires_at'))} "
179
+ f"({_fmt_remaining(p.get('seconds_remaining'))})")
180
+
181
+
182
+ # ---- command ------------------------------------------------------------
183
+
184
+ def main(argv: list[str] | None = None) -> int:
185
+ args = _build_parser().parse_args(argv)
186
+
187
+ if args.expiry and not args.action:
188
+ print(f"{PROG}: --expiry requires --action extend|expire|renew",
189
+ file=sys.stderr)
190
+ return 2
191
+ if args.action and not args.expiry:
192
+ print(f"{PROG}: --action requires --expiry HASH", file=sys.stderr)
193
+ return 2
194
+
195
+ cfg = resolve(base=args.base, key=args.key, config_path=args.config)
196
+
197
+ if args.save_config:
198
+ path = save_config(base=cfg["base"], key=cfg["key"],
199
+ path=cfg["config_path"])
200
+ print(f"saved config to {path}")
201
+ return 0
202
+
203
+ client = PreviewClient(cfg["base"], api_key=cfg["key"])
204
+
205
+ try:
206
+ if args.health:
207
+ res = client.health()
208
+ if args.as_json:
209
+ _print_json(res)
210
+ else:
211
+ print(f"ok: {cfg['base']} is alive")
212
+ return 0
213
+
214
+ if args.list or args.list_all:
215
+ previews = client.list_previews(all=args.list_all)
216
+ if args.as_json:
217
+ _print_json({"previews": previews})
218
+ else:
219
+ _print_list(previews)
220
+ return 0
221
+
222
+ if args.info:
223
+ res = client.info(args.info)
224
+ if args.as_json:
225
+ _print_json(res)
226
+ else:
227
+ _print_preview(res.get("preview", {}), f"preview {args.info}")
228
+ return 0
229
+
230
+ if args.delete:
231
+ res = client.delete(args.delete)
232
+ if args.as_json:
233
+ _print_json(res)
234
+ else:
235
+ print(f"deleted {res.get('hash')} "
236
+ f"(live copy reclaimed; git archive kept)")
237
+ return 0
238
+
239
+ if args.expiry:
240
+ res = client.expiry(args.expiry, args.action, days=args.days)
241
+ if args.as_json:
242
+ _print_json(res)
243
+ else:
244
+ _print_preview(res.get("preview", {}),
245
+ f"{args.action}: preview {args.expiry}")
246
+ return 0
247
+
248
+ # default action: publish (or dry-run)
249
+ if not args.path:
250
+ print(f"{PROG}: a path is required to publish "
251
+ f"(or use --list/--delete/--info/...)", file=sys.stderr)
252
+ return 2
253
+ path = Path(args.path)
254
+ if not path.exists():
255
+ print(f"{PROG}: no such file or directory: {path}", file=sys.stderr)
256
+ return 1
257
+
258
+ if args.dry_run:
259
+ res = client.publish(path, topic=args.topic, dry_run=True,
260
+ new=args.new)
261
+ if args.as_json:
262
+ _print_json(res)
263
+ else:
264
+ _print_check(res)
265
+ return 0
266
+
267
+ parse_expiry(args.expiry_spec) # fail fast on a bad spec, before zipping
268
+ res = client.publish(path, topic=args.topic, expiry=args.expiry_spec,
269
+ new=args.new)
270
+ if args.as_json:
271
+ _print_json(res)
272
+ else:
273
+ _print_publish(res)
274
+ if args.open and res.get("url"):
275
+ webbrowser.open(res["url"])
276
+ return 0
277
+
278
+ except PreviewError as e:
279
+ print(f"{PROG}: {e}", file=sys.stderr)
280
+ return 1
281
+ except ValueError as e:
282
+ print(f"{PROG}: {e}", file=sys.stderr)
283
+ return 2
284
+ except KeyboardInterrupt:
285
+ print(f"{PROG}: interrupted", file=sys.stderr)
286
+ return 130
287
+
288
+
289
+ if __name__ == "__main__":
290
+ sys.exit(main())