bejw 0.2.2__tar.gz → 0.2.4__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: bejw
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A capped reading list for links that shimmer
5
5
  Author: menisadi
6
6
  License: MIT License
@@ -53,11 +53,15 @@ python main.py --help
53
53
  python main.py init --capacity 10
54
54
  python main.py add "https://example.com" "Example"
55
55
  python main.py list
56
- python main.py remove <id>
56
+ python main.py list --format tsv --show-ids | fzf
57
+ python main.py list --format jsonl | jq .
58
+ python main.py remove <number>
57
59
  python main.py capacity 5
58
60
  python main.py clear
59
61
  ```
60
62
 
63
+ `list` output includes a 1-based number column that matches the `remove` command.
64
+
61
65
  ## Storage
62
66
 
63
67
  By default, data is stored in `~/.bejw/links.json`.
@@ -15,11 +15,15 @@ python main.py --help
15
15
  python main.py init --capacity 10
16
16
  python main.py add "https://example.com" "Example"
17
17
  python main.py list
18
- python main.py remove <id>
18
+ python main.py list --format tsv --show-ids | fzf
19
+ python main.py list --format jsonl | jq .
20
+ python main.py remove <number>
19
21
  python main.py capacity 5
20
22
  python main.py clear
21
23
  ```
22
24
 
25
+ `list` output includes a 1-based number column that matches the `remove` command.
26
+
23
27
  ## Storage
24
28
 
25
29
  By default, data is stored in `~/.bejw/links.json`.
@@ -2,12 +2,13 @@
2
2
  bejw: A capped reading list for links that shimmer
