nolag 2.0.0__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.
nolag/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ """
2
+ NoLag Python SDK
3
+ Real-time messaging for Python applications
4
+ """
5
+
6
+ from .client import NoLag
7
+ from .types import (
8
+ NoLagOptions,
9
+ ConnectionStatus,
10
+ ActorType,
11
+ QoS,
12
+ SubscribeOptions,
13
+ EmitOptions,
14
+ MessageMeta,
15
+ ActorPresence,
16
+ LobbyPresenceEvent,
17
+ LobbyPresenceState,
18
+ LobbyPresenceHandler,
19
+ )
20
+ from .api import NoLagApi, NoLagApiError
21
+ from .api_types import (
22
+ NoLagApiOptions,
23
+ ListOptions,
24
+ PaginatedResult,
25
+ ApiError,
26
+ App,
27
+ AppCreate,
28
+ AppUpdate,
29
+ Room,
30
+ RoomCreate,
31
+ RoomUpdate,
32
+ Actor,
33
+ ActorWithToken,
34
+ ActorCreate,
35
+ ActorUpdate,
36
+ )
37
+
38
+ # WebRTC support (optional, requires aiortc)
39
+ try:
40
+ from .webrtc import WebRTCManager, WebRTCOptions, is_webrtc_available
41
+ _WEBRTC_AVAILABLE = True
42
+ except ImportError:
43
+ _WEBRTC_AVAILABLE = False
44
+ WebRTCManager = None
45
+ WebRTCOptions = None
46
+ is_webrtc_available = lambda: False
47
+
48
+ __version__ = "2.0.0"
49
+ __all__ = [
50
+ # WebSocket Client
51
+ "NoLag",
52
+ "NoLagOptions",
53
+ "ConnectionStatus",
54
+ "ActorType",
55
+ "QoS",
56
+ "SubscribeOptions",
57
+ "EmitOptions",
58
+ "MessageMeta",
59
+ "ActorPresence",
60
+ "LobbyPresenceEvent",
61
+ "LobbyPresenceState",
62
+ "LobbyPresenceHandler",
63
+ # REST API Client
64
+ "NoLagApi",
65
+ "NoLagApiError",
66
+ "NoLagApiOptions",
67
+ "ListOptions",
68
+ "PaginatedResult",
69
+ "ApiError",
70
+ "App",
71
+ "AppCreate",
72
+ "AppUpdate",
73
+ "Room",
74
+ "RoomCreate",
75
+ "RoomUpdate",
76
+ "Actor",
77
+ "ActorWithToken",
78
+ "ActorCreate",
79
+ "ActorUpdate",
80
+ # WebRTC (optional)
81
+ "WebRTCManager",
82
+ "WebRTCOptions",
83
+ "is_webrtc_available",
84
+ ]
nolag/api.py ADDED
@@ -0,0 +1,268 @@
1
+ """
2
+ NoLag REST API Client
3
+
4
+ Provides management operations for NoLag resources via REST API.
5
+ API keys are scoped to a specific project, so no organization or project IDs needed.
6
+
7
+ Use this for managing apps, rooms, and actors within your project.
8
+ For real-time messaging, use the main NoLag WebSocket client.
9
+ """
10
+
11
+ import aiohttp
12
+ from typing import Any, Optional
13
+ from urllib.parse import urljoin, urlencode
14
+
15
+ from .api_types import (
16
+ NoLagApiOptions,
17
+ ListOptions,
18
+ PaginatedResult,
19
+ ApiError,
20
+ App,
21
+ AppCreate,
22
+ AppUpdate,
23
+ Room,
24
+ RoomCreate,
25
+ RoomUpdate,
26
+ Actor,
27
+ ActorWithToken,
28
+ ActorCreate,
29
+ ActorUpdate,
30
+ )
31
+
32
+ DEFAULT_BASE_URL = "https://api.nolag.app/v1"
33
+ DEFAULT_TIMEOUT = 30.0
34
+
35
+
36
+ class NoLagApiError(Exception):
37
+ """NoLag API Error"""
38
+
39
+ def __init__(self, message: str, status_code: int, details: Optional[ApiError] = None):
40
+ super().__init__(message)
41
+ self.status_code = status_code
42
+ self.details = details or ApiError(status_code=status_code, message=message)
43
+
44
+
45
+ class AppsApi:
46
+ """Apps API - Manage apps in your project"""
47
+
48
+ def __init__(self, api: "NoLagApi"):
49
+ self._api = api
50
+
51
+ async def list(self, options: Optional[ListOptions] = None) -> PaginatedResult:
52
+ """List all apps in the project"""
53
+ params = options.to_params() if options else {}
54
+ data = await self._api._request("GET", "/apps", params=params)
55
+ return PaginatedResult(
56
+ data=[App.from_dict(item) for item in data.get("data", [])],
57
+ total=data.get("total", 0),
58
+ page=data.get("page", 1),
59
+ limit=data.get("limit", 10),
60
+ total_pages=data.get("totalPages", 1),
61
+ )
62
+
63
+ async def get(self, app_id: str) -> App:
64
+ """Get an app by ID"""
65
+ data = await self._api._request("GET", f"/apps/{app_id}")
66
+ return App.from_dict(data)
67
+
68
+ async def create(self, data: AppCreate) -> App:
69
+ """Create a new app"""
70
+ result = await self._api._request("POST", "/apps", json=data.to_dict())
71
+ return App.from_dict(result)
72
+
73
+ async def update(self, app_id: str, data: AppUpdate) -> App:
74
+ """Update an app"""
75
+ result = await self._api._request("PATCH", f"/apps/{app_id}", json=data.to_dict())
76
+ return App.from_dict(result)
77
+
78
+ async def delete(self, app_id: str) -> dict:
79
+ """Delete an app (soft delete)"""
80
+ return await self._api._request("DELETE", f"/apps/{app_id}")
81
+
82
+ async def reset_to_blueprint(self, app_id: str) -> App:
83
+ """Reset app to its blueprint configuration"""
84
+ result = await self._api._request("POST", f"/apps/{app_id}/reset-to-blueprint")
85
+ return App.from_dict(result)
86
+
87
+
88
+ class RoomsApi:
89
+ """Rooms API - Manage rooms within apps"""
90
+
91
+ def __init__(self, api: "NoLagApi"):
92
+ self._api = api
93
+
94
+ async def list(self, app_id: str) -> list[Room]:
95
+ """List all rooms in an app"""
96
+ data = await self._api._request("GET", f"/apps/{app_id}/rooms")
97
+ return [Room.from_dict(item) for item in data]
98
+
99
+ async def get(self, app_id: str, room_id: str) -> Room:
100
+ """Get a room by ID"""
101
+ data = await self._api._request("GET", f"/apps/{app_id}/rooms/{room_id}")
102
+ return Room.from_dict(data)
103
+
104
+ async def create(self, app_id: str, data: RoomCreate) -> Room:
105
+ """Create a new dynamic room"""
106
+ result = await self._api._request("POST", f"/apps/{app_id}/rooms", json=data.to_dict())
107
+ return Room.from_dict(result)
108
+
109
+ async def update(self, app_id: str, room_id: str, data: RoomUpdate) -> Room:
110
+ """Update a room"""
111
+ result = await self._api._request(
112
+ "PATCH", f"/apps/{app_id}/rooms/{room_id}", json=data.to_dict()
113
+ )
114
+ return Room.from_dict(result)
115
+
116
+ async def delete(self, app_id: str, room_id: str) -> None:
117
+ """Delete a dynamic room (static rooms cannot be deleted)"""
118
+ await self._api._request("DELETE", f"/apps/{app_id}/rooms/{room_id}")
119
+
120
+
121
+ class ActorsApi:
122
+ """Actors API - Manage actors in your project"""
123
+
124
+ def __init__(self, api: "NoLagApi"):
125
+ self._api = api
126
+
127
+ async def list(self) -> list[Actor]:
128
+ """List all actors in the project"""
129
+ data = await self._api._request("GET", "/actors")
130
+ return [Actor.from_dict(item) for item in data]
131
+
132
+ async def get(self, actor_id: str) -> Actor:
133
+ """Get an actor by ID"""
134
+ data = await self._api._request("GET", f"/actors/{actor_id}")
135
+ return Actor.from_dict(data)
136
+
137
+ async def create(self, data: ActorCreate) -> ActorWithToken:
138
+ """
139
+ Create a new actor
140
+
141
+ IMPORTANT: The access token is only returned once! Save it immediately.
142
+ """
143
+ result = await self._api._request("POST", "/actors", json=data.to_dict())
144
+ return ActorWithToken.from_dict(result)
145
+
146
+ async def update(self, actor_id: str, data: ActorUpdate) -> Actor:
147
+ """Update an actor"""
148
+ result = await self._api._request("PATCH", f"/actors/{actor_id}", json=data.to_dict())
149
+ return Actor.from_dict(result)
150
+
151
+ async def delete(self, actor_id: str) -> None:
152
+ """Delete an actor"""
153
+ await self._api._request("DELETE", f"/actors/{actor_id}")
154
+
155
+
156
+ class NoLagApi:
157
+ """
158
+ NoLag REST API Client
159
+
160
+ API keys are project-scoped, so you don't need to pass organization or project IDs.
161
+
162
+ Example:
163
+ ```python
164
+ from nolag import NoLagApi, AppCreate, ActorCreate
165
+
166
+ async with NoLagApi('nlg_live_xxx.secret') as api:
167
+ # List apps in your project
168
+ apps = await api.apps.list()
169
+
170
+ # Create a room
171
+ room = await api.rooms.create(app_id, RoomCreate(
172
+ name='chat-room',
173
+ description='General chat'
174
+ ))
175
+
176
+ # Create an actor and get the access token
177
+ actor = await api.actors.create(ActorCreate(
178
+ name='web-client',
179
+ actor_type='device'
180
+ ))
181
+ print('Save this token:', actor.access_token)
182
+ ```
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ api_key: str,
188
+ options: Optional[NoLagApiOptions] = None,
189
+ ):
190
+ self._api_key = api_key
191
+ self._options = options or NoLagApiOptions()
192
+ self._base_url = self._options.base_url.rstrip("/")
193
+ self._timeout = aiohttp.ClientTimeout(total=self._options.timeout)
194
+ self._session: Optional[aiohttp.ClientSession] = None
195
+
196
+ # Initialize sub-APIs
197
+ self.apps = AppsApi(self)
198
+ self.rooms = RoomsApi(self)
199
+ self.actors = ActorsApi(self)
200
+
201
+ async def __aenter__(self) -> "NoLagApi":
202
+ """Async context manager entry"""
203
+ self._session = aiohttp.ClientSession(timeout=self._timeout)
204
+ return self
205
+
206
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
207
+ """Async context manager exit"""
208
+ if self._session:
209
+ await self._session.close()
210
+ self._session = None
211
+
212
+ async def close(self) -> None:
213
+ """Close the API client session"""
214
+ if self._session:
215
+ await self._session.close()
216
+ self._session = None
217
+
218
+ async def _request(
219
+ self,
220
+ method: str,
221
+ path: str,
222
+ params: Optional[dict] = None,
223
+ json: Optional[dict] = None,
224
+ ) -> Any:
225
+ """Make an authenticated request to the NoLag API"""
226
+ if not self._session:
227
+ self._session = aiohttp.ClientSession(timeout=self._timeout)
228
+
229
+ url = f"{self._base_url}{path}"
230
+ if params:
231
+ url = f"{url}?{urlencode(params)}"
232
+
233
+ headers = {
234
+ "Authorization": f"Bearer {self._api_key}",
235
+ "Content-Type": "application/json",
236
+ **self._options.headers,
237
+ }
238
+
239
+ try:
240
+ async with self._session.request(
241
+ method, url, headers=headers, json=json
242
+ ) as response:
243
+ if not response.ok:
244
+ try:
245
+ error_data = await response.json()
246
+ raise NoLagApiError(
247
+ error_data.get("message", "Request failed"),
248
+ response.status,
249
+ ApiError(
250
+ status_code=response.status,
251
+ message=error_data.get("message", "Request failed"),
252
+ error=error_data.get("error"),
253
+ ),
254
+ )
255
+ except aiohttp.ContentTypeError:
256
+ raise NoLagApiError(
257
+ response.reason or "Request failed",
258
+ response.status,
259
+ )
260
+
261
+ # Handle 204 No Content
262
+ if response.status == 204:
263
+ return None
264
+
265
+ return await response.json()
266
+
267
+ except aiohttp.ClientError as e:
268
+ raise NoLagApiError(str(e), 0, ApiError(status_code=0, message=str(e)))
nolag/api_types.py ADDED
@@ -0,0 +1,330 @@
1
+ """
2
+ NoLag REST API Types
3
+
4
+ These types are for the project-scoped API.
5
+ API keys are scoped to a specific project, so organization and project IDs
6
+ are implicit and not needed in API calls.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Optional, Literal
11
+ from datetime import datetime
12
+
13
+
14
+ # ============ Common Types ============
15
+
16
+ @dataclass
17
+ class PaginatedResult:
18
+ """Paginated API response"""
19
+ data: list
20
+ total: int
21
+ page: int
22
+ limit: int
23
+ total_pages: int
24
+
25
+
26
+ @dataclass
27
+ class ApiError:
28
+ """API error response"""
29
+ status_code: int
30
+ message: str
31
+ error: Optional[str] = None
32
+
33
+
34
+ # ============ App Types ============
35
+
36
+ @dataclass
37
+ class App:
38
+ """App resource"""
39
+ app_id: str
40
+ project_id: str
41
+ name: str
42
+ slug: Optional[str] = None
43
+ description: Optional[str] = None
44
+ blueprint_id: Optional[str] = None
45
+ config: Optional[dict[str, Any]] = None
46
+ created_at: Optional[str] = None
47
+ updated_at: Optional[str] = None
48
+ deleted_at: Optional[str] = None
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict) -> "App":
52
+ return cls(
53
+ app_id=data.get("appId", ""),
54
+ project_id=data.get("projectId", ""),
55
+ name=data.get("name", ""),
56
+ slug=data.get("slug"),
57
+ description=data.get("description"),
58
+ blueprint_id=data.get("blueprintId"),
59
+ config=data.get("config"),
60
+ created_at=data.get("createdAt"),
61
+ updated_at=data.get("updatedAt"),
62
+ deleted_at=data.get("deletedAt"),
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class AppCreate:
68
+ """Create app request"""
69
+ name: str
70
+ slug: Optional[str] = None
71
+ description: Optional[str] = None
72
+ blueprint_id: Optional[str] = None
73
+ config: Optional[dict[str, Any]] = None
74
+
75
+ def to_dict(self) -> dict:
76
+ result = {"name": self.name}
77
+ if self.slug:
78
+ result["slug"] = self.slug
79
+ if self.description:
80
+ result["description"] = self.description
81
+ if self.blueprint_id:
82
+ result["blueprintId"] = self.blueprint_id
83
+ if self.config:
84
+ result["config"] = self.config
85
+ return result
86
+
87
+
88
+ @dataclass
89
+ class AppUpdate:
90
+ """Update app request"""
91
+ name: Optional[str] = None
92
+ slug: Optional[str] = None
93
+ description: Optional[str] = None
94
+ config: Optional[dict[str, Any]] = None
95
+
96
+ def to_dict(self) -> dict:
97
+ result = {}
98
+ if self.name is not None:
99
+ result["name"] = self.name
100
+ if self.slug is not None:
101
+ result["slug"] = self.slug
102
+ if self.description is not None:
103
+ result["description"] = self.description
104
+ if self.config is not None:
105
+ result["config"] = self.config
106
+ return result
107
+
108
+
109
+ # ============ Room Types ============
110
+
111
+ RoomType = Literal["static", "dynamic"]
112
+
113
+
114
+ @dataclass
115
+ class Room:
116
+ """Room resource"""
117
+ room_id: str
118
+ app_id: str
119
+ name: str
120
+ slug: str
121
+ description: Optional[str] = None
122
+ room_type: Optional[RoomType] = None
123
+ is_enabled: bool = True
124
+ topics: Optional[list[str]] = None
125
+ config: Optional[dict[str, Any]] = None
126
+ created_at: Optional[str] = None
127
+ updated_at: Optional[str] = None
128
+
129
+ @classmethod
130
+ def from_dict(cls, data: dict) -> "Room":
131
+ return cls(
132
+ room_id=data.get("roomId", ""),
133
+ app_id=data.get("appId", ""),
134
+ name=data.get("name", ""),
135
+ slug=data.get("slug", ""),
136
+ description=data.get("description"),
137
+ room_type=data.get("roomType"),
138
+ is_enabled=data.get("isEnabled", True),
139
+ topics=data.get("topics"),
140
+ config=data.get("config"),
141
+ created_at=data.get("createdAt"),
142
+ updated_at=data.get("updatedAt"),
143
+ )
144
+
145
+
146
+ @dataclass
147
+ class RoomCreate:
148
+ """Create room request"""
149
+ name: str
150
+ slug: Optional[str] = None
151
+ description: Optional[str] = None
152
+ topics: Optional[list[str]] = None
153
+ config: Optional[dict[str, Any]] = None
154
+
155
+ def to_dict(self) -> dict:
156
+ result = {"name": self.name}
157
+ if self.slug:
158
+ result["slug"] = self.slug
159
+ if self.description:
160
+ result["description"] = self.description
161
+ if self.topics:
162
+ result["topics"] = self.topics
163
+ if self.config:
164
+ result["config"] = self.config
165
+ return result
166
+
167
+
168
+ @dataclass
169
+ class RoomUpdate:
170
+ """Update room request"""
171
+ name: Optional[str] = None
172
+ description: Optional[str] = None
173
+ is_enabled: Optional[bool] = None
174
+ topics: Optional[list[str]] = None
175
+ config: Optional[dict[str, Any]] = None
176
+
177
+ def to_dict(self) -> dict:
178
+ result = {}
179
+ if self.name is not None:
180
+ result["name"] = self.name
181
+ if self.description is not None:
182
+ result["description"] = self.description
183
+ if self.is_enabled is not None:
184
+ result["isEnabled"] = self.is_enabled
185
+ if self.topics is not None:
186
+ result["topics"] = self.topics
187
+ if self.config is not None:
188
+ result["config"] = self.config
189
+ return result
190
+
191
+
192
+ # ============ Actor Types ============
193
+
194
+ ActorTokenType = Literal["device", "user", "server"]
195
+
196
+
197
+ @dataclass
198
+ class Actor:
199
+ """Actor resource"""
200
+ actor_token_id: str
201
+ project_id: str
202
+ name: str
203
+ actor_type: ActorTokenType
204
+ description: Optional[str] = None
205
+ external_id: Optional[str] = None
206
+ metadata: Optional[dict[str, Any]] = None
207
+ is_active: bool = True
208
+ last_connected_at: Optional[str] = None
209
+ created_at: Optional[str] = None
210
+ updated_at: Optional[str] = None
211
+
212
+ @classmethod
213
+ def from_dict(cls, data: dict) -> "Actor":
214
+ return cls(
215
+ actor_token_id=data.get("actorTokenId", ""),
216
+ project_id=data.get("projectId", ""),
217
+ name=data.get("name", ""),
218
+ actor_type=data.get("actorType", "device"),
219
+ description=data.get("description"),
220
+ external_id=data.get("externalId"),
221
+ metadata=data.get("metadata"),
222
+ is_active=data.get("isActive", True),
223
+ last_connected_at=data.get("lastConnectedAt"),
224
+ created_at=data.get("createdAt"),
225
+ updated_at=data.get("updatedAt"),
226
+ )
227
+
228
+
229
+ @dataclass
230
+ class ActorWithToken(Actor):
231
+ """Actor with access token (returned on creation only)"""
232
+ access_token: str = ""
233
+
234
+ @classmethod
235
+ def from_dict(cls, data: dict) -> "ActorWithToken":
236
+ return cls(
237
+ actor_token_id=data.get("actorTokenId", ""),
238
+ project_id=data.get("projectId", ""),
239
+ name=data.get("name", ""),
240
+ actor_type=data.get("actorType", "device"),
241
+ description=data.get("description"),
242
+ external_id=data.get("externalId"),
243
+ metadata=data.get("metadata"),
244
+ is_active=data.get("isActive", True),
245
+ last_connected_at=data.get("lastConnectedAt"),
246
+ created_at=data.get("createdAt"),
247
+ updated_at=data.get("updatedAt"),
248
+ access_token=data.get("accessToken", ""),
249
+ )
250
+
251
+
252
+ @dataclass
253
+ class ActorCreate:
254
+ """Create actor request"""
255
+ name: str
256
+ actor_type: ActorTokenType
257
+ description: Optional[str] = None
258
+ external_id: Optional[str] = None
259
+ metadata: Optional[dict[str, Any]] = None
260
+
261
+ def to_dict(self) -> dict:
262
+ result = {
263
+ "name": self.name,
264
+ "actorType": self.actor_type,
265
+ }
266
+ if self.description:
267
+ result["description"] = self.description
268
+ if self.external_id:
269
+ result["externalId"] = self.external_id
270
+ if self.metadata:
271
+ result["metadata"] = self.metadata
272
+ return result
273
+
274
+
275
+ @dataclass
276
+ class ActorUpdate:
277
+ """Update actor request"""
278
+ name: Optional[str] = None
279
+ description: Optional[str] = None
280
+ external_id: Optional[str] = None
281
+ metadata: Optional[dict[str, Any]] = None
282
+ is_active: Optional[bool] = None
283
+
284
+ def to_dict(self) -> dict:
285
+ result = {}
286
+ if self.name is not None:
287
+ result["name"] = self.name
288
+ if self.description is not None:
289
+ result["description"] = self.description
290
+ if self.external_id is not None:
291
+ result["externalId"] = self.external_id
292
+ if self.metadata is not None:
293
+ result["metadata"] = self.metadata
294
+ if self.is_active is not None:
295
+ result["isActive"] = self.is_active
296
+ return result
297
+
298
+
299
+ # ============ API Options ============
300
+
301
+ @dataclass
302
+ class NoLagApiOptions:
303
+ """Options for the NoLag API client"""
304
+ base_url: str = "https://api.nolag.app/v1"
305
+ timeout: float = 30.0
306
+ headers: dict[str, str] = field(default_factory=dict)
307
+
308
+
309
+ @dataclass
310
+ class ListOptions:
311
+ """Options for list endpoints"""
312
+ page: Optional[int] = None
313
+ limit: Optional[int] = None
314
+ sort_by: Optional[str] = None
315
+ sort_order: Optional[Literal["asc", "desc"]] = None
316
+ search: Optional[str] = None
317
+
318
+ def to_params(self) -> dict:
319
+ params = {}
320
+ if self.page is not None:
321
+ params["page"] = self.page
322
+ if self.limit is not None:
323
+ params["limit"] = self.limit
324
+ if self.sort_by is not None:
325
+ params["sortBy"] = self.sort_by
326
+ if self.sort_order is not None:
327
+ params["sortOrder"] = self.sort_order
328
+ if self.search is not None:
329
+ params["search"] = self.search
330
+ return params