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 +126 -0
- lobstr-0.5.0/README.md +100 -0
- lobstr-0.5.0/lobstr/__init__.py +5 -0
- lobstr-0.5.0/lobstr/client.py +55 -0
- lobstr-0.5.0/lobstr/constants.py +9 -0
- lobstr-0.5.0/lobstr/exceptions.py +115 -0
- lobstr-0.5.0/lobstr/models/__init__.py +18 -0
- lobstr-0.5.0/lobstr/models/comment.py +73 -0
- lobstr-0.5.0/lobstr/models/core.py +40 -0
- lobstr-0.5.0/lobstr/models/feed.py +15 -0
- lobstr-0.5.0/lobstr/models/post.py +62 -0
- lobstr-0.5.0/lobstr/models/story.py +28 -0
- lobstr-0.5.0/lobstr/models/tag.py +30 -0
- lobstr-0.5.0/lobstr/models/user.py +31 -0
- lobstr-0.5.0/lobstr/services/__init__.py +13 -0
- lobstr-0.5.0/lobstr/services/comment.py +36 -0
- lobstr-0.5.0/lobstr/services/core.py +6 -0
- lobstr-0.5.0/lobstr/services/feed.py +106 -0
- lobstr-0.5.0/lobstr/services/story.py +35 -0
- lobstr-0.5.0/lobstr/services/tag.py +46 -0
- lobstr-0.5.0/lobstr/services/user.py +33 -0
- lobstr-0.5.0/pyproject.toml +58 -0
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,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,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,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 = ""
|