nulltap 0.1.1__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nulltap
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Read Nulltap cybersecurity and AI articles from a terminal.
5
5
  Author: Justin Howe
6
6
  License-Expression: MIT
@@ -24,7 +24,7 @@ Dynamic: license-file
24
24
 
25
25
  # nulltap
26
26
 
27
- Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and read the full article without opening a browser.
27
+ Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and choose the full or 1-minute version without opening a browser.
28
28
 
29
29
  ## Install
30
30
 
@@ -51,12 +51,24 @@ nulltap topics # list every topic and its article count
51
51
  nulltap topic identity # browse one topic
52
52
  nulltap search "token theft" # search titles, summaries, and topic tags
53
53
  nulltap read 2 # read the second result in the terminal
54
+ nulltap read 2 --short # read its 1-minute version
54
55
  ```
55
56
 
56
57
  Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
57
58
 
58
59
  Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
59
60
 
61
+ ## Reading modes
62
+
63
+ Full articles are the default. Add `--short` to use the 1-minute version when you read from a list, search result, topic, or direct `read` command:
64
+
65
+ ```sh
66
+ nulltap search "token theft" --short
67
+ nulltap read 2 --short
68
+ ```
69
+
70
+ Set the `NULLTAP_READING_MODE` environment variable to `short` if you want that behavior by default. The `--full` flag overrides the environment for one command. Nulltap stops with a clear message instead of silently showing the full article when a requested 1-minute version is unavailable.
71
+
60
72
  ## Topics
61
73
 
62
74
  Nulltap currently publishes under these topics:
@@ -1,6 +1,6 @@
1
1
  # nulltap
2
2
 
3
- Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and read the full article without opening a browser.
3
+ Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and choose the full or 1-minute version without opening a browser.
4
4
 
5
5
  ## Install
6
6
 
@@ -27,12 +27,24 @@ nulltap topics # list every topic and its article count
27
27
  nulltap topic identity # browse one topic
28
28
  nulltap search "token theft" # search titles, summaries, and topic tags
29
29
  nulltap read 2 # read the second result in the terminal
30
+ nulltap read 2 --short # read its 1-minute version
30
31
  ```
31
32
 
32
33
  Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
33
34
 
34
35
  Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
35
36
 
37
+ ## Reading modes
38
+
39
+ Full articles are the default. Add `--short` to use the 1-minute version when you read from a list, search result, topic, or direct `read` command:
40
+
41
+ ```sh
42
+ nulltap search "token theft" --short
43
+ nulltap read 2 --short
44
+ ```
45
+
46
+ Set the `NULLTAP_READING_MODE` environment variable to `short` if you want that behavior by default. The `--full` flag overrides the environment for one command. Nulltap stops with a clear message instead of silently showing the full article when a requested 1-minute version is unavailable.
47
+
36
48
  ## Topics
37
49
 
38
50
  Nulltap currently publishes under these topics:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nulltap"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Read Nulltap cybersecurity and AI articles from a terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """nulltap command-line client."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
@@ -91,6 +91,10 @@ def normalize_item(raw: Any) -> dict[str, Any] | None:
91
91
  read_time = max(0, int(raw.get("read_time_minutes", 0)))
92
92
  except (TypeError, ValueError):
93
93
  read_time = 0
94
+ try:
95
+ short_read_time = max(0, int(raw.get("short_read_time_minutes", 0)))
96
+ except (TypeError, ValueError):
97
+ short_read_time = 0
94
98
 
95
99
  content_url = safe_http_url(raw.get("content_url"))
96
100
  if content_url and not same_origin(url, content_url):
@@ -105,6 +109,8 @@ def normalize_item(raw: Any) -> dict[str, Any] | None:
105
109
  "date_published": safe_text(raw.get("date_published")),
106
110
  "tags": tags,
107
111
  "read_time_minutes": read_time,
112
+ "short_read_available": bool(raw.get("short_read_available", False)),
113
+ "short_read_time_minutes": short_read_time,
108
114
  "author": safe_text(raw.get("author")),
109
115
  "image": safe_http_url(raw.get("image")),
110
116
  }
@@ -224,6 +230,13 @@ def fetch_article(
224
230
  )
