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/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)
@@ -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,,
@@ -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
+