r | Reload. |
155 | """
156 |
157 | DEFAULT_CSS = """
158 | Items OptionList {
159 | height: 1fr;
160 | border: none;
161 | padding: 0;
162 | background: $panel;
163 |
164 | & > .option-list--option {
165 | padding: 0 1 0 0;
166 | }
167 |
168 | &:focus {
169 | border: none;
170 | background: $panel;
171 | }
172 | }
173 | """
174 |
175 | BINDINGS = [
176 | ("ctrl+r", "reload"),
177 | ]
178 |
179 | compact: var[bool] = var(True)
180 | """Should we use a compact display?"""
181 |
182 | numbered: var[bool] = var(False)
183 | """Should we show numbers against the items?"""
184 |
185 | show_age: var[bool] = var(True)
186 | """Should we show the age of the data?"""
187 |
188 | def __init__(
189 | self, title: str, key: str, source: Callable[[], Awaitable[list[ArticleType]]]
190 | ) -> None:
191 | """Initialise the pane.
192 |
193 | Args:
194 | title: The title for the pane.
195 | key: The key used to switch to this pane.
196 | source: The source of items for the pane.
197 | """
198 | super().__init__(f"{title.capitalize()} [dim]\\[{key}][/]", id=title)
199 | self._description = title
200 | """The description of the pane."""
201 | self._snarfed: datetime | None = None
202 | """The time when the data was snarfed."""
203 | self._source = source
204 | """The source of items to show."""
205 | self._items: list[ArticleType] = []
206 | """The items to show."""
207 |
208 | def compose(self) -> ComposeResult:
209 | """Compose the content of the pane."""
210 | yield ArticleList()
211 |
212 | @property
213 | def description(self) -> str:
214 | """The description for this pane."""
215 | suffix = ""
216 | if self._snarfed is None:
217 | suffix = " - Loading..."
218 | elif not self._items:
219 | suffix = " - Reloading..."
220 | elif self.show_age:
221 | suffix = f" - Updated {naturaltime(self._snarfed)}"
222 | return f"{self._description.capitalize()}{suffix}"
223 |
224 | def _redisplay(self) -> None:
225 | """Redisplay the items."""
226 | display = self.query_one(OptionList)
227 | remember = display.highlighted
228 | display.clear_options().add_options(
229 | [
230 | HackerNewsArticle(item, self.compact, number if self.numbered else None)
231 | for number, item in enumerate(self._items)
232 | if item.looks_valid
233 | ]
234 | )
235 | display.highlighted = remember
236 |
237 | class Loading(Message):
238 | """Message sent when items start loading."""
239 |
240 | class Loaded(Message):
241 | """Message sent when items are loaded."""
242 |
243 | @work
244 | async def _load(self) -> None:
245 | """Load up the items and display them."""
246 | display = self.query_one(OptionList)
247 | display.loading = True
248 | self.post_message(self.Loading())
249 | try:
250 | self._items = await self._source()
251 | except HN.RequestError as error:
252 | self.app.bell()
253 | self.notify(
254 | str(error),
255 | title=f"Error loading items for '{self._description.capitalize()}'",
256 | timeout=8,
257 | severity="error",
258 | )
259 | else:
260 | self._snarfed = datetime.now()
261 | self._redisplay()
262 | display.loading = False
263 | self.post_message(self.Loaded())
264 |
265 | def maybe_load(self) -> bool:
266 | """Start loading the items if they're not loaded and aren't currently loading.
267 |
268 | Returns:
269 | `True` if it was decided to load the items, `False` if not.
270 | """
271 | if not self.loaded and not self.query_one(OptionList).loading:
272 | self._load()
273 | return True
274 | return False
275 |
276 | @property
277 | def items(self) -> list[ArticleType]:
278 | """The items."""
279 | return self._items
280 |
281 | @property
282 | def loaded(self) -> bool:
283 | """Has this tab loaded its items?"""
284 | return bool(self._items)
285 |
286 | def on_show(self) -> None:
287 | """Handle being shown."""
288 | if not self.loaded:
289 | self._load()
290 |
291 | def steal_focus(self) -> None:
292 | """Steal focus for the item list within."""
293 | self.query_one(ArticleList).focus()
294 |
295 | def _watch_compact(self) -> None:
296 | """React to the compact setting being changed."""
297 | if self.loaded:
298 | self._redisplay()
299 |
300 | def _watch_numbered(self) -> None:
301 | """React to the numbered setting being changed."""
302 | if self.loaded:
303 | self._redisplay()
304 |
305 | @on(OptionList.OptionSelected)
306 | def visit(self, event: OptionList.OptionSelected) -> None:
307 | """Handle an option list item being selected."""
308 | assert isinstance(option := event.option, HackerNewsArticle)
309 | open_url(option.article.visitable_url)
310 |
311 | def action_reload(self) -> None:
312 | """Reload the items"""
313 | self._items = []
314 | self._load()
315 |
316 |
317 | ### items.py ends here
318 |
--------------------------------------------------------------------------------
/oshit/hn/__init__.py:
--------------------------------------------------------------------------------
1 | """Provides the client for the HackerNews API."""
2 |
3 | ##############################################################################
4 | # Local imports.
5 | from .client import HN
6 |
7 | ##############################################################################
8 | # Exports.
9 | __all__ = ["HN"]
10 |
11 | ### __init__.py ends here
12 |
--------------------------------------------------------------------------------
/oshit/hn/client.py:
--------------------------------------------------------------------------------
1 | """The HackerNews API client."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from asyncio import Semaphore, gather
6 | from json import loads
7 | from ssl import SSLCertVerificationError
8 | from typing import Any, Final, cast
9 |
10 | ##############################################################################
11 | # HTTPX imports.
12 | from httpx import AsyncClient, HTTPStatusError, RequestError
13 |
14 | ##############################################################################
15 | # Local imports.
16 | from .item import (
17 | Article,
18 | Comment,
19 | ItemType,
20 | Job,
21 | Loader,
22 | ParentItem,
23 | Poll,
24 | PollOption,
25 | Story,
26 | )
27 | from .user import User
28 |
29 |
30 | ##############################################################################
31 | class HN:
32 | """HackerNews API client."""
33 |
34 | AGENT: Final[str] = "Orange Site Hit (https://github.com/davep/oshit)"
35 | """The agent string to use when talking to the API."""
36 |
37 | _BASE: Final[str] = "https://hacker-news.firebaseio.com/v0/"
38 | """The base of the URL for the API."""
39 |
40 | class Error(Exception):
41 | """Base class for HackerNews errors."""
42 |
43 | class RequestError(Error):
44 | """Exception raised if there was a problem making an API request."""
45 |
46 | class NoSuchUser(Error):
47 | """Exception raised if no such user exists."""
48 |
49 | def __init__(self, max_concurrency: int = 50, timeout: int | None = 5) -> None:
50 | """Initialise the API client object.
51 |
52 | Args:
53 | max_concurrency: The maximum number of concurrent connections to use.
54 | timeout: The timeout for an attempted connection.
55 | """
56 | self._client_: AsyncClient | None = None
57 | """The HTTPX client."""
58 | self._max_concurrency = max_concurrency
59 | """The maximum number of concurrent connections to use."""
60 | self._timeout = timeout
61 | """The timeout to use on connections."""
62 |
63 | @property
64 | def _client(self) -> AsyncClient:
65 | """The API client."""
66 | if self._client_ is None:
67 | self._client_ = AsyncClient()
68 | return self._client_
69 |
70 | def _api_url(self, *path: str) -> str:
71 | """Construct a URL for calling on the API.
72 |
73 | Args:
74 | *path: The path to the endpoint.
75 |
76 | Returns:
77 | The URL to use.
78 | """
79 | return f"{self._BASE}{'/'.join(path)}"
80 |
81 | async def _call(self, *path: str, **params: str) -> str:
82 | """Call on the Pinboard API.
83 |
84 | Args:
85 | path: The path for the API call.
86 | params: The parameters for the call.
87 |
88 | Returns:
89 | The text returned from the call.
90 | """
91 | try:
92 | response = await self._client.get(
93 | self._api_url(*path),
94 | params=params,
95 | headers={"user-agent": self.AGENT},
96 | timeout=self._timeout,
97 | )
98 | except (RequestError, SSLCertVerificationError) as error:
99 | raise self.RequestError(str(error))
100 |
101 | try:
102 | response.raise_for_status()
103 | except HTTPStatusError as error:
104 | raise self.RequestError(str(error))
105 |
106 | return response.text
107 |
108 | async def max_item_id(self) -> int:
109 | """Get the current maximum item ID.
110 |
111 | Returns:
112 | The ID of the maximum item on HackerNews.
113 | """
114 | return int(loads(await self._call("maxitem.json")))
115 |
116 | async def _raw_item(self, item_id: int) -> dict[str, Any]:
117 | """Get the raw data of an item from the API.
118 |
119 | Args:
120 | item_id: The ID of the item to get.
121 |
122 | Returns:
123 | The JSON data of that item as a `dict`.
124 | """
125 | # TODO: Possibly cache this.
126 | return cast(dict[str, Any], loads(await self._call("item", f"{item_id}.json")))
127 |
128 | async def item(self, item_type: type[ItemType], item_id: int) -> ItemType:
129 | """Get an item by its ID.
130 |
131 | Args:
132 | item_type: The type of the item to get from the API.
133 | item_id: The ID of the item to get.
134 |
135 | Returns:
136 | The item.
137 | """
138 | # If we can get the item but it comes back with no data at all...
139 | if not (data := await self._raw_item(item_id)):
140 | # ...as https://hacker-news.firebaseio.com/v0/item/41050801.json
141 | # does for some reason, just make an empty version of the item.
142 | return item_type()
143 | if isinstance(item := Loader.load(data), item_type):
144 | return item
145 | raise ValueError(
146 | f"The item of ID '{item_id}' is of type '{item.item_type}', not {item_type.__name__}"
147 | )
148 |
149 | async def _items_from_ids(
150 | self, item_type: type[ItemType], item_ids: list[int]
151 | ) -> list[ItemType]:
152 | """Turn a list of item IDs into a list of items.
153 |
154 | Args:
155 | item_type: The type of the item we'll be getting.
156 | item_ids: The IDs of the items to get.
157 |
158 | Returns:
159 | The list of items.
160 | """
161 | concurrency_limit = Semaphore(self._max_concurrency)
162 |
163 | async def item(item_id: int) -> ItemType:
164 | """Get an item, with a limit on concurrent requests.
165 |
166 | Args:
167 | item_id: The ID of the item to get.
168 |
169 | Returns:
170 | The item.
171 | """
172 | async with concurrency_limit:
173 | return await self.item(item_type, item_id)
174 |
175 | return await gather(*[item(item_id) for item_id in item_ids])
176 |
177 | async def _id_list(self, list_type: str, max_count: int | None = None) -> list[int]:
178 | """Get a given ID list.
179 |
180 | Args:
181 | list_type: The type of list to get.
182 | max_count: Maximum number of IDs to fetch.
183 |
184 | Returns:
185 | The list of item IDs.
186 | """
187 | return cast(
188 | list[int], loads(await self._call(f"{list_type}.json"))[0:max_count]
189 | )
190 |
191 | async def top_story_ids(self, max_count: int | None = None) -> list[int]:
192 | """Get the list of top story IDs.
193 |
194 | Args:
195 | max_count: Maximum number of IDs to fetch.
196 |
197 | Returns:
198 | The list of the top story IDs.
199 | """
200 | return await self._id_list("topstories", max_count)
201 |
202 | async def top_stories(self, max_count: int | None = None) -> list[Article]:
203 | """Get the top stories.
204 |
205 | Args:
206 | max_count: Maximum number of stories to fetch.
207 |
208 | Returns:
209 | The list of the top stories.
210 | """
211 | return await self._items_from_ids(Article, await self.top_story_ids(max_count))
212 |
213 | async def new_story_ids(self, max_count: int | None = None) -> list[int]:
214 | """Get the list of new story IDs.
215 |
216 | Args:
217 | max_count: Maximum number of story IDs to fetch.
218 |
219 | Returns:
220 | The list of the new story IDs.
221 | """
222 | return await self._id_list("newstories", max_count)
223 |
224 | async def new_stories(self, max_count: int | None = None) -> list[Article]:
225 | """Get the new stories.
226 |
227 | Args:
228 | max_count: Maximum number of stories to fetch.
229 |
230 | Returns:
231 | The list of the new stories.
232 | """
233 | return await self._items_from_ids(Article, await self.new_story_ids(max_count))
234 |
235 | async def best_story_ids(self, max_count: int | None = None) -> list[int]:
236 | """Get the list of best story IDs.
237 |
238 | Args:
239 | max_count: Maximum number of story IDs to fetch.
240 |
241 | Returns:
242 | The list of the best story IDs.
243 | """
244 | return await self._id_list("beststories", max_count)
245 |
246 | async def best_stories(self, max_count: int | None = None) -> list[Article]:
247 | """Get the best stories.
248 |
249 | Args:
250 | max_count: Maximum number of stories to fetch.
251 |
252 | Returns:
253 | The list of the best stories.
254 | """
255 | return await self._items_from_ids(Article, await self.best_story_ids(max_count))
256 |
257 | async def latest_ask_story_ids(self, max_count: int | None = None) -> list[int]:
258 | """Get the list of the latest ask story IDs.
259 |
260 | Args:
261 | max_count: Maximum number of story IDs to fetch.
262 |
263 | Returns:
264 | The list of the latest ask story IDs.
265 | """
266 | return await self._id_list("askstories", max_count)
267 |
268 | async def latest_ask_stories(self, max_count: int | None = None) -> list[Story]:
269 | """Get the latest AskHN stories.
270 |
271 | Args:
272 | max_count: Maximum number of stories to fetch.
273 |
274 | Returns:
275 | The list of the latest AskHN stories.
276 | """
277 | return await self._items_from_ids(
278 | Story, await self.latest_ask_story_ids(max_count)
279 | )
280 |
281 | async def latest_show_story_ids(self, max_count: int | None = None) -> list[int]:
282 | """Get the list of the latest show story IDs.
283 |
284 | Args:
285 | max_count: Maximum number of story IDs to fetch.
286 |
287 | Returns:
288 | The list of the latest show story IDs.
289 | """
290 | return await self._id_list("showstories", max_count)
291 |
292 | async def latest_show_stories(self, max_count: int | None = None) -> list[Story]:
293 | """Get the latest ShowHN stories.
294 |
295 | Args:
296 | max_count: Maximum number of stories to fetch.
297 |
298 | Returns:
299 | The list of the latest ShowHN stories.
300 | """
301 | return await self._items_from_ids(
302 | Story, await self.latest_show_story_ids(max_count)
303 | )
304 |
305 | async def latest_job_story_ids(self, max_count: int | None = None) -> list[int]:
306 | """Get the list of the latest job story IDs.
307 |
308 | Args:
309 | max_count: Maximum number of job IDs to fetch.
310 |
311 | Returns:
312 | The list of the latest job story IDs.
313 | """
314 | return await self._id_list("jobstories", max_count)
315 |
316 | async def latest_job_stories(self, max_count: int | None = None) -> list[Job]:
317 | """Get the latest job stories.
318 |
319 | Args:
320 | max_count: Maximum number of jobs to fetch.
321 |
322 | Returns:
323 | The list of the latest job stories.
324 | """
325 | return await self._items_from_ids(
326 | Job, await self.latest_job_story_ids(max_count)
327 | )
328 |
329 | async def user(self, user_id: str) -> User:
330 | """Get the details of the given user.
331 |
332 | Args:
333 | user_id: The ID of the user.
334 |
335 | Returns:
336 | The details of the user.
337 |
338 | Raises:
339 | HN.NoSuchUser: If the user is not known.
340 | """
341 | if user := loads(await self._call("user", f"{user_id}.json")):
342 | return User().populate_with(user)
343 | raise self.NoSuchUser(f"Unknown user: {user_id}")
344 |
345 | async def comments(self, item: ParentItem) -> list[Comment]:
346 | """Get the comments for the given item.
347 |
348 | Args:
349 | item: The item to get the comments for.
350 |
351 | Returns:
352 | The list of comments for the item.
353 | """
354 | return await self._items_from_ids(Comment, item.kids)
355 |
356 | async def poll_options(self, poll: Poll) -> list[PollOption]:
357 | """Get the options for the given poll.
358 |
359 | Args:
360 | item: The poll to get the options for.
361 |
362 | Returns:
363 | The list of options for the poll.
364 | """
365 | return await self._items_from_ids(PollOption, poll.parts)
366 |
367 |
368 | ### client.py ends here
369 |
--------------------------------------------------------------------------------
/oshit/hn/item/__init__.py:
--------------------------------------------------------------------------------
1 | """HackerNews item-oriented classes."""
2 |
3 | ##############################################################################
4 | # Local imports.
5 | from .article import Article
6 | from .base import Item, ItemType, ParentItem
7 | from .comment import Comment
8 | from .link import Job, Link, Story
9 | from .loader import Loader
10 | from .poll import Poll, PollOption
11 | from .unknown import UnknownItem
12 |
13 | ##############################################################################
14 | # Exports.
15 | __all__ = [
16 | "Article",
17 | "Comment",
18 | "Item",
19 | "ItemType",
20 | "Job",
21 | "Link",
22 | "Loader",
23 | "ParentItem",
24 | "Poll",
25 | "PollOption",
26 | "Story",
27 | "UnknownItem",
28 | ]
29 |
30 | ### __init__.py ends here
31 |
--------------------------------------------------------------------------------
/oshit/hn/item/article.py:
--------------------------------------------------------------------------------
1 | """Class for holding an article pulled from HackerNews.
2 |
3 | An article is defined as something that has a title, a score and
4 | descendants.
5 | """
6 |
7 | ##############################################################################
8 | # Python imports.
9 | from typing import Any
10 |
11 | from typing_extensions import Self
12 |
13 | ##############################################################################
14 | # Local imports.
15 | from .base import ParentItem
16 |
17 |
18 | ##############################################################################
19 | class Article(ParentItem):
20 | """Base class for all types of articles on HackerNews."""
21 |
22 | descendants: int = 0
23 | """The number of descendants of the article."""
24 |
25 | score: int = 0
26 | """The score of the article."""
27 |
28 | title: str = ""
29 | """The title of the article."""
30 |
31 | def populate_with(self, data: dict[str, Any]) -> Self:
32 | """Populate the item with the data from the given JSON value.
33 |
34 | Args:
35 | data: The data to populate from.
36 |
37 | Returns:
38 | Self
39 | """
40 | self.descendants = data.get("descendants", 0)
41 | self.score = data["score"]
42 | self.title = data["title"]
43 | return super().populate_with(data)
44 |
45 | def __contains__(self, search_for: str) -> bool:
46 | return (
47 | super().__contains__(search_for)
48 | or search_for.casefold() in self.title.casefold()
49 | )
50 |
51 |
52 | ### article.py ends here
53 |
--------------------------------------------------------------------------------
/oshit/hn/item/base.py:
--------------------------------------------------------------------------------
1 | """Base class for items pulled from HackerNews."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from dataclasses import dataclass, field
6 | from datetime import datetime
7 | from typing import Any, TypeVar
8 |
9 | ##############################################################################
10 | # Backward-compatible typing.
11 | from typing_extensions import Self
12 |
13 | ##############################################################################
14 | # Local imports.
15 | from ..text import tidy_text
16 |
17 |
18 | ##############################################################################
19 | @dataclass
20 | class Item:
21 | """Base class of an item found in the HackerNews API."""
22 |
23 | item_id: int = 0
24 | """The ID of the item."""
25 |
26 | by: str = ""
27 | """The author of the item."""
28 |
29 | item_type: str = ""
30 | """The API's name for the type of the item."""
31 |
32 | time: datetime = datetime(1970, 1, 1)
33 | """The time of the item."""
34 |
35 | raw_text: str = ""
36 | """The raw text of the of the item, if it has text."""
37 |
38 | def populate_with(self, data: dict[str, Any]) -> Self:
39 | """Populate the item with the data from the given JSON value.
40 |
41 | Args:
42 | data: The data to populate from.
43 |
44 | Returns:
45 | Self
46 | """
47 | self.item_id = data["id"]
48 | self.by = data.get("by", "")
49 | self.item_type = data["type"]
50 | self.time = datetime.fromtimestamp(data["time"])
51 | self.raw_text = data.get("text", "")
52 | return self
53 |
54 | @property
55 | def orange_site_url(self) -> str:
56 | """The URL of the item on HackerNews."""
57 | return f"https://news.ycombinator.com/item?id={self.item_id}"
58 |
59 | @property
60 | def visitable_url(self) -> str:
61 | """A visitable URL for the item."""
62 | return self.orange_site_url
63 |
64 | @property
65 | def text(self) -> str:
66 | """The text for the item, if it has text."""
67 | return tidy_text(self.raw_text)
68 |
69 | @property
70 | def has_text(self) -> bool:
71 | """Does the item have any text?"""
72 | return bool(self.text.strip())
73 |
74 | @property
75 | def looks_valid(self) -> bool:
76 | """Does the item look valid?"""
77 | return bool(self.item_id) and bool(self.item_type)
78 |
79 | def __contains__(self, search_for: str) -> bool:
80 | return (
81 | search_for.casefold() in self.by.casefold()
82 | or search_for.casefold() in self.text
83 | )
84 |
85 |
86 | ##############################################################################
87 | @dataclass
88 | class ParentItem(Item):
89 | """Base class for items that can have children."""
90 |
91 | kids: list[int] = field(default_factory=list)
92 | """The children of the item."""
93 |
94 | deleted: bool = False
95 | """Has this item been deleted?"""
96 |
97 | def populate_with(self, data: dict[str, Any]) -> Self:
98 | """Populate the item with the data from the given JSON value.
99 |
100 | Args:
101 | data: The data to populate from.
102 |
103 | Returns:
104 | Self
105 | """
106 | self.kids = data.get("kids", [])
107 | self.deleted = data.get("deleted", False)
108 | return super().populate_with(data)
109 |
110 |
111 | ##############################################################################
112 | ItemType = TypeVar("ItemType", bound="Item")
113 | """Generic type for an item pulled from the API."""
114 |
115 | ### base.py ends here
116 |
--------------------------------------------------------------------------------
/oshit/hn/item/comment.py:
--------------------------------------------------------------------------------
1 | """Provides the class that holds details of a HackerNews comment."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from typing import Any
6 |
7 | from typing_extensions import Self
8 |
9 | ##############################################################################
10 | # Local imports.
11 | from ..text import text_urls
12 | from .base import ParentItem
13 | from .loader import Loader
14 |
15 |
16 | ##############################################################################
17 | @Loader.loads("comment")
18 | class Comment(ParentItem):
19 | """Class that holds the details of a HackerNews comment."""
20 |
21 | parent: int = 0
22 | """The ID of the parent of the comment."""
23 |
24 | def populate_with(self, data: dict[str, Any]) -> Self:
25 | """Populate the item with the data from the given JSON value.
26 |
27 | Args:
28 | data: The data to populate from.
29 |
30 | Returns:
31 | Self
32 | """
33 | self.raw_text = data.get("text", "")
34 | self.parent = data["parent"]
35 | return super().populate_with(data)
36 |
37 | @property
38 | def urls(self) -> list[str]:
39 | """The URLs in the comment."""
40 | return text_urls(self.raw_text)
41 |
42 | @property
43 | def flagged(self) -> bool:
44 | """Does the comment appear to be flagged?"""
45 | return self.raw_text == "[flagged]"
46 |
47 | @property
48 | def dead(self) -> bool:
49 | """Does the comment appear to be dead?"""
50 | return self.raw_text == "[dead]"
51 |
52 |
53 | ### comment.py ends here
54 |
--------------------------------------------------------------------------------
/oshit/hn/item/link.py:
--------------------------------------------------------------------------------
1 | """The class that holds HackerNews items that are some sort of link."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from typing import Any
6 | from urllib.parse import urlparse
7 |
8 | from typing_extensions import Self
9 |
10 | ##############################################################################
11 | # Local imports.
12 | from .article import Article
13 | from .loader import Loader
14 |
15 |
16 | ##############################################################################
17 | class Link(Article):
18 | """Class for holding an article that potentially links to something."""
19 |
20 | url: str = ""
21 | """The URL associated with the article."""
22 |
23 | def populate_with(self, data: dict[str, Any]) -> Self:
24 | """Populate the item with the data from the given JSON value.
25 |
26 | Args:
27 | data: The data to populate from.
28 |
29 | Returns:
30 | Self
31 | """
32 | self.url = data.get("url", "")
33 | return super().populate_with(data)
34 |
35 | @property
36 | def has_url(self) -> bool:
37 | """Does this article actually have a link.
38 |
39 | Some stories fall under the banner of being linkable, but don't
40 | really have a link. This can be used to test if there really is a
41 | link or not.
42 | """
43 | return bool(self.url.strip())
44 |
45 | @property
46 | def visitable_url(self) -> str:
47 | """A visitable URL for the item."""
48 | return self.url if self.has_url else super().visitable_url
49 |
50 | @property
51 | def domain(self) -> str:
52 | """The domain from the URL, if there is one."""
53 | return urlparse(self.url).hostname or ""
54 |
55 | def __contains__(self, search_for: str) -> bool:
56 | return (
57 | super().__contains__(search_for)
58 | or search_for.casefold() in self.domain.casefold()
59 | )
60 |
61 |
62 | ##############################################################################
63 | @Loader.loads("story")
64 | class Story(Link):
65 | """Class for holding a story."""
66 |
67 |
68 | ##############################################################################
69 | @Loader.loads("job")
70 | class Job(Link):
71 | """Class for holding a job."""
72 |
73 |
74 | ### link.py ends here
75 |
--------------------------------------------------------------------------------
/oshit/hn/item/loader.py:
--------------------------------------------------------------------------------
1 | """Central item type to item class type matching code."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from typing import Any, Callable
6 |
7 | ##############################################################################
8 | # Local imports.
9 | from .base import Item, ItemType
10 | from .unknown import UnknownItem
11 |
12 |
13 | ##############################################################################
14 | class Loader:
15 | """Helper class for loading up HackerNews items."""
16 |
17 | _map: dict[str, type[Item]] = {}
18 | """The map of type names to actual types."""
19 |
20 | @classmethod
21 | def loads(cls, item_type: str) -> Callable[[type[ItemType]], type[ItemType]]:
22 | """Decorator for declaring that a class loads a particular item type.
23 |
24 | Args:
25 | item_type: The HackerNews item type string to associate with the class.
26 | """
27 |
28 | def _register(handler: type[ItemType]) -> type[ItemType]:
29 | """Register the item class."""
30 | cls._map[item_type] = handler
31 | return handler
32 |
33 | return _register
34 |
35 | @classmethod
36 | def load(cls, data: dict[str, Any]) -> Item:
37 | """Load the JSON data into the desired type.
38 |
39 | Args:
40 | data: The JSON data to load up.
41 |
42 | Returns:
43 | An instance of a item class, of the best-fit type.
44 | """
45 | return cls._map.get(data["type"], UnknownItem)().populate_with(data)
46 |
47 |
48 | ### loader.py ends here
49 |
--------------------------------------------------------------------------------
/oshit/hn/item/poll.py:
--------------------------------------------------------------------------------
1 | """Class for holding a poll pulled from HackerNews."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from dataclasses import dataclass, field
6 | from typing import Any
7 |
8 | ##############################################################################
9 | # Backward-compatible typing.
10 | from typing_extensions import Self
11 |
12 | ##############################################################################
13 | # Local imports.
14 | from .article import Article
15 | from .base import Item
16 | from .loader import Loader
17 |
18 |
19 | ##############################################################################
20 | @dataclass
21 | @Loader.loads("poll")
22 | class Poll(Article):
23 | """Class that holds the details of a HackerNews poll."""
24 |
25 | parts: list[int] = field(default_factory=list)
26 | """The list of IDs for the parts of the poll."""
27 |
28 | def populate_with(self, data: dict[str, Any]) -> Self:
29 | """Populate the item with the data from the given JSON value.
30 |
31 | Args:
32 | data: The data to populate from.
33 |
34 | Returns:
35 | Self
36 | """
37 | self.parts = data.get("parts", [])
38 | return super().populate_with(data)
39 |
40 |
41 | ##############################################################################
42 | @dataclass
43 | @Loader.loads("pollopt")
44 | class PollOption(Item):
45 | """Class for holding the details of a poll option."""
46 |
47 | poll: int = 0
48 | """The ID of the poll that the option belongs to."""
49 |
50 | score: int = 0
51 | """The score of the poll option."""
52 |
53 | text: str = ""
54 | """The text for the poll option."""
55 |
56 | def populate_with(self, data: dict[str, Any]) -> Self:
57 | """Populate the item with the data from the given JSON value.
58 |
59 | Args:
60 | data: The data to populate from.
61 |
62 | Returns:
63 | Self
64 | """
65 | self.poll = data.get("poll", 0)
66 | self.score = data.get("score", 0)
67 | self.text = data.get("text", "")
68 | return super().populate_with(data)
69 |
70 |
71 | ### poll.py ends here
72 |
--------------------------------------------------------------------------------
/oshit/hn/item/unknown.py:
--------------------------------------------------------------------------------
1 | """Type of an unknown item."""
2 |
3 | ##############################################################################
4 | # Local imports.
5 | from .base import Item
6 |
7 |
8 | ##############################################################################
9 | class UnknownItem(Item):
10 | """A fallback while I work on this. This will go away."""
11 |
12 |
13 | ### unknown.py ends here
14 |
--------------------------------------------------------------------------------
/oshit/hn/text.py:
--------------------------------------------------------------------------------
1 | """Utility code for working with text from HackerNews."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from html import unescape
6 | from re import compile as compile_re
7 | from re import sub
8 | from typing import Pattern
9 |
10 | ##############################################################################
11 | # Backward-compatible typing.
12 | from typing_extensions import Final
13 |
14 | ##############################################################################
15 | # TODO! Throw in some proper HTML parsing here.
16 | ##############################################################################
17 |
18 |
19 | ##############################################################################
20 | def tidy_text(text: str) -> str:
21 | """Tidy up some text from the HackerNews API.
22 |
23 | Args:
24 | text: The text to tidy up.
25 |
26 | Returns:
27 | The text tidied up for use in the terminal rather than on the web.
28 | """
29 | return sub("<[^<]+?>", "", unescape(text.replace("", "\n\n")))
30 |
31 |
32 | HREF: Final[Pattern[str]] = compile_re(r'href="([^"]+)"')
33 | """Regular expression for finding links in some text."""
34 |
35 |
36 | ##############################################################################
37 | def text_urls(text: str) -> list[str]:
38 | """Find any links in the given text.
39 |
40 | Args:
41 | text: The text to look in.
42 |
43 | Returns:
44 | The list of links found in the text.
45 | """
46 | return HREF.findall(unescape(text))
47 |
48 |
49 | ### text.py ends here
50 |
--------------------------------------------------------------------------------
/oshit/hn/user.py:
--------------------------------------------------------------------------------
1 | """Class that holds the details of a HackerNews user."""
2 |
3 | ##############################################################################
4 | # Python imports.
5 | from dataclasses import dataclass, field
6 | from datetime import datetime
7 | from typing import Any
8 |
9 | ##############################################################################
10 | # Backward-compatible typing.
11 | from typing_extensions import Self
12 |
13 | ##############################################################################
14 | # Local imports.
15 | from .text import tidy_text
16 |
17 |
18 | ##############################################################################
19 | @dataclass
20 | class User:
21 | """Details of a HackerNews user."""
22 |
23 | user_id: str = ""
24 | """The ID of the user."""
25 |
26 | raw_about: str = ""
27 | """The raw version of the user's about text."""
28 |
29 | karma: int = 0
30 | """The user's karma."""
31 |
32 | created: datetime = datetime(1970, 1, 1)
33 | """The time the user was created."""
34 |
35 | submitted: list[int] = field(default_factory=list)
36 | """The stories, polls and comments the user has submitted."""
37 |
38 | def populate_with(self, data: dict[str, Any]) -> Self:
39 | """Populate the user with details from the given data.
40 |
41 | Args:
42 | data: The data to populate from.
43 |
44 | Returns:
45 | Self.
46 | """
47 | self.user_id = data["id"]
48 | self.raw_about = data.get("about", "").strip()
49 | self.karma = data["karma"]
50 | self.created = datetime.fromtimestamp(data["created"])
51 | self.submitted = data.get("submitted", [])
52 | return self
53 |
54 | @property
55 | def has_about(self) -> bool:
56 | """Does the user have an about text?"""
57 | return bool(self.raw_about)
58 |
59 | @property
60 | def about(self) -> str:
61 | """A clean version of the about text for the user."""
62 | return tidy_text(self.raw_about)
63 |
64 | @property
65 | def url(self) -> str:
66 | """The HackerNews URL for the user."""
67 | return f"https://news.ycombinator.com/user?id={self.user_id}"
68 |
69 |
70 | ### user.py ends here
71 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=42", "wheel"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | ### pyproject.toml ends here
6 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = oshit
3 | description = A terminal-based HackerNews reader
4 | version = attr: oshit.__version__
5 | long_description = file: README.md
6 | long_description_content_type = text/markdown
7 | url = https://github.com/davep/oshit
8 | author = Dave Pearson
9 | author_email = davep@davep.org
10 | maintainer = Dave Pearson
11 | maintainer_email = davep@davep.org
12 | license = License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
13 | license_files = LICENCE
14 | keywords = terminal
15 | classifiers =
16 | License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
17 | Environment :: Console
18 | Development Status :: 4 - Beta
19 | Intended Audience :: End Users/Desktop
20 | Natural Language :: English
21 | Operating System :: OS Independent
22 | Programming Language :: Python :: 3.10
23 | Programming Language :: Python :: 3.11
24 | Programming Language :: Python :: 3.12
25 | Topic :: Internet
26 | Topic :: Terminals
27 | Typing :: Typed
28 | project_urls =
29 | Documentation = https://github.com/davep/oshit/blob/main/README.md
30 | Source = https://github.com/davep/oshit
31 | Issues = https://github.com/davep/oshit/issues
32 | Discussions = https://github.com/davep/oshit/discussions
33 |
34 | [options]
35 | packages = find:
36 | platforms = any
37 | include_package_data = True
38 | python_requires = >=3.10,<3.13
39 | install_requires =
40 | textual==0.70.0
41 | humanize>=4.8.0
42 | xdg-base-dirs>=6.0.0
43 | httpx
44 |
45 | [options.entry_points]
46 | console_scripts =
47 | oshit = oshit.__main__:run
48 |
49 | ### setup.cfg ends here
50 |
--------------------------------------------------------------------------------