mosir-sdk-python 0.1.1__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.
- mosir_sdk/__init__.py +22 -0
- mosir_sdk/_operations.py +250 -0
- mosir_sdk/client.py +318 -0
- mosir_sdk/exceptions.py +20 -0
- mosir_sdk/py.typed +1 -0
- mosir_sdk_python-0.1.1.dist-info/METADATA +219 -0
- mosir_sdk_python-0.1.1.dist-info/RECORD +10 -0
- mosir_sdk_python-0.1.1.dist-info/WHEEL +5 -0
- mosir_sdk_python-0.1.1.dist-info/licenses/LICENSE +841 -0
- mosir_sdk_python-0.1.1.dist-info/top_level.txt +1 -0
mosir_sdk/client.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator, Awaitable, Mapping
|
|
5
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from graphql import OperationDefinitionNode, OperationType, get_operation_ast, parse
|
|
9
|
+
from httpx_sse import aconnect_sse
|
|
10
|
+
|
|
11
|
+
from ._operations import OPERATION_REGISTRY, OperationSpec
|
|
12
|
+
from .exceptions import GraphQLRequestError, GraphQLTransportError
|
|
13
|
+
|
|
14
|
+
DEFAULT_ENDPOINT = "https://beta.mosir.app/api/v1"
|
|
15
|
+
JSONMapping: TypeAlias = dict[str, Any]
|
|
16
|
+
PreviewImageKind: TypeAlias = Literal["post", "profile", "post_collection"]
|
|
17
|
+
|
|
18
|
+
MEDIA_PROFILE_FALLBACK_ORDER = [
|
|
19
|
+
"QUALITY",
|
|
20
|
+
"COMPATIBLE",
|
|
21
|
+
"THUMBNAIL",
|
|
22
|
+
"ANIMATED_COMPATIBLE",
|
|
23
|
+
"ANIMATED_THUMBNAIL",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
PREVIEW_IMAGE_ROUTE_MAP: dict[PreviewImageKind, str] = {
|
|
27
|
+
"post": "postopengraph",
|
|
28
|
+
"profile": "profileopengraph",
|
|
29
|
+
"post_collection": "collectionopengraph",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncMosirClient:
|
|
34
|
+
"""Async client for the Mosir public GraphQL API.
|
|
35
|
+
|
|
36
|
+
Wrapped snake_case methods are resolved dynamically from `public.operations.graphql`.
|
|
37
|
+
For example: `get_post(...)`, `get_notifications(...)`, `post_updated(...)`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
token: str | None = None,
|
|
44
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
45
|
+
headers: Mapping[str, str] | None = None,
|
|
46
|
+
timeout: float | httpx.Timeout | None = None,
|
|
47
|
+
client: httpx.AsyncClient | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.token = token
|
|
50
|
+
self.endpoint = endpoint
|
|
51
|
+
self._default_headers = dict(headers or {})
|
|
52
|
+
self._owns_client = client is None
|
|
53
|
+
self._client = client or httpx.AsyncClient(timeout=timeout)
|
|
54
|
+
|
|
55
|
+
async def __aenter__(self) -> AsyncMosirClient:
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
59
|
+
await self.aclose()
|
|
60
|
+
|
|
61
|
+
def __getattr__(self, name: str) -> Any:
|
|
62
|
+
spec = OPERATION_REGISTRY.get(name)
|
|
63
|
+
if spec is None:
|
|
64
|
+
raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}")
|
|
65
|
+
|
|
66
|
+
if spec.operation_type == "subscription":
|
|
67
|
+
def subscription_method(**variables: Any) -> AsyncIterator[JSONMapping]:
|
|
68
|
+
return self.subscribe_operation(name, **variables)
|
|
69
|
+
|
|
70
|
+
return subscription_method
|
|
71
|
+
|
|
72
|
+
async def operation_method(**variables: Any) -> JSONMapping:
|
|
73
|
+
return await self.operation(name, **variables)
|
|
74
|
+
|
|
75
|
+
return operation_method
|
|
76
|
+
|
|
77
|
+
async def aclose(self) -> None:
|
|
78
|
+
if self._owns_client:
|
|
79
|
+
await self._client.aclose()
|
|
80
|
+
|
|
81
|
+
def select_media_file(
|
|
82
|
+
self,
|
|
83
|
+
media: Mapping[str, Any],
|
|
84
|
+
*,
|
|
85
|
+
profile: str | None = None,
|
|
86
|
+
) -> JSONMapping | None:
|
|
87
|
+
return select_media_file(media, profile=profile)
|
|
88
|
+
|
|
89
|
+
async def fetch_media(
|
|
90
|
+
self,
|
|
91
|
+
media: Mapping[str, Any],
|
|
92
|
+
*,
|
|
93
|
+
profile: str | None = None,
|
|
94
|
+
headers: Mapping[str, str] | None = None,
|
|
95
|
+
) -> bytes:
|
|
96
|
+
file = select_media_file(media, profile=profile)
|
|
97
|
+
if file is None:
|
|
98
|
+
raise ValueError("No media file is available for the requested media object.")
|
|
99
|
+
|
|
100
|
+
response = await self._client.get(file["url"], headers=self._build_headers(headers))
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
return response.content
|
|
103
|
+
|
|
104
|
+
def get_preview_image_url(self, kind: PreviewImageKind, resource_id: str) -> str:
|
|
105
|
+
return get_preview_image_url(kind, resource_id, endpoint=self.endpoint)
|
|
106
|
+
|
|
107
|
+
async def fetch_preview_image(
|
|
108
|
+
self,
|
|
109
|
+
kind: PreviewImageKind,
|
|
110
|
+
resource_id: str,
|
|
111
|
+
*,
|
|
112
|
+
headers: Mapping[str, str] | None = None,
|
|
113
|
+
) -> bytes:
|
|
114
|
+
response = await self._client.get(
|
|
115
|
+
get_preview_image_url(kind, resource_id, endpoint=self.endpoint),
|
|
116
|
+
headers=self._build_headers(headers),
|
|
117
|
+
)
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
return response.content
|
|
120
|
+
|
|
121
|
+
async def request(
|
|
122
|
+
self,
|
|
123
|
+
document: str,
|
|
124
|
+
variables: Mapping[str, Any] | None = None,
|
|
125
|
+
*,
|
|
126
|
+
operation_name: str | None = None,
|
|
127
|
+
headers: Mapping[str, str] | None = None,
|
|
128
|
+
) -> JSONMapping:
|
|
129
|
+
operation = _get_operation(document, operation_name)
|
|
130
|
+
if operation.operation is OperationType.SUBSCRIPTION:
|
|
131
|
+
raise ValueError("Use subscribe(...) for subscription operations.")
|
|
132
|
+
|
|
133
|
+
response = await self._client.post(
|
|
134
|
+
self.endpoint,
|
|
135
|
+
json=_build_payload(document, variables, operation_name),
|
|
136
|
+
headers=self._build_headers(headers),
|
|
137
|
+
)
|
|
138
|
+
return _parse_json_response(response)
|
|
139
|
+
|
|
140
|
+
def execute(
|
|
141
|
+
self,
|
|
142
|
+
document: str,
|
|
143
|
+
variables: Mapping[str, Any] | None = None,
|
|
144
|
+
*,
|
|
145
|
+
operation_name: str | None = None,
|
|
146
|
+
headers: Mapping[str, str] | None = None,
|
|
147
|
+
) -> Awaitable[JSONMapping] | AsyncIterator[JSONMapping]:
|
|
148
|
+
operation = _get_operation(document, operation_name)
|
|
149
|
+
if operation.operation is OperationType.SUBSCRIPTION:
|
|
150
|
+
return self.subscribe(
|
|
151
|
+
document,
|
|
152
|
+
variables,
|
|
153
|
+
operation_name=operation_name,
|
|
154
|
+
headers=headers,
|
|
155
|
+
)
|
|
156
|
+
return self.request(
|
|
157
|
+
document,
|
|
158
|
+
variables,
|
|
159
|
+
operation_name=operation_name,
|
|
160
|
+
headers=headers,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def operation(self, name: str, /, **variables: Any) -> JSONMapping:
|
|
164
|
+
spec = _get_operation_spec(name)
|
|
165
|
+
if spec.operation_type == "subscription":
|
|
166
|
+
raise ValueError(f"Operation {name!r} is a subscription. Use subscribe_operation(...).")
|
|
167
|
+
|
|
168
|
+
return await self.request(
|
|
169
|
+
spec.document,
|
|
170
|
+
_normalize_variables(variables, spec.variable_map) or None,
|
|
171
|
+
operation_name=spec.operation_name,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def subscribe_operation(self, name: str, /, **variables: Any) -> AsyncIterator[JSONMapping]:
|
|
175
|
+
spec = _get_operation_spec(name)
|
|
176
|
+
if spec.operation_type != "subscription":
|
|
177
|
+
raise ValueError(f"Operation {name!r} is not a subscription. Use operation(...).")
|
|
178
|
+
|
|
179
|
+
return self.subscribe(
|
|
180
|
+
spec.document,
|
|
181
|
+
_normalize_variables(variables, spec.variable_map) or None,
|
|
182
|
+
operation_name=spec.operation_name,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def subscribe(
|
|
186
|
+
self,
|
|
187
|
+
document: str,
|
|
188
|
+
variables: Mapping[str, Any] | None = None,
|
|
189
|
+
*,
|
|
190
|
+
operation_name: str | None = None,
|
|
191
|
+
headers: Mapping[str, str] | None = None,
|
|
192
|
+
) -> AsyncIterator[JSONMapping]:
|
|
193
|
+
operation = _get_operation(document, operation_name)
|
|
194
|
+
if operation.operation is not OperationType.SUBSCRIPTION:
|
|
195
|
+
raise ValueError("Use request(...) for query and mutation operations.")
|
|
196
|
+
|
|
197
|
+
async def iterator() -> AsyncIterator[JSONMapping]:
|
|
198
|
+
async with aconnect_sse(
|
|
199
|
+
self._client,
|
|
200
|
+
"POST",
|
|
201
|
+
self.endpoint,
|
|
202
|
+
json=_build_payload(document, variables, operation_name),
|
|
203
|
+
headers=self._build_headers(headers),
|
|
204
|
+
) as event_source:
|
|
205
|
+
async for event in event_source.aiter_sse():
|
|
206
|
+
if event.event == "complete":
|
|
207
|
+
break
|
|
208
|
+
if not event.data:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
payload = json.loads(event.data)
|
|
212
|
+
errors = payload.get("errors")
|
|
213
|
+
if errors:
|
|
214
|
+
raise GraphQLRequestError(errors)
|
|
215
|
+
|
|
216
|
+
data = payload.get("data")
|
|
217
|
+
if data is not None:
|
|
218
|
+
if not isinstance(data, dict):
|
|
219
|
+
raise GraphQLTransportError("Expected a GraphQL data object in the SSE payload.")
|
|
220
|
+
yield cast(JSONMapping, data)
|
|
221
|
+
|
|
222
|
+
return iterator()
|
|
223
|
+
|
|
224
|
+
def _build_headers(self, headers: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
225
|
+
merged = dict(self._default_headers)
|
|
226
|
+
if self.token:
|
|
227
|
+
merged["Authorization"] = f"Bearer {self.token}"
|
|
228
|
+
if headers:
|
|
229
|
+
merged.update(headers)
|
|
230
|
+
return merged
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def select_media_file(
|
|
234
|
+
media: Mapping[str, Any],
|
|
235
|
+
*,
|
|
236
|
+
profile: str | None = None,
|
|
237
|
+
) -> JSONMapping | None:
|
|
238
|
+
raw_files = media.get("files")
|
|
239
|
+
if not isinstance(raw_files, list):
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
files: list[JSONMapping] = [file for file in raw_files if isinstance(file, dict)]
|
|
243
|
+
if profile is not None:
|
|
244
|
+
return next((file for file in files if file.get("profile") == profile), None)
|
|
245
|
+
|
|
246
|
+
for candidate_profile in MEDIA_PROFILE_FALLBACK_ORDER:
|
|
247
|
+
candidate = next((file for file in files if file.get("profile") == candidate_profile), None)
|
|
248
|
+
if candidate is not None:
|
|
249
|
+
return candidate
|
|
250
|
+
|
|
251
|
+
return files[0] if files else None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_preview_image_url(
|
|
255
|
+
kind: PreviewImageKind,
|
|
256
|
+
resource_id: str,
|
|
257
|
+
*,
|
|
258
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
259
|
+
) -> str:
|
|
260
|
+
base_url = httpx.URL(endpoint)
|
|
261
|
+
return str(base_url.copy_with(path=f"/ogi/{PREVIEW_IMAGE_ROUTE_MAP[kind]}/{resource_id}", query=None))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_operation_spec(name: str) -> OperationSpec:
|
|
265
|
+
spec = OPERATION_REGISTRY.get(name)
|
|
266
|
+
if spec is None:
|
|
267
|
+
raise KeyError(f"Unknown Mosir operation: {name}")
|
|
268
|
+
return spec
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _normalize_variables(variables: Mapping[str, Any], variable_map: Mapping[str, str]) -> dict[str, Any]:
|
|
272
|
+
normalized: dict[str, Any] = {}
|
|
273
|
+
for key, value in variables.items():
|
|
274
|
+
normalized[variable_map.get(key, key)] = value
|
|
275
|
+
return normalized
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _build_payload(
|
|
279
|
+
document: str,
|
|
280
|
+
variables: Mapping[str, Any] | None,
|
|
281
|
+
operation_name: str | None,
|
|
282
|
+
) -> JSONMapping:
|
|
283
|
+
payload: JSONMapping = {"query": document}
|
|
284
|
+
if variables is not None:
|
|
285
|
+
payload["variables"] = dict(variables)
|
|
286
|
+
if operation_name is not None:
|
|
287
|
+
payload["operationName"] = operation_name
|
|
288
|
+
return payload
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _get_operation(document: str, operation_name: str | None) -> OperationDefinitionNode:
|
|
292
|
+
operation = get_operation_ast(parse(document), operation_name)
|
|
293
|
+
if operation is None:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"Unable to resolve GraphQL operation. Provide a single operation document or pass operation_name.",
|
|
296
|
+
)
|
|
297
|
+
return operation
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_json_response(response: httpx.Response) -> JSONMapping:
|
|
301
|
+
try:
|
|
302
|
+
response.raise_for_status()
|
|
303
|
+
except httpx.HTTPStatusError as exc:
|
|
304
|
+
raise GraphQLTransportError(str(exc)) from exc
|
|
305
|
+
|
|
306
|
+
payload = response.json()
|
|
307
|
+
if not isinstance(payload, dict):
|
|
308
|
+
raise GraphQLTransportError("Expected a JSON object response from the Mosir API.")
|
|
309
|
+
|
|
310
|
+
errors = payload.get("errors")
|
|
311
|
+
if isinstance(errors, list) and errors:
|
|
312
|
+
raise GraphQLRequestError(errors)
|
|
313
|
+
|
|
314
|
+
data = payload.get("data")
|
|
315
|
+
if not isinstance(data, dict):
|
|
316
|
+
raise GraphQLTransportError("Expected a GraphQL data object in the response.")
|
|
317
|
+
|
|
318
|
+
return cast(JSONMapping, data)
|
mosir_sdk/exceptions.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MosirSdkError(Exception):
|
|
7
|
+
"""Base exception for mosir-sdk-python."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GraphQLRequestError(MosirSdkError):
|
|
11
|
+
"""Raised when the Mosir API returns GraphQL errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, errors: list[dict[str, Any]]) -> None:
|
|
14
|
+
self.errors = errors
|
|
15
|
+
message = "\n".join(str(error.get("message", error)) for error in errors)
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GraphQLTransportError(MosirSdkError):
|
|
20
|
+
"""Raised when the Mosir API returns a non-GraphQL transport failure."""
|
mosir_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# PEP 561 typing marker - this package provides inline type information
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mosir-sdk-python
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for the Mosir public GraphQL API
|
|
5
|
+
Author-email: catLee <leemiyinghao@gmx.com>
|
|
6
|
+
License-Expression: LGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://beta.mosir.app
|
|
8
|
+
Project-URL: Repository, https://github.com/mosir-social/mosir-sdk-python
|
|
9
|
+
Project-URL: Issues, https://github.com/mosir-social/mosir-sdk-python/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/mosir-social/mosir-sdk-python#readme
|
|
11
|
+
Keywords: graphql,httpx,mosir,sdk,sse
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: graphql-core>=3.2.8
|
|
22
|
+
Requires-Dist: httpx>=0.28.1
|
|
23
|
+
Requires-Dist: httpx-sse>=0.4.3
|
|
24
|
+
Requires-Dist: pydantic>=2.13.4
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# mosir-sdk-python
|
|
28
|
+
|
|
29
|
+
Python SDK for the Mosir public GraphQL API.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
For package users:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mosir-sdk-python
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add mosir-sdk-python
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development in this repository:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv sync
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick example
|
|
52
|
+
|
|
53
|
+
### Anonymous/public request
|
|
54
|
+
|
|
55
|
+
Only public data needs no token.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
|
|
60
|
+
from mosir_sdk import AsyncMosirClient
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
async with AsyncMosirClient() as client:
|
|
65
|
+
post = await client.get_post(post_id="VLO8u7UXqclQ7byjfMEX0")
|
|
66
|
+
print(post["getPost"]["content"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Authenticated request
|
|
73
|
+
|
|
74
|
+
Use a token for authenticated operations such as notifications.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
import os
|
|
79
|
+
|
|
80
|
+
from mosir_sdk import AsyncMosirClient
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def main() -> None:
|
|
84
|
+
async with AsyncMosirClient(token=os.getenv("MOSIR_API_TOKEN")) as client:
|
|
85
|
+
notifications = await client.get_notifications(limit=20)
|
|
86
|
+
print(notifications["getNotifications"])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
asyncio.run(main())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## More examples
|
|
93
|
+
|
|
94
|
+
### Fetch media bytes from a `Media` result
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import asyncio
|
|
98
|
+
|
|
99
|
+
from mosir_sdk import AsyncMosirClient
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def main() -> None:
|
|
103
|
+
async with AsyncMosirClient() as client:
|
|
104
|
+
post = await client.get_post(post_id="VLO8u7UXqclQ7byjfMEX0")
|
|
105
|
+
media = post["getPost"]["attachments"][0]["media"]
|
|
106
|
+
media_bytes = await client.fetch_media(media)
|
|
107
|
+
print(len(media_bytes))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
asyncio.run(main())
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Fetch preview image for a post, profile, or collection
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import asyncio
|
|
117
|
+
|
|
118
|
+
from mosir_sdk import AsyncMosirClient
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def main() -> None:
|
|
122
|
+
async with AsyncMosirClient() as client:
|
|
123
|
+
print(client.get_preview_image_url("post", "VLO8u7UXqclQ7byjfMEX0"))
|
|
124
|
+
preview_bytes = await client.fetch_preview_image("post", "VLO8u7UXqclQ7byjfMEX0")
|
|
125
|
+
print(len(preview_bytes))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
asyncio.run(main())
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### SSE subscription example
|
|
132
|
+
|
|
133
|
+
Subscriptions let your app receive updates from Mosir in near real time without polling.
|
|
134
|
+
This SDK uses **SSE** (Server-Sent Events) for subscriptions.
|
|
135
|
+
|
|
136
|
+
A good example is a Discord bot:
|
|
137
|
+
- subscribe to `post_created_by_author(...)`
|
|
138
|
+
- when a creator publishes something new, turn it into a message
|
|
139
|
+
- send that message into a Discord channel
|
|
140
|
+
|
|
141
|
+
That way the bot reacts immediately instead of polling the API on a timer.
|
|
142
|
+
SSE works especially well for long-running workers, bots, and notification bridges that only need a one-way event stream from the server.
|
|
143
|
+
For public subscriptions like `post_created_by_author(...)`, a token is not required.
|
|
144
|
+
|
|
145
|
+
Note: each SSE connection lasts at most 1 hour. In practice, network conditions may cause it to end earlier.
|
|
146
|
+
If you build a bot, worker, or relay process, make sure you implement reconnect logic.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import asyncio
|
|
150
|
+
|
|
151
|
+
from mosir_sdk import AsyncMosirClient
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def main() -> None:
|
|
155
|
+
async with AsyncMosirClient() as client:
|
|
156
|
+
profile = await client.get_account_profile(username="leemiyinghao")
|
|
157
|
+
author_id = profile["getAccountProfile"]["id"]
|
|
158
|
+
|
|
159
|
+
async for event in client.post_created_by_author(
|
|
160
|
+
author_id=author_id,
|
|
161
|
+
post_type="POST",
|
|
162
|
+
):
|
|
163
|
+
print(event["postCreatedByAuthor"]["id"])
|
|
164
|
+
print(event["postCreatedByAuthor"]["content"])
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
asyncio.run(main())
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Notes
|
|
172
|
+
|
|
173
|
+
- default endpoint: `https://beta.mosir.app/api/v1`
|
|
174
|
+
- `token` is optional for public data and required only for authenticated operations
|
|
175
|
+
- the same applies to subscriptions: public subscription data does not require a token
|
|
176
|
+
- snake_case methods are resolved dynamically from the operation registry, for example:
|
|
177
|
+
- `get_post(...)`
|
|
178
|
+
- `get_notifications(...)`
|
|
179
|
+
- `post_created_by_author(...)`
|
|
180
|
+
- media helpers are available through:
|
|
181
|
+
- `select_media_file(...)`
|
|
182
|
+
- `await client.fetch_media(...)`
|
|
183
|
+
- preview image helpers are available through:
|
|
184
|
+
- `get_preview_image_url(...)`
|
|
185
|
+
- `await client.fetch_preview_image(...)`
|
|
186
|
+
- explicit operation access is also available:
|
|
187
|
+
- `await client.operation("get_post", post_id="...")`
|
|
188
|
+
- `client.subscribe_operation("post_created_by_author", author_id="...", post_type="POST")`
|
|
189
|
+
- you can resolve `author_id` first with `get_account_profile(username="...")`
|
|
190
|
+
- raw GraphQL is still available through:
|
|
191
|
+
- `await client.request(...)`
|
|
192
|
+
- `client.subscribe(...)`
|
|
193
|
+
|
|
194
|
+
## Release / publish prep
|
|
195
|
+
|
|
196
|
+
Build and validate distributions before uploading to PyPI:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
task build
|
|
200
|
+
task package-check
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Typical upload flow:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
uvx twine upload dist/*
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Tasks
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
task install
|
|
213
|
+
task codegen
|
|
214
|
+
task pyright
|
|
215
|
+
task test
|
|
216
|
+
task build
|
|
217
|
+
task package-check
|
|
218
|
+
task smoke
|
|
219
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mosir_sdk/__init__.py,sha256=zAHqq4ZfG9OqxjqNKc7nSkeEVvAJXy4miiAOv6N6a0w,546
|
|
2
|
+
mosir_sdk/_operations.py,sha256=k4-BNV_qMkaJ-dS4F6RkvEbU9XUbieUIlccOxVMrutk,76276
|
|
3
|
+
mosir_sdk/client.py,sha256=y4j64VS1feg1XKzEqG0lafwlt0YlkfpvS9lCCwFRBwg,10908
|
|
4
|
+
mosir_sdk/exceptions.py,sha256=mDZuuaZBFeusqy5Y830apnRu8qrzN4DXY40aUmnJgbc,575
|
|
5
|
+
mosir_sdk/py.typed,sha256=8XG5_343HVYG5_KTfJYlLN8t_rutGmYhYSWg4qouK9c,71
|
|
6
|
+
mosir_sdk_python-0.1.1.dist-info/licenses/LICENSE,sha256=MP2OqI_9a49LYoeAOA4qxz2fPZ7GQdE79HJI-G1kQ_k,42334
|
|
7
|
+
mosir_sdk_python-0.1.1.dist-info/METADATA,sha256=8NofNhCAMDi0sr58a9wX3ZRi9PKZECpK57pqcBq1Vok,5685
|
|
8
|
+
mosir_sdk_python-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
mosir_sdk_python-0.1.1.dist-info/top_level.txt,sha256=8q5l5z4w40BiueoupsZf55CU3O2Pcq8mRBRDYIu38AU,10
|
|
10
|
+
mosir_sdk_python-0.1.1.dist-info/RECORD,,
|