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.
@@ -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())
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())
@@ -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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ html-previewer = html_previewer.cli:main
3
+ preview-publish = html_previewer.cli:main
@@ -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