Skip to content

Commit aaf8c1d

Browse files
committed
architecture: try clean architecture pattern in feedbin subproject
1 parent 4c7366f commit aaf8c1d

20 files changed

+267
-109
lines changed

β€Žcli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import typer
1616

17-
import feedbin.cli as feedbin_cli
17+
import feedbin.adapters.cli as feedbin_cli
1818
import modem.restart as modem_cli
1919

2020
app = typer.Typer(no_args_is_help=True)

β€Žutils/cli.py β€Žcommon/cli.py

File renamed without changes.

β€Žutils/logs.py β€Žcommon/logs.py

File renamed without changes.

β€Žop/secrets.py β€Žcommon/secrets.py

+13
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
"""
2+
To avoid ever exposing the secrets used by the scripts in this repo as plain text (e.g. in a local `.env` file), they
3+
are stored in 1Password and referenced by their location in the vault the 1Password service account has access to.
4+
5+
When running these scripts locally, I'm prompted by the 1Password CLI to authenticate with my fingerprint. To support
6+
running these scripts via GitHub Actions workflows, an OP_SERVICE_ACCOUNT_TOKEN repository secret is needed to allow
7+
the 1Password CLI to authenticate the service account user used in that environment.
8+
9+
Docs:
10+
- https://developer.1password.com/docs/cli/secret-reference-syntax/
11+
- https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository
12+
"""
13+
114
import subprocess
215

316
# Docs (service accounts):

β€Žnotifications/sendgrid.py β€Žcommon/sendgrid.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from sendgrid.helpers.mail import Mail # type: ignore
44
from sendgrid.sendgrid import SendGridAPIClient # type: ignore
55

6-
from op.secrets import get_secret
7-
from utils.logs import log
6+
from common.logs import log
7+
from common.secrets import get_secret
88

99
OP_ITEM = "SendGrid"
1010
OP_FIELD = "api key"

β€Žfeedbin/api/__init__.py β€Žfeedbin/adapters/api/__init__.py

+46-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""
22
Core functionality for interacting with all Feedbin API endpoints.
3+
34
"""
45

56
from dataclasses import dataclass
@@ -8,7 +9,7 @@
89

910
import requests
1011

11-
from op.secrets import get_secret
12+
from common.secrets import get_secret
1213

1314
API = "https://api.feedbin.com/v2"
1415

@@ -46,17 +47,59 @@ def make_request(method: HTTPMethod, args: RequestArgs) -> requests.Response:
4647
"""
4748
Make an HTTP request to the Feedbin API.
4849
50+
Since the possible status codes (and their explanations) vary by endpoint, we raise them at this base level
51+
and catch them one level up in the endpoint-specific API helper functions.
52+
4953
TODO:
50-
- expect different request args based on the method?
54+
- Expect different request args for GET vs POST methods?
5155
"""
52-
return requests.request(
56+
response = requests.request(
5357
method.value,
5458
args.url,
5559
json=args.json,
5660
params=args.params,
5761
headers=args.headers,
5862
auth=_get_auth(),
5963
)
64+
response.raise_for_status()
65+
return response
66+
67+
68+
def parse_link_header(link_header: str) -> dict[str, str]:
69+
"""
70+
Parse the Link header to extract URLs for pagination.
71+
72+
Docs:
73+
- https://github.com/feedbin/feedbin-api?tab=readme-ov-file#pagination
74+
"""
75+
links = {}
76+
for link in link_header.split(","):
77+
parts = link.split(";")
78+
url = parts[0].strip()[1:-1]
79+
rel = parts[1].strip().split("=")[1].strip('"')
80+
links[rel] = url
81+
return links
82+
83+
84+
def make_paginated_request(request_args: RequestArgs) -> list[dict[str, Any]]:
85+
"""
86+
Fetch all pages of results for a paginated request.
87+
88+
Docs:
89+
- https://github.com/feedbin/feedbin-api?tab=readme-ov-file#pagination
90+
"""
91+
all_results = []
92+
while request_args.url:
93+
response = make_request(HTTPMethod.GET, request_args)
94+
all_results.extend(response.json())
95+
link_header = response.headers.get("Link")
96+
if link_header:
97+
links = parse_link_header(link_header)
98+
request_args.url = links.get("next", "")
99+
else:
100+
request_args.url = ""
101+
102+
return all_results
60103

61104

62105
class FeedbinError(Exception):

β€Žfeedbin/adapters/api/entries.py

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Helper functions for Feedbin's entries endpoint.
3+
4+
Docs:
5+
- https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#get-v2feeds203entriesjson
6+
"""
7+
8+
from pydantic import BaseModel
9+
from requests import HTTPError
10+
11+
from common.logs import log
12+
from feedbin.adapters.api import (
13+
API,
14+
ForbiddenError,
15+
NotFoundError,
16+
RequestArgs,
17+
UnexpectedError,
18+
make_paginated_request,
19+
)
20+
21+
22+
class Entry(BaseModel):
23+
title: str
24+
author: str
25+
url: str
26+
feed_id: int
27+
id: int
28+
29+
30+
def get_feed_entries(
31+
feed_id: int,
32+
*,
33+
read: bool | None = None,
34+
starred: bool | None = None,
35+
) -> list[Entry]:
36+
"""
37+
Get all entries for a feed.
38+
39+
Params:
40+
- read: Filter by read status. Options: True, False, None.
41+
- starred: Filter by starred status. Options: True, False, None.
42+
43+
TODO: accept a site_url and look up the feed_id internally?
44+
"""
45+
request_args = RequestArgs(
46+
url=f"{API}/feeds/{feed_id}/entries.json",
47+
params={"read": read, "starred": starred},
48+
)
49+
50+
try:
51+
log.info(f"Feedbin: getting entries for feed {feed_id}")
52+
53+
all_entries = make_paginated_request(request_args)
54+
log.debug(f"πŸ” all_entries: {all_entries}")
55+
56+
parsed_entries = [Entry(**entry) for entry in all_entries]
57+
log.debug(f"πŸ” parsed_entries: {parsed_entries}")
58+
59+
return parsed_entries
60+
# response = make_request(HTTPMethod.GET, request_args)
61+
except HTTPError as e:
62+
match e.response.status_code:
63+
case 403:
64+
raise ForbiddenError("Feedbin: you are not subscribed to feed {feed_id}")
65+
case 404:
66+
raise NotFoundError("Feedbin: no subscriptions found")
67+
case _:
68+
raise UnexpectedError("Feedbin: unexpected error while getting subscriptions")

