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 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