htmlship 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.
- htmlship/__init__.py +101 -0
- htmlship/_version.py +1 -0
- htmlship/cli.py +302 -0
- htmlship/client.py +212 -0
- htmlship/exceptions.py +29 -0
- htmlship/models.py +61 -0
- htmlship-0.1.0.dist-info/METADATA +292 -0
- htmlship-0.1.0.dist-info/RECORD +29 -0
- htmlship-0.1.0.dist-info/WHEEL +4 -0
- htmlship-0.1.0.dist-info/entry_points.txt +3 -0
- htmlship_mcp/__init__.py +1 -0
- htmlship_mcp/server.py +123 -0
- htmlship_server/__init__.py +1 -0
- htmlship_server/config.py +62 -0
- htmlship_server/database.py +38 -0
- htmlship_server/db_models/__init__.py +3 -0
- htmlship_server/db_models/paste.py +60 -0
- htmlship_server/exceptions.py +9 -0
- htmlship_server/main.py +69 -0
- htmlship_server/middleware.py +132 -0
- htmlship_server/routers/__init__.py +0 -0
- htmlship_server/routers/meta.py +17 -0
- htmlship_server/routers/pastes.py +234 -0
- htmlship_server/routers/view.py +175 -0
- htmlship_server/schemas/__init__.py +0 -0
- htmlship_server/schemas/pastes.py +48 -0
- htmlship_server/security.py +49 -0
- htmlship_server/slugs.py +36 -0
- htmlship_server/storage.py +137 -0
htmlship/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""HTMLShip — host and share HTML in one line.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
import htmlship
|
|
6
|
+
paste = htmlship.publish("<h1>Hello</h1>", expires_in=86400)
|
|
7
|
+
print(paste.url)
|
|
8
|
+
print(paste.owner_key)
|
|
9
|
+
|
|
10
|
+
paste.update("<h1>Hello, again</h1>")
|
|
11
|
+
paste.delete()
|
|
12
|
+
|
|
13
|
+
Configuration via environment:
|
|
14
|
+
HTMLSHIP_API_URL (default: https://api.htmlship.com)
|
|
15
|
+
HTMLSHIP_API_KEY (optional; reserved for future use)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .client import HTMLShipClient
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
APIError,
|
|
23
|
+
AuthError,
|
|
24
|
+
HTMLShipError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
)
|
|
28
|
+
from .models import Paste
|
|
29
|
+
|
|
30
|
+
_default_client: HTMLShipClient | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_default_client() -> HTMLShipClient:
|
|
34
|
+
global _default_client
|
|
35
|
+
if _default_client is None:
|
|
36
|
+
_default_client = HTMLShipClient()
|
|
37
|
+
return _default_client
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def configure(
|
|
41
|
+
*,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
) -> HTMLShipClient:
|
|
45
|
+
"""Replace the module-level default client.
|
|
46
|
+
|
|
47
|
+
Useful when you want to point at a different API URL without setting env vars.
|
|
48
|
+
"""
|
|
49
|
+
global _default_client
|
|
50
|
+
if _default_client is not None:
|
|
51
|
+
_default_client.close()
|
|
52
|
+
_default_client = HTMLShipClient(base_url=base_url, api_key=api_key)
|
|
53
|
+
return _default_client
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def publish(
|
|
57
|
+
html: str,
|
|
58
|
+
*,
|
|
59
|
+
title: str | None = None,
|
|
60
|
+
password: str | None = None,
|
|
61
|
+
expires_in: int | None = None,
|
|
62
|
+
parent_slug: str | None = None,
|
|
63
|
+
sandbox_mode: str = "strict",
|
|
64
|
+
) -> Paste:
|
|
65
|
+
return _get_default_client().publish(
|
|
66
|
+
html,
|
|
67
|
+
title=title,
|
|
68
|
+
password=password,
|
|
69
|
+
expires_in=expires_in,
|
|
70
|
+
parent_slug=parent_slug,
|
|
71
|
+
sandbox_mode=sandbox_mode,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get(slug: str) -> Paste:
|
|
76
|
+
return _get_default_client().get(slug)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def update(slug: str, html: str, *, owner_key: str, title: str | None = None) -> Paste:
|
|
80
|
+
return _get_default_client().update(slug, html, owner_key=owner_key, title=title)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def delete(slug: str, *, owner_key: str) -> None:
|
|
84
|
+
return _get_default_client().delete(slug, owner_key=owner_key)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"__version__",
|
|
89
|
+
"HTMLShipClient",
|
|
90
|
+
"Paste",
|
|
91
|
+
"HTMLShipError",
|
|
92
|
+
"NotFoundError",
|
|
93
|
+
"AuthError",
|
|
94
|
+
"RateLimitError",
|
|
95
|
+
"APIError",
|
|
96
|
+
"configure",
|
|
97
|
+
"publish",
|
|
98
|
+
"get",
|
|
99
|
+
"update",
|
|
100
|
+
"delete",
|
|
101
|
+
]
|
htmlship/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
htmlship/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from . import HTMLShipClient
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .exceptions import HTMLShipError, NotFoundError
|
|
15
|
+
|
|
16
|
+
KEYS_DIR = Path(os.environ.get("HTMLSHIP_KEYS_DIR", str(Path.home() / ".htmlship")))
|
|
17
|
+
KEYS_FILE = KEYS_DIR / "keys.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- key store ----------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_keys() -> dict[str, dict[str, Any]]:
|
|
24
|
+
if not KEYS_FILE.exists():
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(KEYS_FILE.read_text())
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_keys(data: dict[str, dict[str, Any]]) -> None:
|
|
33
|
+
KEYS_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
KEYS_FILE.write_text(json.dumps(data, indent=2, sort_keys=True))
|
|
35
|
+
try:
|
|
36
|
+
KEYS_FILE.chmod(0o600)
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _remember(slug: str, owner_key: str, url: str, title: str | None) -> None:
|
|
42
|
+
keys = _load_keys()
|
|
43
|
+
keys[slug] = {
|
|
44
|
+
"owner_key": owner_key,
|
|
45
|
+
"url": url,
|
|
46
|
+
"title": title,
|
|
47
|
+
"saved_at": datetime.now(UTC).isoformat(),
|
|
48
|
+
}
|
|
49
|
+
_save_keys(keys)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _forget(slug: str) -> None:
|
|
53
|
+
keys = _load_keys()
|
|
54
|
+
if slug in keys:
|
|
55
|
+
del keys[slug]
|
|
56
|
+
_save_keys(keys)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _lookup_owner_key(slug: str) -> str | None:
|
|
60
|
+
return _load_keys().get(slug, {}).get("owner_key")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- helpers ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read_html(source: str | None, file: str | None) -> str:
|
|
67
|
+
"""Resolve the HTML payload from --file, a positional path, or stdin."""
|
|
68
|
+
if file:
|
|
69
|
+
return Path(file).read_text(encoding="utf-8")
|
|
70
|
+
if source == "-" or source is None:
|
|
71
|
+
if sys.stdin.isatty() and source is None:
|
|
72
|
+
raise click.UsageError("Provide a file path or pipe HTML on stdin.")
|
|
73
|
+
return sys.stdin.read()
|
|
74
|
+
return Path(source).read_text(encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _try_clipboard(text: str) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
import pyperclip # type: ignore
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
try:
|
|
83
|
+
pyperclip.copy(text)
|
|
84
|
+
return True
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _make_client(api_url: str | None) -> HTMLShipClient:
|
|
90
|
+
return HTMLShipClient(base_url=api_url)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- CLI ----------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@click.group(help="HTMLShip — host and share HTML in one line.")
|
|
97
|
+
@click.version_option(__version__, prog_name="htmlship")
|
|
98
|
+
@click.option(
|
|
99
|
+
"--api-url",
|
|
100
|
+
envvar="HTMLSHIP_API_URL",
|
|
101
|
+
default=None,
|
|
102
|
+
help="API base URL (default: https://api.htmlship.com).",
|
|
103
|
+
)
|
|
104
|
+
@click.pass_context
|
|
105
|
+
def main(ctx: click.Context, api_url: str | None) -> None:
|
|
106
|
+
ctx.ensure_object(dict)
|
|
107
|
+
ctx.obj["api_url"] = api_url
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@main.command()
|
|
111
|
+
@click.argument("source", required=False)
|
|
112
|
+
@click.option("--file", "-f", "file_", type=click.Path(exists=True, dir_okay=False), help="HTML file path.")
|
|
113
|
+
@click.option("--title", default=None, help="Optional title.")
|
|
114
|
+
@click.option("--password", default=None, help="Password-protect the paste.")
|
|
115
|
+
@click.option("--expires-in", type=int, default=None, help="Seconds until expiry.")
|
|
116
|
+
@click.option("--no-clipboard", is_flag=True, help="Don't copy URL to clipboard.")
|
|
117
|
+
@click.option("--quiet", "-q", is_flag=True, help="Print only the URL.")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def publish(
|
|
120
|
+
ctx: click.Context,
|
|
121
|
+
source: str | None,
|
|
122
|
+
file_: str | None,
|
|
123
|
+
title: str | None,
|
|
124
|
+
password: str | None,
|
|
125
|
+
expires_in: int | None,
|
|
126
|
+
no_clipboard: bool,
|
|
127
|
+
quiet: bool,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Publish HTML.
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
|
|
133
|
+
htmlship publish report.html
|
|
134
|
+
cat report.html | htmlship publish -
|
|
135
|
+
htmlship publish --file report.html --title "Q4 Report"
|
|
136
|
+
"""
|
|
137
|
+
html = _read_html(source, file_)
|
|
138
|
+
client = _make_client(ctx.obj["api_url"])
|
|
139
|
+
try:
|
|
140
|
+
paste = client.publish(
|
|
141
|
+
html,
|
|
142
|
+
title=title,
|
|
143
|
+
password=password,
|
|
144
|
+
expires_in=expires_in,
|
|
145
|
+
)
|
|
146
|
+
except HTMLShipError as exc:
|
|
147
|
+
raise click.ClickException(str(exc)) from exc
|
|
148
|
+
finally:
|
|
149
|
+
client.close()
|
|
150
|
+
|
|
151
|
+
_remember(paste.slug, paste.owner_key or "", paste.url, title)
|
|
152
|
+
|
|
153
|
+
if quiet:
|
|
154
|
+
click.echo(paste.url)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
click.echo(paste.url)
|
|
158
|
+
click.echo(f"slug: {paste.slug}", err=True)
|
|
159
|
+
click.echo(f"owner_key: {paste.owner_key} (saved to {KEYS_FILE})", err=True)
|
|
160
|
+
if paste.expires_at:
|
|
161
|
+
click.echo(f"expires: {paste.expires_at.isoformat()}", err=True)
|
|
162
|
+
if not no_clipboard and _try_clipboard(paste.url):
|
|
163
|
+
click.echo("(URL copied to clipboard)", err=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@main.command()
|
|
167
|
+
@click.argument("slug")
|
|
168
|
+
@click.pass_context
|
|
169
|
+
def get(ctx: click.Context, slug: str) -> None:
|
|
170
|
+
"""Show metadata for a paste."""
|
|
171
|
+
client = _make_client(ctx.obj["api_url"])
|
|
172
|
+
try:
|
|
173
|
+
paste = client.get(slug)
|
|
174
|
+
except NotFoundError as exc:
|
|
175
|
+
raise click.ClickException(f"paste '{slug}' not found") from exc
|
|
176
|
+
except HTMLShipError as exc:
|
|
177
|
+
raise click.ClickException(str(exc)) from exc
|
|
178
|
+
finally:
|
|
179
|
+
client.close()
|
|
180
|
+
|
|
181
|
+
click.echo(json.dumps(
|
|
182
|
+
{
|
|
183
|
+
"slug": paste.slug,
|
|
184
|
+
"url": paste.url,
|
|
185
|
+
"title": paste.title,
|
|
186
|
+
"view_count": paste.view_count,
|
|
187
|
+
"size_bytes": paste.size_bytes,
|
|
188
|
+
"has_password": paste.has_password,
|
|
189
|
+
"parent_slug": paste.parent_slug,
|
|
190
|
+
"expires_at": paste.expires_at.isoformat() if paste.expires_at else None,
|
|
191
|
+
"created_at": paste.created_at.isoformat() if paste.created_at else None,
|
|
192
|
+
"updated_at": paste.updated_at.isoformat() if paste.updated_at else None,
|
|
193
|
+
},
|
|
194
|
+
indent=2,
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@main.command()
|
|
199
|
+
@click.argument("slug")
|
|
200
|
+
@click.argument("source", required=False)
|
|
201
|
+
@click.option("--file", "-f", "file_", type=click.Path(exists=True, dir_okay=False))
|
|
202
|
+
@click.option("--title", default=None, help="Optional new title.")
|
|
203
|
+
@click.option("--owner-key", default=None, help="Owner key (defaults to local store).")
|
|
204
|
+
@click.pass_context
|
|
205
|
+
def update(
|
|
206
|
+
ctx: click.Context,
|
|
207
|
+
slug: str,
|
|
208
|
+
source: str | None,
|
|
209
|
+
file_: str | None,
|
|
210
|
+
title: str | None,
|
|
211
|
+
owner_key: str | None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Replace HTML for an existing paste."""
|
|
214
|
+
html = _read_html(source, file_)
|
|
215
|
+
key = owner_key or _lookup_owner_key(slug)
|
|
216
|
+
if not key:
|
|
217
|
+
raise click.ClickException(
|
|
218
|
+
f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
|
|
219
|
+
)
|
|
220
|
+
client = _make_client(ctx.obj["api_url"])
|
|
221
|
+
try:
|
|
222
|
+
paste = client.update(slug, html, owner_key=key, title=title)
|
|
223
|
+
except HTMLShipError as exc:
|
|
224
|
+
raise click.ClickException(str(exc)) from exc
|
|
225
|
+
finally:
|
|
226
|
+
client.close()
|
|
227
|
+
|
|
228
|
+
click.echo(paste.url)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@main.command()
|
|
232
|
+
@click.argument("slug")
|
|
233
|
+
@click.option("--owner-key", default=None, help="Owner key (defaults to local store).")
|
|
234
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
235
|
+
@click.pass_context
|
|
236
|
+
def delete(ctx: click.Context, slug: str, owner_key: str | None, yes: bool) -> None:
|
|
237
|
+
"""Soft-delete a paste."""
|
|
238
|
+
key = owner_key or _lookup_owner_key(slug)
|
|
239
|
+
if not key:
|
|
240
|
+
raise click.ClickException(
|
|
241
|
+
f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
|
|
242
|
+
)
|
|
243
|
+
if not yes:
|
|
244
|
+
click.confirm(f"Delete paste '{slug}'?", abort=True)
|
|
245
|
+
client = _make_client(ctx.obj["api_url"])
|
|
246
|
+
try:
|
|
247
|
+
client.delete(slug, owner_key=key)
|
|
248
|
+
except HTMLShipError as exc:
|
|
249
|
+
raise click.ClickException(str(exc)) from exc
|
|
250
|
+
finally:
|
|
251
|
+
client.close()
|
|
252
|
+
_forget(slug)
|
|
253
|
+
click.echo(f"deleted {slug}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@main.command("list-mine")
|
|
257
|
+
@click.option("--limit", type=int, default=20, show_default=True)
|
|
258
|
+
@click.pass_context
|
|
259
|
+
def list_mine(ctx: click.Context, limit: int) -> None:
|
|
260
|
+
"""List pastes whose owner keys are saved locally."""
|
|
261
|
+
keys = _load_keys()
|
|
262
|
+
if not keys:
|
|
263
|
+
click.echo(f"No saved pastes in {KEYS_FILE}.")
|
|
264
|
+
return
|
|
265
|
+
items = sorted(keys.items(), key=lambda kv: kv[1].get("saved_at", ""), reverse=True)[:limit]
|
|
266
|
+
|
|
267
|
+
client = _make_client(ctx.obj["api_url"])
|
|
268
|
+
try:
|
|
269
|
+
rows = []
|
|
270
|
+
for slug, info in items:
|
|
271
|
+
try:
|
|
272
|
+
paste = client.get(slug)
|
|
273
|
+
size = f"{paste.size_bytes}B"
|
|
274
|
+
views = paste.view_count
|
|
275
|
+
title = paste.title or info.get("title") or ""
|
|
276
|
+
status = "ok"
|
|
277
|
+
except NotFoundError:
|
|
278
|
+
paste = None
|
|
279
|
+
size = "—"
|
|
280
|
+
views = "—"
|
|
281
|
+
title = info.get("title") or ""
|
|
282
|
+
status = "gone"
|
|
283
|
+
rows.append(
|
|
284
|
+
{
|
|
285
|
+
"slug": slug,
|
|
286
|
+
"url": info.get("url"),
|
|
287
|
+
"title": title,
|
|
288
|
+
"size": size,
|
|
289
|
+
"views": views,
|
|
290
|
+
"status": status,
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
finally:
|
|
294
|
+
client.close()
|
|
295
|
+
|
|
296
|
+
width = max((len(r["slug"]) for r in rows), default=8)
|
|
297
|
+
for r in rows:
|
|
298
|
+
click.echo(
|
|
299
|
+
f"{r['slug']:<{width}} {r['status']:<5} {r['views']:>5} views "
|
|
300
|
+
f"{r['size']:>8} {r['title']}"
|
|
301
|
+
)
|
|
302
|
+
click.echo(f" {r['url']}")
|
htmlship/client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .exceptions import APIError, AuthError, HTMLShipError, NotFoundError, RateLimitError
|
|
11
|
+
from .models import Paste
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://api.htmlship.com"
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_dt(v: str | None) -> datetime | None:
|
|
18
|
+
if v is None:
|
|
19
|
+
return None
|
|
20
|
+
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HTMLShipClient:
|
|
24
|
+
"""Synchronous HTTP client for the HTMLShip API.
|
|
25
|
+
|
|
26
|
+
Reads `HTMLSHIP_API_URL` and `HTMLSHIP_API_KEY` from the environment by default.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
api_key: str | None = None,
|
|
33
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
34
|
+
transport: httpx.BaseTransport | None = None,
|
|
35
|
+
):
|
|
36
|
+
self.base_url = (base_url or os.getenv("HTMLSHIP_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
37
|
+
self.api_key = api_key or os.getenv("HTMLSHIP_API_KEY")
|
|
38
|
+
headers = {"User-Agent": f"htmlship-python/{__version__}"}
|
|
39
|
+
if self.api_key:
|
|
40
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
41
|
+
self._http = httpx.Client(
|
|
42
|
+
base_url=self.base_url,
|
|
43
|
+
headers=headers,
|
|
44
|
+
timeout=timeout,
|
|
45
|
+
transport=transport,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self._http.close()
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> "HTMLShipClient":
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *exc: Any) -> None:
|
|
55
|
+
self.close()
|
|
56
|
+
|
|
57
|
+
# --- public API ---------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def publish(
|
|
60
|
+
self,
|
|
61
|
+
html: str,
|
|
62
|
+
*,
|
|
63
|
+
title: str | None = None,
|
|
64
|
+
password: str | None = None,
|
|
65
|
+
expires_in: int | None = None,
|
|
66
|
+
parent_slug: str | None = None,
|
|
67
|
+
sandbox_mode: str = "strict",
|
|
68
|
+
) -> Paste:
|
|
69
|
+
"""Create a paste. Returns a Paste with owner_key set."""
|
|
70
|
+
body: dict[str, Any] = {"html": html, "sandbox_mode": sandbox_mode}
|
|
71
|
+
if title is not None:
|
|
72
|
+
body["title"] = title
|
|
73
|
+
if password is not None:
|
|
74
|
+
body["password"] = password
|
|
75
|
+
if expires_in is not None:
|
|
76
|
+
body["expires_in"] = expires_in
|
|
77
|
+
if parent_slug is not None:
|
|
78
|
+
body["parent_slug"] = parent_slug
|
|
79
|
+
r = self._http.post("/api/v1/pastes", json=body)
|
|
80
|
+
data = self._handle(r)
|
|
81
|
+
return Paste(
|
|
82
|
+
slug=data["slug"],
|
|
83
|
+
url=data["url"],
|
|
84
|
+
owner_key=data["owner_key"],
|
|
85
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
86
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
87
|
+
_client=self,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get(self, slug: str) -> Paste:
|
|
91
|
+
"""Fetch metadata for a paste. owner_key is None on the returned object."""
|
|
92
|
+
r = self._http.get(f"/api/v1/pastes/{slug}")
|
|
93
|
+
data = self._handle(r)
|
|
94
|
+
return Paste(
|
|
95
|
+
slug=data["slug"],
|
|
96
|
+
url=data["url"],
|
|
97
|
+
owner_key=None,
|
|
98
|
+
title=data.get("title"),
|
|
99
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
100
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
101
|
+
updated_at=_parse_dt(data.get("updated_at")),
|
|
102
|
+
view_count=data.get("view_count", 0),
|
|
103
|
+
size_bytes=data.get("size_bytes", 0),
|
|
104
|
+
has_password=data.get("has_password", False),
|
|
105
|
+
parent_slug=data.get("parent_slug"),
|
|
106
|
+
_client=self,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def update(
|
|
110
|
+
self,
|
|
111
|
+
slug: str,
|
|
112
|
+
html: str,
|
|
113
|
+
*,
|
|
114
|
+
owner_key: str,
|
|
115
|
+
title: str | None = None,
|
|
116
|
+
) -> Paste:
|
|
117
|
+
body: dict[str, Any] = {"html": html}
|
|
118
|
+
if title is not None:
|
|
119
|
+
body["title"] = title
|
|
120
|
+
r = self._http.patch(
|
|
121
|
+
f"/api/v1/pastes/{slug}",
|
|
122
|
+
json=body,
|
|
123
|
+
headers={"X-Owner-Key": owner_key},
|
|
124
|
+
)
|
|
125
|
+
data = self._handle(r)
|
|
126
|
+
return Paste(
|
|
127
|
+
slug=data["slug"],
|
|
128
|
+
url=data["url"],
|
|
129
|
+
owner_key=owner_key,
|
|
130
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
131
|
+
updated_at=_parse_dt(data.get("updated_at")),
|
|
132
|
+
_client=self,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def delete(self, slug: str, *, owner_key: str) -> None:
|
|
136
|
+
r = self._http.delete(
|
|
137
|
+
f"/api/v1/pastes/{slug}",
|
|
138
|
+
headers={"X-Owner-Key": owner_key},
|
|
139
|
+
)
|
|
140
|
+
self._handle(r, expect_no_body=True)
|
|
141
|
+
|
|
142
|
+
def create_version(
|
|
143
|
+
self,
|
|
144
|
+
parent_slug: str,
|
|
145
|
+
html: str,
|
|
146
|
+
*,
|
|
147
|
+
title: str | None = None,
|
|
148
|
+
password: str | None = None,
|
|
149
|
+
expires_in: int | None = None,
|
|
150
|
+
) -> Paste:
|
|
151
|
+
body: dict[str, Any] = {"html": html, "sandbox_mode": "strict"}
|
|
152
|
+
if title is not None:
|
|
153
|
+
body["title"] = title
|
|
154
|
+
if password is not None:
|
|
155
|
+
body["password"] = password
|
|
156
|
+
if expires_in is not None:
|
|
157
|
+
body["expires_in"] = expires_in
|
|
158
|
+
r = self._http.post(f"/api/v1/pastes/{parent_slug}/version", json=body)
|
|
159
|
+
data = self._handle(r)
|
|
160
|
+
return Paste(
|
|
161
|
+
slug=data["slug"],
|
|
162
|
+
url=data["url"],
|
|
163
|
+
owner_key=data["owner_key"],
|
|
164
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
165
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
166
|
+
_client=self,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# --- error mapping ------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _handle(self, r: httpx.Response, *, expect_no_body: bool = False) -> dict[str, Any]:
|
|
172
|
+
if 200 <= r.status_code < 300:
|
|
173
|
+
if expect_no_body or not r.content:
|
|
174
|
+
return {}
|
|
175
|
+
return r.json()
|
|
176
|
+
|
|
177
|
+
detail = self._error_detail(r)
|
|
178
|
+
|
|
179
|
+
if r.status_code == 404:
|
|
180
|
+
raise NotFoundError(detail)
|
|
181
|
+
if r.status_code in (401, 403):
|
|
182
|
+
raise AuthError(detail)
|
|
183
|
+
if r.status_code == 429:
|
|
184
|
+
retry = r.headers.get("Retry-After")
|
|
185
|
+
try:
|
|
186
|
+
retry_after = float(retry) if retry else None
|
|
187
|
+
except ValueError:
|
|
188
|
+
retry_after = None
|
|
189
|
+
raise RateLimitError(detail, retry_after=retry_after)
|
|
190
|
+
raise APIError(detail, status_code=r.status_code)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _error_detail(r: httpx.Response) -> str:
|
|
194
|
+
try:
|
|
195
|
+
data = r.json()
|
|
196
|
+
if isinstance(data, dict):
|
|
197
|
+
if "detail" in data:
|
|
198
|
+
return str(data["detail"])
|
|
199
|
+
return str(data)
|
|
200
|
+
return str(data)
|
|
201
|
+
except Exception:
|
|
202
|
+
return r.text or f"HTTP {r.status_code}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = [
|
|
206
|
+
"HTMLShipClient",
|
|
207
|
+
"HTMLShipError",
|
|
208
|
+
"NotFoundError",
|
|
209
|
+
"AuthError",
|
|
210
|
+
"RateLimitError",
|
|
211
|
+
"APIError",
|
|
212
|
+
]
|
htmlship/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HTMLShipError(Exception):
|
|
5
|
+
"""Base class for all HTMLShip client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotFoundError(HTMLShipError):
|
|
9
|
+
"""Returned when a paste does not exist or has expired."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(HTMLShipError):
|
|
13
|
+
"""Owner key missing or invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitError(HTMLShipError):
|
|
17
|
+
"""Server returned 429."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class APIError(HTMLShipError):
|
|
25
|
+
"""Server returned a non-2xx response that doesn't fit a more specific class."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
htmlship/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .client import HTMLShipClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Paste:
|
|
13
|
+
"""A paste created via the HTMLShip API.
|
|
14
|
+
|
|
15
|
+
The `owner_key` is set on creation and is the only credential to mutate or
|
|
16
|
+
delete the paste. It is None for pastes loaded via `get()` since the server
|
|
17
|
+
does not return it after creation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
slug: str
|
|
21
|
+
url: str
|
|
22
|
+
owner_key: str | None = None
|
|
23
|
+
expires_at: datetime | None = None
|
|
24
|
+
created_at: datetime | None = None
|
|
25
|
+
updated_at: datetime | None = None
|
|
26
|
+
title: str | None = None
|
|
27
|
+
view_count: int = 0
|
|
28
|
+
size_bytes: int = 0
|
|
29
|
+
has_password: bool = False
|
|
30
|
+
parent_slug: str | None = None
|
|
31
|
+
|
|
32
|
+
_client: "HTMLShipClient | None" = field(default=None, repr=False, compare=False)
|
|
33
|
+
|
|
34
|
+
def update(self, html: str, *, title: str | None = None) -> "Paste":
|
|
35
|
+
"""Replace this paste's HTML. Requires owner_key."""
|
|
36
|
+
if self._client is None:
|
|
37
|
+
raise RuntimeError("Paste is detached from a client; reattach to mutate")
|
|
38
|
+
if not self.owner_key:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
"owner_key is required to update; load via get() first or pass owner_key explicitly"
|
|
41
|
+
)
|
|
42
|
+
return self._client.update(self.slug, html, owner_key=self.owner_key, title=title)
|
|
43
|
+
|
|
44
|
+
def delete(self) -> None:
|
|
45
|
+
"""Soft-delete this paste. Requires owner_key."""
|
|
46
|
+
if self._client is None:
|
|
47
|
+
raise RuntimeError("Paste is detached from a client; reattach to delete")
|
|
48
|
+
if not self.owner_key:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"owner_key is required to delete; load via get() first or pass owner_key explicitly"
|
|
51
|
+
)
|
|
52
|
+
self._client.delete(self.slug, owner_key=self.owner_key)
|
|
53
|
+
|
|
54
|
+
def fetch_metadata(self) -> "Paste":
|
|
55
|
+
"""Refresh metadata from the server."""
|
|
56
|
+
if self._client is None:
|
|
57
|
+
raise RuntimeError("Paste is detached from a client; reattach to fetch metadata")
|
|
58
|
+
fresh = self._client.get(self.slug)
|
|
59
|
+
# Preserve owner_key (server doesn't return it).
|
|
60
|
+
fresh.owner_key = self.owner_key
|
|
61
|
+
return fresh
|