β€Žfeedbin/api/subscriptions.py β€Žfeedbin/adapters/api/subscriptions.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
from pydantic import BaseModel
99

10-
from feedbin.api import (
10+
from common.logs import log
11+
from feedbin.adapters.api import (
1112
API,
1213
FeedbinError,
1314
ForbiddenError,
@@ -17,7 +18,6 @@
1718
UnexpectedError,
1819
make_request,
1920
)
20-
from utils.logs import log
2121

2222

2323
class Subscription(BaseModel):
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Helper functions for Feedbin's unread entries endpoint.
3+
4+
Docs:
5+
- https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md
6+
"""
7+
8+
from common.logs import log
9+
from feedbin.adapters.api import API, HTTPMethod, RequestArgs, UnexpectedError, make_request
10+
11+
12+
def create_unread_entries(entry_ids: list[int]) -> list[int]:
13+
"""
14+
Mark up to 1,000 entry IDs as unread.
15+
16+
The response will contain all of the entry_ids that were successfully marked as unread.
17+
If any IDs that were sent are not returned in the response, it usually means the user
18+
no longer has access to the feed the entry belongs to.
19+
"""
20+
request_args = RequestArgs(
21+
url=f"{API}/unread-entries.json",
22+
json={"unread_entries": entry_ids},
23+
headers={"Content-Type": "application/json; charset=utf-8"},
24+
)
25+
26+
log.info(f"Marking {len(entry_ids)} entries as unread")
27+
response = make_request(HTTPMethod.POST, request_args)
28+
29+
match response.status_code:
30+
case 200:
31+
entry_ids_marked_as_unread = response.json()
32+
log.info(f"Marked {len(entry_ids_marked_as_unread)} entries as unread")
33+
34+
entry_ids_not_marked_as_unread = set(entry_ids) - set(entry_ids_marked_as_unread)
35+
if entry_ids_not_marked_as_unread:
36+
log.warning(f"Failed to mark the following entries as unread: {entry_ids_not_marked_as_unread}")
37+
38+
return entry_ids_marked_as_unread
39+
case _:
40+
raise UnexpectedError("Unexpected error while marking entries as unread")

β€Žfeedbin/adapters/cli.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
CLI adapter for Feedbin. All typer commands should be defined in this module.
3+
"""
4+
5+
from typing import Annotated
6+
7+
import typer
8+
9+
from common.cli import DryRun
10+
from feedbin.adapters.api.entries import get_feed_entries
11+
from feedbin.application.add_subscription import add_subscription
12+
from feedbin.application.list_subscriptions import list_subscriptions
13+
from feedbin.application.mark_entries_unread import mark_entries_unread
14+
15+
subscriptions_app = typer.Typer(no_args_is_help=True)
16+
entries_app = typer.Typer(no_args_is_help=True)
17+
18+
app = typer.Typer(no_args_is_help=True)
19+
app.add_typer(subscriptions_app, name="subscriptions")
20+
app.add_typer(entries_app, name="entries")
21+
22+
23+
MarkUnread = Annotated[bool, typer.Option("--unread", "-u", help="Mark backlog unread")]
24+
25+
26+
@app.command("add", no_args_is_help=True)
27+
def add(url: str, mark_backlog_unread: MarkUnread = False, dry_run: DryRun = False) -> None:
28+
add_subscription(url, mark_backlog_unread=mark_backlog_unread, dry_run=dry_run)
29+
30+
31+
subscriptions_app.command(name="list")(list_subscriptions)
32+
entries_app.command(name="mark-unread", no_args_is_help=True)(mark_entries_unread)
33+
entries_app.command(name="list", no_args_is_help=True)(get_feed_entries)
34+
35+
# michaeluloth.com = feed_id: 2338770

β€Žfeedbin/api/entries.py

-54
This file was deleted.

β€Žfeedbin/commands/add.py β€Žfeedbin/application/add_subscription.py

+20-8
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
# TODO: move all "typer" usage to feedbin.adapters.cli
2+
13
import os
24
from typing import Annotated
35

46
import rich
57
import typer
68

7-
from feedbin.api import NotFoundError, UnexpectedError
8-
from feedbin.api.subscriptions import FeedOption, MultipleChoicesError, Subscription, create_subscription
9-
from utils.cli import DryRun
10-
from utils.logs import log
9+
from common.cli import DryRun
10+
from common.logs import log
11+
from feedbin.adapters.api import NotFoundError, UnexpectedError
12+
from feedbin.adapters.api.entries import get_feed_entries
13+
from feedbin.adapters.api.subscriptions import FeedOption, MultipleChoicesError, Subscription, create_subscription
14+
from feedbin.application.mark_entries_unread import mark_entries_unread
1115

1216
app = typer.Typer(no_args_is_help=True)
1317

@@ -55,7 +59,7 @@ def subscribe_to_feed(url: str) -> Subscription:
5559
Unread = Annotated[bool, typer.Option("--unread", "-u", help="Mark backlog unread")]
5660

5761

58-
def main(url: str, mark_backlog_unread: Unread = False, dry_run: DryRun = False) -> None:
62+
def add_subscription(url: str, mark_backlog_unread: Unread = False, dry_run: DryRun = False) -> None:
5963
dry_run = os.getenv("DRY_RUN") == "true" or dry_run
6064

6165
typer.confirm(f"πŸ”– Subscribe to '{url}'?", abort=True)
@@ -67,13 +71,21 @@ def main(url: str, mark_backlog_unread: Unread = False, dry_run: DryRun = False)
6771
new_subscription = subscribe_to_feed(url)
6872
log.debug(f"πŸ” new_subscription: {new_subscription}")
6973

70-
mark_unread = typer.confirm("πŸ”– Mark backlog unread?", default=mark_backlog_unread)
74+
mark_backlog_unread = typer.confirm("πŸ”– Mark backlog unread?", default=mark_backlog_unread)
7175

72-
if not mark_unread:
76+
if not mark_backlog_unread:
7377
rich.print("πŸ‘‹ You're all set!")
7478
typer.Exit()
7579

76-
log.info("πŸ”– Marking backlog as unread")
80+
log.info("πŸ”– Getting all entries")
81+
entries = get_feed_entries(new_subscription.feed_id)
82+
log.debug(f"πŸ” entries: {entries}")
83+
84+
entry_ids = [entry.id for entry in entries]
85+
log.debug(f"πŸ” entry_ids: {entry_ids}")
86+
87+
log.info("πŸ”– Marking all entries as unread")
88+
mark_entries_unread(entry_ids)
7789
# TODO: get all entry ids for this subscription via get_feed_entries
7890
# TODO: mark all entries as unread via https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#create-unread-entries-mark-as-unread
7991

0 commit comments

Comments
Β (0)