3
3
  """
4
4
 
5
+ import webbrowser
5
6
  from pathlib import Path
6
7
 
7
8
  import typer
8
9
 
9
10
  from .models import CapacityError, ReadingList
10
- from .render import render_links
11
+ from .render import OutputFormat, render_links
11
12
  from .storage import load, save
12
13
  from rich import print
13
14
 
@@ -50,25 +51,63 @@ def add(url: str, title: str, file_path: str = DEFAULT_FILE_PATH) -> None:
50
51
  )
51
52
  raise typer.Exit(code=1)
52
53
  save(reading_list, file_path)
53
- typer.echo(f"Added {link.id}")
54
+ ordered = reading_list.ordered_links()
55
+ number = next(
56
+ (index for index, item in enumerate(ordered, start=1) if item.id == link.id),
57
+ len(ordered),
58
+ )
59
+ typer.echo(f"Added #{number}")
54
60
 
55
61
 
56
62
  @app.command()
57
- def remove(link_id: str, file_path: str = DEFAULT_FILE_PATH) -> None:
58
- """Remove a link from the reading list by id."""
63
+ def remove(number: int, file_path: str = DEFAULT_FILE_PATH) -> None:
64
+ """Remove a link from the reading list by number."""
59
65
  reading_list = load(file_path)
60
- removed = reading_list.remove_link(link_id)
66
+ removed = reading_list.remove_by_number(number)
61
67
  save(reading_list, file_path)
62
68
  if not removed:
63
- typer.echo("No link found with that id.")
69
+ typer.echo("No link found with that number.")
70
+ raise typer.Exit(code=1)
71
+
72
+
73
+ @app.command()
74
+ def read(number: int, file_path: str = DEFAULT_FILE_PATH) -> None:
75
+ """Open a link from the reading list by number."""
76
+ reading_list = load(file_path)
77
+ ordered = reading_list.ordered_links()
78
+ if number < 1 or number > len(ordered):
79
+ typer.echo("No link found with that number.")
64
80
  raise typer.Exit(code=1)
65
81
 
82
+ url = ordered[number - 1].url
83
+ webbrowser.open(url)
84
+ typer.echo(f"Opened #{number}: {url}")
85
+
66
86
 
67
87
  @app.command()
68
- def list(file_path: str = DEFAULT_FILE_PATH, show_ids: bool = False) -> None:
88
+ def list(
89
+ file_path: str = DEFAULT_FILE_PATH,
90
+ show_ids: bool = False,
91
+ output_format: OutputFormat = typer.Option(
92
+ OutputFormat.TABLE,
93
+ "--format",
94
+ "-f",
95
+ help="Output format: table, tsv, csv, or jsonl.",
96
+ ),
97
+ no_header: bool = typer.Option(
98
+ False,
99
+ "--no-header",
100
+ help="Omit the header row for tsv and csv output.",
101
+ ),
102
+ ) -> None:
69
103
  """Display the reading list."""
70
104
  reading_list = load(file_path)
71
- render_links(reading_list, show_ids=show_ids)
105
+ render_links(
106
+ reading_list,
107
+ show_ids=show_ids,
108
+ output_format=output_format,
109
+ include_header=not no_header,
110
+ )
72
111
 
73
112
 
74
113
  @app.command()
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from datetime import datetime, timedelta, timezone
4
5
  from typing import Iterable
5
6
  from uuid import uuid4
6
7
 
@@ -17,10 +18,22 @@ class Link:
17
18
  id: str
18
19
  url: str
19
20
  title: str
21
+ created_at: str
20
22
 
21
23
  @staticmethod
22
24
  def create(url: str, title: str) -> "Link":
23
- return Link(id=str(uuid4()), url=url, title=title)
25
+ created_at = datetime.now(timezone.utc).isoformat()
26
+ return Link(id=str(uuid4()), url=url, title=title, created_at=created_at)
27
+
28
+
29
+ def _created_at_key(link: Link) -> datetime:
30
+ try:
31
+ created_at = datetime.fromisoformat(link.created_at)
32
+ except ValueError:
33
+ return datetime.min.replace(tzinfo=timezone.utc)
34
+ if created_at.tzinfo is None:
35
+ return created_at.replace(tzinfo=timezone.utc)
36
+ return created_at
24
37
 
25
38
 
26
39
  class ReadingList:
@@ -35,11 +48,21 @@ class ReadingList:
35
48
  self.links.append(link)
36
49
  return link
37
50
 
51
+ def ordered_links(self) -> list[Link]:
52
+ return sorted(self.links, key=_created_at_key)
53
+
38
54
  def remove_link(self, link_id: str) -> bool:
39
55
  before = len(self.links)
40
56
  self.links = [link for link in self.links if link.id != link_id]
41
57
  return len(self.links) < before
42
58
 
59
+ def remove_by_number(self, number: int) -> bool:
60
+ ordered = self.ordered_links()
61
+ if number < 1 or number > len(ordered):
62
+ return False
63
+ target_id = ordered[number - 1].id
64
+ return self.remove_link(target_id)
65
+
43
66
  def clear_links(self) -> None:
44
67
  self.links = []
45
68
 
@@ -51,6 +74,15 @@ class ReadingList:
51
74
 
52
75
  @staticmethod
53
76
  def from_dict(data: dict) -> "ReadingList":
54
- links = [Link(**item) for item in data.get("links", [])]
77
+ raw_links = data.get("links", [])
78
+ base_time = datetime(1970, 1, 1, tzinfo=timezone.utc)
79
+ links = []
80
+ for index, item in enumerate(raw_links):
81
+ if "created_at" not in item:
82
+ item = {
83
+ **item,
84
+ "created_at": (base_time + timedelta(seconds=index)).isoformat(),
85
+ }
86
+ links.append(Link(**item))
55
87
  capacity = data.get("capacity", 10)
56
88
  return ReadingList(capacity=capacity, links=links)
@@ -0,0 +1,105 @@
1
+ import csv
2
+ import io
3
+ import json
4
+ from enum import StrEnum
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table, box
8
+
9
+ from .models import ReadingList
10
+
11
+ console = Console()
12
+
13
+
14
+ class OutputFormat(StrEnum):
15
+ TABLE = "table"
16
+ TSV = "tsv"
17
+ CSV = "csv"
18
+ JSONL = "jsonl"
19
+
20
+
21
+ def _link_headers(show_ids: bool) -> list[str]:
22
+ headers = ["no"]
23
+ if show_ids:
24
+ headers.append("id")
25
+ headers.extend(["title", "url"])
26
+ return headers
27
+
28
+
29
+ def _link_values(reading_list: ReadingList, show_ids: bool) -> list[list[str]]:
30
+ rows: list[list[str]] = []
31
+ ordered = reading_list.ordered_links()
32
+ for index, link in enumerate(ordered, start=1):
33
+ row: list[str] = [str(index)]
34
+ if show_ids:
35
+ row.append(link.id)
36
+ row.append(link.title)
37
+ row.append(link.url)
38
+ rows.append(row)
39
+ return rows
40
+
41
+
42
+ def _render_delimited(
43
+ reading_list: ReadingList, show_ids: bool, include_header: bool, delimiter: str
44
+ ) -> str:
45
+ output = io.StringIO()
46
+ writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
47
+
48
+ if include_header:
49
+ writer.writerow(_link_headers(show_ids))
50
+ writer.writerows(_link_values(reading_list, show_ids))
51
+ return output.getvalue()
52
+
53
+
54
+ def _render_jsonl(reading_list: ReadingList, show_ids: bool) -> str:
55
+ """Render the reading list as JSONL (JSON Lines) format."""
56
+ lines = []
57
+ ordered = reading_list.ordered_links()
58
+ for index, link in enumerate(ordered, start=1):
59
+ payload = {"number": index, "title": link.title, "url": link.url}
60
+ if show_ids:
61
+ payload["id"] = link.id
62
+ lines.append(json.dumps(payload, ensure_ascii=False))
63
+ # Join the lines. Add a final newline at the end if there are any lines
64
+ return "\n".join(lines) + ("\n" if lines else "")
65
+
66
+
67
+ def render_links(
68
+ reading_list: ReadingList,
69
+ show_ids: bool = False,
70
+ output_format: OutputFormat = OutputFormat.TABLE,
71
+ include_header: bool = True,
72
+ ) -> None:
73
+ if output_format == OutputFormat.TSV:
74
+ console.file.write(_render_delimited(reading_list, show_ids, include_header, "\t"))
75
+ return
76
+ if output_format == OutputFormat.CSV:
77
+ console.file.write(_render_delimited(reading_list, show_ids, include_header, ","))
78
+ return
79
+ if output_format == OutputFormat.JSONL:
80
+ console.file.write(_render_jsonl(reading_list, show_ids))
81
+ return
82
+
83
+ # If non of the above formats were selected, rendering a table
84
+ table = Table(
85
+ title="Bejeweled Reading List",
86
+ box=box.SIMPLE,
87
+ title_justify="left",
88
+ title_style="bold red",
89
+ )
90
+ table.add_column("No.", style="cyan", no_wrap=True)
91
+ if show_ids:
92
+ table.add_column("ID", style="cyan", no_wrap=True)
93
+ table.add_column("Title", style="magenta")
94
+ table.add_column("URL", style="green")
95
+
96
+ ordered = reading_list.ordered_links()
97
+ for index, link in enumerate(ordered, start=1):
98
+ row = [str(index)]
99
+ if show_ids:
100
+ row.append(str(link.id))
101
+ row.append(link.title)
102
+ row.append(link.url)
103
+ table.add_row(*row)
104
+
105
+ console.print(table)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bejw
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A capped reading list for links that shimmer
5
5
  Author: menisadi
6
6
  License: MIT License
@@ -53,11 +53,15 @@ python main.py --help
53
53
  python main.py init --capacity 10
54
54
  python main.py add "https://example.com" "Example"
55
55
  python main.py list
56
- python main.py remove <id>
56
+ python main.py list --format tsv --show-ids | fzf
57
+ python main.py list --format jsonl | jq .
58
+ python main.py remove <number>
57
59
  python main.py capacity 5
58
60
  python main.py clear
59
61
  ```
60
62
 
63
+ `list` output includes a 1-based number column that matches the `remove` command.
64
+
61
65
  ## Storage
62
66
 
63
67
  By default, data is stored in `~/.bejw/links.json`.
@@ -12,5 +12,7 @@ bejw.egg-info/dependency_links.txt
12
12
  bejw.egg-info/entry_points.txt
13
13
  bejw.egg-info/requires.txt
14
14
  bejw.egg-info/top_level.txt
15
+ tests/test_cli_list_formats.py
16
+ tests/test_cli_read.py
15
17
  tests/test_models.py
16
18
  tests/test_storage.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bejw"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "A capped reading list for links that shimmer"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -0,0 +1,83 @@
1
+ from pathlib import Path
2
+
3
+ from typer.testing import CliRunner
4
+
5
+ from bejw.main import app
6
+ from bejw.models import Link, ReadingList
7
+ from bejw.storage import save
8
+
9
+
10
+ runner = CliRunner()
11
+
12
+
13
+ def _seed_reading_list(file_path: Path) -> None:
14
+ reading_list = ReadingList(
15
+ capacity=10,
16
+ links=[
17
+ Link(
18
+ id="id-1",
19
+ title="Example One",
20
+ url="https://example.com/1",
21
+ created_at="2024-01-01T00:00:00+00:00",
22
+ ),
23
+ Link(
24
+ id="id-2",
25
+ title="Example Two",
26
+ url="https://example.com/2",
27
+ created_at="2024-01-02T00:00:00+00:00",
28
+ ),
29
+ ],
30
+ )
31
+ save(reading_list, str(file_path))
32
+
33
+
34
+ def test_list_tsv_with_header_and_ids(tmp_path: Path) -> None:
35
+ file_path = tmp_path / "links.json"
36
+ _seed_reading_list(file_path)
37
+
38
+ result = runner.invoke(
39
+ app,
40
+ ["list", "--file-path", str(file_path), "--show-ids", "--format", "tsv"],
41
+ )
42
+
43
+ assert result.exit_code == 0
44
+ assert (
45
+ result.stdout
46
+ == "no\tid\ttitle\turl\n"
47
+ "1\tid-1\tExample One\thttps://example.com/1\n"
48
+ "2\tid-2\tExample Two\thttps://example.com/2\n"
49
+ )
50
+
51
+
52
+ def test_list_csv_without_header(tmp_path: Path) -> None:
53
+ file_path = tmp_path / "links.json"
54
+ _seed_reading_list(file_path)
55
+
56
+ result = runner.invoke(
57
+ app,
58
+ ["list", "--file-path", str(file_path), "--format", "csv", "--no-header"],
59
+ )
60
+
61
+ assert result.exit_code == 0
62
+ assert (
63
+ result.stdout
64
+ == "1,Example One,https://example.com/1\n"
65
+ "2,Example Two,https://example.com/2\n"
66
+ )
67
+
68
+
69
+ def test_list_jsonl_with_ids(tmp_path: Path) -> None:
70
+ file_path = tmp_path / "links.json"
71
+ _seed_reading_list(file_path)
72
+
73
+ result = runner.invoke(
74
+ app,
75
+ ["list", "--file-path", str(file_path), "--show-ids", "--format", "jsonl"],
76
+ )
77
+
78
+ assert result.exit_code == 0
79
+ assert (
80
+ result.stdout
81
+ == '{"number": 1, "title": "Example One", "url": "https://example.com/1", "id": "id-1"}\n'
82
+ '{"number": 2, "title": "Example Two", "url": "https://example.com/2", "id": "id-2"}\n'
83
+ )
@@ -0,0 +1,72 @@
1
+ import webbrowser
2
+ from pathlib import Path
3
+
4
+ from typer.testing import CliRunner
5
+
6
+ from bejw.main import app
7
+ from bejw.models import Link, ReadingList
8
+ from bejw.storage import save
9
+
10
+
11
+ runner = CliRunner()
12
+
13
+
14
+ def _seed_reading_list(file_path: Path) -> None:
15
+ reading_list = ReadingList(
16
+ capacity=10,
17
+ links=[
18
+ Link(
19
+ id="id-1",
20
+ title="Example One",
21
+ url="https://example.com/1",
22
+ created_at="2024-01-01T00:00:00+00:00",
23
+ ),
24
+ Link(
25
+ id="id-2",
26
+ title="Example Two",
27
+ url="https://example.com/2",
28
+ created_at="2024-01-02T00:00:00+00:00",
29
+ ),
30
+ ],
31
+ )
32
+ save(reading_list, str(file_path))
33
+
34
+
35
+ def test_read_opens_selected_link_and_prints_confirmation(
36
+ tmp_path: Path, monkeypatch
37
+ ) -> None:
38
+ file_path = tmp_path / "links.json"
39
+ _seed_reading_list(file_path)
40
+ opened: list[str] = []
41
+
42
+ def _fake_open(url: str, *_args, **_kwargs) -> bool:
43
+ opened.append(url)
44
+ return True
45
+
46
+ monkeypatch.setattr(webbrowser, "open", _fake_open)
47
+
48
+ result = runner.invoke(app, ["read", "2", "--file-path", str(file_path)])
49
+
50
+ assert result.exit_code == 0
51
+ assert opened == ["https://example.com/2"]
52
+ assert result.stdout == "Opened #2: https://example.com/2\n"
53
+
54
+
55
+ def test_read_returns_error_when_number_is_missing(tmp_path: Path) -> None:
56
+ file_path = tmp_path / "links.json"
57
+ _seed_reading_list(file_path)
58
+
59
+ result = runner.invoke(app, ["read", "99", "--file-path", str(file_path)])
60
+
61
+ assert result.exit_code == 1
62
+ assert result.stdout == "No link found with that number.\n"
63
+
64
+
65
+ def test_read_returns_error_for_empty_list(tmp_path: Path) -> None:
66
+ file_path = tmp_path / "links.json"
67
+ save(ReadingList(capacity=10), str(file_path))
68
+
69
+ result = runner.invoke(app, ["read", "1", "--file-path", str(file_path)])
70
+
71
+ assert result.exit_code == 1
72
+ assert result.stdout == "No link found with that number.\n"
@@ -11,6 +11,7 @@ def test_add_link_increases_size_and_returns_link() -> None:
11
11
  assert isinstance(link, Link)
12
12
  assert link.url == "https://example.com"
13
13
  assert link.title == "Example"
14
+ assert link.created_at
14
15
  assert len(reading_list.links) == 1
15
16
 
16
17
 
@@ -22,20 +23,20 @@ def test_add_link_raises_when_capacity_reached() -> None:
22
23
  reading_list.add_link("https://second.com", "Second")
23
24
 
24
25
 
25
- def test_remove_link_returns_true_when_found() -> None:
26
+ def test_remove_by_number_returns_true_when_found() -> None:
26
27
  reading_list = ReadingList()
27
- link = reading_list.add_link("https://example.com", "Example")
28
+ reading_list.add_link("https://example.com", "Example")
28
29
 
29
- removed = reading_list.remove_link(link.id)
30
+ removed = reading_list.remove_by_number(1)
30
31
 
31
32
  assert removed is True
32
33
  assert reading_list.links == []
33
34
 
34
35
 
35
- def test_remove_link_returns_false_when_missing() -> None:
36
+ def test_remove_by_number_returns_false_when_missing() -> None:
36
37
  reading_list = ReadingList()
37
38
 
38
- removed = reading_list.remove_link("missing-id")
39
+ removed = reading_list.remove_by_number(2)
39
40
 
40
41
  assert removed is False
41
42
 
bejw-0.2.2/bejw/render.py DELETED
@@ -1,29 +0,0 @@
1
- from rich.console import Console
2
- from rich.table import Table, box
3
-
4
- from .models import ReadingList
5
-
6
- console = Console()
7
-
8
-
9
- def render_links(reading_list: ReadingList, show_ids: bool = False) -> None:
10
- table = Table(
11
- title="Bejeweled Reading List",
12
- box=box.SIMPLE,
13
- title_justify="left",
14
- title_style="bold red",
15
- )
16
- if show_ids:
17
- table.add_column("ID", style="cyan", no_wrap=True)
18
- table.add_column("Title", style="magenta")
19
- table.add_column("URL", style="green")
20
-
21
- for link in reading_list.links:
22
- row = []
23
- if show_ids:
24
- row.append(str(link.id))
25
- row.append(link.title)
26
- row.append(link.url)
27
- table.add_row(*row)
28
-
29
- console.print(table)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes