html-previewer 0.1.1__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.
- html_previewer/__init__.py +15 -0
- html_previewer/__main__.py +6 -0
- html_previewer/cli.py +290 -0
- html_previewer/client.py +239 -0
- html_previewer/config.py +117 -0
- html_previewer-0.1.1.dist-info/METADATA +119 -0
- html_previewer-0.1.1.dist-info/RECORD +11 -0
- html_previewer-0.1.1.dist-info/WHEEL +5 -0
- html_previewer-0.1.1.dist-info/entry_points.txt +3 -0
- html_previewer-0.1.1.dist-info/licenses/LICENSE +21 -0
- html_previewer-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
html_previewer/cli.py
ADDED
|
@@ -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())
|
html_previewer/client.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Client library for the html-previewer ephemeral preview hosting service.
|
|
2
|
+
|
|
3
|
+
Stdlib-only, works on Linux, macOS and Windows. Talks to the service's
|
|
4
|
+
``/_api/`` HTTP endpoints using the X-API-Key header.
|
|
5
|
+
|
|
6
|
+
Typical use::
|
|
7
|
+
|
|
8
|
+
from html_previewer import PreviewClient
|
|
9
|
+
|
|
10
|
+
c = PreviewClient("https://preview.example.com", api_key="…")
|
|
11
|
+
result = c.publish("./site", topic="my-site", expiry="14") # or "2m"
|
|
12
|
+
print(result["url"])
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
import zipfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PreviewClient",
|
|
28
|
+
"PreviewError",
|
|
29
|
+
"build_zip",
|
|
30
|
+
"default_topic",
|
|
31
|
+
"parse_expiry",
|
|
32
|
+
"slugify_topic",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PreviewError(Exception):
|
|
37
|
+
"""A request to the preview service failed."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, status: int | None = None):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status = status
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Mirrors slugify_topic() in server/preview_common.py so the client can reason
|
|
45
|
+
# about the final topic segment (and print it) exactly like the server will.
|
|
46
|
+
_TOPIC_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def slugify_topic(raw: str) -> str:
|
|
50
|
+
raw = (raw or "").strip()
|
|
51
|
+
raw = _TOPIC_RE.sub("-", raw)
|
|
52
|
+
raw = raw.strip(".-_")
|
|
53
|
+
if not raw:
|
|
54
|
+
raw = "preview"
|
|
55
|
+
return raw[:64]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_EXPIRY_RE = re.compile(r"^(\d+)([smhd]?)$", re.IGNORECASE)
|
|
59
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_expiry(spec: str | None):
|
|
63
|
+
"""Parse an expiry spec into publish parameters.
|
|
64
|
+
|
|
65
|
+
``"14"`` -> 14 days (X-Days)
|
|
66
|
+
``"2m"`` -> 2 minutes as seconds (X-TTL); also Ns / Nh / Nd
|
|
67
|
+
``None`` -> server default (no header sent)
|
|
68
|
+
Returns ``{"days": n}``, ``{"ttl": seconds}`` or ``None``.
|
|
69
|
+
"""
|
|
70
|
+
if spec is None or spec == "":
|
|
71
|
+
return None
|
|
72
|
+
m = _EXPIRY_RE.match(str(spec).strip())
|
|
73
|
+
if not m:
|
|
74
|
+
raise ValueError(f"bad expiry {spec!r}: use N for days, or Ns/Nm/Nh/Nd (e.g. 2m)")
|
|
75
|
+
n, unit = int(m.group(1)), m.group(2).lower()
|
|
76
|
+
if n < 1:
|
|
77
|
+
raise ValueError(f"bad expiry {spec!r}: must be at least 1")
|
|
78
|
+
if not unit:
|
|
79
|
+
return {"days": n}
|
|
80
|
+
return {"ttl": n * _UNIT_SECONDS[unit]}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_topic(path: Path | str) -> str:
|
|
84
|
+
"""Derive the default topic from a folder/file name (matches the shell client)."""
|
|
85
|
+
p = Path(path)
|
|
86
|
+
if p.is_dir():
|
|
87
|
+
return p.name
|
|
88
|
+
return p.stem or p.name
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_zip(folder: Path | str) -> bytes:
|
|
92
|
+
"""Zip the *contents* of a folder so files sit at the archive root.
|
|
93
|
+
|
|
94
|
+
``index.html`` lands directly at the zip root (and thus under
|
|
95
|
+
``{hash}/{topic}/`` on the server). Arcnames always use forward slashes
|
|
96
|
+
so archives built on Windows unpack correctly on the server.
|
|
97
|
+
"""
|
|
98
|
+
folder = Path(folder)
|
|
99
|
+
buf = io.BytesIO()
|
|
100
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
101
|
+
for root, _dirs, files in os.walk(folder):
|
|
102
|
+
for name in files:
|
|
103
|
+
full = os.path.join(root, name)
|
|
104
|
+
arcname = os.path.relpath(full, folder).replace(os.sep, "/")
|
|
105
|
+
zf.write(full, arcname)
|
|
106
|
+
return buf.getvalue()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PreviewClient:
|
|
110
|
+
"""HTTP client for a html-previewer service."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, base_url: str, api_key: str | None = None,
|
|
113
|
+
timeout: float = 120.0):
|
|
114
|
+
self.base = base_url.rstrip("/")
|
|
115
|
+
self.api_key = api_key
|
|
116
|
+
self.timeout = timeout
|
|
117
|
+
|
|
118
|
+
# -- transport ---------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _url(self, path: str, params: dict | None = None) -> str:
|
|
121
|
+
url = f"{self.base}{path}"
|
|
122
|
+
if params:
|
|
123
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
124
|
+
return url
|
|
125
|
+
|
|
126
|
+
def _request(self, method: str, path: str, *, params: dict | None = None,
|
|
127
|
+
headers: dict | None = None, data: bytes | None = None) -> dict:
|
|
128
|
+
req = urllib.request.Request(self._url(path, params), data=data,
|
|
129
|
+
method=method)
|
|
130
|
+
if self.api_key:
|
|
131
|
+
req.add_header("X-API-Key", self.api_key)
|
|
132
|
+
for k, v in (headers or {}).items():
|
|
133
|
+
req.add_header(k, v)
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
136
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
137
|
+
except urllib.error.HTTPError as e:
|
|
138
|
+
detail = ""
|
|
139
|
+
try:
|
|
140
|
+
body = json.loads(e.read().decode("utf-8"))
|
|
141
|
+
detail = body.get("error", "")
|
|
142
|
+
except Exception: # noqa: BLE001 - best-effort error decoration
|
|
143
|
+
pass
|
|
144
|
+
msg = f"HTTP {e.code} {e.reason}"
|
|
145
|
+
if detail:
|
|
146
|
+
msg = f"{msg}: {detail}"
|
|
147
|
+
raise PreviewError(msg, status=e.code) from None
|
|
148
|
+
except urllib.error.URLError as e:
|
|
149
|
+
raise PreviewError(f"cannot reach {self.base}: {e.reason}") from None
|
|
150
|
+
|
|
151
|
+
def _require_key(self) -> str:
|
|
152
|
+
if not self.api_key:
|
|
153
|
+
raise PreviewError(
|
|
154
|
+
"missing API key: set PREVIEW_API_KEY (env or config file), "
|
|
155
|
+
"pass --key, or run with --save-config"
|
|
156
|
+
)
|
|
157
|
+
return self.api_key
|
|
158
|
+
|
|
159
|
+
# -- API operations ----------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def health(self) -> dict:
|
|
162
|
+
return self._request("GET", "/_api/health")
|
|
163
|
+
|
|
164
|
+
def check(self, topic: str, new: bool = False) -> dict:
|
|
165
|
+
"""Dry-run: would publishing this topic update in place or create a new URL?"""
|
|
166
|
+
self._require_key()
|
|
167
|
+
topic = slugify_topic(topic)
|
|
168
|
+
params = {"topic": topic}
|
|
169
|
+
if new:
|
|
170
|
+
params["new"] = "1"
|
|
171
|
+
return self._request("GET", "/_api/check", params=params)
|
|
172
|
+
|
|
173
|
+
def publish(self, path: Path | str, topic: str | None = None,
|
|
174
|
+
expiry: str | None = None, new: bool = False,
|
|
175
|
+
dry_run: bool = False) -> dict:
|
|
176
|
+
"""Publish a folder, a .zip, or a single .html file.
|
|
177
|
+
|
|
178
|
+
topic URL label; defaults to the folder/file name
|
|
179
|
+
expiry N days ("14") or N{s,m,h,d} ("2m"); None = server default
|
|
180
|
+
new force a brand-new URL instead of a same-name in-place update
|
|
181
|
+
dry_run only predict the outcome (no upload happens)
|
|
182
|
+
"""
|
|
183
|
+
self._require_key()
|
|
184
|
+
p = Path(path)
|
|
185
|
+
if not p.exists():
|
|
186
|
+
raise PreviewError(f"no such file or directory: {p}")
|
|
187
|
+
|
|
188
|
+
if p.is_dir():
|
|
189
|
+
ptype = "zip"
|
|
190
|
+
elif p.suffix.lower() == ".zip":
|
|
191
|
+
ptype = "zip"
|
|
192
|
+
elif p.suffix.lower() in (".html", ".htm"):
|
|
193
|
+
ptype = "html"
|
|
194
|
+
else:
|
|
195
|
+
raise PreviewError(
|
|
196
|
+
f"unsupported type (use a folder, .zip, or .html): {p}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
topic = slugify_topic(topic if topic is not None else default_topic(p))
|
|
200
|
+
|
|
201
|
+
if dry_run:
|
|
202
|
+
return self.check(topic, new=new)
|
|
203
|
+
|
|
204
|
+
data = build_zip(p) if p.is_dir() else p.read_bytes()
|
|
205
|
+
|
|
206
|
+
headers = {"X-Topic": topic, "X-Type": ptype}
|
|
207
|
+
|
|
208
|
+
expiry_hdr = parse_expiry(expiry)
|
|
209
|
+
if expiry_hdr:
|
|
210
|
+
for k, v in expiry_hdr.items():
|
|
211
|
+
headers[f"X-{k.capitalize()}"] = str(v)
|
|
212
|
+
if new:
|
|
213
|
+
headers["X-New"] = "1"
|
|
214
|
+
return self._request("POST", "/_api/publish", headers=headers, data=data)
|
|
215
|
+
|
|
216
|
+
def list_previews(self, all: bool = False) -> list:
|
|
217
|
+
"""List previews (live only by default; everything with all=True)."""
|
|
218
|
+
self._require_key()
|
|
219
|
+
params = {"all": "1"} if all else None
|
|
220
|
+
return self._request("GET", "/_api/list", params=params).get("previews", [])
|
|
221
|
+
|
|
222
|
+
def info(self, hash: str) -> dict:
|
|
223
|
+
self._require_key()
|
|
224
|
+
return self._request("GET", f"/_api/info/{hash}")
|
|
225
|
+
|
|
226
|
+
def delete(self, hash: str) -> dict:
|
|
227
|
+
"""Reclaim a preview now (the git archive, if any, is kept)."""
|
|
228
|
+
self._require_key()
|
|
229
|
+
return self._request("DELETE", f"/_api/preview/{hash}")
|
|
230
|
+
|
|
231
|
+
def expiry(self, hash: str, action: str, days: int | None = None) -> dict:
|
|
232
|
+
"""Admin expiry control: action = extend | expire | renew."""
|
|
233
|
+
self._require_key()
|
|
234
|
+
if action not in ("extend", "expire", "renew"):
|
|
235
|
+
raise PreviewError("action must be extend, expire, or renew")
|
|
236
|
+
params = {"action": action}
|
|
237
|
+
if days is not None:
|
|
238
|
+
params["days"] = str(days)
|
|
239
|
+
return self._request("POST", f"/_api/expiry/{hash}", params=params)
|
html_previewer/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Cross-platform config discovery for the html-previewer client.
|
|
2
|
+
|
|
3
|
+
Precedence (highest first):
|
|
4
|
+
1. explicit values passed by the caller (CLI flags)
|
|
5
|
+
2. environment variables (PREVIEW_API_KEY / PREVIEW_API_BASE)
|
|
6
|
+
3. a key=value config file
|
|
7
|
+
|
|
8
|
+
The config file uses plain ``KEY=VALUE`` lines (an optional ``export`` prefix
|
|
9
|
+
and surrounding quotes are tolerated), so the same file works for both the
|
|
10
|
+
classic ``preview-publish.sh`` client and this Python client. On Linux/macOS
|
|
11
|
+
the default location matches the shell client: ``~/.config/preview/config``.
|
|
12
|
+
On Windows it is ``%APPDATA%\\preview\\config``.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ENV_KEY = "PREVIEW_API_KEY"
|
|
21
|
+
ENV_BASE = "PREVIEW_API_BASE"
|
|
22
|
+
ENV_CONFIG = "PREVIEW_CONFIG"
|
|
23
|
+
DEFAULT_BASE = "https://preview.example.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_config_path() -> Path:
|
|
27
|
+
if os.name == "nt":
|
|
28
|
+
appdata = os.environ.get("APPDATA")
|
|
29
|
+
if appdata:
|
|
30
|
+
return Path(appdata) / "preview" / "config"
|
|
31
|
+
return Path.home() / ".config" / "preview" / "config"
|
|
32
|
+
if sys.platform == "darwin":
|
|
33
|
+
return Path.home() / "Library" / "Application Support" / "preview" / "config"
|
|
34
|
+
return Path.home() / ".config" / "preview" / "config"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_config(text: str) -> dict:
|
|
38
|
+
cfg = {}
|
|
39
|
+
for line in text.splitlines():
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line or line.startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
if line.startswith("export "):
|
|
44
|
+
line = line[len("export "):].lstrip()
|
|
45
|
+
if "=" not in line:
|
|
46
|
+
continue
|
|
47
|
+
key, _, value = line.partition("=")
|
|
48
|
+
key, value = key.strip(), value.strip()
|
|
49
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
50
|
+
value = value[1:-1]
|
|
51
|
+
if key:
|
|
52
|
+
cfg[key] = value
|
|
53
|
+
return cfg
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config(path: Path | str | None = None) -> dict:
|
|
57
|
+
path = Path(path) if path is not None else default_config_path()
|
|
58
|
+
try:
|
|
59
|
+
text = path.read_text(encoding="utf-8")
|
|
60
|
+
except OSError:
|
|
61
|
+
return {}
|
|
62
|
+
return parse_config(text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_config(base: str | None = None, key: str | None = None,
|
|
66
|
+
path: Path | str | None = None) -> Path:
|
|
67
|
+
"""Write/update PREVIEW_API_BASE and PREVIEW_API_KEY in the config file.
|
|
68
|
+
|
|
69
|
+
Existing lines for these keys are replaced in place; unrelated lines are
|
|
70
|
+
preserved. The file is created (0600 where supported) if missing.
|
|
71
|
+
"""
|
|
72
|
+
path = Path(path) if path is not None else default_config_path()
|
|
73
|
+
updates = {}
|
|
74
|
+
if base:
|
|
75
|
+
updates[ENV_BASE] = base
|
|
76
|
+
if key:
|
|
77
|
+
updates[ENV_KEY] = key
|
|
78
|
+
if not updates:
|
|
79
|
+
raise ValueError("nothing to save: pass base and/or key")
|
|
80
|
+
|
|
81
|
+
lines: list[str] = []
|
|
82
|
+
seen = set()
|
|
83
|
+
try:
|
|
84
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
out = []
|
|
88
|
+
for line in lines:
|
|
89
|
+
stripped = line.strip()
|
|
90
|
+
parsed = parse_config(stripped)
|
|
91
|
+
if len(parsed) == 1:
|
|
92
|
+
(name, _old), = parsed.items()
|
|
93
|
+
if name in updates:
|
|
94
|
+
out.append(f"{name}={updates[name]}")
|
|
95
|
+
seen.add(name)
|
|
96
|
+
continue
|
|
97
|
+
out.append(line)
|
|
98
|
+
for name, value in updates.items():
|
|
99
|
+
if name not in seen:
|
|
100
|
+
out.append(f"{name}={value}")
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
103
|
+
try:
|
|
104
|
+
path.chmod(0o600)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass # Windows: ACLs already restrict to the user profile
|
|
107
|
+
return path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve(base: str | None = None, key: str | None = None,
|
|
111
|
+
config_path: Path | str | None = None) -> dict:
|
|
112
|
+
"""Resolve (base_url, api_key, config_path_used) from flags/env/config."""
|
|
113
|
+
path = Path(config_path) if config_path is not None else default_config_path()
|
|
114
|
+
file_cfg = load_config(path)
|
|
115
|
+
base = base or os.environ.get(ENV_BASE) or file_cfg.get(ENV_BASE, DEFAULT_BASE)
|
|
116
|
+
key = key or os.environ.get(ENV_KEY) or file_cfg.get(ENV_KEY)
|
|
117
|
+
return {"base": base.rstrip("/"), "key": key, "config_path": path}
|
|
@@ -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,11 @@
|
|
|
1
|
+
html_previewer/__init__.py,sha256=TjM1LnW7wIAy2K0lx8BQeAcaZ1YjFYqZ3M-Ohq1MCmc,381
|
|
2
|
+
html_previewer/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
html_previewer/cli.py,sha256=2DWCNdlLtZxoE7yARLhKeQHMWgIbctsOC0ePtcTtBFs,10179
|
|
4
|
+
html_previewer/client.py,sha256=OaXJmGzrc4P7QLJLMNMbMakuT-vURSglvmzTkvXcuPo,8367
|
|
5
|
+
html_previewer/config.py,sha256=Cr7N9i0GuWyA9ScFk22uo25vjR3DMCJqWkHIOabqdVQ,4084
|
|
6
|
+
html_previewer-0.1.1.dist-info/licenses/LICENSE,sha256=EuEvfowiJjYGQobNKS6hmfMdVTcTlkDma73goUJZPd8,1062
|
|
7
|
+
html_previewer-0.1.1.dist-info/METADATA,sha256=RjPuX0fl0EMQL9m6fTI5SssggeMo6XzFTMhZLFFl6wQ,4366
|
|
8
|
+
html_previewer-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
html_previewer-0.1.1.dist-info/entry_points.txt,sha256=vPY0GjfWCGPK__uR8g-Givt30zMH0InKcH7p5TbQwUo,101
|
|
10
|
+
html_previewer-0.1.1.dist-info/top_level.txt,sha256=w39R9JmT5G12Ojiw8pwDvKbdgooxEWdutb9Q6M92K6U,15
|
|
11
|
+
html_previewer-0.1.1.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
html_previewer
|