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 +33 -0
- semble/_client.py +204 -0
- semble/_exceptions.py +60 -0
- semble/_utils.py +9 -0
- semble/cli.py +158 -0
- semble/mcp.py +42 -0
- semble/py.typed +0 -0
- semble/records.py +82 -0
- semble/resources/__init__.py +8 -0
- semble/resources/_base.py +14 -0
- semble/resources/actors.py +35 -0
- semble/resources/cards.py +344 -0
- semble/resources/collections.py +447 -0
- semble/resources/connections.py +199 -0
- semble/resources/feeds.py +121 -0
- semble/resources/graph.py +155 -0
- semble/resources/notifications.py +87 -0
- semble/resources/search.py +135 -0
- semble/settings.py +26 -0
- semble/types.py +268 -0
- semble_api-0.0.1.dist-info/METADATA +165 -0
- semble_api-0.0.1.dist-info/RECORD +25 -0
- semble_api-0.0.1.dist-info/WHEEL +4 -0
- semble_api-0.0.1.dist-info/entry_points.txt +3 -0
- semble_api-0.0.1.dist-info/licenses/LICENSE +21 -0
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
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
|
+
)
|