semble-api 0.0.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.
semble/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from semble._client import AsyncSemble, Semble
4
+ from semble.settings import DEFAULT_BASE_URL, SembleSettings
5
+ from semble._exceptions import (
6
+ APIStatusError,
7
+ AuthenticationError,
8
+ NotFoundError,
9
+ PermissionDeniedError,
10
+ RateLimitError,
11
+ SembleError,
12
+ ServerError,
13
+ )
14
+
15
+ try:
16
+ __version__ = version("semble-api")
17
+ except PackageNotFoundError:
18
+ __version__ = "0.0.0"
19
+
20
+ __all__ = [
21
+ "DEFAULT_BASE_URL",
22
+ "APIStatusError",
23
+ "AsyncSemble",
24
+ "AuthenticationError",
25
+ "NotFoundError",
26
+ "PermissionDeniedError",
27
+ "RateLimitError",
28
+ "Semble",
29
+ "SembleError",
30
+ "SembleSettings",
31
+ "ServerError",
32
+ "__version__",
33
+ ]
semble/_client.py ADDED
@@ -0,0 +1,204 @@
1
+ from types import TracebackType
2
+ from typing import Any
3
+
4
+ import httpx2 as httpx
5
+ from pydantic import SecretStr
6
+
7
+ from semble._exceptions import status_error
8
+ from semble.resources.actors import Actors, AsyncActors
9
+ from semble.resources.cards import AsyncCards, Cards
10
+ from semble.resources.collections import AsyncCollections, Collections
11
+ from semble.resources.connections import AsyncConnections, Connections
12
+ from semble.resources.feeds import AsyncFeeds, Feeds
13
+ from semble.resources.graph import AsyncGraph, Graph
14
+ from semble.resources.notifications import AsyncNotifications, Notifications
15
+ from semble.resources.search import AsyncSearch, Search
16
+ from semble.settings import SembleSettings
17
+
18
+
19
+ class _BaseClient:
20
+ def __init__(
21
+ self,
22
+ api_key: str | SecretStr | None,
23
+ base_url: str | None,
24
+ timeout: float | None,
25
+ ) -> None:
26
+ settings = SembleSettings()
27
+ if api_key is None:
28
+ self.api_key = settings.api_key
29
+ elif isinstance(api_key, str):
30
+ self.api_key = SecretStr(api_key) if api_key else None
31
+ else:
32
+ self.api_key = api_key
33
+ self.base_url = (base_url or settings.base_url).rstrip("/")
34
+ self.timeout = timeout if timeout is not None else settings.timeout
35
+
36
+ def _url(self, nsid: str) -> str:
37
+ return f"{self.base_url}/{nsid}"
38
+
39
+ def _headers(self) -> dict[str, str]:
40
+ headers = {"accept": "application/json"}
41
+ if self.api_key is not None:
42
+ headers["x-api-key"] = self.api_key.get_secret_value()
43
+ return headers
44
+
45
+ @staticmethod
46
+ def _parse(response: httpx.Response, cast_to: Any) -> Any:
47
+ if not response.is_success:
48
+ raise status_error(response)
49
+ if not response.content:
50
+ return None
51
+ data = response.json()
52
+ if cast_to is None:
53
+ return data
54
+ return cast_to.model_validate(data)
55
+
56
+
57
+ class Semble(_BaseClient):
58
+ """synchronous client for the semble api.
59
+
60
+ configuration not passed explicitly comes from `SembleSettings`
61
+ (`SEMBLE_*` environment variables, then a local `.env` file). create
62
+ keys at https://semble.so/settings/api-keys.
63
+
64
+ usable directly or as a context manager. `close()` only closes the
65
+ underlying http client if this client created it — a borrowed
66
+ `http_client` stays open and its lifecycle (including its timeout)
67
+ remains the caller's.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ api_key: str | SecretStr | None = None,
74
+ base_url: str | None = None,
75
+ timeout: float | None = None,
76
+ http_client: httpx.Client | None = None,
77
+ ) -> None:
78
+ super().__init__(api_key, base_url, timeout)
79
+ self._owns_http = http_client is None
80
+ self._http = http_client or httpx.Client(timeout=self.timeout)
81
+
82
+ self.actors = Actors(self)
83
+ self.cards = Cards(self)
84
+ self.collections = Collections(self)
85
+ self.connections = Connections(self)
86
+ self.feeds = Feeds(self)
87
+ self.graph = Graph(self)
88
+ self.notifications = Notifications(self)
89
+ self.search = Search(self)
90
+
91
+ def get(
92
+ self,
93
+ nsid: str,
94
+ params: dict[str, Any] | None = None,
95
+ *,
96
+ cast_to: Any = None,
97
+ ) -> Any:
98
+ """GET an xrpc query by nsid. escape hatch for unwrapped endpoints."""
99
+ response = self._http.get(
100
+ self._url(nsid), params=params, headers=self._headers()
101
+ )
102
+ return self._parse(response, cast_to)
103
+
104
+ def post(
105
+ self,
106
+ nsid: str,
107
+ json: dict[str, Any] | None = None,
108
+ *,
109
+ cast_to: Any = None,
110
+ ) -> Any:
111
+ """POST an xrpc procedure by nsid. escape hatch for unwrapped endpoints."""
112
+ response = self._http.post(self._url(nsid), json=json, headers=self._headers())
113
+ return self._parse(response, cast_to)
114
+
115
+ def close(self) -> None:
116
+ if self._owns_http:
117
+ self._http.close()
118
+
119
+ def __enter__(self) -> "Semble":
120
+ return self
121
+
122
+ def __exit__(
123
+ self,
124
+ exc_type: type[BaseException] | None,
125
+ exc: BaseException | None,
126
+ tb: TracebackType | None,
127
+ ) -> None:
128
+ self.close()
129
+
130
+
131
+ class AsyncSemble(_BaseClient):
132
+ """asynchronous client for the semble api.
133
+
134
+ configuration not passed explicitly comes from `SembleSettings`
135
+ (`SEMBLE_*` environment variables, then a local `.env` file). create
136
+ keys at https://semble.so/settings/api-keys.
137
+
138
+ usable directly or as a context manager. `close()` only closes the
139
+ underlying http client if this client created it — a borrowed
140
+ `http_client` stays open and its lifecycle (including its timeout)
141
+ remains the caller's.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ *,
147
+ api_key: str | SecretStr | None = None,
148
+ base_url: str | None = None,
149
+ timeout: float | None = None,
150
+ http_client: httpx.AsyncClient | None = None,
151
+ ) -> None:
152
+ super().__init__(api_key, base_url, timeout)
153
+ self._owns_http = http_client is None
154
+ self._http = http_client or httpx.AsyncClient(timeout=self.timeout)
155
+
156
+ self.actors = AsyncActors(self)
157
+ self.cards = AsyncCards(self)
158
+ self.collections = AsyncCollections(self)
159
+ self.connections = AsyncConnections(self)
160
+ self.feeds = AsyncFeeds(self)
161
+ self.graph = AsyncGraph(self)
162
+ self.notifications = AsyncNotifications(self)
163
+ self.search = AsyncSearch(self)
164
+
165
+ async def get(
166
+ self,
167
+ nsid: str,
168
+ params: dict[str, Any] | None = None,
169
+ *,
170
+ cast_to: Any = None,
171
+ ) -> Any:
172
+ """GET an xrpc query by nsid. escape hatch for unwrapped endpoints."""
173
+ response = await self._http.get(
174
+ self._url(nsid), params=params, headers=self._headers()
175
+ )
176
+ return self._parse(response, cast_to)
177
+
178
+ async def post(
179
+ self,
180
+ nsid: str,
181
+ json: dict[str, Any] | None = None,
182
+ *,
183
+ cast_to: Any = None,
184
+ ) -> Any:
185
+ """POST an xrpc procedure by nsid. escape hatch for unwrapped endpoints."""
186
+ response = await self._http.post(
187
+ self._url(nsid), json=json, headers=self._headers()
188
+ )
189
+ return self._parse(response, cast_to)
190
+
191
+ async def close(self) -> None:
192
+ if self._owns_http:
193
+ await self._http.aclose()
194
+
195
+ async def __aenter__(self) -> "AsyncSemble":
196
+ return self
197
+
198
+ async def __aexit__(
199
+ self,
200
+ exc_type: type[BaseException] | None,
201
+ exc: BaseException | None,
202
+ tb: TracebackType | None,
203
+ ) -> None:
204
+ await self.close()
semble/_exceptions.py ADDED
@@ -0,0 +1,60 @@
1
+ import httpx2 as httpx
2
+
3
+
4
+ class SembleError(Exception):
5
+ """base for all errors raised by this library."""
6
+
7
+
8
+ class APIStatusError(SembleError):
9
+ """a non-2xx response from the semble api."""
10
+
11
+ def __init__(self, message: str, *, response: httpx.Response) -> None:
12
+ super().__init__(message)
13
+ self.message = message
14
+ self.response = response
15
+ self.status_code = response.status_code
16
+
17
+
18
+ class AuthenticationError(APIStatusError):
19
+ """401 — missing or invalid api key."""
20
+
21
+
22
+ class PermissionDeniedError(APIStatusError):
23
+ """403 — authenticated but not allowed."""
24
+
25
+
26
+ class NotFoundError(APIStatusError):
27
+ """404 — no such resource."""
28
+
29
+
30
+ class RateLimitError(APIStatusError):
31
+ """429 — slow down."""
32
+
33
+
34
+ class ServerError(APIStatusError):
35
+ """5xx — something broke on semble's end."""
36
+
37
+
38
+ _STATUS_ERRORS: dict[int, type[APIStatusError]] = {
39
+ 401: AuthenticationError,
40
+ 403: PermissionDeniedError,
41
+ 404: NotFoundError,
42
+ 429: RateLimitError,
43
+ }
44
+
45
+
46
+ def status_error(response: httpx.Response) -> APIStatusError:
47
+ message = ""
48
+ try:
49
+ data = response.json()
50
+ except ValueError:
51
+ data = None
52
+ if isinstance(data, dict):
53
+ message = data.get("message") or data.get("error") or ""
54
+ if not message:
55
+ message = response.text.strip() or f"HTTP {response.status_code}"
56
+
57
+ cls = _STATUS_ERRORS.get(response.status_code)
58
+ if cls is None:
59
+ cls = ServerError if response.status_code >= 500 else APIStatusError
60
+ return cls(message, response=response)
semble/_utils.py ADDED
@@ -0,0 +1,9 @@
1
+ from typing import Any
2
+
3
+
4
+ def drop_none(**kwargs: Any) -> dict[str, Any]:
5
+ """build a params/body dict, omitting unset values.
6
+
7
+ keys are passed in api casing (camelCase) directly.
8
+ """
9
+ return {k: v for k, v in kwargs.items() if v is not None}
semble/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """command-line interface for semble.
2
+
3
+ machine-readable by default: lists are ndjson (one json object per line,
4
+ api-cased keys) and single results are one json object, so output pipes
5
+ straight into jq or an agent. pass --pretty for human-formatted output.
6
+
7
+ requires the `cli` extra: `uv add 'semble-api[cli]'`.
8
+ """
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ try:
14
+ import cyclopts
15
+ except ImportError as exc: # pragma: no cover
16
+ raise SystemExit(
17
+ "the semble cli requires the `cli` extra: uv add 'semble-api[cli]'"
18
+ ) from exc
19
+
20
+ from pydantic import BaseModel
21
+
22
+ import semble
23
+ from semble import Semble, SembleError
24
+
25
+ app = cyclopts.App(
26
+ name="semble",
27
+ help="interact with semble (semble.so) from the terminal",
28
+ version=semble.__version__,
29
+ )
30
+
31
+
32
+ def _dump(model: BaseModel) -> dict[str, Any]:
33
+ return model.model_dump(by_alias=True, exclude_none=True, mode="json")
34
+
35
+
36
+ def _emit(data: BaseModel | dict[str, Any]) -> None:
37
+ if isinstance(data, BaseModel):
38
+ data = _dump(data)
39
+ print(json.dumps(data))
40
+
41
+
42
+ @app.command
43
+ def whoami(*, pretty: bool = False) -> None:
44
+ """show my profile and unread notification count."""
45
+ with Semble() as client:
46
+ me = client.actors.get_my_profile(include_stats=True)
47
+ unread = client.notifications.get_unread_count()
48
+ count = unread.unread_count if unread.unread_count is not None else unread.count
49
+
50
+ if not pretty:
51
+ _emit({"profile": _dump(me), "unreadNotifications": count})
52
+ return
53
+
54
+ print(f"@{me.handle} ({me.name})")
55
+ print(f" cards: {me.url_card_count}")
56
+ print(f" collections: {me.collection_count}")
57
+ print(f" connections: {me.connection_count}")
58
+ print(f" followers: {me.follower_count} / following: {me.following_count}")
59
+ print(f" unread notifications: {count}")
60
+
61
+
62
+ @app.command
63
+ def feed(limit: int = 10, *, following: bool = False, pretty: bool = False) -> None:
64
+ """show recent activity from the global (or following) feed."""
65
+ with Semble() as client:
66
+ get = client.feeds.get_following if following else client.feeds.get_global
67
+ for activity in get(limit=limit):
68
+ if not pretty:
69
+ _emit(activity)
70
+ continue
71
+ when = f"{activity.created_at:%m-%d %H:%M}" if activity.created_at else "?"
72
+ who = f"@{activity.user.handle}" if activity.user else "?"
73
+ what = activity.card.url if activity.card else ""
74
+ print(f"{when} {activity.activity_type:<22} {who:<24} {what}")
75
+
76
+
77
+ @app.command
78
+ def search(query: str, limit: int = 10, *, pretty: bool = False) -> None:
79
+ """semantic search across semble."""
80
+ with Semble() as client:
81
+ for hit in client.search.semantic(query, limit=limit):
82
+ if not pretty:
83
+ _emit(hit)
84
+ continue
85
+ title = (
86
+ hit.metadata.title
87
+ if hit.metadata and hit.metadata.title
88
+ else "(untitled)"
89
+ )
90
+ print(f"{title}\n {hit.url}\n")
91
+
92
+
93
+ @app.command
94
+ def library(
95
+ handle: str | None = None, limit: int = 10, *, pretty: bool = False
96
+ ) -> None:
97
+ """list cards in a library — mine by default, or any user's."""
98
+ with Semble() as client:
99
+ if handle:
100
+ page = client.cards.list_by_user(handle, limit=limit)
101
+ else:
102
+ page = client.cards.list_mine(limit=limit)
103
+
104
+ for card in page:
105
+ if not pretty:
106
+ _emit(card)
107
+ continue
108
+ note = f" [note: {card.note.text}]" if card.note and card.note.text else ""
109
+ print(f"{card.id} {card.url}{note}")
110
+
111
+ if pretty and page.pagination and page.pagination.total_count is not None:
112
+ print(f"\n{len(page)} of {page.pagination.total_count} cards")
113
+
114
+
115
+ @app.command
116
+ def add(
117
+ url: str,
118
+ *,
119
+ note: str | None = None,
120
+ collection: list[str] | None = None,
121
+ pretty: bool = False,
122
+ ) -> None:
123
+ """add a url to my library, optionally with a note and collection ids."""
124
+ with Semble() as client:
125
+ added = client.cards.add_url(url, note=note, collection_ids=collection)
126
+
127
+ if not pretty:
128
+ _emit(added)
129
+ return
130
+
131
+ print(f"added {url}")
132
+ print(f" url card: {added.url_card_id}")
133
+ if added.note_card_id:
134
+ print(f" note card: {added.note_card_id}")
135
+
136
+
137
+ @app.command
138
+ def rm(card_id: str, *, pretty: bool = False) -> None:
139
+ """remove a card from my library."""
140
+ with Semble() as client:
141
+ client.cards.remove_from_library(card_id)
142
+
143
+ if not pretty:
144
+ _emit({"cardId": card_id, "removed": True})
145
+ return
146
+
147
+ print(f"removed {card_id}")
148
+
149
+
150
+ def main() -> None:
151
+ try:
152
+ app()
153
+ except SembleError as exc:
154
+ raise SystemExit(f"error: {exc}") from exc
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
semble/mcp.py ADDED
@@ -0,0 +1,42 @@
1
+ """code-mode mcp server over the semble sdk.
2
+
3
+ instead of one mcp tool per endpoint, the full sdk surface is registered
4
+ host-side and hidden behind fastmcp's CodeMode transform — clients see only
5
+ `search` / `get_schema` / `execute`, and compose sdk calls as python running
6
+ in a monty sandbox.
7
+
8
+ requires the `mcp` extra: `uv add 'semble-api[mcp]'`.
9
+ """
10
+
11
+ import inspect
12
+
13
+ try:
14
+ from fastmcp import FastMCP
15
+ from fastmcp.experimental.transforms.code_mode import CodeMode
16
+ except ImportError as exc: # pragma: no cover
17
+ raise SystemExit(
18
+ "the semble mcp server requires the `mcp` extra: uv add 'semble-api[mcp]'"
19
+ ) from exc
20
+
21
+ from semble import Semble
22
+ from semble.resources._base import SyncResource
23
+
24
+
25
+ def build_server(client: Semble | None = None) -> FastMCP:
26
+ client = client or Semble()
27
+ mcp = FastMCP("semble", transforms=[CodeMode()])
28
+ resources = {
29
+ name: attr
30
+ for name, attr in vars(client).items()
31
+ if isinstance(attr, SyncResource)
32
+ }
33
+ for resource_name, resource in sorted(resources.items()):
34
+ for name, method in inspect.getmembers(resource, inspect.ismethod):
35
+ if name.startswith("_"):
36
+ continue
37
+ mcp.tool(method, name=f"{resource_name}_{name}", tags={resource_name})
38
+ return mcp
39
+
40
+
41
+ def main() -> None:
42
+ build_server().run()
semble/py.typed ADDED
File without changes
semble/records.py ADDED
@@ -0,0 +1,82 @@
1
+ """atproto record shapes for the network.cosmik.* lexicons.
2
+
3
+ these model the records as they live on a pds, separate from the app-view
4
+ dtos in `semble.types`. useful when reading or writing semble records
5
+ directly (e.g. with pdsx or the atproto sdk) instead of going through the
6
+ api.
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import Field
12
+
13
+ from semble.types import Model
14
+
15
+ RECORD_TYPE_CARD = "network.cosmik.card"
16
+ RECORD_TYPE_URL_CONTENT = "network.cosmik.card#urlContent"
17
+ RECORD_TYPE_NOTE_CONTENT = "network.cosmik.card#noteContent"
18
+ RECORD_TYPE_COLLECTION = "network.cosmik.collection"
19
+ RECORD_TYPE_COLLECTION_LINK = "network.cosmik.collectionLink"
20
+ RECORD_TYPE_PROVENANCE = "network.cosmik.defs#provenance"
21
+
22
+ CARD_TYPE_URL = "URL"
23
+ CARD_TYPE_NOTE = "NOTE"
24
+
25
+
26
+ class StrongRef(Model):
27
+ """an atproto strong reference: an at-uri plus the record cid."""
28
+
29
+ uri: str
30
+ cid: str
31
+
32
+
33
+ class URLMetadataRecord(Model):
34
+ record_type: str | None = Field(default=None, alias="$type")
35
+ content_type: str | None = Field(default=None, alias="type")
36
+ title: str | None = None
37
+ description: str | None = None
38
+ author: str | None = None
39
+ site_name: str | None = None
40
+ image_url: str | None = None
41
+ published_date: str | None = None
42
+ retrieved_at: str | None = None
43
+
44
+
45
+ class URLContentRecord(Model):
46
+ record_type: str = Field(default=RECORD_TYPE_URL_CONTENT, alias="$type")
47
+ url: str
48
+ metadata: URLMetadataRecord | None = None
49
+
50
+
51
+ class NoteContentRecord(Model):
52
+ record_type: str = Field(default=RECORD_TYPE_NOTE_CONTENT, alias="$type")
53
+ text: str
54
+
55
+
56
+ class ProvenanceRecord(Model):
57
+ record_type: str = Field(default=RECORD_TYPE_PROVENANCE, alias="$type")
58
+ via: StrongRef | None = None
59
+
60
+
61
+ class CardRecord(Model):
62
+ record_type: str = Field(default=RECORD_TYPE_CARD, alias="$type")
63
+ card_type: str = Field(alias="type")
64
+ content: Any = None
65
+ url: str | None = None
66
+ parent_card: StrongRef | None = None
67
+ created_at: str
68
+ provenance: ProvenanceRecord | None = None
69
+
70
+
71
+ class CollectionRecord(Model):
72
+ record_type: str = Field(default=RECORD_TYPE_COLLECTION, alias="$type")
73
+ name: str
74
+ description: str | None = None
75
+ created_at: str
76
+
77
+
78
+ class CollectionLinkRecord(Model):
79
+ record_type: str = Field(default=RECORD_TYPE_COLLECTION_LINK, alias="$type")
80
+ card: StrongRef
81
+ collection: StrongRef
82
+ created_at: str
@@ -0,0 +1,8 @@
1
+ from semble.resources.actors import Actors, AsyncActors
2
+ from semble.resources.cards import AsyncCards, Cards
3
+ from semble.resources.collections import AsyncCollections, Collections
4
+ from semble.resources.connections import AsyncConnections, Connections
5
+ from semble.resources.feeds import AsyncFeeds, Feeds
6
+ from semble.resources.graph import AsyncGraph, Graph
7
+ from semble.resources.notifications import AsyncNotifications, Notifications
8
+ from semble.resources.search import AsyncSearch, Search
@@ -0,0 +1,14 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from semble._client import AsyncSemble, Semble
5
+
6
+
7
+ class SyncResource:
8
+ def __init__(self, client: "Semble") -> None:
9
+ self._client = client
10
+
11
+
12
+ class AsyncResource:
13
+ def __init__(self, client: "AsyncSemble") -> None:
14
+ self._client = client
@@ -0,0 +1,35 @@
1
+ """network.cosmik.actor.* — user profiles."""
2
+
3
+ from semble._utils import drop_none
4
+ from semble.resources._base import AsyncResource, SyncResource
5
+ from semble.types import User
6
+
7
+
8
+ class Actors(SyncResource):
9
+ def get_my_profile(self, *, include_stats: bool | None = None) -> User:
10
+ params = drop_none(includeStats=include_stats)
11
+ return self._client.get(
12
+ "network.cosmik.actor.getMyProfile", params, cast_to=User
13
+ )
14
+
15
+ def get_profile(
16
+ self, identifier: str, *, include_stats: bool | None = None
17
+ ) -> User:
18
+ params = drop_none(identifier=identifier, includeStats=include_stats)
19
+ return self._client.get("network.cosmik.actor.getProfile", params, cast_to=User)
20
+
21
+
22
+ class AsyncActors(AsyncResource):
23
+ async def get_my_profile(self, *, include_stats: bool | None = None) -> User:
24
+ params = drop_none(includeStats=include_stats)
25
+ return await self._client.get(
26
+ "network.cosmik.actor.getMyProfile", params, cast_to=User
27
+ )
28
+
29
+ async def get_profile(
30
+ self, identifier: str, *, include_stats: bool | None = None
31
+ ) -> User:
32
+ params = drop_none(identifier=identifier, includeStats=include_stats)
33
+ return await self._client.get(
34
+ "network.cosmik.actor.getProfile", params, cast_to=User
35
+ )