attenpy 0.1.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.
- attenpy-0.1.0/LICENSE +21 -0
- attenpy-0.1.0/PKG-INFO +21 -0
- attenpy-0.1.0/README.md +3 -0
- attenpy-0.1.0/attenpy/__init__.py +8 -0
- attenpy-0.1.0/attenpy/client.py +25 -0
- attenpy-0.1.0/attenpy/endpoints/__init__.py +4 -0
- attenpy-0.1.0/attenpy/endpoints/posts.py +116 -0
- attenpy-0.1.0/attenpy/endpoints/users.py +106 -0
- attenpy-0.1.0/attenpy/exceptions.py +13 -0
- attenpy-0.1.0/attenpy/http.py +78 -0
- attenpy-0.1.0/attenpy/models/__init__.py +21 -0
- attenpy-0.1.0/attenpy/models/attachment.py +20 -0
- attenpy-0.1.0/attenpy/models/base.py +80 -0
- attenpy-0.1.0/attenpy/models/post.py +41 -0
- attenpy-0.1.0/attenpy/models/user.py +38 -0
- attenpy-0.1.0/attenpy/pagination.py +43 -0
- attenpy-0.1.0/attenpy/payloads/__init__.py +3 -0
- attenpy-0.1.0/attenpy/payloads/post.py +9 -0
- attenpy-0.1.0/attenpy/ref.py +54 -0
- attenpy-0.1.0/attenpy/types.py +35 -0
- attenpy-0.1.0/attenpy/utils.py +5 -0
- attenpy-0.1.0/attenpy.egg-info/PKG-INFO +21 -0
- attenpy-0.1.0/attenpy.egg-info/SOURCES.txt +26 -0
- attenpy-0.1.0/attenpy.egg-info/dependency_links.txt +1 -0
- attenpy-0.1.0/attenpy.egg-info/requires.txt +6 -0
- attenpy-0.1.0/attenpy.egg-info/top_level.txt +1 -0
- attenpy-0.1.0/pyproject.toml +31 -0
- attenpy-0.1.0/setup.cfg +4 -0
attenpy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AttenTeam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
attenpy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attenpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK for AttenAPI
|
|
5
|
+
Author: AttenTeam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://atten.win/
|
|
8
|
+
Project-URL: Repository, https://github.com/attensns/attenpy
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# attenpy
|
|
20
|
+
|
|
21
|
+
attenAPIツール
|
attenpy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .endpoints import PostEndpoint, UserEndpoint
|
|
2
|
+
from .http import HTTPClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Client:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
token: str | None = None,
|
|
9
|
+
base_url: str = "https://api.atten.win",
|
|
10
|
+
):
|
|
11
|
+
self.base_url = base_url
|
|
12
|
+
self.http: HTTPClient = HTTPClient(self.base_url, token)
|
|
13
|
+
|
|
14
|
+
self.users = UserEndpoint(self)
|
|
15
|
+
self.posts = PostEndpoint(self)
|
|
16
|
+
|
|
17
|
+
async def close(self) -> None:
|
|
18
|
+
if self.http:
|
|
19
|
+
await self.http.close()
|
|
20
|
+
|
|
21
|
+
async def __aenter__(self):
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
25
|
+
await self.close()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, AsyncIterator, Unpack
|
|
2
|
+
|
|
3
|
+
from ..models import PartialPost, PartialUser, Post
|
|
4
|
+
from ..pagination import (
|
|
5
|
+
PaginateOptions,
|
|
6
|
+
paginate,
|
|
7
|
+
)
|
|
8
|
+
from ..payloads import ParentsPostPayload
|
|
9
|
+
from ..utils import int_or_none
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..client import Client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PostEndpoint:
|
|
16
|
+
def __init__(self, client: "Client"):
|
|
17
|
+
self.client = client
|
|
18
|
+
|
|
19
|
+
async def create(
|
|
20
|
+
self,
|
|
21
|
+
content: str,
|
|
22
|
+
quote: int | PartialPost | None = None,
|
|
23
|
+
) -> Post:
|
|
24
|
+
return Post.model_validate(
|
|
25
|
+
(
|
|
26
|
+
await self.client.http.post(
|
|
27
|
+
"/posts", json={"content": content, "quote": int_or_none(quote)}
|
|
28
|
+
)
|
|
29
|
+
).data
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def get(self, post: int | PartialPost) -> Post:
|
|
33
|
+
return Post.model_validate(
|
|
34
|
+
(await self.client.http.get(f"/posts/{int(post)}")).data
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def reply(
|
|
38
|
+
self,
|
|
39
|
+
parent: int | PartialPost,
|
|
40
|
+
content: str,
|
|
41
|
+
quote: int | PartialPost | None = None,
|
|
42
|
+
) -> Post:
|
|
43
|
+
return Post.model_validate(
|
|
44
|
+
(
|
|
45
|
+
await self.client.http.post(
|
|
46
|
+
f"/posts/{int(parent)}/reply",
|
|
47
|
+
json={"content": content, "quote": int_or_none(quote)},
|
|
48
|
+
)
|
|
49
|
+
).data
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def delete(self, post: int | PartialPost):
|
|
53
|
+
await self.client.http.post(f"/posts/{int(post)}")
|
|
54
|
+
|
|
55
|
+
async def love(self, post: int | PartialPost):
|
|
56
|
+
await self.client.http.post(f"/posts/{int(post)}/love")
|
|
57
|
+
|
|
58
|
+
async def unlove(self, post: int | PartialPost):
|
|
59
|
+
await self.client.http.delete(f"/posts/{int(post)}/love")
|
|
60
|
+
|
|
61
|
+
async def bookmark(self, post: int | PartialPost):
|
|
62
|
+
await self.client.http.post(f"/posts/{int(post)}/bookmark")
|
|
63
|
+
|
|
64
|
+
async def unbookmark(self, post: int | PartialPost):
|
|
65
|
+
await self.client.http.delete(f"/posts/{int(post)}/bookmark")
|
|
66
|
+
|
|
67
|
+
async def repost(self, post: int | PartialPost) -> Post:
|
|
68
|
+
return Post.model_validate(
|
|
69
|
+
(await self.client.http.post(f"/posts/{int(post)}/repost")).data
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def unrepost(self, post: int | PartialPost):
|
|
73
|
+
await self.client.http.delete(f"/posts/{int(post)}/repost")
|
|
74
|
+
|
|
75
|
+
async def get_loves(
|
|
76
|
+
self, post: int | PartialPost, **kw: Unpack[PaginateOptions]
|
|
77
|
+
) -> AsyncIterator[PartialUser]:
|
|
78
|
+
async for data in paginate(self.client.http, f"/posts/{int(post)}/loves", **kw):
|
|
79
|
+
yield PartialUser.model_validate(data)
|
|
80
|
+
|
|
81
|
+
async def get_history(
|
|
82
|
+
self, post: int | PartialPost, **kw: Unpack[PaginateOptions]
|
|
83
|
+
) -> AsyncIterator[Post]:
|
|
84
|
+
async for data in paginate(
|
|
85
|
+
self.client.http, f"/posts/{int(post)}/history", **kw
|
|
86
|
+
):
|
|
87
|
+
yield Post.model_validate(data)
|
|
88
|
+
|
|
89
|
+
async def get_quotes(
|
|
90
|
+
self, post: int | PartialPost, **kw: Unpack[PaginateOptions]
|
|
91
|
+
) -> AsyncIterator[Post]:
|
|
92
|
+
async for data in paginate(
|
|
93
|
+
self.client.http, f"/posts/{int(post)}/quotes", **kw
|
|
94
|
+
):
|
|
95
|
+
yield Post.model_validate(data)
|
|
96
|
+
|
|
97
|
+
async def get_replies(
|
|
98
|
+
self, post: int | PartialPost, **kw: Unpack[PaginateOptions]
|
|
99
|
+
) -> AsyncIterator[Post]:
|
|
100
|
+
async for data in paginate(
|
|
101
|
+
self.client.http, f"/posts/{int(post)}/replies", **kw
|
|
102
|
+
):
|
|
103
|
+
yield Post.model_validate(data)
|
|
104
|
+
|
|
105
|
+
async def get_reposts(
|
|
106
|
+
self, post: int | PartialPost, **kw: Unpack[PaginateOptions]
|
|
107
|
+
) -> AsyncIterator[Post]:
|
|
108
|
+
async for data in paginate(
|
|
109
|
+
self.client.http, f"/posts/{int(post)}/reposts", **kw
|
|
110
|
+
):
|
|
111
|
+
yield Post.model_validate(data)
|
|
112
|
+
|
|
113
|
+
async def get_parents(self, post: int | PartialPost) -> ParentsPostPayload:
|
|
114
|
+
return ParentsPostPayload.model_validate(
|
|
115
|
+
(await self.client.http.get(f"/posts/{int(post)}/parents")).data
|
|
116
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from types import EllipsisType
|
|
2
|
+
from typing import TYPE_CHECKING, AsyncIterator, Unpack
|
|
3
|
+
|
|
4
|
+
from ..models import Attachment, PartialPost, PartialUser, Post, User
|
|
5
|
+
from ..pagination import (
|
|
6
|
+
PaginateOptions,
|
|
7
|
+
paginate,
|
|
8
|
+
)
|
|
9
|
+
from ..ref import UserRef
|
|
10
|
+
from ..utils import int_or_none
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..client import Client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserEndpoint:
|
|
17
|
+
def __init__(self, client: "Client"):
|
|
18
|
+
self.client = client
|
|
19
|
+
|
|
20
|
+
async def get(self, user: UserRef | str) -> User:
|
|
21
|
+
return User.model_validate((await self.client.http.get(f"/users/{user}")).data)
|
|
22
|
+
|
|
23
|
+
async def edit(
|
|
24
|
+
self,
|
|
25
|
+
user: UserRef | str,
|
|
26
|
+
*,
|
|
27
|
+
display_name: str | None | EllipsisType = ...,
|
|
28
|
+
bio: str | EllipsisType = ...,
|
|
29
|
+
icon: int | Attachment | None | EllipsisType = ...,
|
|
30
|
+
banner: int | Attachment | None | EllipsisType = ...,
|
|
31
|
+
pinned: int | PartialPost | None | EllipsisType = ...,
|
|
32
|
+
) -> User:
|
|
33
|
+
payload = {}
|
|
34
|
+
if display_name is not ...:
|
|
35
|
+
payload["display_name"] = display_name
|
|
36
|
+
if bio is not ...:
|
|
37
|
+
payload["bio"] = bio
|
|
38
|
+
if icon is not ...:
|
|
39
|
+
payload["icon_id"] = int_or_none(icon)
|
|
40
|
+
if banner is not ...:
|
|
41
|
+
payload["banner_id"] = int_or_none(banner)
|
|
42
|
+
if pinned is not ...:
|
|
43
|
+
payload["pinned_id"] = int_or_none(pinned)
|
|
44
|
+
return User.model_validate(
|
|
45
|
+
(await self.client.http.patch(f"/users/{user}", json=payload)).data
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def follow(self, user: UserRef | str):
|
|
49
|
+
await self.client.http.post(f"/users/{user}/follow")
|
|
50
|
+
|
|
51
|
+
async def unfollow(self, user: UserRef | str):
|
|
52
|
+
await self.client.http.delete(f"/users/{user}/follow")
|
|
53
|
+
|
|
54
|
+
async def mute(self, user: UserRef | str):
|
|
55
|
+
await self.client.http.post(f"/users/{user}/mute")
|
|
56
|
+
|
|
57
|
+
async def unmute(self, user: UserRef | str):
|
|
58
|
+
await self.client.http.delete(f"/users/{user}/mute")
|
|
59
|
+
|
|
60
|
+
async def get_followers(
|
|
61
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
62
|
+
) -> AsyncIterator[PartialUser]:
|
|
63
|
+
async for data in paginate(self.client.http, f"/users/{user}/followers", **kw):
|
|
64
|
+
yield PartialUser.model_validate(data)
|
|
65
|
+
|
|
66
|
+
async def get_following(
|
|
67
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
68
|
+
) -> AsyncIterator[PartialUser]:
|
|
69
|
+
async for data in paginate(self.client.http, f"/users/{user}/following", **kw):
|
|
70
|
+
yield PartialUser.model_validate(data)
|
|
71
|
+
|
|
72
|
+
async def get_mutes(
|
|
73
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
74
|
+
) -> AsyncIterator[PartialUser]:
|
|
75
|
+
async for data in paginate(self.client.http, f"/users/{user}/mutes", **kw):
|
|
76
|
+
yield PartialUser.model_validate(data)
|
|
77
|
+
|
|
78
|
+
async def get_posts(
|
|
79
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
80
|
+
) -> AsyncIterator[Post]:
|
|
81
|
+
async for data in paginate(self.client.http, f"/users/{user}/posts", **kw):
|
|
82
|
+
yield Post.model_validate(data)
|
|
83
|
+
|
|
84
|
+
async def get_medias(
|
|
85
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
86
|
+
) -> AsyncIterator[Post]:
|
|
87
|
+
async for data in paginate(self.client.http, f"/users/{user}/medias", **kw):
|
|
88
|
+
yield Post.model_validate(data)
|
|
89
|
+
|
|
90
|
+
async def get_loves(
|
|
91
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
92
|
+
) -> AsyncIterator[Post]:
|
|
93
|
+
async for data in paginate(self.client.http, f"/users/{user}/loves", **kw):
|
|
94
|
+
yield Post.model_validate(data)
|
|
95
|
+
|
|
96
|
+
async def get_bookmarks(
|
|
97
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
98
|
+
) -> AsyncIterator[Post]:
|
|
99
|
+
async for data in paginate(self.client.http, f"/users/{user}/bookmarks", **kw):
|
|
100
|
+
yield Post.model_validate(data)
|
|
101
|
+
|
|
102
|
+
async def get_reposts(
|
|
103
|
+
self, user: UserRef | str, **kw: Unpack[PaginateOptions]
|
|
104
|
+
) -> AsyncIterator[Post]:
|
|
105
|
+
async for data in paginate(self.client.http, f"/users/{user}/reposts", **kw):
|
|
106
|
+
yield Post.model_validate(data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .types import ErrorResponsePayload
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AttenpyException(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HTTPException(AttenpyException):
|
|
9
|
+
def __init__(self, status: int, data: ErrorResponsePayload):
|
|
10
|
+
self.status = status
|
|
11
|
+
self.code = data["code"]
|
|
12
|
+
self.details = data["details"]
|
|
13
|
+
super().__init__(f"HTTP {self.status}: {self.code}")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Any, Optional, TypedDict, Unpack, cast
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
|
|
5
|
+
from .exceptions import HTTPException
|
|
6
|
+
from .models import ListResponse, SuccessResponse
|
|
7
|
+
from .types import AnyResponsePayload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RequestOptions(TypedDict, total=False):
|
|
11
|
+
json: Any
|
|
12
|
+
params: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HTTPClient:
|
|
16
|
+
def __init__(self, base_url: str, token: str | None):
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self.token = token
|
|
19
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
20
|
+
|
|
21
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
22
|
+
if self._session is None or self._session.closed:
|
|
23
|
+
headers = {}
|
|
24
|
+
if self.token:
|
|
25
|
+
headers["Authorization"] = "Bearer " + self.token
|
|
26
|
+
self._session = aiohttp.ClientSession(headers=headers)
|
|
27
|
+
return self._session
|
|
28
|
+
|
|
29
|
+
async def _request[T](
|
|
30
|
+
self, method: str, path: str, **kw: Unpack[RequestOptions]
|
|
31
|
+
) -> Any:
|
|
32
|
+
if not path.startswith("/"):
|
|
33
|
+
raise ValueError()
|
|
34
|
+
session = await self._get_session()
|
|
35
|
+
url = self.base_url + path
|
|
36
|
+
|
|
37
|
+
async with session.request(
|
|
38
|
+
method, url, json=kw.get("json"), params=kw.get("params")
|
|
39
|
+
) as resp:
|
|
40
|
+
data = cast(AnyResponsePayload, await resp.json()) # TODO レスポンスの検証
|
|
41
|
+
if data["ok"] is False:
|
|
42
|
+
raise HTTPException(resp.status, data) # TODO statusごとにクラス分ける
|
|
43
|
+
# TODO 429
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
async def get(self, path: str, **kw: Unpack[RequestOptions]) -> SuccessResponse:
|
|
47
|
+
data = await self._request("GET", path, **kw)
|
|
48
|
+
return SuccessResponse.from_json(data)
|
|
49
|
+
|
|
50
|
+
async def get_list(self, path: str, **kw: Unpack[RequestOptions]) -> ListResponse:
|
|
51
|
+
data = await self._request("GET", path, **kw)
|
|
52
|
+
return ListResponse.from_json(data)
|
|
53
|
+
|
|
54
|
+
async def post(self, path: str, **kw: Unpack[RequestOptions]) -> SuccessResponse:
|
|
55
|
+
data = await self._request("POST", path, **kw)
|
|
56
|
+
return SuccessResponse.from_json(data)
|
|
57
|
+
|
|
58
|
+
async def put(self, path: str, **kw: Unpack[RequestOptions]) -> SuccessResponse:
|
|
59
|
+
data = await self._request("PUT", path, **kw)
|
|
60
|
+
return SuccessResponse.from_json(data)
|
|
61
|
+
|
|
62
|
+
async def patch(self, path: str, **kw: Unpack[RequestOptions]) -> SuccessResponse:
|
|
63
|
+
data = await self._request("PATCH", path, **kw)
|
|
64
|
+
return SuccessResponse.from_json(data)
|
|
65
|
+
|
|
66
|
+
async def delete(self, path: str, **kw: Unpack[RequestOptions]) -> SuccessResponse:
|
|
67
|
+
data = await self._request("DELETE", path, **kw)
|
|
68
|
+
return SuccessResponse.from_json(data)
|
|
69
|
+
|
|
70
|
+
async def close(self) -> None:
|
|
71
|
+
if self._session and not self._session.closed:
|
|
72
|
+
await self._session.close()
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self):
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
78
|
+
await self.close()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .attachment import Attachment, ExternalAttachment
|
|
2
|
+
from .base import CursorPage, ListResponse, SuccessResponse
|
|
3
|
+
from .post import PartialPost, Post
|
|
4
|
+
from .user import PartialUser, User
|
|
5
|
+
|
|
6
|
+
PartialPost.model_rebuild()
|
|
7
|
+
Post.model_rebuild()
|
|
8
|
+
PartialUser.model_rebuild()
|
|
9
|
+
User.model_rebuild()
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Attachment",
|
|
13
|
+
"CursorPage",
|
|
14
|
+
"ExternalAttachment",
|
|
15
|
+
"ListResponse",
|
|
16
|
+
"PartialPost",
|
|
17
|
+
"PartialUser",
|
|
18
|
+
"Post",
|
|
19
|
+
"SuccessResponse",
|
|
20
|
+
"User",
|
|
21
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Attachment(BaseModel):
|
|
7
|
+
id: int
|
|
8
|
+
url: str
|
|
9
|
+
category: Literal[
|
|
10
|
+
"icon", "banner", "post_attachment", "group_icon", "chat_attachment"
|
|
11
|
+
]
|
|
12
|
+
mime: str
|
|
13
|
+
deleted: bool
|
|
14
|
+
|
|
15
|
+
def __int__(self) -> int:
|
|
16
|
+
return self.id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExternalAttachment(BaseModel):
|
|
20
|
+
url: str
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Generic, Self, TypeVar
|
|
3
|
+
|
|
4
|
+
from ..types import (
|
|
5
|
+
CursorPagePayload,
|
|
6
|
+
ListResponsePayload,
|
|
7
|
+
RequestMetaPayload,
|
|
8
|
+
SuccessResponsePayload,
|
|
9
|
+
)
|
|
10
|
+
from ..utils import int_or_none
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class RequestMeta:
|
|
17
|
+
request_id: str
|
|
18
|
+
session_id: int | None
|
|
19
|
+
actor_id: int | None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_json(cls, data: RequestMetaPayload) -> Self:
|
|
23
|
+
return cls(
|
|
24
|
+
data["request_id"],
|
|
25
|
+
int_or_none(data["session_id"]),
|
|
26
|
+
int_or_none(data["actor_id"]),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class SuccessResponse(Generic[T]):
|
|
32
|
+
ok: bool
|
|
33
|
+
meta: RequestMeta
|
|
34
|
+
data: T
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_json(cls, data: SuccessResponsePayload) -> Self:
|
|
38
|
+
return cls(
|
|
39
|
+
data["ok"],
|
|
40
|
+
RequestMeta.from_json(data["meta"]),
|
|
41
|
+
data["data"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class CursorPage:
|
|
47
|
+
limit: int
|
|
48
|
+
order: str
|
|
49
|
+
cursor: int | None
|
|
50
|
+
next: int | None
|
|
51
|
+
back: int | None
|
|
52
|
+
has_more: bool
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_json(cls, data: CursorPagePayload) -> Self:
|
|
56
|
+
return cls(
|
|
57
|
+
data["limit"],
|
|
58
|
+
data["order"],
|
|
59
|
+
int_or_none(data["cursor"]),
|
|
60
|
+
int_or_none(data["next"]),
|
|
61
|
+
int_or_none(data["back"]),
|
|
62
|
+
data["has_more"],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class ListResponse(Generic[T]):
|
|
68
|
+
ok: bool
|
|
69
|
+
meta: RequestMeta
|
|
70
|
+
data: list[T]
|
|
71
|
+
page: CursorPage
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_json(cls, data: ListResponsePayload) -> Self:
|
|
75
|
+
return cls(
|
|
76
|
+
data["ok"],
|
|
77
|
+
RequestMeta.from_json(data["meta"]),
|
|
78
|
+
data["data"],
|
|
79
|
+
CursorPage.from_json(data["page"]),
|
|
80
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .attachment import Attachment
|
|
7
|
+
from .user import PartialUser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PartialPost(BaseModel):
|
|
11
|
+
id: int
|
|
12
|
+
author_id: str
|
|
13
|
+
author: "PartialUser"
|
|
14
|
+
content_md: str
|
|
15
|
+
parent_id: str | None
|
|
16
|
+
target_history_id: str | None
|
|
17
|
+
current_history_id: str
|
|
18
|
+
quote_id: str | None
|
|
19
|
+
root_id: str | None
|
|
20
|
+
is_repost: bool
|
|
21
|
+
is_edited: bool
|
|
22
|
+
deleted: bool
|
|
23
|
+
attachments: list["Attachment"]
|
|
24
|
+
|
|
25
|
+
def __int__(self) -> int:
|
|
26
|
+
return self.id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Post(PartialPost):
|
|
30
|
+
parent: PartialPost | None
|
|
31
|
+
quote: PartialPost | None
|
|
32
|
+
love_count: int
|
|
33
|
+
bookmark_count: int
|
|
34
|
+
reply_count: int
|
|
35
|
+
quote_count: int
|
|
36
|
+
is_loved: bool | None
|
|
37
|
+
is_bookmarked: bool | None
|
|
38
|
+
is_reposted: bool | None
|
|
39
|
+
target: "Post | None" = None
|
|
40
|
+
version: int | None = None
|
|
41
|
+
is_latest: bool = True
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..ref import UserRef
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .attachment import Attachment, ExternalAttachment
|
|
9
|
+
from .post import Post
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PartialUser(BaseModel):
|
|
13
|
+
id: int
|
|
14
|
+
scratch_name: str
|
|
15
|
+
display_name: str | None
|
|
16
|
+
icon: "Attachment | ExternalAttachment"
|
|
17
|
+
public_flags: int # TODO
|
|
18
|
+
deleted: bool
|
|
19
|
+
is_muted: bool | None
|
|
20
|
+
is_following: bool | None = None
|
|
21
|
+
is_followed: bool | None = None
|
|
22
|
+
|
|
23
|
+
def __int__(self) -> int:
|
|
24
|
+
return self.id
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ref(self):
|
|
28
|
+
return UserRef(user_id=self.id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class User(PartialUser):
|
|
32
|
+
scratch_id: int
|
|
33
|
+
bio: str
|
|
34
|
+
pinned: "Post | None"
|
|
35
|
+
followers_count: int
|
|
36
|
+
following_count: int
|
|
37
|
+
post_count: int
|
|
38
|
+
love_count: int
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from typing import Any, Literal, TypedDict, TypeVar, Unpack
|
|
3
|
+
|
|
4
|
+
from .http import HTTPClient
|
|
5
|
+
|
|
6
|
+
ItemT = TypeVar("ItemT")
|
|
7
|
+
CursorT = TypeVar("CursorT")
|
|
8
|
+
|
|
9
|
+
MAX_PAGINATION_LIMIT = 30
|
|
10
|
+
|
|
11
|
+
PAGINATE_ORDER = Literal["desc", "asc"]
|
|
12
|
+
PAGINATE_ORDER_DEFAULT: PAGINATE_ORDER = "desc"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PaginateOptions(TypedDict, total=False):
|
|
16
|
+
cursor: int
|
|
17
|
+
limit: int
|
|
18
|
+
order: PAGINATE_ORDER
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def paginate(
|
|
22
|
+
http: HTTPClient,
|
|
23
|
+
path: str,
|
|
24
|
+
*,
|
|
25
|
+
params: dict[str, Any] | None = None,
|
|
26
|
+
**kw: Unpack[PaginateOptions],
|
|
27
|
+
) -> AsyncIterator:
|
|
28
|
+
params = (params and params.copy()) or {}
|
|
29
|
+
params["order"] = kw.get("order", PAGINATE_ORDER_DEFAULT)
|
|
30
|
+
if "cursor" in kw:
|
|
31
|
+
params["cursor"] = kw["cursor"]
|
|
32
|
+
remaining = kw.get("limit", MAX_PAGINATION_LIMIT)
|
|
33
|
+
while remaining > 0:
|
|
34
|
+
params["limit"] = min(remaining, MAX_PAGINATION_LIMIT)
|
|
35
|
+
resp = await http.get_list(path, params=params)
|
|
36
|
+
for item in resp.data:
|
|
37
|
+
yield item
|
|
38
|
+
remaining -= 1
|
|
39
|
+
if remaining == 0:
|
|
40
|
+
return
|
|
41
|
+
if not resp.page.has_more:
|
|
42
|
+
break
|
|
43
|
+
params["cursor"] = resp.page.next
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import ClassVar, Literal, Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True, kw_only=True)
|
|
6
|
+
class UserRef:
|
|
7
|
+
user_id: int | None = None
|
|
8
|
+
username: str | None = None
|
|
9
|
+
me: Literal[True] | None = None
|
|
10
|
+
|
|
11
|
+
ME: ClassVar["UserRef"]
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
match self.value:
|
|
15
|
+
case "username", username:
|
|
16
|
+
return username
|
|
17
|
+
case "user_id", user_id:
|
|
18
|
+
return f":{user_id}"
|
|
19
|
+
case "me", _:
|
|
20
|
+
return ":me"
|
|
21
|
+
raise ValueError()
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
if sum(i is not None for i in (self.username, self.user_id, self.me)) != 1:
|
|
25
|
+
raise ValueError
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def value(
|
|
29
|
+
self,
|
|
30
|
+
) -> Union[
|
|
31
|
+
tuple[Literal["username"], str],
|
|
32
|
+
tuple[Literal["user_id"], int],
|
|
33
|
+
tuple[Literal["me"], Literal[True]],
|
|
34
|
+
]:
|
|
35
|
+
if self.username is not None:
|
|
36
|
+
return "username", self.username
|
|
37
|
+
elif self.user_id is not None:
|
|
38
|
+
return "user_id", self.user_id
|
|
39
|
+
elif self.me is not None:
|
|
40
|
+
return "me", self.me
|
|
41
|
+
raise ValueError
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_str(cls, path: str):
|
|
45
|
+
if path.startswith(":"):
|
|
46
|
+
user_id = path[1:]
|
|
47
|
+
if user_id == "me":
|
|
48
|
+
return cls(me=True)
|
|
49
|
+
if user_id.isdecimal():
|
|
50
|
+
return cls(user_id=int(user_id))
|
|
51
|
+
return cls(username=path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
UserRef.ME = UserRef(me=True)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, Literal, TypedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RequestMetaPayload(TypedDict):
|
|
5
|
+
request_id: str
|
|
6
|
+
session_id: str | None
|
|
7
|
+
actor_id: str | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SuccessResponsePayload[T](TypedDict):
|
|
11
|
+
ok: Literal[True]
|
|
12
|
+
data: T
|
|
13
|
+
meta: RequestMetaPayload
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CursorPagePayload(TypedDict):
|
|
17
|
+
limit: int
|
|
18
|
+
order: Literal["asc", "desc"]
|
|
19
|
+
cursor: str | None
|
|
20
|
+
next: str | None
|
|
21
|
+
back: str | None
|
|
22
|
+
has_more: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ListResponsePayload[T](SuccessResponsePayload[list[T]]):
|
|
26
|
+
page: CursorPagePayload
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ErrorResponsePayload(TypedDict):
|
|
30
|
+
ok: Literal[False]
|
|
31
|
+
code: str
|
|
32
|
+
details: dict[str, Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
AnyResponsePayload = SuccessResponsePayload | ErrorResponsePayload
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attenpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK for AttenAPI
|
|
5
|
+
Author: AttenTeam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://atten.win/
|
|
8
|
+
Project-URL: Repository, https://github.com/attensns/attenpy
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# attenpy
|
|
20
|
+
|
|
21
|
+
attenAPIツール
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
attenpy/__init__.py
|
|
5
|
+
attenpy/client.py
|
|
6
|
+
attenpy/exceptions.py
|
|
7
|
+
attenpy/http.py
|
|
8
|
+
attenpy/pagination.py
|
|
9
|
+
attenpy/ref.py
|
|
10
|
+
attenpy/types.py
|
|
11
|
+
attenpy/utils.py
|
|
12
|
+
attenpy.egg-info/PKG-INFO
|
|
13
|
+
attenpy.egg-info/SOURCES.txt
|
|
14
|
+
attenpy.egg-info/dependency_links.txt
|
|
15
|
+
attenpy.egg-info/requires.txt
|
|
16
|
+
attenpy.egg-info/top_level.txt
|
|
17
|
+
attenpy/endpoints/__init__.py
|
|
18
|
+
attenpy/endpoints/posts.py
|
|
19
|
+
attenpy/endpoints/users.py
|
|
20
|
+
attenpy/models/__init__.py
|
|
21
|
+
attenpy/models/attachment.py
|
|
22
|
+
attenpy/models/base.py
|
|
23
|
+
attenpy/models/post.py
|
|
24
|
+
attenpy/models/user.py
|
|
25
|
+
attenpy/payloads/__init__.py
|
|
26
|
+
attenpy/payloads/post.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attenpy
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "attenpy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDK for AttenAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"aiohttp>=3.9.0",
|
|
13
|
+
"pydantic>=2.0.0",
|
|
14
|
+
]
|
|
15
|
+
license = { text = "MIT" }
|
|
16
|
+
authors = [
|
|
17
|
+
{ name = "AttenTeam" }
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://atten.win/"
|
|
22
|
+
Repository = "https://github.com/attensns/attenpy"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=7.0.0",
|
|
27
|
+
"pytest-asyncio>=0.21.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
include = ["attenpy*"]
|
attenpy-0.1.0/setup.cfg
ADDED