Async Microcosm API client wrapper written in python. Heavy wip.

Initial commit: Microcosm client package

Badtz de175bdb

+11
.gitignore
··· 1 + __pycache__/ 2 + *.py[cod] 3 + *.egg-info/ 4 + dist/ 5 + build/ 6 + .env 7 + .venv 8 + .pytest_cache/ 9 + .mypy_cache/ 10 + .ruff_cache/ 11 + .vscode/
+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
··· 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
··· 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
··· 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
··· 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)