+11
.gitignore
+11
.gitignore
+19
README.md
+19
README.md
···
1
+
# microcosm-client
2
+
3
+
Async Microcosm API client wrapper
4
+
5
+
## Quickstart
6
+
7
+
```python
8
+
import asyncio
9
+
from microcosm import AsyncMicrocosmClient
10
+
11
+
async def main():
12
+
async with AsyncMicrocosmClient() as micro:
13
+
data = await micro.links_distinct_dids("at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3lzu6nguhwk2f", "app.bsky.feed.like", ".subject.uri")
14
+
print(data)
15
+
16
+
asyncio.run(main())
17
+
```
18
+
19
+
See docstrings for more details on method return shapes.
+15
microcosmClient/__init__.py
+15
microcosmClient/__init__.py
···
1
+
from .microcosm import AsyncMicrocosmClient, MicrocosmError, MicrocosmHTTPError
2
+
3
+
try:
4
+
from atproto import NSID, AtUri
5
+
except ImportError:
6
+
NSID = None
7
+
AtUri = None
8
+
9
+
__all__ = [
10
+
"AsyncMicrocosmClient",
11
+
"MicrocosmError",
12
+
"MicrocosmHTTPError",
13
+
"NSID",
14
+
"AtUri",
15
+
]
+162
microcosmClient/microcosm.py
+162
microcosmClient/microcosm.py
···
1
+
import logging
2
+
from typing import Any, Dict, Optional, Union
3
+
4
+
import httpx
5
+
from atproto import NSID, AtUri
6
+
7
+
__all__ = ["AsyncMicrocosmClient", "NSID", "AtUri",
8
+
"MicrocosmError", "MicrocosmHTTPError"]
9
+
10
+
11
+
logger = logging.getLogger(__name__)
12
+
13
+
14
+
BASE_URL = "https://constellation.microcosm.blue/"
15
+
16
+
17
+
def _coerce_aturi(value: Union[AtUri, str]) -> AtUri:
18
+
"""Coerce a value to AtUri. If it's already an AtUri return it, else parse from string."""
19
+
if isinstance(value, AtUri):
20
+
return value
21
+
if isinstance(value, str):
22
+
return AtUri.from_str(value)
23
+
raise TypeError("target must be an AtUri or str")
24
+
25
+
26
+
def _coerce_nsid(value: Union[NSID, str]) -> NSID:
27
+
"""Coerce a value to NSID. If it's already an NSID return it, else parse from string."""
28
+
if isinstance(value, NSID):
29
+
return value
30
+
if isinstance(value, str):
31
+
return NSID.from_str(value)
32
+
raise TypeError("collection must be an NSID or str")
33
+
34
+
35
+
class AsyncMicrocosmClient:
36
+
"""Asynchronous Microcosm client.
37
+
38
+
Methods accept either typed objects (`AtUri`, `NSID`) or their string
39
+
representations. Strings are coerced to the appropriate type before use.
40
+
"""
41
+
42
+
def __init__(self, base_url: str = BASE_URL, timeout: float = 10.0):
43
+
self.base_url = base_url
44
+
self.client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
45
+
46
+
def aclose(self): return self.close()
47
+
48
+
async def __aenter__(self):
49
+
return self
50
+
51
+
async def __aexit__(self, exc_type, exc, tb):
52
+
await self.close()
53
+
54
+
async def links(self, target: Union[AtUri, str], collection: Union[NSID, str], path: str) -> Dict[str, Any]:
55
+
"""List records linking to a target."""
56
+
target = _coerce_aturi(target)
57
+
collection = _coerce_nsid(collection)
58
+
r = await self.client.get(
59
+
"/links",
60
+
params={"target": str(target), "collection": str(
61
+
collection), "path": path},
62
+
)
63
+
try:
64
+
r.raise_for_status()
65
+
except httpx.HTTPStatusError as exc:
66
+
logger.debug("links request failed: %s %s", r.status_code, r.text)
67
+
raise MicrocosmHTTPError(r.status_code, r.text) from exc
68
+
return r.json()
69
+
70
+
async def links_distinct_dids(self, target: Union[AtUri, str], collection: Union[NSID, str], path: str) -> Dict[str, Any]:
71
+
"""List distinct DIDs with links to a target."""
72
+
target = _coerce_aturi(target)
73
+
collection = _coerce_nsid(collection)
74
+
r = await self.client.get(
75
+
"/links/distinct-dids",
76
+
params={"target": str(target), "collection": str(
77
+
collection), "path": path},
78
+
)
79
+
try:
80
+
r.raise_for_status()
81
+
except httpx.HTTPStatusError as exc:
82
+
logger.debug("links_distinct_dids failed: %s %s",
83
+
r.status_code, r.text)
84
+
raise MicrocosmHTTPError(r.status_code, r.text) from exc
85
+
return r.json()
86
+
87
+
async def links_count(self, target: Union[AtUri, str], collection: Union[NSID, str], path: str, cursor: Optional[str] = None) -> Dict[str, Any]:
88
+
"""Get total number of links pointing at a target."""
89
+
target = _coerce_aturi(target)
90
+
collection = _coerce_nsid(collection)
91
+
params = {"target": str(target), "collection": str(
92
+
collection), "path": path}
93
+
if cursor:
94
+
params["cursor"] = cursor
95
+
r = await self.client.get("/links/count", params=params)
96
+
try:
97
+
r.raise_for_status()
98
+
except httpx.HTTPStatusError as exc:
99
+
logger.debug("links_count failed: %s %s", r.status_code, r.text)
100
+
raise MicrocosmHTTPError(r.status_code, r.text) from exc
101
+
return r.json()
102
+
103
+
async def links_count_distinct_dids(self, target: Union[AtUri, str], collection: Union[NSID, str], path: str, cursor: Optional[str] = None) -> Dict[str, Any]:
104
+
"""Get total number of distinct DIDs linking to a target."""
105
+
target = _coerce_aturi(target)
106
+
collection = _coerce_nsid(collection)
107
+
params = {"target": str(target), "collection": str(
108
+
collection), "path": path}
109
+
if cursor:
110
+
params["cursor"] = cursor
111
+
r = await self.client.get("/links/count/distinct-dids", params=params)
112
+
try:
113
+
r.raise_for_status()
114
+
except httpx.HTTPStatusError as exc:
115
+
logger.debug("links_count_distinct_dids failed: %s %s",
116
+
r.status_code, r.text)
117
+
raise MicrocosmHTTPError(r.status_code, r.text) from exc
118
+
return r.json()
119
+
120
+
async def links_all(self, target: Union[AtUri, str]) -> Dict[str, Any]:
121
+
"""Show all sources with links to a target, including counts and distinct linking DIDs."""
122
+
target = _coerce_aturi(target)
123
+
r = await self.client.get("/links/all", params={"target": str(target)})
124
+
try:
125
+
r.raise_for_status()
126
+
except httpx.HTTPStatusError as exc:
127
+
logger.debug("links_all failed: %s %s", r.status_code, r.text)
128
+
raise MicrocosmHTTPError(r.status_code, r.text) from exc
129
+
return r.json()
130
+
131
+
async def links_all_count(self, target: Union[AtUri, str]) -> Dict[str, Any]:
132
+
"""[Deprecated] Total counts of all links pointing at a target, grouped by collection and path."""
133
+
target = _coerce_aturi(target)
134
+
r = await self.client.get("/links/all/count", params={"target": str(target)})
135
+
try:
136
+
r.raise_for_status()
137
+
except httpx.HTTPStatusError as exc:
138
+
logger.debug("links_all_count failed: %s %s",
139
+
r.status_code, r.text)
140
+
raise MicrocosmHTTPError(r.status_code, r.text) from exc
141
+
return r.json()
142
+
143
+
async def close(self):
144
+
await self.client.aclose()
145
+
146
+
147
+
class MicrocosmError(Exception):
148
+
"""Base exception for Microcosm wrapper errors."""
149
+
150
+
151
+
class MicrocosmHTTPError(MicrocosmError):
152
+
"""Raised when Microcosm responds with an error status code.
153
+
154
+
Attributes:
155
+
status_code: HTTP status code returned by the server
156
+
response_text: text content of the response
157
+
"""
158
+
159
+
def __init__(self, status_code: int, response_text: str):
160
+
super().__init__(f"Microcosm HTTP {status_code}: {response_text}")
161
+
self.status_code = status_code
162
+
self.response_text = response_text
+20
pyproject.toml
+20
pyproject.toml
···
1
+
[build-system]
2
+
requires = ["setuptools>=61.0", "wheel"]
3
+
build-backend = "setuptools.build_meta"
4
+
5
+
[project]
6
+
name = "microcosm-client"
7
+
version = "0.1.0"
8
+
description = "Async Microcosm API client wrapper"
9
+
readme = "README.md"
10
+
requires-python = ">=3.11"
11
+
license = {text = "MIT"}
12
+
authors = [ {name = "kamo.moe"} ]
13
+
dependencies = [
14
+
"httpx",
15
+
"atproto"
16
+
]
17
+
18
+
19
+
[tool.pytest.ini_options]
20
+
addopts = "-v"
+82
tests/test_client.py
+82
tests/test_client.py
···
1
+
import pytest
2
+
3
+
from microcosmClient.microcosm import AsyncMicrocosmClient
4
+
5
+
6
+
@pytest.mark.asyncio
7
+
async def test_links():
8
+
client = AsyncMicrocosmClient()
9
+
uri = "at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r"
10
+
nsid = "app.bsky.feed.like"
11
+
result = await client.links(uri, nsid, ".subject.uri")
12
+
await client.close()
13
+
14
+
assert isinstance(result, dict)
15
+
assert "linking_records" in result
16
+
assert isinstance(result["linking_records"], list)
17
+
assert "total" in result
18
+
assert isinstance(result["total"], int)
19
+
20
+
21
+
@pytest.mark.asyncio
22
+
async def test_links_distinct_dids():
23
+
client = AsyncMicrocosmClient()
24
+
uri = "at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r"
25
+
nsid = "app.bsky.feed.like"
26
+
result = await client.links_distinct_dids(uri, nsid, ".subject.uri")
27
+
await client.close()
28
+
29
+
assert isinstance(result, dict)
30
+
assert "linking_dids" in result
31
+
assert isinstance(result["linking_dids"], list)
32
+
assert "total" in result
33
+
assert isinstance(result["total"], int)
34
+
35
+
36
+
@pytest.mark.asyncio
37
+
async def test_links_count():
38
+
client = AsyncMicrocosmClient()
39
+
target = "did:plc:vc7f4oafdgxsihk4cry2xpze"
40
+
collection = "app.bsky.graph.block"
41
+
result = await client.links_count(target, collection, ".subject")
42
+
await client.close()
43
+
44
+
assert isinstance(result, dict)
45
+
assert "total" in result
46
+
assert isinstance(result["total"], int)
47
+
48
+
49
+
@pytest.mark.asyncio
50
+
async def test_links_count_distinct_dids():
51
+
client = AsyncMicrocosmClient()
52
+
target = "did:plc:vc7f4oafdgxsihk4cry2xpze"
53
+
collection = "app.bsky.graph.block"
54
+
result = await client.links_count_distinct_dids(target, collection, ".subject")
55
+
await client.close()
56
+
57
+
assert isinstance(result, dict)
58
+
assert "total" in result
59
+
assert isinstance(result["total"], int)
60
+
61
+
62
+
@pytest.mark.asyncio
63
+
async def test_links_all():
64
+
client = AsyncMicrocosmClient()
65
+
target = "did:plc:oky5czdrnfjpqslsw2a5iclo"
66
+
result = await client.links_all(target)
67
+
await client.close()
68
+
69
+
assert isinstance(result, dict)
70
+
assert all(isinstance(v, dict) for v in result.values())
71
+
72
+
73
+
@pytest.mark.asyncio
74
+
async def test_links_all_count():
75
+
client = AsyncMicrocosmClient()
76
+
target = "did:plc:oky5czdrnfjpqslsw2a5iclo"
77
+
result = await client.links_all_count(target)
78
+
await client.close()
79
+
80
+
assert isinstance(result, dict)
81
+
assert "links" in result
82
+
assert isinstance(result["links"], dict)