lobstr 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lobstr-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.3
2
+ Name: lobstr
3
+ Version: 0.5.0
4
+ Summary: unofficial API client for lobste.rs
5
+ Keywords: lobste.rs,lobsters,asyncio,httpx,pydantic
6
+ Author: kavin
7
+ Author-email: kavin <kavin.srinivasan2@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: License :: OSI Approved :: MIT License
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 :: Internet
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Dist: httpx>=0.28.1
20
+ Requires-Dist: pydantic>=2.13.4
21
+ Requires-Python: >=3.10
22
+ Project-URL: Homepage, https://github.com/kavin81/lobstr
23
+ Project-URL: Issues, https://github.com/kavin81/lobstr/issues
24
+ Project-URL: Repository, https://github.com/kavin81/lobstr
25
+ Description-Content-Type: text/markdown
26
+
27
+ # lobstr
28
+
29
+ `lobstr` is an unofficial async-first object-oriented api wrapper for [lobste.rs](https://lobste.rs/).
30
+
31
+ the library only supports `GET` requests or read operations.
32
+ It plans to support the following objects
33
+ - [x] feeds (homepage, newest, by tags, etc)
34
+ - [x] stories (the post + comments)
35
+ - [x] comments (info about a singular comment)
36
+ - [x] tags (info regarding a tag, list of all tags)
37
+ - [ ] search (search for stories, comments, users)
38
+
39
+ ## Table of Contents
40
+
41
+ * [Features](#features)
42
+ * [Installation](#installation)
43
+ * [Quick Start](#quick-start)
44
+ * [Documentation](#documentation)
45
+ * [License](#license)
46
+
47
+ ## Features
48
+
49
+ * Async-first API built on `httpx`
50
+ * Fully typed Pydantic models
51
+ * Well-documented sdk with docstrings
52
+
53
+ ## Installation
54
+
55
+ > Requires Python 3.10 or higher.
56
+
57
+ ### pip
58
+
59
+ ```bash
60
+ pip install lobstr
61
+ ```
62
+
63
+ ### uv
64
+
65
+ ```bash
66
+ uv add lobstr
67
+ ```
68
+
69
+ ### Poetry
70
+
71
+ ```bash
72
+ poetry add lobstr
73
+ ```
74
+
75
+ ### Pipenv
76
+
77
+ ```bash
78
+ pipenv install lobstr
79
+ ```
80
+
81
+ ## Quick Start
82
+
83
+ ### Fetch a Story
84
+
85
+ ```python
86
+ import asyncio
87
+ from lobstr import Lobster
88
+
89
+ async def main():
90
+ async with Lobster() as lob:
91
+ story = await lob.story(short_id="ishgbs")
92
+ print(story.title) # title of the story
93
+ print(story.username) # author's username
94
+ print(story.article_url) # article attached to the story
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ ### Fetch Hottest Stories
100
+
101
+ ```python
102
+ import asyncio
103
+ from lobstr import Lobster
104
+
105
+ async def main():
106
+ async with Lobster() as lob:
107
+ stories = await lob.feeds.hot()
108
+ for story in stories[:5]: # print the top 5 hottest stories
109
+ print(story.title) # title of the story
110
+ print(story.username) # author's username
111
+ print(story.article_url) # article attached to the story
112
+
113
+ asyncio.run(main())
114
+ ```
115
+
116
+
117
+ ## Documentation
118
+
119
+ Complete documentation, API reference, guides, and examples are available at:
120
+
121
+ **https://kavin81.github.io/lobstr**
122
+
123
+ For lobste.rs API documentation, [`API_SPEC.md`](./API_SPEC.md).
124
+ ## License
125
+
126
+ - [MIT](LICENSE)
lobstr-0.5.0/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # lobstr
2
+
3
+ `lobstr` is an unofficial async-first object-oriented api wrapper for [lobste.rs](https://lobste.rs/).
4
+
5
+ the library only supports `GET` requests or read operations.
6
+ It plans to support the following objects
7
+ - [x] feeds (homepage, newest, by tags, etc)
8
+ - [x] stories (the post + comments)
9
+ - [x] comments (info about a singular comment)
10
+ - [x] tags (info regarding a tag, list of all tags)
11
+ - [ ] search (search for stories, comments, users)
12
+
13
+ ## Table of Contents
14
+
15
+ * [Features](#features)
16
+ * [Installation](#installation)
17
+ * [Quick Start](#quick-start)
18
+ * [Documentation](#documentation)
19
+ * [License](#license)
20
+
21
+ ## Features
22
+
23
+ * Async-first API built on `httpx`
24
+ * Fully typed Pydantic models
25
+ * Well-documented sdk with docstrings
26
+
27
+ ## Installation
28
+
29
+ > Requires Python 3.10 or higher.
30
+
31
+ ### pip
32
+
33
+ ```bash
34
+ pip install lobstr
35
+ ```
36
+
37
+ ### uv
38
+
39
+ ```bash
40
+ uv add lobstr
41
+ ```
42
+
43
+ ### Poetry
44
+
45
+ ```bash
46
+ poetry add lobstr
47
+ ```
48
+
49
+ ### Pipenv
50
+
51
+ ```bash
52
+ pipenv install lobstr
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ### Fetch a Story
58
+
59
+ ```python
60
+ import asyncio
61
+ from lobstr import Lobster
62
+
63
+ async def main():
64
+ async with Lobster() as lob:
65
+ story = await lob.story(short_id="ishgbs")
66
+ print(story.title) # title of the story
67
+ print(story.username) # author's username
68
+ print(story.article_url) # article attached to the story
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ ### Fetch Hottest Stories
74
+
75
+ ```python
76
+ import asyncio
77
+ from lobstr import Lobster
78
+
79
+ async def main():
80
+ async with Lobster() as lob:
81
+ stories = await lob.feeds.hot()
82
+ for story in stories[:5]: # print the top 5 hottest stories
83
+ print(story.title) # title of the story
84
+ print(story.username) # author's username
85
+ print(story.article_url) # article attached to the story
86
+
87
+ asyncio.run(main())
88
+ ```
89
+
90
+
91
+ ## Documentation
92
+
93
+ Complete documentation, API reference, guides, and examples are available at:
94
+
95
+ **https://kavin81.github.io/lobstr**
96
+
97
+ For lobste.rs API documentation, [`API_SPEC.md`](./API_SPEC.md).
98
+ ## License
99
+
100
+ - [MIT](LICENSE)
@@ -0,0 +1,5 @@
1
+ from .client import Lobstr
2
+
3
+ __all__ = ["Lobstr"]
4
+
5
+
@@ -0,0 +1,55 @@
1
+ import httpx
2
+
3
+ from lobstr.services import (
4
+ CommentService,
5
+ FeedService,
6
+ StoryService,
7
+ UserService,
8
+ TagService,
9
+ )
10
+
11
+ from .constants import CONSTANTS
12
+
13
+
14
+ class Lobstr:
15
+ """
16
+ main client class for interacting with the Lobstr API.
17
+
18
+ Attributes:
19
+ client (httpx.AsyncClient): The HTTP client used for making requests.
20
+ user (UserService): Service for user-related operations.
21
+ comment (CommentService): Service for comment-related operations.
22
+ feed (FeedService): Service for feed-related operations.
23
+ story (StoryService): Service for story-related operations.
24
+ tag (TagService): Service for tag-related operations.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str = CONSTANTS.BASE_URL,
30
+ timeout: int = CONSTANTS.DEFAULT_TIMEOUT,
31
+ ) -> None:
32
+ self.client: httpx.AsyncClient = httpx.AsyncClient(
33
+ base_url=base_url,
34
+ timeout=timeout,
35
+ event_hooks={
36
+ # "response": [raise_for_status],
37
+ },
38
+ )
39
+
40
+ self.user = UserService(self.client)
41
+ self.comment = CommentService(self.client)
42
+ self.feed = FeedService(self.client)
43
+ self.story = StoryService(self.client)
44
+ self.tag = TagService(self.client)
45
+
46
+ async def __aenter__(self):
47
+ return self
48
+
49
+ async def __aexit__(
50
+ self,
51
+ exc_type,
52
+ exc,
53
+ tb,
54
+ ):
55
+ await self.client.aclose()
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Constants(BaseModel):
5
+ BASE_URL: str = "https://lobste.rs"
6
+ DEFAULT_TIMEOUT: int = 10
7
+
8
+
9
+ CONSTANTS = Constants()
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from httpx import URL
4
+
5
+
6
+ class LobstersError(Exception):
7
+ """Base exception for the lobste.rs client."""
8
+
9
+
10
+ class LobstersAPIError(LobstersError):
11
+ """Base exception for API-related failures."""
12
+
13
+ def __init__(
14
+ self,
15
+ *,
16
+ resource: str,
17
+ identifier: str | int,
18
+ endpoint: URL,
19
+ status_code: int,
20
+ ) -> None:
21
+ self.identifier = identifier
22
+ self.endpoint = endpoint
23
+ self.status_code = status_code
24
+
25
+ identifier_repr = (
26
+ repr(identifier) if isinstance(identifier, str) else str(identifier)
27
+ )
28
+
29
+ super().__init__(
30
+ f"{resource} {identifier_repr} not found "
31
+ f"(GET {endpoint} returned {status_code})"
32
+ )
33
+
34
+
35
+ class ResourceNotFound(LobstersAPIError):
36
+ """Base exception for missing Lobsters resources."""
37
+
38
+ resource_name = "Resource"
39
+ status_code = 404
40
+
41
+ def __init__(
42
+ self,
43
+ identifier: str | int,
44
+ *,
45
+ endpoint: URL,
46
+ ) -> None:
47
+ super().__init__(
48
+ resource=self.resource_name,
49
+ identifier=identifier,
50
+ endpoint=endpoint,
51
+ status_code=self.status_code,
52
+ )
53
+
54
+
55
+ class StoryNotFound(ResourceNotFound):
56
+ resource_name = "Story"
57
+
58
+ def __init__(self, story_id: str, *, endpoint: URL) -> None:
59
+ self.story_id = story_id
60
+ super().__init__(
61
+ identifier=story_id,
62
+ endpoint=endpoint,
63
+ )
64
+
65
+
66
+ class CommentNotFound(ResourceNotFound):
67
+ resource_name = "Comment"
68
+ status_code = 400
69
+
70
+ def __init__(self, comment_id: str, *, endpoint: URL) -> None:
71
+ self.comment_id = comment_id
72
+ super().__init__(
73
+ identifier=comment_id,
74
+ endpoint=endpoint,
75
+ )
76
+
77
+
78
+ class UserNotFound(ResourceNotFound):
79
+ resource_name = "User"
80
+
81
+ def __init__(self, username: str, *, endpoint: URL) -> None:
82
+ self.username = username
83
+ super().__init__(
84
+ identifier=username,
85
+ endpoint=endpoint,
86
+ )
87
+
88
+
89
+ class TagNotFound(ResourceNotFound):
90
+ resource_name = "Tag"
91
+
92
+ def __init__(self, tag: str, *, endpoint: URL) -> None:
93
+ self.tag = tag
94
+ super().__init__(
95
+ identifier=tag,
96
+ endpoint=endpoint,
97
+ )
98
+
99
+
100
+ class FeedPageNotFound(ResourceNotFound):
101
+ resource_name = "Feed page"
102
+
103
+ def __init__(self, page: int, *, endpoint: URL) -> None:
104
+ self.page = page
105
+ super().__init__(
106
+ identifier=page,
107
+ endpoint=endpoint,
108
+ )
109
+
110
+
111
+ # [x] for story /s/not_found.json -> STATUS_CODE=404
112
+ # [x] for comments /c/not_found.json -> STATUS_CODE=400 , MSG="can't find comment"
113
+ # [x] for feeds /*/page/<page_no>.json -> STATUS_CODE=404
114
+ # [x] for users /~usernamenotfound.json -> STATUS_CODE=404
115
+ # [x] for tags /t/tag_does_not_exist.json -> STATUS_CODE=404
@@ -0,0 +1,18 @@
1
+ from .core import PageMixin
2
+ from .user import User
3
+ from .post import Post
4
+ from .feed import Feed
5
+ from .comment import Comment
6
+ from .story import Story
7
+ from .tag import Tag, Tags
8
+
9
+ __all__: list[str] = [
10
+ "PageMixin",
11
+ "User",
12
+ "Post",
13
+ "Feed",
14
+ "Comment",
15
+ "Story",
16
+ "Tag",
17
+ "Tags",
18
+ ]
@@ -0,0 +1,73 @@
1
+ from datetime import datetime
2
+ from typing import Optional, Any
3
+
4
+ from pydantic import BaseModel, Field, ConfigDict, model_validator
5
+
6
+ from .core import Content, URLPair
7
+
8
+
9
+ class Comment(BaseModel):
10
+ """
11
+ Represents a comment on a [post](https://lobste.rs/c/szmc6l).
12
+
13
+ Attributes:
14
+ id (str): The unique identifier for the comment.
15
+ created_at (datetime): The timestamp when the comment was created.
16
+ edited_at (Optional[datetime]): The timestamp when the comment was last edited, if applicable.
17
+ is_deleted (bool): Indicates whether the comment has been deleted.
18
+ is_moderated (bool): Indicates whether the comment has been moderated.
19
+ score (int): The score of the comment, representing upvotes minus downvotes.
20
+ flags (int): The number of flags the comment has received.
21
+ depth (int): The depth of the comment in the thread hierarchy.
22
+ username (str): The username of the commenter.
23
+ content (Content): The content of the comment, including both HTML (`.content`) and plain text versions (`.content.plain`).
24
+ parent (Optional[str]): The ID of the parent comment, if this comment is a reply.
25
+ url (URLPair): URLs linking to the comment, `url.canonical` and `url.short` versions.
26
+ """
27
+
28
+ model_config = ConfigDict(
29
+ arbitrary_types_allowed=True,
30
+ extra="ignore",
31
+ )
32
+
33
+ id: str = Field(validation_alias="short_id")
34
+
35
+ created_at: datetime
36
+ edited_at: Optional[datetime] = Field(
37
+ default=None,
38
+ validation_alias="last_edited_at",
39
+ )
40
+
41
+ is_deleted: bool
42
+ is_moderated: bool
43
+
44
+ score: int
45
+ flags: int
46
+ depth: int
47
+
48
+ username: str = Field(validation_alias="commenting_user")
49
+
50
+ content: Content
51
+
52
+ parent: Optional[str] = Field(
53
+ default=None,
54
+ validation_alias="parent_comment",
55
+ )
56
+
57
+ url: URLPair
58
+
59
+ @model_validator(mode="before")
60
+ @classmethod
61
+ def comment_preprocessing(cls, data: Any):
62
+ if isinstance(data, dict):
63
+ data["content"] = Content(
64
+ data.pop("comment", ""),
65
+ data.pop("comment_plain", ""),
66
+ )
67
+
68
+ data["url"] = URLPair(
69
+ canonical=data.pop("url"),
70
+ short=data.pop("short_id_url"),
71
+ )
72
+
73
+ return data
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class URLPair:
8
+ """
9
+ dataclass that holds both canonical and short versions of a URL to a resource.
10
+
11
+ Attributes:
12
+ canonical (str): The canonical version of the URL.
13
+ short (str): The shortened version of the URL.
14
+ """
15
+
16
+ canonical: str
17
+ short: str
18
+
19
+
20
+ class Content(str):
21
+ """
22
+ A string subclass that holds both the original content and a plain version of it.
23
+
24
+ Attributes:
25
+ cls/self (str): The original HTML content.
26
+ plain (str): The plain version of the content.
27
+ """
28
+
29
+ __slots__ = ("plain",)
30
+
31
+ def __new__(cls, content: str, plain_content: str):
32
+ obj = super().__new__(cls, content)
33
+ obj.plain = plain_content
34
+ return obj
35
+
36
+
37
+ class PageMixin(BaseModel):
38
+ """Mixin for adding pagination information to a model."""
39
+
40
+ page: int = 1
@@ -0,0 +1,15 @@
1
+ from .post import Post
2
+ from .core import PageMixin
3
+ from pydantic import Field
4
+
5
+
6
+ class Feed(PageMixin):
7
+ """
8
+ A Feed is an array of posts along with pagination info
9
+
10
+ Attributes:
11
+ posts (list[Post]): A list of Post objects in the feed
12
+ page (int): The current page number
13
+ """
14
+
15
+ posts: list[Post] = Field(default_factory=list)
@@ -0,0 +1,62 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, Field, ConfigDict, model_validator
4
+
5
+ from .core import Content, URLPair
6
+
7
+
8
+ class Post(BaseModel):
9
+ """
10
+ Represents a post on the lobste.rs [home page](https://lobste.rs).
11
+
12
+ Attributes:
13
+ id (str): The unique identifier for the post.
14
+ description (Content): The description of the post, including both HTML (`.description`) and plain text versions (`.description.plain`).
15
+ created_at (datetime): The timestamp when the post was created.
16
+ title (str): The title of the post.
17
+ score (int): The score of the post, representing upvotes minus downvotes.
18
+ flags (int): The number of flags the post has received.
19
+ tags (list[str]): A list of [tags](https://lobste.rs/tags) associated with the post.
20
+ comment_count (int): The number of comments on the post.
21
+ username (str): The username of the poster.
22
+ article_url (str): The URL of the article linked to the post.
23
+ url (URLPair): A pair of URLs for the post, including canonical (`.url.canonical`) and short (`.url.short`) versions.
24
+ """
25
+
26
+ model_config = ConfigDict(
27
+ arbitrary_types_allowed=True,
28
+ extra="ignore",
29
+ )
30
+
31
+ id: str = Field(validation_alias="short_id")
32
+ description: Content
33
+
34
+ created_at: datetime
35
+
36
+ title: str
37
+ score: int
38
+ flags: int
39
+ comment_count: int
40
+ username: str = Field(validation_alias="submitter_user")
41
+
42
+ article_url: str
43
+ url: URLPair
44
+
45
+ tags: list[str] = Field(default_factory=list)
46
+
47
+ @model_validator(mode="before")
48
+ @classmethod
49
+ def post_preprocessing(cls, data):
50
+ if isinstance(data, dict):
51
+ data["description"] = Content(
52
+ data.pop("description", ""),
53
+ data.pop("description_plain", ""),
54
+ )
55
+ data["article_url"] = data.pop("url")
56
+
57
+ data["url"] = URLPair(
58
+ canonical=data.pop("comments_url"),
59
+ short=data.pop("short_id_url"),
60
+ )
61
+
62
+ return data
@@ -0,0 +1,28 @@
1
+ from typing import List
2
+
3
+ from pydantic import Field
4
+
5
+ from .comment import Comment
6
+ from .post import Post
7
+
8
+
9
+ class Story(Post):
10
+ """
11
+ Represents a [Story](https://lobste.rs/s/109l2t) i.e. A Post & Its comments
12
+
13
+ Attributes:
14
+ id (str): The unique identifier for the post.
15
+ description (Content): The description of the post, including both HTML (`.description`) and plain text versions (`.description.plain`).
16
+ created_at (datetime): The timestamp when the post was created.
17
+ title (str): The title of the post.
18
+ score (int): The score of the post, representing upvotes minus downvotes.
19
+ flags (int): The number of flags the post has received.
20
+ tags (list[str]): A list of [tags](https://lobste.rs/tags) associated with the post.
21
+ comment_count (int): The number of comments on the post.
22
+ username (str): The username of the poster.
23
+ article_url (str): The URL of the article linked to the post.
24
+ url (URLPair): A pair of URLs for the post, including canonical (`.url.canonical`) and short (`.url.short`) versions.
25
+ comments (List[Comment]): A list of comments associated with the story
26
+ """
27
+
28
+ comments: List[Comment] = Field(default_factory=list)
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel, Field, TypeAdapter
2
+ from typing import List
3
+
4
+
5
+ class Tag(BaseModel):
6
+ """
7
+ Represents metadata related to a [tag](https://lobste.rs/tags).
8
+
9
+ Attributes:
10
+ name (str): The name of the tag.
11
+ description (str): A brief description of the tag.
12
+ category (str): The category of the tag.
13
+ is_privileged (bool): Indicates if the tag needs additional permissions to be used.
14
+ is_media (bool): Indicates if the tag is related to media content.
15
+ is_active (bool): Indicates if the tag is currently active.
16
+ hotness_mod (float): A modifier that affects the "hotness" score of stories (used for home page ranking)
17
+ """
18
+
19
+ name: str = Field(..., alias="tag")
20
+ description: str
21
+ category: str
22
+
23
+ is_privileged: bool = Field(..., alias="privileged")
24
+ is_media: bool
25
+ is_active: bool = Field(..., alias="active")
26
+
27
+ hotness_mod: float
28
+
29
+
30
+ Tags = TypeAdapter(List[Tag])
@@ -0,0 +1,31 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class User(BaseModel):
7
+ """
8
+ represents a [user](https://lobste.rs/~pushcx).
9
+
10
+ Attributes:
11
+ username (str): The user's username.
12
+ karma (int): The user's karma score.
13
+ about (str): The user's about section.
14
+ avatar_url (str): The URL of the user's avatar image.
15
+ created_at (datetime): The date and time when the user was created.
16
+ is_admin (bool): Whether the user is an admin.
17
+ is_moderator (bool): Whether the user is a moderator.
18
+ invited_by_user (Optional[str]): The username of the user who invited this user, if applicable.
19
+ """
20
+
21
+ username: str
22
+ karma: int
23
+ about: str
24
+ avatar_url: str
25
+
26
+ created_at: datetime
27
+
28
+ is_admin: bool
29
+ is_moderator: bool
30
+
31
+ invited_by_user: Optional[str] = None
@@ -0,0 +1,13 @@
1
+ from .user import UserService
2
+ from .comment import CommentService
3
+ from .feed import FeedService
4
+ from .story import StoryService
5
+ from .tag import TagService
6
+
7
+ __all__ = [
8
+ "UserService",
9
+ "CommentService",
10
+ "FeedService",
11
+ "StoryService",
12
+ "TagService",
13
+ ]
@@ -0,0 +1,36 @@
1
+ from lobstr.exceptions import CommentNotFound
2
+
3
+ from .core import BaseService
4
+ from lobstr.models import Comment
5
+
6
+
7
+ class CommentService(BaseService):
8
+ """
9
+ Service for interacting with the comments API.
10
+
11
+ Methods:
12
+ `get(short_id: str) -> Comment`: Retrieve a comment by its short ID.
13
+ """
14
+
15
+ async def get(self, short_id: str) -> Comment:
16
+ """
17
+ Retrieve a comment by its short ID.
18
+
19
+ Args:
20
+ short_id (str): The short ID of the comment to retrieve.
21
+
22
+ Returns:
23
+ Comment: The retrieved comment.
24
+
25
+ Raises:
26
+ CommentNotFound: If the comment with the given short ID does not exist.
27
+ HTTPError: If the request to the API fails for any other reason.
28
+ """
29
+ resp = await self._client.get(f"/c/{short_id}.json")
30
+ if resp.status_code == 400 and resp.text == "can't find comment":
31
+ raise CommentNotFound(
32
+ comment_id=short_id,
33
+ endpoint=resp.url,
34
+ )
35
+ resp.raise_for_status()
36
+ return Comment.model_validate(resp.json())
@@ -0,0 +1,6 @@
1
+ import httpx
2
+
3
+
4
+ class BaseService:
5
+ def __init__(self, client: httpx.AsyncClient) -> None:
6
+ self._client = client
@@ -0,0 +1,106 @@
1
+ from lobstr.exceptions import FeedPageNotFound
2
+
3
+ from .core import BaseService
4
+ from lobstr.models import Feed
5
+
6
+
7
+ class FeedService(BaseService):
8
+ """
9
+ Service for interacting with the feed endpoints of the Lobstr API.
10
+
11
+ Methods:
12
+ `hot(page: int = 1) -> Feed`: Retrieve the hottest posts from the feed.
13
+ `new(page: int = 1) -> Feed`: Retrieve the newest posts from the feed.
14
+ `tag(tag: str, page: int = 1) -> Feed`: Retrieve posts associated with a specific tag.
15
+ `filter(*tag: str, page: int = 1) -> Feed`: Retrieve posts associated with multiple tags.
16
+ """
17
+
18
+ async def _feed(self, endpoint: str, page: int = 1) -> Feed:
19
+ """
20
+ Internal method to retrieve feed data from a specific endpoint and page.
21
+ """
22
+ resp = await self._client.get(f"{endpoint}/page/{page}.json")
23
+ if resp.status_code == 404:
24
+ raise FeedPageNotFound(
25
+ page=page,
26
+ endpoint=resp.url,
27
+ )
28
+ resp.raise_for_status()
29
+
30
+ return Feed.model_validate(
31
+ {
32
+ "page": page,
33
+ "posts": resp.json(),
34
+ }
35
+ )
36
+
37
+ async def hot(self, page: int = 1) -> Feed:
38
+ """
39
+ Retrieve the hottest posts from the feed.
40
+
41
+ Args:
42
+ page (int): The page number to retrieve. Defaults to 1.
43
+
44
+ Returns:
45
+ Feed: The feed object containing the hottest posts for the specified page.
46
+
47
+ Raises:
48
+ FeedPageNotFound: If the specified page does not exist in the feed.
49
+ HTTPError: If the request to the API fails for any other reason.
50
+ """
51
+ resp = await self._feed("/hottest", page)
52
+ return resp
53
+
54
+ async def new(self, page: int = 1) -> Feed:
55
+ """
56
+ Retrieve the newest posts from the feed.
57
+
58
+ Args:
59
+ page (int): The page number to retrieve. Defaults to 1.
60
+
61
+
62
+ Returns:
63
+ Feed: The feed object containing the newest posts for the specified page.
64
+
65
+ Raises:
66
+ FeedPageNotFound: If the specified page does not exist in the feed.
67
+ HTTPError: If the request to the API fails for any other reason.
68
+ """
69
+ resp = await self._feed("/newest", page)
70
+ return resp
71
+
72
+ async def tag(self, tag: str, page: int = 1) -> Feed:
73
+ """
74
+ Retrieve posts associated with a specific tag.
75
+
76
+ Args:
77
+ tag (str): The tag to filter posts by.
78
+ page (int): The page number to retrieve. Defaults to 1.
79
+
80
+ Returns:
81
+ Feed: The feed object containing posts associated with the specified tag for the specified page.
82
+
83
+ Raises:
84
+ FeedPageNotFound: If the specified page does not exist for the given tag in the feed.
85
+ HTTPError: If the request to the API fails for any other reason.
86
+ """
87
+ resp = await self._feed(f"/t/{tag}", page)
88
+ return resp
89
+
90
+ async def filter(self, *tag: str, page: int = 1) -> Feed:
91
+ """
92
+ Retrieve posts associated with multiple tags.
93
+
94
+ Args:
95
+ *tag (str): The tags to filter posts by.
96
+ page (int): The page number to retrieve. Defaults to 1.
97
+
98
+ Returns:
99
+ Feed: The feed object containing posts associated with the specified tags for the specified page.
100
+
101
+ Raises:
102
+ FeedPageNotFound: If the specified page does not exist for the given tags in the feed.
103
+ HTTPError: If the request to the API fails for any other reason.
104
+ """
105
+ resp = await self._feed(f"/t/{','.join(tag)}", page)
106
+ return resp
@@ -0,0 +1,35 @@
1
+ from .core import BaseService
2
+ from lobstr.models import Story
3
+ from lobstr.exceptions import StoryNotFound
4
+
5
+
6
+ class StoryService(BaseService):
7
+ """
8
+ Service for interacting with the story endpoints of the Lobstr API.
9
+
10
+ Methods:
11
+ `get(short_id: str) -> Story`: Retrieve a story by its short ID.
12
+ """
13
+
14
+ async def get(self, short_id: str) -> Story:
15
+ """
16
+ Retrieve a story by its short ID.
17
+
18
+ Args:
19
+ short_id (str): The short ID of the story to retrieve.
20
+
21
+ Returns:
22
+ Story: An instance of the Story model representing the retrieved story.
23
+
24
+ Raises:
25
+ StoryNotFound: If the story with the given short ID does not exist.
26
+ HTTPError: If the request to the API fails for any other reason.
27
+ """
28
+ resp = await self._client.get(f"/s/{short_id}.json")
29
+ if resp.status_code == 404:
30
+ raise StoryNotFound(
31
+ story_id=short_id,
32
+ endpoint=resp.url,
33
+ )
34
+ resp.raise_for_status()
35
+ return Story.model_validate(resp.json())
@@ -0,0 +1,46 @@
1
+ from typing import List
2
+
3
+ from lobstr.models import Tag, Tags
4
+ from lobstr.exceptions import TagNotFound
5
+
6
+ from .core import BaseService
7
+
8
+
9
+ class TagService(BaseService):
10
+ """
11
+ Service for interacting with the tag endpoints of the Lobstr API.
12
+
13
+ Methods:
14
+ `get(name: str) -> Tag`: Retrieve a tag by its name.
15
+ `list() -> List[Tag]`: Retrieve a list of all available tags.
16
+ """
17
+
18
+ async def get(self, name: str) -> Tag:
19
+ """
20
+ Retrieve a tag by its name.
21
+
22
+ Args:
23
+ name (str): The name of the tag to retrieve.
24
+
25
+ Returns:
26
+ Tag: The tag object corresponding to the provided name.
27
+
28
+ Raises:
29
+ TagNotFound: If the tag with the specified name does not exist.
30
+ """
31
+ tags = await self.list()
32
+ for tag in tags:
33
+ if tag.name == name:
34
+ return tag
35
+ raise TagNotFound(name, endpoint=self._client.base_url)
36
+
37
+ async def list(self) -> List[Tag]:
38
+ """
39
+ Retrieve a list of all available tags.
40
+
41
+ Returns:
42
+ List[Tag]: A list of tag objects.
43
+ """
44
+ resp = await self._client.get("/tags.json")
45
+ resp.raise_for_status()
46
+ return Tags.validate_python(resp.json())
@@ -0,0 +1,33 @@
1
+ from .core import BaseService
2
+
3
+ from lobstr.models import User
4
+ from lobstr.exceptions import UserNotFound
5
+
6
+
7
+ class UserService(BaseService):
8
+ """
9
+ Service for interacting with the Lobstr API's user endpoints.
10
+
11
+ Methods:
12
+ `get(username: str) -> User`: Fetches user information by username.
13
+ """
14
+
15
+ async def get(self, username: str) -> User:
16
+ """
17
+ Fetches user information by username.
18
+
19
+ Args:
20
+ username (str): The username of the user to fetch.
21
+
22
+ Returns:
23
+ User: An instance of the User model containing user information.
24
+
25
+ Raises:
26
+ UserNotFound: If the user with the specified username does not exist.
27
+ HTTPError: If the request to the API fails for any other reason.
28
+ """
29
+ resp = await self._client.get(f"/~{username}.json")
30
+ if resp.status_code == 404:
31
+ raise UserNotFound(username, endpoint=self._client.base_url)
32
+ resp.raise_for_status()
33
+ return User.model_validate(resp.json())
@@ -0,0 +1,58 @@
1
+ [project]
2
+
3
+ name = "lobstr"
4
+ version = "0.5.0"
5
+ description = "unofficial API client for lobste.rs"
6
+ readme = "README.md"
7
+
8
+ license = { text = "MIT" }
9
+ authors = [{ name = "kavin", email = "kavin.srinivasan2@gmail.com" }]
10
+
11
+ requires-python = ">=3.10"
12
+
13
+ keywords = ["lobste.rs", "lobsters", "asyncio", "httpx", "pydantic"]
14
+
15
+
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Framework :: AsyncIO",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Internet",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+
29
+ dependencies = ["httpx>=0.28.1", "pydantic>=2.13.4"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/kavin81/lobstr"
33
+ Repository = "https://github.com/kavin81/lobstr"
34
+ Issues = "https://github.com/kavin81/lobstr/issues"
35
+
36
+ [dependency-groups]
37
+
38
+ dev = [
39
+ "isort>=8.0.1",
40
+ "ruff>=0.15.17",
41
+ "twine>=6.2.0",
42
+ ]
43
+
44
+ docs = [
45
+ "mkdocs-shadcn>=0.10.7",
46
+ "mkdocstrings[python]>=1.0.4",
47
+ "properdocs>=1.6.7",
48
+ "pygments>=2.20.0",
49
+ "pymdown-extensions>=10.21.3",
50
+ ]
51
+
52
+ [build-system]
53
+ requires = ["uv-build>=0.8.15,<0.9.0"]
54
+ build-backend = "uv_build"
55
+
56
+ [tool.uv.build-backend]
57
+ module-name = "lobstr"
58
+ module-root = ""