nulltap 0.1.0__tar.gz → 0.1.1__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/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: nulltap
3
+ Version: 0.1.1
4
+ Summary: Read Nulltap cybersecurity and AI articles 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
+ Project-URL: Issues, https://github.com/JustinHowe/nulltap/issues
10
+ Project-URL: Releases, https://github.com/JustinHowe/nulltap/releases
11
+ Keywords: cybersecurity,ai,news,cli,nulltap
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
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 :: Security
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # nulltap
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.
28
+
29
+ ## Install
30
+
31
+ Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
32
+
33
+ ```sh
34
+ pipx install nulltap
35
+ ```
36
+
37
+ To upgrade an existing installation:
38
+
39
+ ```sh
40
+ pipx upgrade nulltap
41
+ ```
42
+
43
+ A regular pip installation also works: `python -m pip install nulltap`.
44
+
45
+ ## Start here
46
+
47
+ ```sh
48
+ nulltap # browse recent articles
49
+ nulltap latest --days 7 # articles published in the last seven days
50
+ nulltap topics # list every topic and its article count
51
+ nulltap topic identity # browse one topic
52
+ nulltap search "token theft" # search titles, summaries, and topic tags
53
+ nulltap read 2 # read the second result in the terminal
54
+ ```
55
+
56
+ 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
+ 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
+ ## Topics
61
+
62
+ Nulltap currently publishes under these topics:
63
+
64
+ ```text
65
+ endpoint
66
+ cloud
67
+ network
68
+ identity
69
+ appsec
70
+ AI
71
+ threats
72
+ ```
73
+
74
+ `nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
75
+
76
+ ## Search and recent filters
77
+
78
+ Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
79
+
80
+ Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
81
+
82
+ Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
83
+
84
+ ```sh
85
+ nulltap search ransomware --days 30
86
+ nulltap topic cloud --days 14
87
+ nulltap topics --days 90
88
+ ```
89
+
90
+ ## Automation
91
+
92
+ Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
93
+
94
+ ```sh
95
+ nulltap latest --days 7 --json
96
+ nulltap search ransomware --json
97
+ nulltap topics --plain
98
+ ```
99
+
100
+ Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
101
+
102
+ ## Network and privacy
103
+
104
+ Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
105
+
106
+ Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
107
+
108
+ Licensed under the MIT License.
@@ -0,0 +1,84 @@
1
+ # nulltap
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.
4
+
5
+ ## Install
6
+
7
+ Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
8
+
9
+ ```sh
10
+ pipx install nulltap
11
+ ```
12
+
13
+ To upgrade an existing installation:
14
+
15
+ ```sh
16
+ pipx upgrade nulltap
17
+ ```
18
+
19
+ A regular pip installation also works: `python -m pip install nulltap`.
20
+
21
+ ## Start here
22
+
23
+ ```sh
24
+ nulltap # browse recent articles
25
+ nulltap latest --days 7 # articles published in the last seven days
26
+ nulltap topics # list every topic and its article count
27
+ nulltap topic identity # browse one topic
28
+ nulltap search "token theft" # search titles, summaries, and topic tags
29
+ nulltap read 2 # read the second result in the terminal
30
+ ```
31
+
32
+ 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
+ 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
+ ## Topics
37
+
38
+ Nulltap currently publishes under these topics:
39
+
40
+ ```text
41
+ endpoint
42
+ cloud
43
+ network
44
+ identity
45
+ appsec
46
+ AI
47
+ threats
48
+ ```
49
+
50
+ `nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
51
+
52
+ ## Search and recent filters
53
+
54
+ Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
55
+
56
+ Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
57
+
58
+ Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
59
+
60
+ ```sh
61
+ nulltap search ransomware --days 30
62
+ nulltap topic cloud --days 14
63
+ nulltap topics --days 90
64
+ ```
65
+
66
+ ## Automation
67
+
68
+ Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
69
+
70
+ ```sh
71
+ nulltap latest --days 7 --json
72
+ nulltap search ransomware --json
73
+ nulltap topics --plain
74
+ ```
75
+
76
+ Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
77
+
78
+ ## Network and privacy
79
+
80
+ Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
81
+
82
+ Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
83
+
84
+ Licensed under the MIT License.
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nulltap"
7
- version = "0.1.0"
8
- description = "Read the nulltap.sh cybersecurity feed from a terminal."
7
+ version = "0.1.1"
8
+ description = "Read Nulltap cybersecurity and AI articles from a terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  authors = [{ name = "Justin Howe" }]
13
- keywords = ["cybersecurity", "news", "cli", "nulltap"]
13
+ keywords = ["cybersecurity", "ai", "news", "cli", "nulltap"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
16
16
  "Environment :: Console",
@@ -29,6 +29,8 @@ nulltap = "nulltap.cli:main"
29
29
  [project.urls]
30
30
  Homepage = "https://nulltap.sh"
31
31
  Repository = "https://github.com/JustinHowe/nulltap"
32
+ Issues = "https://github.com/JustinHowe/nulltap/issues"
33
+ Releases = "https://github.com/JustinHowe/nulltap/releases"
32
34
 
33
35
  [tool.setuptools.packages.find]
34
36
  where = ["src"]
@@ -1,3 +1,3 @@
1
1
  """nulltap command-line client."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -14,6 +14,7 @@ import urllib.request
14
14
  import webbrowser
15
15
  from collections import Counter
16
16
  from collections.abc import Callable, Iterable, Sequence
17
+ from datetime import datetime, timedelta, timezone
17
18
  from typing import Any, TextIO
18
19
 
19
20
  from . import __version__
@@ -109,6 +110,35 @@ def normalize_item(raw: Any) -> dict[str, Any] | None:
109
110
  }
110
111
 
111
112
 
113
+ def normalize_topic(raw: Any) -> dict[str, str] | None:
114
+ if not isinstance(raw, dict):
115
+ return None
116
+
117
+ topic_id = safe_text(raw.get("id") or raw.get("topic")).lower()
118
+ if not topic_id:
119
+ return None
120
+ return {
121
+ "topic": topic_id,
122
+ "label": safe_text(raw.get("label")) or topic_id,
123
+ "description": safe_text(raw.get("description")),
124
+ }
125
+
126
+
127
+ def normalize_feed_payload(payload: Any) -> dict[str, list[dict[str, Any]]]:
128
+ if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
129
+ raise FeedError("feed does not contain an items array")
130
+
131
+ items = [item for raw in payload["items"] if (item := normalize_item(raw))]
132
+ raw_topics = payload.get("topics", [])
133
+ if not isinstance(raw_topics, list):
134
+ raw_topics = []
135
+ topics = [topic for raw in raw_topics if (topic := normalize_topic(raw))]
136
+ return {
137
+ "items": sorted(items, key=lambda item: item["date_published"], reverse=True),
138
+ "topics": list({topic["topic"]: topic for topic in topics}.values()),
139
+ }
140
+
141
+
112
142
  def _fetch_json(
113
143
  url: str,
114
144
  *,
@@ -150,11 +180,11 @@ def _fetch_json(
150
180
  raise FeedError(f"{label} returned invalid JSON") from exc
151
181
 
152
182
 
153
- def fetch_feed(
183
+ def fetch_feed_document(
154
184
  url: str = DEFAULT_FEED_URL,
155
185
  timeout: float = 10.0,
156
186
  opener: Callable[..., Any] = urllib.request.urlopen,
157
- ) -> list[dict[str, Any]]:
187
+ ) -> dict[str, list[dict[str, Any]]]:
158
188
  payload = _fetch_json(
159
189
  url,
160
190
  timeout=timeout,
@@ -163,11 +193,16 @@ def fetch_feed(
163
193
  accept="application/feed+json, application/json",
164
194
  opener=opener,
165
195
  )
166
- if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
167
- raise FeedError("feed does not contain an items array")
196
+ return normalize_feed_payload(payload)
168
197
 
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)
198
+
199
+ def fetch_feed(
200
+ url: str = DEFAULT_FEED_URL,
201
+ timeout: float = 10.0,
202
+ opener: Callable[..., Any] = urllib.request.urlopen,
203
+ ) -> list[dict[str, Any]]:
204
+ """Return feed items for callers using the 0.1.0 list-based API."""
205
+ return fetch_feed_document(url, timeout, opener)["items"]
171
206
 
172
207
 
173
208
  def fetch_article(
@@ -219,6 +254,16 @@ def positive_int(value: str) -> int:
219
254
  return parsed
220
255
 
221
256
 
257
+ def positive_days(value: str) -> int:
258
+ try:
259
+ parsed = int(value)
260
+ except ValueError as exc:
261
+ raise argparse.ArgumentTypeError("must be an integer") from exc
262
+ if parsed < 1 or parsed > 36500:
263
+ raise argparse.ArgumentTypeError("must be between 1 and 36500")
264
+ return parsed
265
+
266
+
222
267
  def build_parser() -> argparse.ArgumentParser:
223
268
  common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
224
269
  common.add_argument(
@@ -230,42 +275,82 @@ def build_parser() -> argparse.ArgumentParser:
230
275
  common.add_argument("--json", action="store_true", help="emit JSON for scripts")
231
276
  common.add_argument("--plain", action="store_true", help="print once without menus or a pager")
232
277
  common.add_argument("--page-size", type=positive_int, metavar="N", help="articles per page (default: 5)")
278
+ common.add_argument("--days", type=positive_days, metavar="N", help="limit the feed to the last N days")
233
279
  common.add_argument("--no-color", action="store_true", help="disable terminal color")
234
280
 
235
281
  parser = argparse.ArgumentParser(
236
282
  prog="nulltap",
237
283
  parents=[common],
238
- description="Browse and read nulltap from a terminal.",
284
+ description="Read Nulltap articles without leaving the terminal.",
285
+ formatter_class=argparse.RawDescriptionHelpFormatter,
286
+ epilog=textwrap.dedent(
287
+ """
288
+ examples:
289
+ nulltap browse recent articles
290
+ nulltap latest --days 7 show the last seven days
291
+ nulltap topics list the current topic catalog
292
+ nulltap topic identity browse one topic
293
+ nulltap search "token theft" search titles, summaries, and tags
294
+ nulltap read 2 read the second result in the terminal
295
+
296
+ Run 'nulltap COMMAND --help' for command-specific options.
297
+ """
298
+ ),
239
299
  )
240
300
  parser.add_argument("--version", action="version", version=f"nulltap {__version__}")
241
- subparsers = parser.add_subparsers(dest="command")
301
+ subparsers = parser.add_subparsers(dest="command", title="commands", metavar="COMMAND")
242
302
 
243
- browse = subparsers.add_parser("browse", parents=[common], help="browse recent articles")
244
- browse.add_argument("-n", "--limit", type=positive_int, default=50)
303
+ browse = subparsers.add_parser(
304
+ "browse",
305
+ parents=[common],
306
+ help="browse the feed (default)",
307
+ description="Browse recent Nulltap articles, newest first.",
308
+ )
309
+ browse.add_argument("-n", "--limit", type=positive_int, default=50, help="maximum articles shown (default: 50)")
245
310
  browse.add_argument("-t", "--topic", help="limit results to one topic")
246
311
 
247
- latest = subparsers.add_parser("latest", parents=[common], help="browse recent articles")
248
- latest.add_argument("-n", "--limit", type=positive_int, default=50)
312
+ latest = subparsers.add_parser(
313
+ "latest",
314
+ parents=[common],
315
+ help="browse the newest articles",
316
+ description="Browse the newest Nulltap articles.",
317
+ )
318
+ latest.add_argument("-n", "--limit", type=positive_int, default=50, help="maximum articles shown (default: 50)")
249
319
  latest.add_argument("-t", "--topic", help="limit results to one topic")
250
320
 
251
- subparsers.add_parser("topics", parents=[common], help="choose a topic")
321
+ subparsers.add_parser(
322
+ "topics",
323
+ parents=[common],
324
+ help="list topics or choose one interactively",
325
+ description="List every Nulltap topic and its article count.",
326
+ )
252
327
 
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)
328
+ topic = subparsers.add_parser(
329
+ "topic",
330
+ parents=[common],
331
+ help="browse articles for one topic",
332
+ description="Browse articles filed under one topic.",
333
+ )
334
+ topic.add_argument("name", nargs="?", help="topic ID; run 'nulltap topics' to list them")
335
+ topic.add_argument("-n", "--limit", type=positive_int, default=100, help="maximum articles shown (default: 100)")
256
336
 
257
- search = subparsers.add_parser("search", parents=[common], help="search and choose an article")
337
+ search = subparsers.add_parser(
338
+ "search",
339
+ parents=[common],
340
+ help="search titles, summaries, and topic tags",
341
+ description="Search article titles, summaries, and topic tags.",
342
+ )
258
343
  search.add_argument("query", nargs="*", help="words to search for")
259
344
  search.add_argument("-t", "--topic", help="limit results to one topic")
260
- search.add_argument("-n", "--limit", type=positive_int, default=100)
345
+ search.add_argument("-n", "--limit", type=positive_int, default=100, help="maximum matches shown (default: 100)")
261
346
 
262
- read = subparsers.add_parser("read", parents=[common], help="read an article or browse when omitted")
347
+ read = subparsers.add_parser("read", parents=[common], help="read in the terminal or browse when omitted")
263
348
  read.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
264
349
 
265
350
  show = subparsers.add_parser("show", parents=[common], help="alias for read")
266
351
  show.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
267
352
 
268
- open_command = subparsers.add_parser("open", parents=[common], help="open one article in a browser")
353
+ open_command = subparsers.add_parser("open", parents=[common], help="optional handoff to a web browser")
269
354
  open_command.add_argument("target", help="result number, article ID, slug, or URL")
270
355
 
271
356
  return parser
@@ -278,6 +363,33 @@ def filter_topic(items: Iterable[dict[str, Any]], topic: str | None) -> list[dic
278
363
  return [item for item in items if wanted in item["tags"]]
279
364
 
280
365
 
366
+ def filter_days(
367
+ items: Iterable[dict[str, Any]],
368
+ days: int | None,
369
+ *,
370
+ now: datetime | None = None,
371
+ ) -> list[dict[str, Any]]:
372
+ if not days:
373
+ return list(items)
374
+
375
+ current = now or datetime.now(timezone.utc)
376
+ if current.tzinfo is None:
377
+ current = current.replace(tzinfo=timezone.utc)
378
+ cutoff = current.astimezone(timezone.utc) - timedelta(days=days)
379
+ selected = []
380
+ for item in items:
381
+ raw_date = item.get("date_published", "")
382
+ try:
383
+ published = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
384
+ except (AttributeError, TypeError, ValueError):
385
+ continue
386
+ if published.tzinfo is None:
387
+ published = published.replace(tzinfo=timezone.utc)
388
+ if published.astimezone(timezone.utc) >= cutoff:
389
+ selected.append(item)
390
+ return selected
391
+
392
+
281
393
  def search_items(items: Iterable[dict[str, Any]], query: str) -> list[dict[str, Any]]:
282
394
  phrase = safe_text(query).lower()
283
395
  tokens = [token for token in phrase.split() if token]
@@ -609,23 +721,41 @@ def json_dump(value: Any, stream: TextIO) -> None:
609
721
  print(file=stream)
610
722
 
611
723
 
612
- def topic_counts(items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
724
+ def topic_counts(
725
+ items: Iterable[dict[str, Any]],
726
+ catalog: Iterable[dict[str, str]] = (),
727
+ ) -> list[dict[str, Any]]:
613
728
  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
- ]
729
+ rows = []
730
+ seen = set()
731
+ for entry in catalog:
732
+ topic = entry["topic"]
733
+ if topic in seen:
734
+ continue
735
+ seen.add(topic)
736
+ rows.append({**entry, "articles": counts.get(topic, 0)})
737
+ for topic, count in sorted(counts.items(), key=lambda row: (-row[1], row[0])):
738
+ if topic not in seen:
739
+ rows.append({"topic": topic, "label": topic, "description": "", "articles": count})
740
+ return rows
618
741
 
619
742
 
620
- def choose_topic(items: Sequence[dict[str, Any]], stdin: TextIO, stdout: TextIO, use_color: bool) -> str | None:
621
- topics = topic_counts(items)
743
+ def choose_topic(
744
+ items: Sequence[dict[str, Any]],
745
+ catalog: Sequence[dict[str, str]],
746
+ stdin: TextIO,
747
+ stdout: TextIO,
748
+ use_color: bool,
749
+ ) -> str | None:
750
+ topics = topic_counts(items, catalog)
622
751
  if not topics:
623
752
  print("No published topics yet.", file=stdout)
624
753
  return None
625
754
  print(file=stdout)
626
755
  print(paint("Topics", "1;38;5;214", use_color), file=stdout)
627
756
  for index, row in enumerate(topics, 1):
628
- print(f"[{index}] {row['topic']:<12} {row['articles']} articles", file=stdout)
757
+ noun = "article" if row["articles"] == 1 else "articles"
758
+ print(f"[{index}] {row['label']:<12} {row['articles']} {noun}", file=stdout)
629
759
  choice = read_prompt("\nChoose a topic number, or q to quit\n> ", stdin, stdout).lower()
630
760
  if choice in {"q", "quit", "exit"}:
631
761
  return None
@@ -641,7 +771,7 @@ def run(
641
771
  stdin: TextIO = sys.stdin,
642
772
  stdout: TextIO = sys.stdout,
643
773
  stderr: TextIO = sys.stderr,
644
- feed_loader: Callable[[str, float], list[dict[str, Any]]] = fetch_feed,
774
+ feed_loader: Callable[[str, float], Any] = fetch_feed_document,
645
775
  article_loader: Callable[[dict[str, Any], float], dict[str, Any]] = fetch_article,
646
776
  browser_opener: Callable[..., bool] = webbrowser.open,
647
777
  pager: Callable[[str], None] = pydoc.pager,
@@ -659,21 +789,31 @@ def run(
659
789
  if timeout <= 0 or timeout > 120:
660
790
  parser.error("--timeout must be greater than 0 and no more than 120 seconds")
661
791
 
662
- items = feed_loader(feed_url, timeout)
792
+ loaded_feed = feed_loader(feed_url, timeout)
793
+ if isinstance(loaded_feed, dict):
794
+ feed = normalize_feed_payload(loaded_feed)
795
+ else:
796
+ feed = normalize_feed_payload({"items": list(loaded_feed), "topics": []})
797
+ items = filter_days(feed["items"], getattr(args, "days", None))
798
+ topic_catalog = feed["topics"]
663
799
  use_color = color_enabled(stdout, no_color)
664
800
  interactive = interactive_enabled(stdin, stdout, plain, json_output)
665
801
 
666
802
  if command == "topics":
667
- topics = topic_counts(items)
803
+ topics = topic_counts(items, topic_catalog)
668
804
  if json_output:
669
805
  json_dump(topics, stdout)
670
806
  return 0
671
807
  if interactive:
672
- chosen = choose_topic(items, stdin, stdout, use_color)
808
+ chosen = choose_topic(items, topic_catalog, stdin, stdout, use_color)
673
809
  if chosen:
810
+ chosen_label = next(
811
+ (row["label"] for row in topics if row["topic"] == chosen),
812
+ chosen,
813
+ )
674
814
  return browse_items(
675
815
  filter_topic(items, chosen),
676
- heading=f"Topic: {chosen}",
816
+ heading=f"Topic: {chosen_label}",
677
817
  page_size=page_size,
678
818
  stdin=stdin,
679
819
  stdout=stdout,
@@ -684,9 +824,9 @@ def run(
684
824
  )
685
825
  return 0
686
826
  if topics:
687
- width = max(len(row["topic"]) for row in topics)
827
+ width = max(len(row["label"]) for row in topics)
688
828
  for row in topics:
689
- print(f"{row['topic']:<{width}} {row['articles']}", file=stdout)
829
+ print(f"{row['label']:<{width}} {row['articles']}", file=stdout)
690
830
  else:
691
831
  print("No published topics yet.", file=stdout)
692
832
  return 0
@@ -694,21 +834,24 @@ def run(
694
834
  if command == "topic":
695
835
  topic_name = args.name
696
836
  if not topic_name and interactive:
697
- topic_name = choose_topic(items, stdin, stdout, use_color)
837
+ topic_name = choose_topic(items, topic_catalog, stdin, stdout, use_color)
698
838
  if not topic_name:
699
839
  return 0
700
840
  if not topic_name:
701
841
  print("nulltap: provide a topic name or run this command in a terminal", file=stderr)
702
842
  return 1
703
843
  selected = filter_topic(items, topic_name)
704
- if not selected and items:
705
- available = ", ".join(row["topic"] for row in topic_counts(items))
844
+ available_rows = topic_counts(items, topic_catalog)
845
+ wanted_topic = safe_text(topic_name).lower()
846
+ if wanted_topic not in {row["topic"] for row in available_rows}:
847
+ available = ", ".join(row["topic"] for row in available_rows)
706
848
  print(f"nulltap: no topic named '{safe_text(topic_name)}'", file=stderr)
707
849
  if available:
708
850
  print(f"available topics: {available}", file=stderr)
709
851
  return 1
710
852
  selected = selected[: args.limit]
711
- heading = f"Topic: {safe_text(topic_name).lower()}"
853
+ label = next((row["label"] for row in available_rows if row["topic"] == wanted_topic), wanted_topic)
854
+ heading = f"Topic: {label}"
712
855
  elif command == "search":
713
856
  query = " ".join(args.query)
714
857
  if not query and interactive:
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: nulltap
3
+ Version: 0.1.1
4
+ Summary: Read Nulltap cybersecurity and AI articles 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
+ Project-URL: Issues, https://github.com/JustinHowe/nulltap/issues
10
+ Project-URL: Releases, https://github.com/JustinHowe/nulltap/releases
11
+ Keywords: cybersecurity,ai,news,cli,nulltap
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
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 :: Security
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # nulltap
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.
28
+
29
+ ## Install
30
+
31
+ Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
32
+
33
+ ```sh
34
+ pipx install nulltap
35
+ ```
36
+
37
+ To upgrade an existing installation:
38
+
39
+ ```sh
40
+ pipx upgrade nulltap
41
+ ```
42
+
43
+ A regular pip installation also works: `python -m pip install nulltap`.
44
+
45
+ ## Start here
46
+
47
+ ```sh
48
+ nulltap # browse recent articles
49
+ nulltap latest --days 7 # articles published in the last seven days
50
+ nulltap topics # list every topic and its article count
51
+ nulltap topic identity # browse one topic
52
+ nulltap search "token theft" # search titles, summaries, and topic tags
53
+ nulltap read 2 # read the second result in the terminal
54
+ ```
55
+
56
+ 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
+ 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
+ ## Topics
61
+
62
+ Nulltap currently publishes under these topics:
63
+
64
+ ```text
65
+ endpoint
66
+ cloud
67
+ network
68
+ identity
69
+ appsec
70
+ AI
71
+ threats
72
+ ```
73
+
74
+ `nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
75
+
76
+ ## Search and recent filters
77
+
78
+ Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
79
+
80
+ Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
81
+
82
+ Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
83
+
84
+ ```sh
85
+ nulltap search ransomware --days 30
86
+ nulltap topic cloud --days 14
87
+ nulltap topics --days 90
88
+ ```
89
+
90
+ ## Automation
91
+
92
+ Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
93
+
94
+ ```sh
95
+ nulltap latest --days 7 --json
96
+ nulltap search ransomware --json
97
+ nulltap topics --plain
98
+ ```
99
+
100
+ Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
101
+
102
+ ## Network and privacy
103
+
104
+ Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
105
+
106
+ Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
107
+
108
+ Licensed under the MIT License.
@@ -2,10 +2,20 @@ import io
2
2
  import json
3
3
  import re
4
4
  import unittest
5
+ from datetime import datetime, timezone
5
6
  from pathlib import Path
6
7
 
7
8
  from nulltap import __version__
8
- from nulltap.cli import fetch_feed, normalize_item, render_article, run, search_items
9
+ from nulltap.cli import (
10
+ build_parser,
11
+ fetch_feed,
12
+ fetch_feed_document,
13
+ filter_days,
14
+ normalize_item,
15
+ render_article,
16
+ run,
17
+ search_items,
18
+ )
9
19
 
10
20
 
11
21
  ITEMS = [
@@ -35,6 +45,16 @@ ITEMS = [
35
45
  },
36
46
  ]
37
47
 
48
+ TOPICS = [
49
+ {"id": "endpoint", "label": "endpoint", "description": "Endpoint security."},
50
+ {"id": "cloud", "label": "cloud", "description": "Cloud security."},
51
+ {"id": "network", "label": "network", "description": "Network security."},
52
+ {"id": "identity", "label": "identity", "description": "Identity security."},
53
+ {"id": "appsec", "label": "appsec", "description": "Application security."},
54
+ {"id": "ai", "label": "AI", "description": "AI security."},
55
+ {"id": "threats", "label": "threats", "description": "Threat activity."},
56
+ ]
57
+
38
58
 
39
59
  class FakeResponse:
40
60
  def __init__(self, payload):
@@ -70,7 +90,10 @@ class NulltapCliTests(unittest.TestCase):
70
90
  args,
71
91
  stdout=stdout,
72
92
  stderr=stderr,
73
- feed_loader=lambda _url, _timeout: list(ITEMS if items is None else items),
93
+ feed_loader=lambda _url, _timeout: {
94
+ "items": list(ITEMS if items is None else items),
95
+ "topics": TOPICS,
96
+ },
74
97
  article_loader=lambda item, _timeout: {
75
98
  **item,
76
99
  "content_text": "## Access path\n\nThe token crossed a trust boundary.\n\n[Image: Token path]",
@@ -90,7 +113,10 @@ class NulltapCliTests(unittest.TestCase):
90
113
  stdin=stdin,
91
114
  stdout=stdout,
92
115
  stderr=stderr,
93
- feed_loader=lambda _url, _timeout: list(ITEMS if items is None else items),
116
+ feed_loader=lambda _url, _timeout: {
117
+ "items": list(ITEMS if items is None else items),
118
+ "topics": TOPICS,
119
+ },
94
120
  article_loader=lambda item, _timeout: {
95
121
  **item,
96
122
  "content_text": "## Access path\n\nThe token crossed a trust boundary.",
@@ -117,8 +143,16 @@ class NulltapCliTests(unittest.TestCase):
117
143
  code, stdout, _ = self.invoke(["topics", "--json"])
118
144
  self.assertEqual(code, 0)
119
145
  topics = json.loads(stdout)
120
- self.assertIn({"topic": "identity", "articles": 1}, topics)
121
- self.assertIn({"topic": "ai", "articles": 1}, topics)
146
+ counts = {row["topic"]: row["articles"] for row in topics}
147
+ self.assertEqual(counts["identity"], 1)
148
+ self.assertEqual(counts["ai"], 1)
149
+ self.assertEqual(counts["endpoint"], 0)
150
+
151
+ def test_help_surfaces_examples_and_date_filter(self):
152
+ help_text = build_parser().format_help()
153
+ self.assertIn("nulltap latest --days 7", help_text)
154
+ self.assertIn("nulltap topics", help_text)
155
+ self.assertIn("limit the feed to the last N days", help_text)
122
156
 
123
157
  def test_search_uses_title_summary_and_tags(self):
124
158
  results = search_items(ITEMS, "production credentials")
@@ -177,13 +211,27 @@ class NulltapCliTests(unittest.TestCase):
177
211
  self.assertIn("The AI Gateway Had Root", pages[0])
178
212
 
179
213
  def test_topics_menu_opens_a_topic_and_article(self):
180
- code, stdout, _, pages = self.invoke_interactive(["topics"], "1\n1\nq\n")
214
+ code, stdout, _, pages = self.invoke_interactive(["topics"], "6\n1\nq\n")
181
215
  self.assertEqual(code, 0)
182
216
  self.assertIn("Topics", stdout)
183
- self.assertIn("Topic: ai", stdout)
217
+ self.assertIn("Topic: AI", stdout)
184
218
  self.assertEqual(len(pages), 1)
185
219
  self.assertIn("The AI Gateway Had Root", pages[0])
186
220
 
221
+ def test_known_empty_topic_is_not_reported_as_invalid(self):
222
+ code, stdout, stderr = self.invoke(["topic", "endpoint"])
223
+ self.assertEqual(code, 0)
224
+ self.assertIn("No published articles matched.", stdout)
225
+ self.assertEqual(stderr, "")
226
+
227
+ def test_days_filter_uses_publication_time(self):
228
+ selected = filter_days(
229
+ ITEMS,
230
+ 1,
231
+ now=datetime(2026, 7, 26, 18, 0, tzinfo=timezone.utc),
232
+ )
233
+ self.assertEqual([item["id"] for item in selected], ["2026-07-26-cloud-token"])
234
+
187
235
  def test_direct_read_uses_pager_in_a_terminal(self):
188
236
  code, _, _, pages = self.invoke_interactive(["read", "1"], "")
189
237
  self.assertEqual(code, 0)
@@ -246,6 +294,16 @@ class NulltapCliTests(unittest.TestCase):
246
294
  self.assertEqual(len(items), 1)
247
295
  self.assertEqual(seen, {"url": "https://nulltap.sh/feed.json", "timeout": 3})
248
296
 
297
+ def test_fetch_feed_document_includes_topic_catalog(self):
298
+ payload = json.dumps({"items": ITEMS, "topics": TOPICS}).encode()
299
+ document = fetch_feed_document(
300
+ "https://nulltap.sh/feed.json",
301
+ 3,
302
+ opener=lambda _request, timeout: FakeResponse(payload),
303
+ )
304
+ self.assertEqual(document["topics"][0]["topic"], "endpoint")
305
+ self.assertEqual(document["topics"][5]["label"], "AI")
306
+
249
307
  def test_unknown_topic_returns_helpful_error(self):
250
308
  code, _, stderr = self.invoke(["topic", "nope"])
251
309
  self.assertEqual(code, 1)
nulltap-0.1.0/PKG-INFO DELETED
@@ -1,98 +0,0 @@
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.
nulltap-0.1.0/README.md DELETED
@@ -1,76 +0,0 @@
1
- # nulltap
2
-
3
- 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.
4
-
5
- ## Install
6
-
7
- Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
8
-
9
- ```sh
10
- pipx install nulltap
11
- ```
12
-
13
- Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
14
-
15
- Run `nulltap --help` after installation.
16
-
17
- ## Commands
18
-
19
- ```sh
20
- nulltap # paginated recent articles; type a number to read
21
- nulltap browse # explicit form of the default command
22
- nulltap latest -n 20 # browse the newest 20 articles
23
- nulltap latest --topic identity
24
- nulltap topics # choose a topic, then choose an article
25
- nulltap topic ai # paginated articles tagged AI
26
- nulltap topic # choose a topic interactively
27
- nulltap search # enter search words interactively
28
- nulltap search "token theft"
29
- nulltap search gateway --topic appsec
30
- nulltap read 1 # directly read the newest article
31
- nulltap read # browse first when no number is supplied
32
- nulltap --plain # print once; no prompts or pager
33
- ```
34
-
35
- 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.
36
-
37
- 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.
38
-
39
- 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.
40
-
41
- ## Search
42
-
43
- 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.
44
-
45
- Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
46
-
47
- ## Scripts
48
-
49
- Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
50
-
51
- ```sh
52
- nulltap search ransomware --json
53
- nulltap topics --json
54
- ```
55
-
56
- Use a different compatible feed during development:
57
-
58
- ```sh
59
- nulltap --feed http://127.0.0.1:4321/feed.json
60
- # or
61
- NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
62
- ```
63
-
64
- 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.
65
-
66
- ## Development
67
-
68
- ```sh
69
- python -m venv .venv
70
- # Windows: .venv\Scripts\activate
71
- # macOS/Linux: source .venv/bin/activate
72
- python -m pip install -e .
73
- python -m unittest discover -s tests -v
74
- ```
75
-
76
- Licensed under the MIT License.
@@ -1,98 +0,0 @@
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.
File without changes
File without changes
File without changes