attenpy 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .client import Client
2
+ from .ref import UserRef
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = [
6
+ "Client",
7
+ "UserRef",
8
+ ]
attenpy/client.py 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,4 @@
1
+ from .posts import PostEndpoint
2
+ from .users import UserEndpoint
3
+
4
+ __all__ = ["UserEndpoint", "PostEndpoint"]
@@ -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)
attenpy/exceptions.py ADDED
@@ -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}")
attenpy/http.py ADDED
@@ -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
attenpy/models/base.py ADDED
@@ -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
+ )
attenpy/models/post.py ADDED
@@ -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
attenpy/models/user.py ADDED
@@ -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
attenpy/pagination.py ADDED
@@ -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,3 @@
1
+ from .post import ParentsPostPayload
2
+
3
+ __all__ = ["ParentsPostPayload"]
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ..models import Post
4
+
5
+
6
+ class ParentsPostPayload(BaseModel):
7
+ root: Post | None
8
+ parents: list[Post]
9
+ next_id: int | None
attenpy/ref.py ADDED
@@ -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)
attenpy/types.py ADDED
@@ -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
attenpy/utils.py ADDED
@@ -0,0 +1,5 @@
1
+ from typing import Any
2
+
3
+
4
+ def int_or_none(obj: Any) -> int | None:
5
+ return None if obj is None else int(obj)
@@ -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,23 @@
1
+ attenpy/__init__.py,sha256=A09L8Rvu_cxinq-QV1ljMR-7OLh6k4IPV0MkL6cQ9Dc,118
2
+ attenpy/client.py,sha256=eIP5OEC9-ExqZGaIwlZ2Jmoz3QJe9O93HofIoIkPfVo,621
3
+ attenpy/exceptions.py,sha256=d_3CvXnaAK-Fz9ltWMBAmC1EZRMXHW90zgn40gWijHs,354
4
+ attenpy/http.py,sha256=melMmaROj23WSVEcIHysZ0X4K7H2DLGyXH7KH7QgSOM,2887
5
+ attenpy/pagination.py,sha256=kEhsL7p55RtoNLhSfSA0dSunqVTwrG5iNdFMhlVv3VQ,1171
6
+ attenpy/ref.py,sha256=P30_abZZMaJGWO9mhFq0qunALKVpxsK4B8sxSHGh0oE,1473
7
+ attenpy/types.py,sha256=LiO6ZIcOy_G6hCUholrva5jJUCC9SSGQIcamnzhtX0E,702
8
+ attenpy/utils.py,sha256=POzFWt4ZFmDv5xZJUIlZZxZvnag4u82es6Wc9ZvnR3M,111
9
+ attenpy/endpoints/__init__.py,sha256=Vw0HOM2fEREWWdp5DN6P4SxJdjcd9Ybc2m0SkISzCFQ,108
10
+ attenpy/endpoints/posts.py,sha256=iiugty-Pk5SF497cawU9CcNCVehLbmHiKqDGJXRKNf0,3873
11
+ attenpy/endpoints/users.py,sha256=j-dyhf9WS5SnYbMmm0ff0Zwf2L8agLPGPUyyDXLNyeo,3987
12
+ attenpy/models/__init__.py,sha256=zjgISt9uomgiD_imjAagQ23FT2Pt93PDjtjThdk8BXs,468
13
+ attenpy/models/attachment.py,sha256=dRjs9y4B_WR0Xdj1inWFn-ClViiFO22Ga1nkZIMNPeo,359
14
+ attenpy/models/base.py,sha256=sP1nZbKQbBNuuVpI_SNrW0AhbYrFRiIXg98l87iFXac,1811
15
+ attenpy/models/post.py,sha256=jOp3QYxZlSf7d0PsMDaTurKXxe_5rZfEalSzewP41Y8,902
16
+ attenpy/models/user.py,sha256=BFFdkbx8NuSDd-HRCFhEzUtag7fUZLcww9ylKSAe2i0,787
17
+ attenpy/payloads/__init__.py,sha256=mRuAe8ln_t9gdw8PxhnQRXMgNgyXKVz_0jY1J1ZSdtI,71
18
+ attenpy/payloads/post.py,sha256=IsNxxlXZaKxCpypFDlrkpo2PSRKLFXN6Pocd08i2T64,167
19
+ attenpy-0.1.0.dist-info/licenses/LICENSE,sha256=vJL8eExltbXjraffK8-IjNYygq-AE8jF_8H6-Z7ixAk,1066
20
+ attenpy-0.1.0.dist-info/METADATA,sha256=AgaEVw_g6coB1OPV_gdM2DQ2hax779s75T9wHiEiv34,529
21
+ attenpy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
22
+ attenpy-0.1.0.dist-info/top_level.txt,sha256=L2dDUmZB6ZIBxs8GUQVfbiEgVsTn1HUzk-8pTrrWJpc,8
23
+ attenpy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ attenpy