nulltap 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.
nulltap/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """nulltap command-line client."""
2
+
3
+ __version__ = "0.1.0"
nulltap/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
nulltap/cli.py ADDED
@@ -0,0 +1,784 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import pydoc
7
+ import re
8
+ import shutil
9
+ import sys
10
+ import textwrap
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ import webbrowser
15
+ from collections import Counter
16
+ from collections.abc import Callable, Iterable, Sequence
17
+ from typing import Any, TextIO
18
+
19
+ from . import __version__
20
+
21
+
22
+ DEFAULT_FEED_URL = "https://nulltap.sh/feed.json"
23
+ MAX_FEED_BYTES = 5 * 1024 * 1024
24
+ MAX_ARTICLE_BYTES = 2 * 1024 * 1024
25
+ ANSI_ESCAPE = re.compile(r"\x1b(?:[@-Z\-_]|\[[0-?]*[ -/]*[@-~])")
26
+ CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
27
+ MARKDOWN_IMAGE = re.compile(r"!\[([^]]*)]\([^)]+\)")
28
+ MARKDOWN_LINK = re.compile(r"(?<!!)\[([^]]+)]\((https?://[^\s)]+)\)")
29
+ HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*$")
30
+ BULLET = re.compile(r"^\s*[-*+]\s+(.+)$")
31
+ NUMBERED = re.compile(r"^\s*(\d+)[.)]\s+(.+)$")
32
+ RULE = re.compile(r"^\s*(?:[-*_]\s*){3,}$")
33
+
34
+
35
+ class FeedError(RuntimeError):
36
+ pass
37
+
38
+
39
+ def safe_text(value: Any) -> str:
40
+ text = ANSI_ESCAPE.sub("", str(value or ""))
41
+ text = CONTROL_CHARS.sub("", text)
42
+ return " ".join(text.split())
43
+
44
+
45
+ def safe_http_url(value: Any) -> str:
46
+ url = str(value or "").strip()
47
+ parsed = urllib.parse.urlparse(url)
48
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
49
+ return ""
50
+ if parsed.username or parsed.password:
51
+ return ""
52
+ return url
53
+
54
+
55
+ def safe_article_text(value: Any) -> str:
56
+ text = ANSI_ESCAPE.sub("", str(value or ""))
57
+ text = CONTROL_CHARS.sub("", text.replace("\r\n", "\n").replace("\r", "\n"))
58
+ text = MARKDOWN_IMAGE.sub(
59
+ lambda match: f"[Image: {safe_text(match.group(1))}]" if match.group(1) else "[Image]",
60
+ text,
61
+ )
62
+ return re.sub(r"\n{4,}", "\n\n\n", text).strip()
63
+
64
+
65
+ def same_origin(first: str, second: str) -> bool:
66
+ left = urllib.parse.urlparse(first)
67
+ right = urllib.parse.urlparse(second)
68
+ return (left.scheme.lower(), left.hostname, left.port) == (
69
+ right.scheme.lower(),
70
+ right.hostname,
71
+ right.port,
72
+ )
73
+
74
+
75
+ def normalize_item(raw: Any) -> dict[str, Any] | None:
76
+ if not isinstance(raw, dict):
77
+ return None
78
+
79
+ title = safe_text(raw.get("title"))
80
+ url = safe_http_url(raw.get("url"))
81
+ if not title or not url:
82
+ return None
83
+
84
+ raw_tags = raw.get("tags", [])
85
+ if not isinstance(raw_tags, list):
86
+ raw_tags = []
87
+ tags = list(dict.fromkeys(safe_text(tag).lower() for tag in raw_tags if safe_text(tag)))
88
+
89
+ try:
90
+ read_time = max(0, int(raw.get("read_time_minutes", 0)))
91
+ except (TypeError, ValueError):
92
+ read_time = 0
93
+
94
+ content_url = safe_http_url(raw.get("content_url"))
95
+ if content_url and not same_origin(url, content_url):
96
+ content_url = ""
97
+
98
+ return {
99
+ "id": safe_text(raw.get("id")) or url,
100
+ "url": url,
101
+ "content_url": content_url,
102
+ "title": title,
103
+ "summary": safe_text(raw.get("summary")),
104
+ "date_published": safe_text(raw.get("date_published")),
105
+ "tags": tags,
106
+ "read_time_minutes": read_time,
107
+ "author": safe_text(raw.get("author")),
108
+ "image": safe_http_url(raw.get("image")),
109
+ }
110
+
111
+
112
+ def _fetch_json(
113
+ url: str,
114
+ *,
115
+ timeout: float,
116
+ maximum_bytes: int,
117
+ label: str,
118
+ accept: str,
119
+ opener: Callable[..., Any],
120
+ ) -> Any:
121
+ if not safe_http_url(url):
122
+ raise FeedError(f"{label} URL must be an http or https URL without embedded credentials")
123
+
124
+ request = urllib.request.Request(
125
+ url,
126
+ headers={
127
+ "Accept": accept,
128
+ "User-Agent": f"nulltap/{__version__} (+https://nulltap.sh)",
129
+ },
130
+ )
131
+
132
+ try:
133
+ with opener(request, timeout=timeout) as response:
134
+ content_length = response.headers.get("Content-Length")
135
+ if content_length and int(content_length) > maximum_bytes:
136
+ raise FeedError(f"{label} is larger than {maximum_bytes // (1024 * 1024)} MiB")
137
+ body = response.read(maximum_bytes + 1)
138
+ except FeedError:
139
+ raise
140
+ except (OSError, ValueError, urllib.error.URLError) as exc:
141
+ reason = getattr(exc, "reason", exc)
142
+ raise FeedError(f"could not fetch {label}: {safe_text(reason)}") from exc
143
+
144
+ if len(body) > maximum_bytes:
145
+ raise FeedError(f"{label} is larger than {maximum_bytes // (1024 * 1024)} MiB")
146
+
147
+ try:
148
+ return json.loads(body.decode("utf-8"))
149
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
150
+ raise FeedError(f"{label} returned invalid JSON") from exc
151
+
152
+
153
+ def fetch_feed(
154
+ url: str = DEFAULT_FEED_URL,
155
+ timeout: float = 10.0,
156
+ opener: Callable[..., Any] = urllib.request.urlopen,
157
+ ) -> list[dict[str, Any]]:
158
+ payload = _fetch_json(
159
+ url,
160
+ timeout=timeout,
161
+ maximum_bytes=MAX_FEED_BYTES,
162
+ label="feed",
163
+ accept="application/feed+json, application/json",
164
+ opener=opener,
165
+ )
166
+ if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
167
+ raise FeedError("feed does not contain an items array")
168
+
169
+ items = [item for raw in payload["items"] if (item := normalize_item(raw))]
170
+ return sorted(items, key=lambda item: item["date_published"], reverse=True)
171
+
172
+
173
+ def fetch_article(
174
+ item: dict[str, Any],
175
+ timeout: float = 10.0,
176
+ opener: Callable[..., Any] = urllib.request.urlopen,
177
+ ) -> dict[str, Any]:
178
+ content_url = item.get("content_url", "")
179
+ if not content_url:
180
+ raise FeedError("this article is not available for terminal reading")
181
+
182
+ payload = _fetch_json(
183
+ content_url,
184
+ timeout=timeout,
185
+ maximum_bytes=MAX_ARTICLE_BYTES,
186
+ label="article",
187
+ accept="application/json",
188
+ opener=opener,
189
+ )
190
+ if not isinstance(payload, dict) or not isinstance(payload.get("content_text"), str):
191
+ raise FeedError("article does not contain terminal-readable text")
192
+
193
+ raw_sources = payload.get("sources", [])
194
+ if not isinstance(raw_sources, list):
195
+ raw_sources = []
196
+ sources = []
197
+ for raw_source in raw_sources:
198
+ if not isinstance(raw_source, dict):
199
+ continue
200
+ label = safe_text(raw_source.get("label"))
201
+ url = safe_http_url(raw_source.get("url"))
202
+ if label and url:
203
+ sources.append({"label": label, "url": url})
204
+
205
+ return {
206
+ **item,
207
+ "content_text": safe_article_text(payload["content_text"]),
208
+ "sources": sources,
209
+ }
210
+
211
+
212
+ def positive_int(value: str) -> int:
213
+ try:
214
+ parsed = int(value)
215
+ except ValueError as exc:
216
+ raise argparse.ArgumentTypeError("must be an integer") from exc
217
+ if parsed < 1 or parsed > 500:
218
+ raise argparse.ArgumentTypeError("must be between 1 and 500")
219
+ return parsed
220
+
221
+
222
+ def build_parser() -> argparse.ArgumentParser:
223
+ common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
224
+ common.add_argument(
225
+ "--feed",
226
+ metavar="URL",
227
+ help="JSON feed URL (default: NULLTAP_FEED_URL or nulltap.sh/feed.json)",
228
+ )
229
+ common.add_argument("--timeout", type=float, metavar="SECONDS", help="network timeout (default: 10)")
230
+ common.add_argument("--json", action="store_true", help="emit JSON for scripts")
231
+ common.add_argument("--plain", action="store_true", help="print once without menus or a pager")
232
+ common.add_argument("--page-size", type=positive_int, metavar="N", help="articles per page (default: 5)")
233
+ common.add_argument("--no-color", action="store_true", help="disable terminal color")
234
+
235
+ parser = argparse.ArgumentParser(
236
+ prog="nulltap",
237
+ parents=[common],
238
+ description="Browse and read nulltap from a terminal.",
239
+ )
240
+ parser.add_argument("--version", action="version", version=f"nulltap {__version__}")
241
+ subparsers = parser.add_subparsers(dest="command")
242
+
243
+ browse = subparsers.add_parser("browse", parents=[common], help="browse recent articles")
244
+ browse.add_argument("-n", "--limit", type=positive_int, default=50)
245
+ browse.add_argument("-t", "--topic", help="limit results to one topic")
246
+
247
+ latest = subparsers.add_parser("latest", parents=[common], help="browse recent articles")
248
+ latest.add_argument("-n", "--limit", type=positive_int, default=50)
249
+ latest.add_argument("-t", "--topic", help="limit results to one topic")
250
+
251
+ subparsers.add_parser("topics", parents=[common], help="choose a topic")
252
+
253
+ topic = subparsers.add_parser("topic", parents=[common], help="browse articles for one topic")
254
+ topic.add_argument("name", nargs="?", help="topic name, such as identity or cloud")
255
+ topic.add_argument("-n", "--limit", type=positive_int, default=100)
256
+
257
+ search = subparsers.add_parser("search", parents=[common], help="search and choose an article")
258
+ search.add_argument("query", nargs="*", help="words to search for")
259
+ search.add_argument("-t", "--topic", help="limit results to one topic")
260
+ search.add_argument("-n", "--limit", type=positive_int, default=100)
261
+
262
+ read = subparsers.add_parser("read", parents=[common], help="read an article or browse when omitted")
263
+ read.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
264
+
265
+ show = subparsers.add_parser("show", parents=[common], help="alias for read")
266
+ show.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
267
+
268
+ open_command = subparsers.add_parser("open", parents=[common], help="open one article in a browser")
269
+ open_command.add_argument("target", help="result number, article ID, slug, or URL")
270
+
271
+ return parser
272
+
273
+
274
+ def filter_topic(items: Iterable[dict[str, Any]], topic: str | None) -> list[dict[str, Any]]:
275
+ if not topic:
276
+ return list(items)
277
+ wanted = safe_text(topic).lower()
278
+ return [item for item in items if wanted in item["tags"]]
279
+
280
+
281
+ def search_items(items: Iterable[dict[str, Any]], query: str) -> list[dict[str, Any]]:
282
+ phrase = safe_text(query).lower()
283
+ tokens = [token for token in phrase.split() if token]
284
+ if not tokens:
285
+ return []
286
+
287
+ ranked: list[tuple[int, str, dict[str, Any]]] = []
288
+ for item in items:
289
+ title = item["title"].lower()
290
+ summary = item["summary"].lower()
291
+ tags = " ".join(item["tags"]).lower()
292
+ haystack = f"{title} {summary} {tags}"
293
+ if not all(token in haystack for token in tokens):
294
+ continue
295
+
296
+ score = 100 if phrase in title else 0
297
+ score += sum(12 for token in tokens if token in title)
298
+ score += sum(7 for token in tokens if token in tags)
299
+ score += sum(3 for token in tokens if token in summary)
300
+ ranked.append((score, item["date_published"], item))
301
+
302
+ ranked.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
303
+ return [item for _, _, item in ranked]
304
+
305
+
306
+ def resolve_item(items: Sequence[dict[str, Any]], target: str) -> dict[str, Any] | None:
307
+ clean_target = safe_text(target)
308
+ if clean_target.isdigit():
309
+ index = int(clean_target) - 1
310
+ return items[index] if 0 <= index < len(items) else None
311
+
312
+ lowered = clean_target.lower().rstrip("/")
313
+ for item in items:
314
+ url = item["url"].lower().rstrip("/")
315
+ slug = urllib.parse.urlparse(url).path.rstrip("/").split("/")[-1]
316
+ if lowered in {item["id"].lower(), url, slug}:
317
+ return item
318
+ return None
319
+
320
+
321
+ def color_enabled(stream: TextIO, disabled: bool) -> bool:
322
+ return not disabled and "NO_COLOR" not in os.environ and bool(getattr(stream, "isatty", lambda: False)())
323
+
324
+
325
+ def interactive_enabled(stdin: TextIO, stdout: TextIO, plain: bool, json_output: bool) -> bool:
326
+ return (
327
+ not plain
328
+ and not json_output
329
+ and bool(getattr(stdin, "isatty", lambda: False)())
330
+ and bool(getattr(stdout, "isatty", lambda: False)())
331
+ )
332
+
333
+
334
+ def paint(text: str, code: str, enabled: bool) -> str:
335
+ return f"\033[{code}m{text}\033[0m" if enabled else text
336
+
337
+
338
+ def display_date(value: str) -> str:
339
+ return value[:10] if len(value) >= 10 else value or "unknown date"
340
+
341
+
342
+ def terminal_width() -> int:
343
+ return max(60, min(110, shutil.get_terminal_size((92, 24)).columns))
344
+
345
+
346
+ def _clean_inline(text: str, links: list[str]) -> str:
347
+ def replace_link(match: re.Match[str]) -> str:
348
+ url = safe_http_url(match.group(2))
349
+ if not url:
350
+ return safe_text(match.group(1))
351
+ if url not in links:
352
+ links.append(url)
353
+ return f"{safe_text(match.group(1))} [{links.index(url) + 1}]"
354
+
355
+ text = MARKDOWN_LINK.sub(replace_link, text)
356
+ text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
357
+ text = re.sub(r"(?<!\*)\*([^*]+)\*", r"\1", text)
358
+ text = re.sub(r"__([^_]+)__", r"\1", text)
359
+ text = re.sub(r"(?<!_)_([^_]+)_", r"\1", text)
360
+ text = re.sub(r"`([^`]+)`", r"\1", text)
361
+ return safe_text(text)
362
+
363
+
364
+ def render_markdown(markdown: str, width: int, use_color: bool) -> tuple[str, list[str]]:
365
+ output: list[str] = []
366
+ links: list[str] = []
367
+ paragraph: list[str] = []
368
+ in_code = False
369
+
370
+ def blank() -> None:
371
+ if output and output[-1] != "":
372
+ output.append("")
373
+
374
+ def flush_paragraph() -> None:
375
+ if not paragraph:
376
+ return
377
+ text = _clean_inline(" ".join(paragraph), links)
378
+ output.extend(textwrap.wrap(text, width=width) or [""])
379
+ paragraph.clear()
380
+
381
+ for raw_line in markdown.splitlines():
382
+ line = raw_line.rstrip()
383
+ if line.lstrip().startswith("```"):
384
+ flush_paragraph()
385
+ in_code = not in_code
386
+ blank()
387
+ continue
388
+ if in_code:
389
+ output.append(paint(f" {line}", "2", use_color))
390
+ continue
391
+ if not line.strip():
392
+ flush_paragraph()
393
+ blank()
394
+ continue
395
+
396
+ heading = HEADING.match(line)
397
+ bullet = BULLET.match(line)
398
+ numbered = NUMBERED.match(line)
399
+ if heading:
400
+ flush_paragraph()
401
+ blank()
402
+ title = _clean_inline(heading.group(2), links)
403
+ output.extend(paint(part, "1;38;5;214", use_color) for part in textwrap.wrap(title, width=width))
404
+ continue
405
+ if RULE.match(line):
406
+ flush_paragraph()
407
+ blank()
408
+ output.append(paint("─" * min(width, 72), "2", use_color))
409
+ continue
410
+ if bullet:
411
+ flush_paragraph()
412
+ text = _clean_inline(bullet.group(1), links)
413
+ output.append(textwrap.fill(text, width=width, initial_indent="• ", subsequent_indent=" "))
414
+ continue
415
+ if numbered:
416
+ flush_paragraph()
417
+ prefix = f"{numbered.group(1)}. "
418
+ text = _clean_inline(numbered.group(2), links)
419
+ output.append(
420
+ textwrap.fill(text, width=width, initial_indent=prefix, subsequent_indent=" " * len(prefix))
421
+ )
422
+ continue
423
+ if line.lstrip().startswith(">"):
424
+ flush_paragraph()
425
+ text = _clean_inline(line.lstrip()[1:].lstrip(), links)
426
+ output.append(textwrap.fill(text, width=width, initial_indent="│ ", subsequent_indent="│ "))
427
+ continue
428
+ if line.startswith("|") and line.endswith("|"):
429
+ flush_paragraph()
430
+ if all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in line.strip("|").split("|")):
431
+ continue
432
+ output.append(_clean_inline(line, links))
433
+ continue
434
+ paragraph.append(line.strip())
435
+
436
+ flush_paragraph()
437
+ while output and output[-1] == "":
438
+ output.pop()
439
+ return "\n".join(output), links
440
+
441
+
442
+ def render_article(item: dict[str, Any], use_color: bool, width: int | None = None) -> str:
443
+ width = width or terminal_width()
444
+ lines: list[str] = []
445
+ title_lines = textwrap.wrap(item["title"], width=width) or [item["title"]]
446
+ lines.extend(paint(line, "1", use_color) for line in title_lines)
447
+
448
+ metadata = [display_date(item["date_published"]), *item["tags"]]
449
+ if item["read_time_minutes"]:
450
+ metadata.append(f"{item['read_time_minutes']} min")
451
+ if item["author"]:
452
+ metadata.append(item["author"])
453
+ lines.append(paint(" · ".join(metadata), "2", use_color))
454
+
455
+ if item["summary"]:
456
+ lines.append("")
457
+ lines.append(textwrap.fill(item["summary"], width=width))
458
+
459
+ body, links = render_markdown(item.get("content_text", ""), width, use_color)
460
+ if body:
461
+ lines.extend(["", body])
462
+
463
+ if links:
464
+ lines.extend(["", paint("Links", "1;38;5;214", use_color)])
465
+ for index, url in enumerate(links, 1):
466
+ lines.append(textwrap.fill(f"[{index}] {url}", width=width, subsequent_indent=" "))
467
+
468
+ sources = item.get("sources", [])
469
+ if sources:
470
+ lines.extend(["", paint("Primary sources", "1;38;5;214", use_color)])
471
+ for source in sources:
472
+ entry = f"• {source['label']}: {source['url']}"
473
+ lines.append(textwrap.fill(entry, width=width, subsequent_indent=" "))
474
+
475
+ return "\n".join(lines).rstrip() + "\n"
476
+
477
+
478
+ def print_items(
479
+ items: Sequence[dict[str, Any]],
480
+ stream: TextIO,
481
+ use_color: bool,
482
+ *,
483
+ start_index: int = 1,
484
+ width: int | None = None,
485
+ ) -> None:
486
+ if not items:
487
+ print("No published articles matched.", file=stream)
488
+ return
489
+
490
+ width = width or terminal_width()
491
+ number_width = len(str(start_index + len(items) - 1)) + 2
492
+ for offset, item in enumerate(items):
493
+ index = start_index + offset
494
+ number = paint(f"[{index}]", "2", use_color)
495
+ title = paint(item["title"], "1", use_color)
496
+ print(f"{number:<{number_width}} {title}", file=stream)
497
+ metadata = [display_date(item["date_published"]), *item["tags"]]
498
+ if item["read_time_minutes"]:
499
+ metadata.append(f"{item['read_time_minutes']} min")
500
+ print(f"{' ' * (number_width + 1)}{' · '.join(metadata)}", file=stream)
501
+ if item["summary"]:
502
+ print(
503
+ textwrap.fill(
504
+ item["summary"],
505
+ width=width,
506
+ initial_indent=" " * (number_width + 1),
507
+ subsequent_indent=" " * (number_width + 1),
508
+ ),
509
+ file=stream,
510
+ )
511
+ if offset != len(items) - 1:
512
+ print(file=stream)
513
+
514
+
515
+ def read_prompt(prompt: str, stdin: TextIO, stdout: TextIO) -> str:
516
+ print(prompt, end="", file=stdout, flush=True)
517
+ value = stdin.readline()
518
+ if value == "":
519
+ return "q"
520
+ return safe_text(value)
521
+
522
+
523
+ def show_article(
524
+ item: dict[str, Any],
525
+ *,
526
+ article_loader: Callable[[dict[str, Any], float], dict[str, Any]],
527
+ timeout: float,
528
+ stdout: TextIO,
529
+ use_color: bool,
530
+ use_pager: bool,
531
+ pager: Callable[[str], None],
532
+ ) -> None:
533
+ article = article_loader(item, timeout)
534
+ rendered = render_article(article, use_color)
535
+ if use_pager:
536
+ pager(rendered)
537
+ else:
538
+ print(rendered, end="", file=stdout)
539
+
540
+
541
+ def browse_items(
542
+ items: Sequence[dict[str, Any]],
543
+ *,
544
+ heading: str,
545
+ page_size: int,
546
+ stdin: TextIO,
547
+ stdout: TextIO,
548
+ use_color: bool,
549
+ article_loader: Callable[[dict[str, Any], float], dict[str, Any]],
550
+ timeout: float,
551
+ pager: Callable[[str], None],
552
+ ) -> int:
553
+ if not items:
554
+ print("No published articles matched.", file=stdout)
555
+ return 0
556
+
557
+ page = 0
558
+ page_count = (len(items) + page_size - 1) // page_size
559
+ while True:
560
+ start = page * page_size
561
+ end = min(start + page_size, len(items))
562
+ print(file=stdout)
563
+ print(paint(heading, "1;38;5;214", use_color), file=stdout)
564
+ print(paint(f"Articles {start + 1}–{end} of {len(items)} · page {page + 1}/{page_count}", "2", use_color), file=stdout)
565
+ print(file=stdout)
566
+ print_items(items[start:end], stdout, use_color, start_index=start + 1)
567
+ print(file=stdout)
568
+
569
+ commands = [f"{start + 1}-{end} read"]
570
+ if page < page_count - 1:
571
+ commands.append("n next")
572
+ if page > 0:
573
+ commands.append("p previous")
574
+ commands.append("q quit")
575
+ choice = read_prompt(" ".join(commands) + "\n> ", stdin, stdout).lower()
576
+
577
+ if choice in {"q", "quit", "exit"}:
578
+ return 0
579
+ if choice in {"n", "next", ""}:
580
+ if page < page_count - 1:
581
+ page += 1
582
+ else:
583
+ print("Already on the last page.", file=stdout)
584
+ continue
585
+ if choice in {"p", "prev", "previous"}:
586
+ if page > 0:
587
+ page -= 1
588
+ else:
589
+ print("Already on the first page.", file=stdout)
590
+ continue
591
+ if choice.isdigit():
592
+ index = int(choice) - 1
593
+ if start <= index < end:
594
+ show_article(
595
+ items[index],
596
+ article_loader=article_loader,
597
+ timeout=timeout,
598
+ stdout=stdout,
599
+ use_color=use_color,
600
+ use_pager=True,
601
+ pager=pager,
602
+ )
603
+ continue
604
+ print(f"Choose an article from {start + 1} to {end}, or use n, p, or q.", file=stdout)
605
+
606
+
607
+ def json_dump(value: Any, stream: TextIO) -> None:
608
+ json.dump(value, stream, ensure_ascii=False, indent=2)
609
+ print(file=stream)
610
+
611
+
612
+ def topic_counts(items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
613
+ counts = Counter(tag for item in items for tag in item["tags"])
614
+ return [
615
+ {"topic": topic, "articles": count}
616
+ for topic, count in sorted(counts.items(), key=lambda row: (-row[1], row[0]))
617
+ ]
618
+
619
+
620
+ def choose_topic(items: Sequence[dict[str, Any]], stdin: TextIO, stdout: TextIO, use_color: bool) -> str | None:
621
+ topics = topic_counts(items)
622
+ if not topics:
623
+ print("No published topics yet.", file=stdout)
624
+ return None
625
+ print(file=stdout)
626
+ print(paint("Topics", "1;38;5;214", use_color), file=stdout)
627
+ for index, row in enumerate(topics, 1):
628
+ print(f"[{index}] {row['topic']:<12} {row['articles']} articles", file=stdout)
629
+ choice = read_prompt("\nChoose a topic number, or q to quit\n> ", stdin, stdout).lower()
630
+ if choice in {"q", "quit", "exit"}:
631
+ return None
632
+ if choice.isdigit() and 1 <= int(choice) <= len(topics):
633
+ return topics[int(choice) - 1]["topic"]
634
+ print("That topic number is not available.", file=stdout)
635
+ return None
636
+
637
+
638
+ def run(
639
+ argv: Sequence[str] | None = None,
640
+ *,
641
+ stdin: TextIO = sys.stdin,
642
+ stdout: TextIO = sys.stdout,
643
+ stderr: TextIO = sys.stderr,
644
+ feed_loader: Callable[[str, float], list[dict[str, Any]]] = fetch_feed,
645
+ article_loader: Callable[[dict[str, Any], float], dict[str, Any]] = fetch_article,
646
+ browser_opener: Callable[..., bool] = webbrowser.open,
647
+ pager: Callable[[str], None] = pydoc.pager,
648
+ ) -> int:
649
+ parser = build_parser()
650
+ args = parser.parse_args(argv)
651
+ command = args.command or "browse"
652
+ feed_url = getattr(args, "feed", os.environ.get("NULLTAP_FEED_URL", DEFAULT_FEED_URL))
653
+ timeout = getattr(args, "timeout", 10.0)
654
+ json_output = getattr(args, "json", False)
655
+ plain = getattr(args, "plain", False)
656
+ no_color = getattr(args, "no_color", False)
657
+ page_size = min(getattr(args, "page_size", 5), 50)
658
+
659
+ if timeout <= 0 or timeout > 120:
660
+ parser.error("--timeout must be greater than 0 and no more than 120 seconds")
661
+
662
+ items = feed_loader(feed_url, timeout)
663
+ use_color = color_enabled(stdout, no_color)
664
+ interactive = interactive_enabled(stdin, stdout, plain, json_output)
665
+
666
+ if command == "topics":
667
+ topics = topic_counts(items)
668
+ if json_output:
669
+ json_dump(topics, stdout)
670
+ return 0
671
+ if interactive:
672
+ chosen = choose_topic(items, stdin, stdout, use_color)
673
+ if chosen:
674
+ return browse_items(
675
+ filter_topic(items, chosen),
676
+ heading=f"Topic: {chosen}",
677
+ page_size=page_size,
678
+ stdin=stdin,
679
+ stdout=stdout,
680
+ use_color=use_color,
681
+ article_loader=article_loader,
682
+ timeout=timeout,
683
+ pager=pager,
684
+ )
685
+ return 0
686
+ if topics:
687
+ width = max(len(row["topic"]) for row in topics)
688
+ for row in topics:
689
+ print(f"{row['topic']:<{width}} {row['articles']}", file=stdout)
690
+ else:
691
+ print("No published topics yet.", file=stdout)
692
+ return 0
693
+
694
+ if command == "topic":
695
+ topic_name = args.name
696
+ if not topic_name and interactive:
697
+ topic_name = choose_topic(items, stdin, stdout, use_color)
698
+ if not topic_name:
699
+ return 0
700
+ if not topic_name:
701
+ print("nulltap: provide a topic name or run this command in a terminal", file=stderr)
702
+ return 1
703
+ selected = filter_topic(items, topic_name)
704
+ if not selected and items:
705
+ available = ", ".join(row["topic"] for row in topic_counts(items))
706
+ print(f"nulltap: no topic named '{safe_text(topic_name)}'", file=stderr)
707
+ if available:
708
+ print(f"available topics: {available}", file=stderr)
709
+ return 1
710
+ selected = selected[: args.limit]
711
+ heading = f"Topic: {safe_text(topic_name).lower()}"
712
+ elif command == "search":
713
+ query = " ".join(args.query)
714
+ if not query and interactive:
715
+ query = read_prompt("Search nulltap\n> ", stdin, stdout)
716
+ if not query:
717
+ print("nulltap: provide search words", file=stderr)
718
+ return 1
719
+ selected = filter_topic(items, getattr(args, "topic", None))
720
+ selected = search_items(selected, query)[: args.limit]
721
+ heading = f"Search: {safe_text(query)}"
722
+ elif command in {"browse", "latest"}:
723
+ limit = getattr(args, "limit", 50)
724
+ selected = filter_topic(items, getattr(args, "topic", None))[:limit]
725
+ heading = "Recent articles"
726
+ elif command in {"read", "show", "open"}:
727
+ target = getattr(args, "target", None)
728
+ if command in {"read", "show"} and not target:
729
+ selected = items[:50]
730
+ heading = "Recent articles"
731
+ else:
732
+ item = resolve_item(items, target or "")
733
+ if not item:
734
+ print(f"nulltap: article not found: {safe_text(target)}", file=stderr)
735
+ return 1
736
+ if command in {"read", "show"}:
737
+ article = article_loader(item, timeout)
738
+ if json_output:
739
+ json_dump(article, stdout)
740
+ else:
741
+ rendered = render_article(article, use_color)
742
+ if interactive:
743
+ pager(rendered)
744
+ else:
745
+ print(rendered, end="", file=stdout)
746
+ return 0
747
+
748
+ opened = browser_opener(item["url"], new=2)
749
+ if json_output:
750
+ json_dump({"url": item["url"], "opened": bool(opened)}, stdout)
751
+ else:
752
+ print(item["url"], file=stdout)
753
+ return 0 if opened else 1
754
+ else:
755
+ parser.error(f"unknown command: {command}")
756
+
757
+ if json_output:
758
+ json_dump(selected, stdout)
759
+ elif interactive:
760
+ return browse_items(
761
+ selected,
762
+ heading=heading,
763
+ page_size=page_size,
764
+ stdin=stdin,
765
+ stdout=stdout,
766
+ use_color=use_color,
767
+ article_loader=article_loader,
768
+ timeout=timeout,
769
+ pager=pager,
770
+ )
771
+ else:
772
+ print_items(selected, stdout, use_color)
773
+ return 0
774
+
775
+
776
+ def main(argv: Sequence[str] | None = None) -> int:
777
+ try:
778
+ return run(argv)
779
+ except FeedError as exc:
780
+ print(f"nulltap: {exc}", file=sys.stderr)
781
+ return 1
782
+ except KeyboardInterrupt:
783
+ print("\nnulltap: interrupted", file=sys.stderr)
784
+ return 130
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: nulltap
3
+ Version: 0.1.0
4
+ Summary: Read the nulltap.sh cybersecurity feed from a terminal.
5
+ Author: Justin Howe
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://nulltap.sh
8
+ Project-URL: Repository, https://github.com/JustinHowe/nulltap
9
+ Keywords: cybersecurity,news,cli,nulltap
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # nulltap
24
+
25
+ Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
26
+
27
+ ## Install
28
+
29
+ Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
30
+
31
+ ```sh
32
+ pipx install nulltap
33
+ ```
34
+
35
+ Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
36
+
37
+ Run `nulltap --help` after installation.
38
+
39
+ ## Commands
40
+
41
+ ```sh
42
+ nulltap # paginated recent articles; type a number to read
43
+ nulltap browse # explicit form of the default command
44
+ nulltap latest -n 20 # browse the newest 20 articles
45
+ nulltap latest --topic identity
46
+ nulltap topics # choose a topic, then choose an article
47
+ nulltap topic ai # paginated articles tagged AI
48
+ nulltap topic # choose a topic interactively
49
+ nulltap search # enter search words interactively
50
+ nulltap search "token theft"
51
+ nulltap search gateway --topic appsec
52
+ nulltap read 1 # directly read the newest article
53
+ nulltap read # browse first when no number is supplied
54
+ nulltap --plain # print once; no prompts or pager
55
+ ```
56
+
57
+ Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
58
+
59
+ Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
60
+
61
+ When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
62
+
63
+ ## Search
64
+
65
+ Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
66
+
67
+ Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
68
+
69
+ ## Scripts
70
+
71
+ Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
72
+
73
+ ```sh
74
+ nulltap search ransomware --json
75
+ nulltap topics --json
76
+ ```
77
+
78
+ Use a different compatible feed during development:
79
+
80
+ ```sh
81
+ nulltap --feed http://127.0.0.1:4321/feed.json
82
+ # or
83
+ NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
84
+ ```
85
+
86
+ Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
87
+
88
+ ## Development
89
+
90
+ ```sh
91
+ python -m venv .venv
92
+ # Windows: .venv\Scripts\activate
93
+ # macOS/Linux: source .venv/bin/activate
94
+ python -m pip install -e .
95
+ python -m unittest discover -s tests -v
96
+ ```
97
+
98
+ Licensed under the MIT License.
@@ -0,0 +1,9 @@
1
+ nulltap/__init__.py,sha256=s82c-MXnXvrIybA4MI_97go-rQ1SG0XEttFy5wJWZSI,58
2
+ nulltap/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ nulltap/cli.py,sha256=wMJ486CRV7th67QsdSTX3-8zladVvucalNS5IwCpcgc,27986
4
+ nulltap-0.1.0.dist-info/licenses/LICENSE,sha256=hh97_vBa-UamJetW2Mf5DJSkuwvuZh1dE4L6r2je9po,1068
5
+ nulltap-0.1.0.dist-info/METADATA,sha256=ku4nQMXdKSztrJR0BhlAAYwPMQ7Iy-d7Kqs8NM9KxsA,3994
6
+ nulltap-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ nulltap-0.1.0.dist-info/entry_points.txt,sha256=OzRzrMg_7HejnMTejZuPbeL6DlrRHCsv-0MrwF-d0qA,45
8
+ nulltap-0.1.0.dist-info/top_level.txt,sha256=fJdJwgMlobWGv7eHIV-MHbJtGsQ1TOgsJYchAg_nX3E,8
9
+ nulltap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nulltap = nulltap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Justin Howe
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
+ nulltap