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.
- {nulltap-0.1.1/src/nulltap.egg-info → nulltap-0.2.0}/PKG-INFO +14 -2
- {nulltap-0.1.1 → nulltap-0.2.0}/README.md +13 -1
- {nulltap-0.1.1 → nulltap-0.2.0}/pyproject.toml +1 -1
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap/__init__.py +1 -1
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap/cli.py +50 -2
- {nulltap-0.1.1 → nulltap-0.2.0/src/nulltap.egg-info}/PKG-INFO +14 -2
- {nulltap-0.1.1 → nulltap-0.2.0}/tests/test_cli.py +31 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/LICENSE +0 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/setup.cfg +0 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap/__main__.py +0 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap.egg-info/SOURCES.txt +0 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap.egg-info/dependency_links.txt +0 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap.egg-info/entry_points.txt +0 -0
- {nulltap-0.1.1 → nulltap-0.2.0}/src/nulltap.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nulltap
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
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:
|
|
@@ -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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|