omglol-api 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.
omglol.py ADDED
@@ -0,0 +1,917 @@
1
+ """
2
+ omg.lol API client
3
+ Focused on weblog management and markdown publishing.
4
+
5
+ Usage:
6
+ client = OmgLol(api_key="your_key", address="yourname")
7
+
8
+ # Post a markdown file
9
+ client.post_markdown("my-post.md")
10
+
11
+ # Or post content directly
12
+ client.create_post(title="Hello World", content="# Hello\n\nThis is my post.")
13
+
14
+ # List all posts
15
+ for post in client.list_posts():
16
+ print(post.title, post.location)
17
+ """
18
+
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ import requests
28
+ from dotenv import load_dotenv
29
+
30
+ load_dotenv()
31
+
32
+
33
+ class OmgLolError(Exception):
34
+ pass
35
+
36
+
37
+ # ── Data models ──────────────────────────────────────────────────────────────
38
+
39
+
40
+ @dataclass
41
+ class Post:
42
+ entry_id: str = ""
43
+ title: str = ""
44
+ slug: str = ""
45
+ location: str = ""
46
+ source: str = ""
47
+ body: str = ""
48
+ date: str = ""
49
+ status: str = ""
50
+ raw: dict = field(default_factory=dict, repr=False)
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict) -> "Post":
54
+ return cls(
55
+ entry_id=data.get("entry") or data.get("entry_id", ""),
56
+ title=data.get("title", ""),
57
+ slug=data.get("slug", ""),
58
+ location=data.get("location", ""),
59
+ source=data.get("source", ""),
60
+ body=data.get("body", ""),
61
+ date=data.get("date", ""),
62
+ status=data.get("status", ""),
63
+ raw=data,
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class Paste:
69
+ title: str = ""
70
+ content: str = ""
71
+ modified_on: str = ""
72
+ listed: bool = True
73
+ raw: dict = field(default_factory=dict, repr=False)
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict) -> "Paste":
77
+ return cls(
78
+ title=data.get("title", ""),
79
+ content=data.get("content", ""),
80
+ modified_on=data.get("modified_on", ""),
81
+ listed=bool(data.get("listed", True)),
82
+ raw=data,
83
+ )
84
+
85
+
86
+ @dataclass
87
+ class Status:
88
+ id: str = ""
89
+ emoji: str = ""
90
+ content: str = ""
91
+ created: str = ""
92
+ raw: dict = field(default_factory=dict, repr=False)
93
+
94
+ @classmethod
95
+ def from_dict(cls, data: dict) -> "Status":
96
+ return cls(
97
+ id=data.get("id", ""),
98
+ emoji=data.get("emoji", ""),
99
+ content=data.get("content", ""),
100
+ created=data.get("created", ""),
101
+ raw=data,
102
+ )
103
+
104
+
105
+ @dataclass
106
+ class Purl:
107
+ name: str = ""
108
+ url: str = ""
109
+ listed: bool = True
110
+ raw: dict = field(default_factory=dict, repr=False)
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: dict) -> "Purl":
114
+ return cls(
115
+ name=data.get("name", ""),
116
+ url=data.get("url", ""),
117
+ listed=bool(data.get("listed", True)),
118
+ raw=data,
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class DnsRecord:
124
+ id: str = ""
125
+ record_type: str = ""
126
+ name: str = ""
127
+ data: str = ""
128
+ ttl: int = 3600
129
+ raw: dict = field(default_factory=dict, repr=False)
130
+
131
+ @classmethod
132
+ def from_dict(cls, data: dict) -> "DnsRecord":
133
+ return cls(
134
+ id=data.get("id", ""),
135
+ record_type=data.get("type", ""),
136
+ name=data.get("name", ""),
137
+ data=data.get("data", ""),
138
+ ttl=int(data.get("ttl", 3600)),
139
+ raw=data,
140
+ )
141
+
142
+
143
+ @dataclass
144
+ class NowPage:
145
+ content: str = ""
146
+ listed: bool = True
147
+ updated: str = ""
148
+ raw: dict = field(default_factory=dict, repr=False)
149
+
150
+ @classmethod
151
+ def from_dict(cls, data: dict) -> "NowPage":
152
+ return cls(
153
+ content=data.get("content", ""),
154
+ listed=bool(data.get("listed", True)),
155
+ updated=data.get("updated", ""),
156
+ raw=data,
157
+ )
158
+
159
+
160
+ class OmgLol:
161
+ BASE_URL = "https://api.omg.lol"
162
+
163
+ def __init__(self, api_key: str, address: str):
164
+ self.address = address
165
+ self.session = requests.Session()
166
+ self.session.headers.update({
167
+ "Authorization": f"Bearer {api_key}",
168
+ "Content-Type": "application/json",
169
+ })
170
+
171
+ MAX_RETRIES = 3
172
+ RETRY_BACKOFF = 1.0 # seconds, doubled each retry
173
+
174
+ def _request(self, method: str, path: str, **kwargs):
175
+ url = f"{self.BASE_URL}{path}"
176
+ last_exc = None
177
+ for attempt in range(self.MAX_RETRIES):
178
+ try:
179
+ resp = self.session.request(method, url, **kwargs)
180
+ if resp.status_code == 429:
181
+ retry_after = float(resp.headers.get("Retry-After", self.RETRY_BACKOFF * 2**attempt))
182
+ time.sleep(retry_after)
183
+ continue
184
+ resp.raise_for_status()
185
+ data = resp.json()
186
+ if not data.get("request", {}).get("success", True):
187
+ raise OmgLolError(data.get("response", {}).get("message", "Unknown error"))
188
+ return data.get("response", data)
189
+ except requests.ConnectionError as exc:
190
+ last_exc = exc
191
+ time.sleep(self.RETRY_BACKOFF * 2**attempt)
192
+ raise last_exc or OmgLolError("Max retries exceeded")
193
+
194
+ # ── Weblog ────────────────────────────────────────────────────────────────
195
+
196
+ def list_posts(self) -> list[Post]:
197
+ """Return all weblog entries."""
198
+ response = self._request("GET", f"/address/{self.address}/weblog/entries")
199
+ return [Post.from_dict(e) for e in response.get("entries", [])]
200
+
201
+ def get_post(self, entry_id: str) -> Post:
202
+ """Fetch a single weblog entry by ID."""
203
+ response = self._request("GET", f"/address/{self.address}/weblog/entry/{entry_id}")
204
+ return Post.from_dict(response.get("entry", response))
205
+
206
+ def get_latest_post(self) -> Post:
207
+ """Fetch the latest published post (no auth required)."""
208
+ response = self._request("GET", f"/address/{self.address}/weblog/post/latest")
209
+ return Post.from_dict(response.get("post", response))
210
+
211
+ def create_post(
212
+ self,
213
+ title: str,
214
+ content: str,
215
+ date: datetime | None = None,
216
+ slug: str | None = None,
217
+ entry_id: str | None = None,
218
+ ) -> Post:
219
+ """
220
+ Create or update a weblog entry.
221
+
222
+ The omg.lol weblog format uses a frontmatter block delimited by ---:
223
+
224
+ ---
225
+ Date: 2025-03-14 12:00
226
+ Slug: optional-custom-slug
227
+ ---
228
+
229
+ # Your title here
230
+
231
+ Body content in markdown...
232
+
233
+ Args:
234
+ title: Post title (used as the H1 heading).
235
+ content: Markdown body (without the title line).
236
+ date: Publication datetime (defaults to now).
237
+ slug: Optional URL slug (defaults to slugified title).
238
+ entry_id: Existing entry ID to update, or None to create new.
239
+ """
240
+ if date is None:
241
+ date = datetime.now()
242
+
243
+ date_str = date.strftime("%Y-%m-%d %H:%M")
244
+
245
+ if slug is None:
246
+ slug = re.sub(r"[^\w]+", "-", title.lower()).strip("-")
247
+
248
+ # Compose the omg.lol weblog source format with --- delimiters
249
+ frontmatter = f"---\nDate: {date_str}\nSlug: {slug}\n---"
250
+ body = f"# {title}\n\n{content.strip()}"
251
+ source = f"{frontmatter}\n\n{body}"
252
+
253
+ if entry_id is None:
254
+ entry_id = slug # omg.lol accepts a slug as the entry ID for new posts
255
+
256
+ response = self._request(
257
+ "POST",
258
+ f"/address/{self.address}/weblog/entry/{entry_id}",
259
+ data=source,
260
+ headers={"Content-Type": "text/plain"},
261
+ )
262
+ return Post.from_dict(response.get("entry", response))
263
+
264
+ def post_markdown(self, filepath: str | Path, entry_id: str | None = None) -> Post:
265
+ """
266
+ Publish a markdown file to your omg.lol weblog.
267
+
268
+ The file may optionally start with a YAML-like frontmatter block:
269
+
270
+ ---
271
+ title: My Post Title
272
+ date: 2025-03-14 12:00
273
+ slug: my-post-title
274
+ ---
275
+
276
+ # My Post Title
277
+
278
+ Body content here...
279
+
280
+ If no frontmatter is present, the first H1 heading is used as
281
+ the title, and the date defaults to now.
282
+
283
+ Args:
284
+ filepath: Path to the .md file.
285
+ entry_id: Existing entry ID to update (optional).
286
+
287
+ Returns:
288
+ The created/updated Post.
289
+ """
290
+ path = Path(filepath)
291
+ if not path.exists():
292
+ raise FileNotFoundError(f"File not found: {filepath}")
293
+
294
+ raw = path.read_text(encoding="utf-8").strip()
295
+
296
+ # Parse optional frontmatter
297
+ title = None
298
+ date = None
299
+ slug = None
300
+
301
+ if raw.startswith("---"):
302
+ parts = raw.split("---", 2)
303
+ if len(parts) >= 3:
304
+ fm_block = parts[1].strip()
305
+ body = parts[2].strip()
306
+ for line in fm_block.splitlines():
307
+ if ":" in line:
308
+ key, _, val = line.partition(":")
309
+ key = key.strip().lower()
310
+ val = val.strip()
311
+ if key == "title":
312
+ title = val
313
+ elif key == "date":
314
+ try:
315
+ date = datetime.fromisoformat(val)
316
+ except ValueError:
317
+ pass
318
+ elif key == "slug":
319
+ slug = val
320
+ else:
321
+ body = raw
322
+ else:
323
+ body = raw
324
+
325
+ # Fall back to first H1 in the body as title
326
+ if title is None:
327
+ for line in body.splitlines():
328
+ if line.startswith("# "):
329
+ title = line[2:].strip()
330
+ break
331
+
332
+ if title is None:
333
+ title = path.stem.replace("-", " ").replace("_", " ").title()
334
+
335
+ # Strip leading H1 from body to avoid duplication (create_post adds it)
336
+ body_lines = body.splitlines()
337
+ if body_lines and body_lines[0].strip() == f"# {title}":
338
+ body = "\n".join(body_lines[1:]).strip()
339
+
340
+ print(f"Publishing: '{title}' from {path.name}")
341
+ result = self.create_post(title=title, content=body, date=date, slug=slug, entry_id=entry_id)
342
+ print(f" Published at: https://{self.address}.weblog.lol{result.location}")
343
+ return result
344
+
345
+ def delete_post(self, entry_id: str) -> str:
346
+ """Permanently delete a weblog entry. Returns the confirmation message."""
347
+ response = self._request("DELETE", f"/address/{self.address}/weblog/delete/{entry_id}")
348
+ return response.get("message", "Deleted.")
349
+
350
+ # ── Weblog config ─────────────────────────────────────────────────────────
351
+
352
+ def get_weblog_config(self) -> dict:
353
+ """Retrieve weblog configuration."""
354
+ return self._request("GET", f"/address/{self.address}/weblog/configuration")
355
+
356
+ # ── Pastebin ───────────────────────────────────────────────────────────────
357
+
358
+ def list_pastes(self) -> list[Paste]:
359
+ """Return all pastes (including unlisted, requires auth)."""
360
+ response = self._request("GET", f"/address/{self.address}/pastebin")
361
+ return [Paste.from_dict(p) for p in response.get("pastes", [])]
362
+
363
+ def get_paste(self, title: str) -> Paste:
364
+ """Fetch a single paste by title (public, no auth required)."""
365
+ response = self._request("GET", f"/address/{self.address}/pastebin/{title}")
366
+ return Paste.from_dict(response.get("paste", response))
367
+
368
+ def create_paste(self, title: str, content: str) -> Paste:
369
+ """Create or update a paste."""
370
+ response = self._request(
371
+ "POST",
372
+ f"/address/{self.address}/pastebin/",
373
+ json={"title": title, "content": content},
374
+ )
375
+ return Paste.from_dict(response)
376
+
377
+ def delete_paste(self, title: str) -> str:
378
+ """Permanently delete a paste. Returns the confirmation message."""
379
+ response = self._request("DELETE", f"/address/{self.address}/pastebin/{title}")
380
+ return response.get("message", "Deleted.")
381
+
382
+ # ── Statuslog ─────────────────────────────────────────────────────────────
383
+
384
+ def post_status(self, content: str, emoji: str = "✍️") -> Status:
385
+ """Share a new status to your statuslog."""
386
+ response = self._request(
387
+ "POST",
388
+ f"/address/{self.address}/statuses/",
389
+ json={"emoji": emoji, "content": content},
390
+ )
391
+ return Status.from_dict(response)
392
+
393
+ def list_statuses(self) -> list[Status]:
394
+ """Retrieve all statuses for the address."""
395
+ response = self._request("GET", f"/address/{self.address}/statuses/")
396
+ return [Status.from_dict(s) for s in response.get("statuses", [])]
397
+
398
+ # ── PURLs (Persistent URLs) ────────────────────────────────────────────────
399
+
400
+ def list_purls(self) -> list[Purl]:
401
+ """Return all PURLs for the address."""
402
+ response = self._request("GET", f"/address/{self.address}/purls")
403
+ return [Purl.from_dict(p) for p in response.get("purls", [])]
404
+
405
+ def get_purl(self, name: str) -> Purl:
406
+ """Fetch a single PURL by name."""
407
+ response = self._request("GET", f"/address/{self.address}/purl/{name}")
408
+ return Purl.from_dict(response.get("purl", response))
409
+
410
+ def create_purl(self, name: str, url: str, listed: bool = True) -> Purl:
411
+ """Create a new PURL (persistent redirect)."""
412
+ response = self._request(
413
+ "POST",
414
+ f"/address/{self.address}/purl",
415
+ json={"name": name, "url": url, "listed": listed},
416
+ )
417
+ return Purl.from_dict(response)
418
+
419
+ def delete_purl(self, name: str) -> str:
420
+ """Delete a PURL."""
421
+ response = self._request("DELETE", f"/address/{self.address}/purl/{name}")
422
+ return response.get("message", "Deleted.")
423
+
424
+ # ── Now page ───────────────────────────────────────────────────────────────
425
+
426
+ def get_now(self) -> NowPage:
427
+ """Retrieve the /now page content (public, no auth required)."""
428
+ response = self._request("GET", f"/address/{self.address}/now")
429
+ return NowPage.from_dict(response.get("now", response))
430
+
431
+ def update_now(self, content: str, listed: bool = True) -> NowPage:
432
+ """Update the /now page."""
433
+ response = self._request(
434
+ "POST",
435
+ f"/address/{self.address}/now",
436
+ json={"content": content, "listed": "1" if listed else "0"},
437
+ )
438
+ return NowPage.from_dict(response)
439
+
440
+ @staticmethod
441
+ def get_now_garden() -> list[NowPage]:
442
+ """Retrieve the Now Garden — all listed /now pages (no auth)."""
443
+ resp = requests.get("https://api.omg.lol/now/garden")
444
+ data = resp.json()
445
+ return [NowPage.from_dict(n) for n in data.get("response", {}).get("garden", [])]
446
+
447
+ # ── DNS ────────────────────────────────────────────────────────────────────
448
+
449
+ def list_dns_records(self) -> list[DnsRecord]:
450
+ """Return all DNS records for the address."""
451
+ response = self._request("GET", f"/address/{self.address}/dns")
452
+ return [DnsRecord.from_dict(r) for r in response.get("dns", [])]
453
+
454
+ def create_dns_record(self, record_type: str, name: str, data: str, ttl: int = 3600) -> DnsRecord:
455
+ """Create a DNS record."""
456
+ response = self._request(
457
+ "POST",
458
+ f"/address/{self.address}/dns",
459
+ json={"type": record_type, "name": name, "data": data, "ttl": ttl},
460
+ )
461
+ return DnsRecord.from_dict(response)
462
+
463
+ def update_dns_record(self, record_id: str, record_type: str, name: str, data: str, ttl: int = 3600) -> DnsRecord:
464
+ """Update an existing DNS record."""
465
+ response = self._request(
466
+ "PATCH",
467
+ f"/address/{self.address}/dns/{record_id}",
468
+ json={"type": record_type, "name": name, "data": data, "ttl": ttl},
469
+ )
470
+ return DnsRecord.from_dict(response)
471
+
472
+ def delete_dns_record(self, record_id: str) -> str:
473
+ """Delete a DNS record."""
474
+ response = self._request("DELETE", f"/address/{self.address}/dns/{record_id}")
475
+ return response.get("message", "Deleted.")
476
+
477
+ # ── Profile picture ─────────────────────────────────────────────────────────
478
+
479
+ def upload_pfp(self, filepath: str | Path) -> str:
480
+ """Upload a profile picture. Returns the API confirmation message."""
481
+ path = Path(filepath)
482
+ if not path.exists():
483
+ raise FileNotFoundError(f"File not found: {filepath}")
484
+ image_data = path.read_bytes()
485
+ response = self._request(
486
+ "POST",
487
+ f"/address/{self.address}/pfp",
488
+ data=image_data,
489
+ headers={"Content-Type": "application/octet-stream"},
490
+ )
491
+ return response.get("message", "Uploaded.")
492
+
493
+ # ── Web / Profile ──────────────────────────────────────────────────────────
494
+
495
+ def get_web(self) -> dict:
496
+ """Retrieve the web page / profile content."""
497
+ return self._request("GET", f"/address/{self.address}/web")
498
+
499
+ def update_web(self, content: str, publish: bool = True) -> dict:
500
+ """Update and optionally publish the web page / profile."""
501
+ payload = {"content": content}
502
+ if publish:
503
+ payload["publish"] = True
504
+ return self._request(
505
+ "POST",
506
+ f"/address/{self.address}/web",
507
+ json=payload,
508
+ )
509
+
510
+ # ── Email ──────────────────────────────────────────────────────────────────
511
+
512
+ def get_email_forwarding(self) -> dict:
513
+ """Retrieve current email forwarding settings."""
514
+ return self._request("GET", f"/address/{self.address}/email/")
515
+
516
+ def set_email_forwarding(self, destination: str) -> dict:
517
+ """Set email forwarding destination."""
518
+ return self._request(
519
+ "POST",
520
+ f"/address/{self.address}/email/",
521
+ json={"destination": destination},
522
+ )
523
+
524
+ # ── Account / address info ────────────────────────────────────────────────
525
+
526
+ def get_address_info(self) -> dict:
527
+ """Get private info about the address."""
528
+ return self._request("GET", f"/address/{self.address}/info")
529
+
530
+ def get_service_info(self) -> dict:
531
+ """Get public omg.lol service stats (no auth needed)."""
532
+ return self._request("GET", "/service/info")
533
+
534
+
535
+ # ── CLI ──────────────────────────────────────────────────────────────────────
536
+
537
+ import argparse
538
+
539
+
540
+ def _get_client() -> OmgLol:
541
+ api_key = os.environ.get("OMGLOL_API_KEY")
542
+ address = os.environ.get("OMGLOL_ADDRESS")
543
+ if not api_key or not address:
544
+ print("Error: Set OMGLOL_API_KEY and OMGLOL_ADDRESS in .env or environment")
545
+ sys.exit(1)
546
+ return OmgLol(api_key=api_key, address=address)
547
+
548
+
549
+ # ── Weblog commands ──────────────────────────────────────────────────────────
550
+
551
+ def cmd_post(args: argparse.Namespace) -> None:
552
+ client = _get_client()
553
+ for f in args.files:
554
+ if args.dry_run:
555
+ print(f"[dry-run] Would publish: {f}")
556
+ continue
557
+ try:
558
+ client.post_markdown(f)
559
+ except Exception as e:
560
+ print(f" Error publishing {f}: {e}", file=sys.stderr)
561
+
562
+
563
+ def cmd_posts_list(args: argparse.Namespace) -> None:
564
+ client = _get_client()
565
+ for post in client.list_posts():
566
+ print(f" {post.date:16s} {post.title} ({post.slug})")
567
+
568
+
569
+ def cmd_posts_get(args: argparse.Namespace) -> None:
570
+ client = _get_client()
571
+ post = client.get_post(args.entry_id)
572
+ print(f"Title: {post.title}")
573
+ print(f"Date: {post.date}")
574
+ print(f"Slug: {post.slug}")
575
+ print(f"URL: {post.location}")
576
+ print()
577
+ print(post.body or post.source)
578
+
579
+
580
+ def cmd_posts_delete(args: argparse.Namespace) -> None:
581
+ if args.dry_run:
582
+ print(f"[dry-run] Would delete post: {args.entry_id}")
583
+ return
584
+ client = _get_client()
585
+ print(client.delete_post(args.entry_id))
586
+
587
+
588
+ # ── Paste commands ───────────────────────────────────────────────────────────
589
+
590
+ def cmd_paste_list(args: argparse.Namespace) -> None:
591
+ client = _get_client()
592
+ for paste in client.list_pastes():
593
+ print(f" {paste.title:30s} {paste.modified_on}")
594
+
595
+
596
+ def cmd_paste_get(args: argparse.Namespace) -> None:
597
+ client = _get_client()
598
+ paste = client.get_paste(args.title)
599
+ print(paste.content)
600
+
601
+
602
+ def cmd_paste_create(args: argparse.Namespace) -> None:
603
+ content = args.content
604
+ if content == "-":
605
+ content = sys.stdin.read()
606
+ if args.dry_run:
607
+ print(f"[dry-run] Would create paste '{args.title}' ({len(content)} chars)")
608
+ return
609
+ client = _get_client()
610
+ client.create_paste(args.title, content)
611
+ print(f"Paste '{args.title}' created.")
612
+
613
+
614
+ def cmd_paste_delete(args: argparse.Namespace) -> None:
615
+ if args.dry_run:
616
+ print(f"[dry-run] Would delete paste: {args.title}")
617
+ return
618
+ client = _get_client()
619
+ print(client.delete_paste(args.title))
620
+
621
+
622
+ # ── Status commands ──────────────────────────────────────────────────────────
623
+
624
+ def cmd_status_post(args: argparse.Namespace) -> None:
625
+ if args.dry_run:
626
+ print(f"[dry-run] Would post status: {args.emoji} {args.content}")
627
+ return
628
+ client = _get_client()
629
+ client.post_status(args.content, emoji=args.emoji)
630
+ print("Status posted.")
631
+
632
+
633
+ def cmd_status_list(args: argparse.Namespace) -> None:
634
+ client = _get_client()
635
+ for status in client.list_statuses():
636
+ print(f" {status.emoji} {status.content} ({status.created})")
637
+
638
+
639
+ # ── PURL commands ────────────────────────────────────────────────────────────
640
+
641
+ def cmd_purl_list(args: argparse.Namespace) -> None:
642
+ client = _get_client()
643
+ for purl in client.list_purls():
644
+ print(f" {purl.name:20s} → {purl.url}")
645
+
646
+
647
+ def cmd_purl_create(args: argparse.Namespace) -> None:
648
+ if args.dry_run:
649
+ print(f"[dry-run] Would create PURL '{args.name}' → {args.url}")
650
+ return
651
+ client = _get_client()
652
+ client.create_purl(args.name, args.url, listed=not args.unlisted)
653
+ print(f"PURL '{args.name}' → {args.url}")
654
+
655
+
656
+ def cmd_purl_delete(args: argparse.Namespace) -> None:
657
+ if args.dry_run:
658
+ print(f"[dry-run] Would delete PURL: {args.name}")
659
+ return
660
+ client = _get_client()
661
+ print(client.delete_purl(args.name))
662
+
663
+
664
+ # ── Now commands ─────────────────────────────────────────────────────────────
665
+
666
+ def cmd_now_get(args: argparse.Namespace) -> None:
667
+ client = _get_client()
668
+ now = client.get_now()
669
+ print(now.content)
670
+
671
+
672
+ def cmd_now_update(args: argparse.Namespace) -> None:
673
+ content = args.content
674
+ if content == "-":
675
+ content = sys.stdin.read()
676
+ if args.dry_run:
677
+ print(f"[dry-run] Would update /now page ({len(content)} chars)")
678
+ return
679
+ client = _get_client()
680
+ client.update_now(content, listed=not args.unlisted)
681
+ print("Now page updated.")
682
+
683
+
684
+ # ── DNS commands ─────────────────────────────────────────────────────────────
685
+
686
+ def cmd_dns_list(args: argparse.Namespace) -> None:
687
+ client = _get_client()
688
+ for rec in client.list_dns_records():
689
+ print(f" [{rec.id}] {rec.record_type:6s} {rec.name:30s} → {rec.data} (TTL {rec.ttl})")
690
+
691
+
692
+ def cmd_dns_create(args: argparse.Namespace) -> None:
693
+ if args.dry_run:
694
+ print(f"[dry-run] Would create DNS: {args.type} {args.name} → {args.data}")
695
+ return
696
+ client = _get_client()
697
+ rec = client.create_dns_record(args.type, args.name, args.data, ttl=args.ttl)
698
+ print(f"DNS record created: {rec.record_type} {rec.name} → {rec.data}")
699
+
700
+
701
+ def cmd_dns_delete(args: argparse.Namespace) -> None:
702
+ if args.dry_run:
703
+ print(f"[dry-run] Would delete DNS record: {args.id}")
704
+ return
705
+ client = _get_client()
706
+ print(client.delete_dns_record(args.id))
707
+
708
+
709
+ # ── Web commands ─────────────────────────────────────────────────────────────
710
+
711
+ def cmd_web_get(args: argparse.Namespace) -> None:
712
+ client = _get_client()
713
+ web = client.get_web()
714
+ print(web.get("content", web))
715
+
716
+
717
+ def cmd_web_update(args: argparse.Namespace) -> None:
718
+ content = args.content
719
+ if content == "-":
720
+ content = sys.stdin.read()
721
+ if args.dry_run:
722
+ print(f"[dry-run] Would update web page ({len(content)} chars, {'draft' if args.draft else 'publish'})")
723
+ return
724
+ client = _get_client()
725
+ client.update_web(content, publish=not args.draft)
726
+ print("Web page updated.")
727
+
728
+
729
+ # ── Email commands ───────────────────────────────────────────────────────────
730
+
731
+ def cmd_email_get(args: argparse.Namespace) -> None:
732
+ client = _get_client()
733
+ info = client.get_email_forwarding()
734
+ print(info)
735
+
736
+
737
+ def cmd_email_set(args: argparse.Namespace) -> None:
738
+ if args.dry_run:
739
+ print(f"[dry-run] Would set email forwarding to: {args.destination}")
740
+ return
741
+ client = _get_client()
742
+ client.set_email_forwarding(args.destination)
743
+ print(f"Email forwarding set to {args.destination}")
744
+
745
+
746
+ # ── PFP command ──────────────────────────────────────────────────────────────
747
+
748
+ def cmd_pfp_upload(args: argparse.Namespace) -> None:
749
+ if args.dry_run:
750
+ print(f"[dry-run] Would upload profile picture: {args.file}")
751
+ return
752
+ client = _get_client()
753
+ print(client.upload_pfp(args.file))
754
+
755
+
756
+ # ── Info commands ────────────────────────────────────────────────────────────
757
+
758
+ def cmd_info(args: argparse.Namespace) -> None:
759
+ client = _get_client()
760
+ info = client.get_address_info()
761
+ print(info)
762
+
763
+
764
+ # ── Parser ───────────────────────────────────────────────────────────────────
765
+
766
+ def _build_parser() -> argparse.ArgumentParser:
767
+ parser = argparse.ArgumentParser(prog="omglol", description="omg.lol CLI client")
768
+ parser.add_argument("--dry-run", action="store_true", help="Preview what would be sent without making changes")
769
+ sub = parser.add_subparsers(dest="command")
770
+
771
+ # post (publish markdown files)
772
+ p = sub.add_parser("post", help="Publish markdown files to weblog")
773
+ p.add_argument("files", nargs="+", help="Markdown files to publish")
774
+ p.set_defaults(func=cmd_post)
775
+
776
+ # posts
777
+ posts = sub.add_parser("posts", help="Manage weblog posts")
778
+ posts_sub = posts.add_subparsers(dest="subcommand")
779
+
780
+ p = posts_sub.add_parser("list", help="List all posts")
781
+ p.set_defaults(func=cmd_posts_list)
782
+
783
+ p = posts_sub.add_parser("get", help="Get a post by ID")
784
+ p.add_argument("entry_id")
785
+ p.set_defaults(func=cmd_posts_get)
786
+
787
+ p = posts_sub.add_parser("delete", help="Delete a post")
788
+ p.add_argument("entry_id")
789
+ p.set_defaults(func=cmd_posts_delete)
790
+
791
+ # paste
792
+ paste = sub.add_parser("paste", help="Manage pastes")
793
+ paste_sub = paste.add_subparsers(dest="subcommand")
794
+
795
+ p = paste_sub.add_parser("list", help="List all pastes")
796
+ p.set_defaults(func=cmd_paste_list)
797
+
798
+ p = paste_sub.add_parser("get", help="Get a paste")
799
+ p.add_argument("title")
800
+ p.set_defaults(func=cmd_paste_get)
801
+
802
+ p = paste_sub.add_parser("create", help="Create a paste (use '-' for stdin)")
803
+ p.add_argument("title")
804
+ p.add_argument("content")
805
+ p.set_defaults(func=cmd_paste_create)
806
+
807
+ p = paste_sub.add_parser("delete", help="Delete a paste")
808
+ p.add_argument("title")
809
+ p.set_defaults(func=cmd_paste_delete)
810
+
811
+ # status
812
+ status = sub.add_parser("status", help="Manage statuslog")
813
+ status_sub = status.add_subparsers(dest="subcommand")
814
+
815
+ p = status_sub.add_parser("post", help="Post a status")
816
+ p.add_argument("content")
817
+ p.add_argument("--emoji", default="✍️")
818
+ p.set_defaults(func=cmd_status_post)
819
+
820
+ p = status_sub.add_parser("list", help="List all statuses")
821
+ p.set_defaults(func=cmd_status_list)
822
+
823
+ # purl
824
+ purl = sub.add_parser("purl", help="Manage PURLs")
825
+ purl_sub = purl.add_subparsers(dest="subcommand")
826
+
827
+ p = purl_sub.add_parser("list", help="List all PURLs")
828
+ p.set_defaults(func=cmd_purl_list)
829
+
830
+ p = purl_sub.add_parser("create", help="Create a PURL")
831
+ p.add_argument("name")
832
+ p.add_argument("url")
833
+ p.add_argument("--unlisted", action="store_true")
834
+ p.set_defaults(func=cmd_purl_create)
835
+
836
+ p = purl_sub.add_parser("delete", help="Delete a PURL")
837
+ p.add_argument("name")
838
+ p.set_defaults(func=cmd_purl_delete)
839
+
840
+ # now
841
+ now = sub.add_parser("now", help="Manage /now page")
842
+ now_sub = now.add_subparsers(dest="subcommand")
843
+
844
+ p = now_sub.add_parser("get", help="Show /now page")
845
+ p.set_defaults(func=cmd_now_get)
846
+
847
+ p = now_sub.add_parser("update", help="Update /now page (use '-' for stdin)")
848
+ p.add_argument("content")
849
+ p.add_argument("--unlisted", action="store_true")
850
+ p.set_defaults(func=cmd_now_update)
851
+
852
+ # dns
853
+ dns = sub.add_parser("dns", help="Manage DNS records")
854
+ dns_sub = dns.add_subparsers(dest="subcommand")
855
+
856
+ p = dns_sub.add_parser("list", help="List DNS records")
857
+ p.set_defaults(func=cmd_dns_list)
858
+
859
+ p = dns_sub.add_parser("create", help="Create a DNS record")
860
+ p.add_argument("type", choices=["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA"])
861
+ p.add_argument("name")
862
+ p.add_argument("data")
863
+ p.add_argument("--ttl", type=int, default=3600)
864
+ p.set_defaults(func=cmd_dns_create)
865
+
866
+ p = dns_sub.add_parser("delete", help="Delete a DNS record")
867
+ p.add_argument("id")
868
+ p.set_defaults(func=cmd_dns_delete)
869
+
870
+ # web
871
+ web = sub.add_parser("web", help="Manage web page / profile")
872
+ web_sub = web.add_subparsers(dest="subcommand")
873
+
874
+ p = web_sub.add_parser("get", help="Show web page content")
875
+ p.set_defaults(func=cmd_web_get)
876
+
877
+ p = web_sub.add_parser("update", help="Update web page (use '-' for stdin)")
878
+ p.add_argument("content")
879
+ p.add_argument("--draft", action="store_true", help="Save as draft without publishing")
880
+ p.set_defaults(func=cmd_web_update)
881
+
882
+ # email
883
+ email = sub.add_parser("email", help="Manage email forwarding")
884
+ email_sub = email.add_subparsers(dest="subcommand")
885
+
886
+ p = email_sub.add_parser("get", help="Show forwarding settings")
887
+ p.set_defaults(func=cmd_email_get)
888
+
889
+ p = email_sub.add_parser("set", help="Set forwarding destination")
890
+ p.add_argument("destination")
891
+ p.set_defaults(func=cmd_email_set)
892
+
893
+ # pfp
894
+ p = sub.add_parser("pfp", help="Upload profile picture")
895
+ p.add_argument("file", help="Image file to upload")
896
+ p.set_defaults(func=cmd_pfp_upload)
897
+
898
+ # info
899
+ p = sub.add_parser("info", help="Show address info")
900
+ p.set_defaults(func=cmd_info)
901
+
902
+ return parser
903
+
904
+
905
+ def main():
906
+ parser = _build_parser()
907
+ args = parser.parse_args()
908
+
909
+ if not hasattr(args, "func"):
910
+ parser.print_help()
911
+ sys.exit(1)
912
+
913
+ args.func(args)
914
+
915
+
916
+ if __name__ == "__main__":
917
+ main()
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: omglol-api
3
+ Version: 0.1.0
4
+ Summary: Python client for the omg.lol API — weblog, pastebin, PURLs, statuslog, DNS, and more
5
+ Project-URL: Homepage, https://github.com/dkd-dobberkau/omglol
6
+ Project-URL: Repository, https://github.com/dkd-dobberkau/omglol
7
+ Project-URL: Issues, https://github.com/dkd-dobberkau/omglol/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: api-client,omg.lol,pastebin,statuslog,weblog
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: python-dotenv>=1.0.0
23
+ Requires-Dist: requests>=2.28.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # omglol
27
+
28
+ Python client and CLI for the [omg.lol](https://omg.lol) API — weblog, pastebin, statuslog, PURLs, DNS, /now page, and more.
29
+
30
+ ## Setup
31
+
32
+ ```bash
33
+ uv sync
34
+ cp .env.example .env # add your API key and address
35
+ ```
36
+
37
+ Get your API key at [home.omg.lol/account](https://home.omg.lol/account).
38
+
39
+ ## CLI
40
+
41
+ After `uv sync`, the `omglol` command is available:
42
+
43
+ ```bash
44
+ # Publish markdown files to your weblog
45
+ omglol post article.md draft.md
46
+
47
+ # List weblog posts
48
+ omglol posts list
49
+
50
+ # Get a specific post
51
+ omglol posts get my-post-slug
52
+
53
+ # Delete a post
54
+ omglol posts delete my-post-slug
55
+
56
+ # Manage pastes
57
+ omglol paste list
58
+ omglol paste create my-note "Some content here"
59
+ omglol paste get my-note
60
+ omglol paste delete my-note
61
+
62
+ # Post and list statuses
63
+ omglol status post "Working on something cool"
64
+ omglol status post "Celebrating!" --emoji "🎉"
65
+ omglol status list
66
+
67
+ # Manage PURLs (persistent redirects)
68
+ omglol purl list
69
+ omglol purl create gh https://github.com/you
70
+ omglol purl delete gh
71
+
72
+ # /now page
73
+ omglol now get
74
+ omglol now update "Currently learning Rust"
75
+
76
+ # DNS records
77
+ omglol dns list
78
+ omglol dns create CNAME www example.com
79
+ omglol dns delete <record-id>
80
+
81
+ # Web page / profile
82
+ omglol web get
83
+ omglol web update "<h1>Hello</h1>"
84
+ omglol web update - < page.html # read from stdin
85
+
86
+ # Email forwarding
87
+ omglol email get
88
+ omglol email set me@example.com
89
+
90
+ # Profile picture
91
+ omglol pfp avatar.png
92
+
93
+ # Account info
94
+ omglol info
95
+ ```
96
+
97
+ ### Dry run
98
+
99
+ Preview what would be sent without making changes:
100
+
101
+ ```bash
102
+ omglol --dry-run post article.md
103
+ omglol --dry-run status post "Test"
104
+ omglol --dry-run dns create A @ 1.2.3.4
105
+ ```
106
+
107
+ ## Library
108
+
109
+ ```python
110
+ from omglol import OmgLol
111
+
112
+ client = OmgLol(api_key="your_key", address="yourname")
113
+
114
+ # Weblog
115
+ client.post_markdown("my-post.md")
116
+ client.create_post(title="Hello World", content="This is my post.")
117
+ for post in client.list_posts():
118
+ print(post.title, post.location)
119
+ client.delete_post("my-post-slug")
120
+
121
+ # Pastebin
122
+ client.create_paste("notes", "Some text")
123
+ for paste in client.list_pastes():
124
+ print(paste.title)
125
+
126
+ # Statuslog
127
+ client.post_status("Hello from the API!", emoji="🎉")
128
+ for status in client.list_statuses():
129
+ print(status.emoji, status.content)
130
+
131
+ # PURLs
132
+ client.create_purl("gh", "https://github.com/you")
133
+ for purl in client.list_purls():
134
+ print(f"{purl.name} → {purl.url}")
135
+
136
+ # /now page
137
+ now = client.get_now()
138
+ print(now.content)
139
+ client.update_now("Currently building things")
140
+
141
+ # DNS
142
+ for rec in client.list_dns_records():
143
+ print(f"{rec.record_type} {rec.name} → {rec.data}")
144
+ client.create_dns_record("CNAME", "www", "example.com")
145
+
146
+ # Web page / profile
147
+ client.update_web("<h1>My page</h1>")
148
+
149
+ # Email forwarding
150
+ client.set_email_forwarding("me@example.com")
151
+
152
+ # Profile picture
153
+ client.upload_pfp("avatar.png")
154
+
155
+ # Account
156
+ info = client.get_address_info()
157
+ ```
158
+
159
+ All list/get methods return typed dataclasses (`Post`, `Paste`, `Status`, `Purl`, `DnsRecord`, `NowPage`) instead of raw dicts.
160
+
161
+ ### Markdown frontmatter
162
+
163
+ Weblog posts can include optional frontmatter:
164
+
165
+ ```markdown
166
+ ---
167
+ title: My Post Title
168
+ date: 2026-03-14 12:00
169
+ slug: custom-slug
170
+ ---
171
+
172
+ Body content here...
173
+ ```
174
+
175
+ If no frontmatter is present, the first `# Heading` is used as the title.
176
+
177
+ ## Environment variables
178
+
179
+ | Variable | Description |
180
+ |---|---|
181
+ | `OMGLOL_API_KEY` | Your omg.lol API key |
182
+ | `OMGLOL_ADDRESS` | Your omg.lol address (e.g. `olivier`) |
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ uv sync
188
+ uv run pytest -v
189
+ ```
190
+
191
+ ## License
192
+
193
+ MIT
@@ -0,0 +1,6 @@
1
+ omglol.py,sha256=XZ68kvoxXQ5QpQCUiEocZ83HzT80eaodiczE1hkloiY,33608
2
+ omglol_api-0.1.0.dist-info/METADATA,sha256=Ax8B0lq8QAHn-1P8MnP5yOPRBuOOv4WOXh0hvj1pPN4,4477
3
+ omglol_api-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ omglol_api-0.1.0.dist-info/entry_points.txt,sha256=fm6OGIGfIm0Xh29mK046UoiTknCp0ZNozz_L7RvArJ8,39
5
+ omglol_api-0.1.0.dist-info/licenses/LICENSE,sha256=JayWs0Ytc8fmxxCU2T1-iGSDC1N0fVEoSe02apJqj30,1074
6
+ omglol_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ omglol = omglol:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Olivier Dobberkau
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.