225
231
  if not isinstance(payload, dict) or not isinstance(payload.get("content_text"), str):
226
232
  raise FeedError("article does not contain terminal-readable text")
233
+ short_content = payload.get("short_content_text", "")
234
+ if short_content and not isinstance(short_content, str):
235
+ raise FeedError("article contains invalid 1-minute text")
236
+ try:
237
+ short_read_time = max(0, int(payload.get("short_read_time_minutes", 0) or 0))
238
+ except (TypeError, ValueError):
239
+ short_read_time = 0
227
240
 
228
241
  raw_sources = payload.get("sources", [])
229
242
  if not isinstance(raw_sources, list):
@@ -240,6 +253,8 @@ def fetch_article(
240
253
  return {
241
254
  **item,
242
255
  "content_text": safe_article_text(payload["content_text"]),
256
+ "short_content_text": safe_article_text(short_content),
257
+ "short_read_time_minutes": short_read_time,
243
258
  "sources": sources,
244
259
  }
245
260
 
@@ -277,6 +292,13 @@ def build_parser() -> argparse.ArgumentParser:
277
292
  common.add_argument("--page-size", type=positive_int, metavar="N", help="articles per page (default: 5)")
278
293
  common.add_argument("--days", type=positive_days, metavar="N", help="limit the feed to the last N days")
279
294
  common.add_argument("--no-color", action="store_true", help="disable terminal color")
295
+ reading_mode = common.add_mutually_exclusive_group()
296
+ reading_mode.add_argument(
297
+ "--short",
298
+ action="store_true",
299
+ help="read 1-minute versions (or set NULLTAP_READING_MODE=short)",
300
+ )
301
+ reading_mode.add_argument("--full", action="store_true", help="force full articles")
280
302
 
281
303
  parser = argparse.ArgumentParser(
282
304
  prog="nulltap",
@@ -292,6 +314,7 @@ def build_parser() -> argparse.ArgumentParser:
292
314
  nulltap topic identity browse one topic
293
315
  nulltap search "token theft" search titles, summaries, and tags
294
316
  nulltap read 2 read the second result in the terminal
317
+ nulltap read 2 --short read its 1-minute version
295
318
 
296
319
  Run 'nulltap COMMAND --help' for command-specific options.
297
320
  """
@@ -551,6 +574,20 @@ def render_markdown(markdown: str, width: int, use_color: bool) -> tuple[str, li
551
574
  return "\n".join(output), links
552
575
 
553
576
 
577
+ def select_reading_mode(article: dict[str, Any], short_mode: bool) -> dict[str, Any]:
578
+ if not short_mode:
579
+ return {**article, "reading_mode": "full"}
580
+ short_content = article.get("short_content_text", "")
581
+ if not short_content:
582
+ raise FeedError("this article does not include a 1-minute read")
583
+ return {
584
+ **article,
585
+ "content_text": short_content,
586
+ "read_time_minutes": article.get("short_read_time_minutes", 1) or 1,
587
+ "reading_mode": "short",
588
+ }
589
+
590
+
554
591
  def render_article(item: dict[str, Any], use_color: bool, width: int | None = None) -> str:
555
592
  width = width or terminal_width()
556
593
  lines: list[str] = []
@@ -558,6 +595,8 @@ def render_article(item: dict[str, Any], use_color: bool, width: int | None = No
558
595
  lines.extend(paint(line, "1", use_color) for line in title_lines)
559
596
 
560
597
  metadata = [display_date(item["date_published"]), *item["tags"]]
598
+ if item.get("reading_mode") == "short":
599
+ metadata.append("1-minute read")
561
600
  if item["read_time_minutes"]:
562
601
  metadata.append(f"{item['read_time_minutes']} min")
563
602
  if item["author"]:
@@ -641,8 +680,9 @@ def show_article(
641
680
  use_color: bool,
642
681
  use_pager: bool,
643
682
  pager: Callable[[str], None],
683
+ short_mode: bool,
644
684
  ) -> None:
645
- article = article_loader(item, timeout)
685
+ article = select_reading_mode(article_loader(item, timeout), short_mode)
646
686
  rendered = render_article(article, use_color)
647
687
  if use_pager:
648
688
  pager(rendered)
@@ -661,6 +701,7 @@ def browse_items(
661
701
  article_loader: Callable[[dict[str, Any], float], dict[str, Any]],
662
702
  timeout: float,
663
703
  pager: Callable[[str], None],
704
+ short_mode: bool,
664
705
  ) -> int:
665
706
  if not items:
666
707
  print("No published articles matched.", file=stdout)
@@ -711,6 +752,7 @@ def browse_items(
711
752
  use_color=use_color,
712
753
  use_pager=True,
713
754
  pager=pager,
755
+ short_mode=short_mode,
714
756
  )
715
757
  continue
716
758
  print(f"Choose an article from {start + 1} to {end}, or use n, p, or q.", file=stdout)
@@ -784,6 +826,10 @@ def run(
784
826
  json_output = getattr(args, "json", False)
785
827
  plain = getattr(args, "plain", False)
786
828
  no_color = getattr(args, "no_color", False)
829
+ configured_mode = safe_text(os.environ.get("NULLTAP_READING_MODE", "full")).lower()
830
+ short_mode = getattr(args, "short", False) or (
831
+ configured_mode == "short" and not getattr(args, "full", False)
832
+ )
787
833
  page_size = min(getattr(args, "page_size", 5), 50)
788
834
 
789
835
  if timeout <= 0 or timeout > 120:
@@ -821,6 +867,7 @@ def run(
821
867
  article_loader=article_loader,
822
868
  timeout=timeout,
823
869
  pager=pager,
870
+ short_mode=short_mode,
824
871
  )
825
872
  return 0
826
873
  if topics:
@@ -877,7 +924,7 @@ def run(
877
924
  print(f"nulltap: article not found: {safe_text(target)}", file=stderr)
878
925
  return 1
879
926
  if command in {"read", "show"}:
880
- article = article_loader(item, timeout)
927
+ article = select_reading_mode(article_loader(item, timeout), short_mode)
881
928
  if json_output:
882
929
  json_dump(article, stdout)
883
930
  else:
@@ -910,6 +957,7 @@ def run(
910
957
  article_loader=article_loader,
911
958
  timeout=timeout,
912
959
  pager=pager,
960
+ short_mode=short_mode,
913
961
  )
914
962
  else:
915
963
  print_items(selected, stdout, use_color)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nulltap
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Read Nulltap cybersecurity and AI articles from a terminal.
5
5
  Author: Justin Howe
6
6
  License-Expression: MIT
@@ -24,7 +24,7 @@ Dynamic: license-file
24
24
 
25
25
  # nulltap
26
26
 
27
- Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and read the full article without opening a browser.
27
+ Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and choose the full or 1-minute version without opening a browser.
28
28
 
29
29
  ## Install
30
30
 
@@ -51,12 +51,24 @@ nulltap topics # list every topic and its article count
51
51
  nulltap topic identity # browse one topic
52
52
  nulltap search "token theft" # search titles, summaries, and topic tags
53
53
  nulltap read 2 # read the second result in the terminal
54
+ nulltap read 2 --short # read its 1-minute version
54
55
  ```
55
56
 
56
57
  Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
57
58
 
58
59
  Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
59
60
 
61
+ ## Reading modes
62
+
63
+ Full articles are the default. Add `--short` to use the 1-minute version when you read from a list, search result, topic, or direct `read` command:
64
+
65
+ ```sh
66
+ nulltap search "token theft" --short
67
+ nulltap read 2 --short
68
+ ```
69
+
70
+ Set the `NULLTAP_READING_MODE` environment variable to `short` if you want that behavior by default. The `--full` flag overrides the environment for one command. Nulltap stops with a clear message instead of silently showing the full article when a requested 1-minute version is unavailable.
71
+
60
72
  ## Topics
61
73
 
62
74
  Nulltap currently publishes under these topics:
@@ -2,6 +2,7 @@ import io
2
2
  import json
3
3
  import re
4
4
  import unittest
5
+ from unittest.mock import patch
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
 
@@ -15,6 +16,8 @@ from nulltap.cli import (
15
16
  render_article,
16
17
  run,
17
18
  search_items,
19
+ select_reading_mode,
20
+ FeedError,
18
21
  )
19
22
 
20
23
 
@@ -28,6 +31,8 @@ ITEMS = [
28
31
  "date_published": "2026-07-26T12:00:00Z",
29
32
  "tags": ["cloud", "identity"],
30
33
  "read_time_minutes": 6,
34
+ "short_read_available": True,
35
+ "short_read_time_minutes": 1,
31
36
  "author": "nulltap",
32
37
  "image": "",
33
38
  },
@@ -40,6 +45,8 @@ ITEMS = [
40
45
  "date_published": "2026-07-25T12:00:00Z",
41
46
  "tags": ["ai", "appsec"],
42
47
  "read_time_minutes": 4,
48
+ "short_read_available": True,
49
+ "short_read_time_minutes": 1,
43
50
  "author": "nulltap",
44
51
  "image": "",
45
52
  },
@@ -97,6 +104,8 @@ class NulltapCliTests(unittest.TestCase):
97
104
  article_loader=lambda item, _timeout: {
98
105
  **item,
99
106
  "content_text": "## Access path\n\nThe token crossed a trust boundary.\n\n[Image: Token path]",
107
+ "short_content_text": "The token crossed a trust boundary. Revoke it and inspect the audit log.",
108
+ "short_read_time_minutes": 1,
100
109
  "sources": [{"label": "Primary advisory", "url": "https://example.com/advisory"}],
101
110
  },
102
111
  browser_opener=opener or (lambda _url, **_kwargs: True),
@@ -120,6 +129,8 @@ class NulltapCliTests(unittest.TestCase):
120
129
  article_loader=lambda item, _timeout: {
121
130
  **item,
122
131
  "content_text": "## Access path\n\nThe token crossed a trust boundary.",
132
+ "short_content_text": "The short read keeps the affected boundary and the immediate check.",
133
+ "short_read_time_minutes": 1,
123
134
  "sources": [],
124
135
  },
125
136
  pager=pages.append,
@@ -153,6 +164,7 @@ class NulltapCliTests(unittest.TestCase):
153
164
  self.assertIn("nulltap latest --days 7", help_text)
154
165
  self.assertIn("nulltap topics", help_text)
155
166
  self.assertIn("limit the feed to the last N days", help_text)
167
+ self.assertIn("nulltap read 2 --short", help_text)
156
168
 
157
169
  def test_search_uses_title_summary_and_tags(self):
158
170
  results = search_items(ITEMS, "production credentials")
@@ -177,6 +189,25 @@ class NulltapCliTests(unittest.TestCase):
177
189
  self.assertIn("Access path", stdout)
178
190
  self.assertNotIn("## Access path", stdout)
179
191
 
192
+ def test_short_mode_reads_the_one_minute_version(self):
193
+ code, stdout, stderr = self.invoke(["read", "1", "--short"])
194
+ self.assertEqual(code, 0)
195
+ self.assertEqual(stderr, "")
196
+ self.assertIn("1-minute read", stdout)
197
+ self.assertIn("Revoke it and inspect the audit log.", stdout)
198
+ self.assertNotIn("Access path", stdout)
199
+
200
+ def test_full_flag_overrides_short_environment_default(self):
201
+ with patch.dict("os.environ", {"NULLTAP_READING_MODE": "short"}):
202
+ code, stdout, _ = self.invoke(["read", "1", "--full"])
203
+ self.assertEqual(code, 0)
204
+ self.assertIn("Access path", stdout)
205
+ self.assertNotIn("1-minute read", stdout)
206
+
207
+ def test_short_mode_fails_clearly_when_article_has_no_short_read(self):
208
+ with self.assertRaisesRegex(FeedError, "does not include a 1-minute read"):
209
+ select_reading_mode({**ITEMS[0], "content_text": "Full article"}, True)
210
+
180
211
  def test_list_does_not_require_browser_urls(self):
181
212
  code, stdout, _ = self.invoke(["latest"])
182
213
  self.assertEqual(code, 0)
File without changes
File without changes
File without changes