synchra.py 0.1.0__tar.gz

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.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: synchra.py
3
+ Version: 0.1.0
4
+ Summary: A modern, async-first Python SDK for the Synchra API v2.0.
5
+ Author-email: Bluscream <blu.pypi@minopia.de>, "Antigravity.AI" <antigravity@google.com>
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: aiohttp>=3.9.0
10
+ Requires-Dist: pydantic>=2.5.0
11
+ Requires-Dist: websockets>=12.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: pytest-asyncio; extra == "dev"
15
+ Requires-Dist: aioresponses; extra == "dev"
16
+ Requires-Dist: datamodel-code-generator; extra == "dev"
17
+ Requires-Dist: build; extra == "dev"
18
+ Requires-Dist: twine; extra == "dev"
19
+
20
+ # Synchra Python SDK (v2.0)
21
+
22
+ A modern, async-first Python SDK for the [Synchra API](https://api.synchra.net), providing robust access to channel management, real-time events, and multi-platform moderation.
23
+
24
+ ## Features
25
+
26
+ - **Async Native**: Built from the ground up for `asyncio` using `aiohttp`.
27
+ - **Full Type Safety**: Comprehensive [Pydantic v2](https://pydantic.dev) models for all API resources and WebSocket events.
28
+ - **WebSocket Client**: Real-time event subscription and dispatcher with automatic reconnection.
29
+ - **Provider Support**: Specialized API groups for **Twitch**, **YouTube**, and **Kick**.
30
+ - **OAuth2 Ready**: Support for both manual access tokens and full OAuth2 refresh flows.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install synchra
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ### HTTP API Client
41
+
42
+ ```python
43
+ import asyncio
44
+ from synchra import SynchraClient
45
+
46
+ async def main():
47
+ # Initialize client (with token or OAuth credentials)
48
+ client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
49
+
50
+ # List channels
51
+ channels = await client.channels.list()
52
+ for channel in channels:
53
+ print(f"Channel: {channel.display_name} ({channel.id})")
54
+
55
+ # Get channel providers
56
+ providers = await client.channels.list_providers(channels[0].id)
57
+ for p in providers:
58
+ print(f"Provider: {p.provider} ({p.provider_channel_name})")
59
+
60
+ await client.close()
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ### WebSocket Real-time Events
66
+
67
+ ```python
68
+ import asyncio
69
+ from synchra import SynchraClient
70
+
71
+ async def main():
72
+ client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
73
+
74
+ # Register an event handler
75
+ @client.ws.on("activity")
76
+ async def on_activity(event):
77
+ data = event['data']
78
+ print(f"[{event['type']}] New {data['type']} from {data['viewer_display_name']}")
79
+
80
+ # Connect and subscribe
81
+ await client.connect()
82
+ await client.ws.subscribe("activity", channel_id="YOUR_CHANNEL_UUID")
83
+
84
+ # Keep the client running
85
+ try:
86
+ while True:
87
+ await asyncio.sleep(1)
88
+ except KeyboardInterrupt:
89
+ await client.close()
90
+
91
+ asyncio.run(main())
92
+ ```
93
+
94
+ ## Development & Testing
95
+
96
+ The SDK includes a full test suite using `pytest`.
97
+
98
+ ```bash
99
+ # Install dev dependencies
100
+ pip install "synchra[dev]"
101
+
102
+ # Run tests
103
+ python -m pytest tests/
104
+ ```
105
+
106
+ ## API Groups
107
+
108
+ - **`client.channels`**: Main channel operations and provider listings.
109
+ - **`client.twitch`**: Twitch-specific features (Ban, Unban, Raid, Shoutout, Emulation).
110
+ - **`client.youtube`**: YouTube broadcasts and moderation.
111
+ - **`client.kick`**: Kick-specific moderation tools.
112
+
113
+ ## Documentation
114
+
115
+ For full API documentation and endpoint details, refer to the [Synchra OpenAPI Spec](https://api.synchra.net/api/2/docs).
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,100 @@
1
+ # Synchra Python SDK (v2.0)
2
+
3
+ A modern, async-first Python SDK for the [Synchra API](https://api.synchra.net), providing robust access to channel management, real-time events, and multi-platform moderation.
4
+
5
+ ## Features
6
+
7
+ - **Async Native**: Built from the ground up for `asyncio` using `aiohttp`.
8
+ - **Full Type Safety**: Comprehensive [Pydantic v2](https://pydantic.dev) models for all API resources and WebSocket events.
9
+ - **WebSocket Client**: Real-time event subscription and dispatcher with automatic reconnection.
10
+ - **Provider Support**: Specialized API groups for **Twitch**, **YouTube**, and **Kick**.
11
+ - **OAuth2 Ready**: Support for both manual access tokens and full OAuth2 refresh flows.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install synchra
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### HTTP API Client
22
+
23
+ ```python
24
+ import asyncio
25
+ from synchra import SynchraClient
26
+
27
+ async def main():
28
+ # Initialize client (with token or OAuth credentials)
29
+ client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
30
+
31
+ # List channels
32
+ channels = await client.channels.list()
33
+ for channel in channels:
34
+ print(f"Channel: {channel.display_name} ({channel.id})")
35
+
36
+ # Get channel providers
37
+ providers = await client.channels.list_providers(channels[0].id)
38
+ for p in providers:
39
+ print(f"Provider: {p.provider} ({p.provider_channel_name})")
40
+
41
+ await client.close()
42
+
43
+ asyncio.run(main())
44
+ ```
45
+
46
+ ### WebSocket Real-time Events
47
+
48
+ ```python
49
+ import asyncio
50
+ from synchra import SynchraClient
51
+
52
+ async def main():
53
+ client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
54
+
55
+ # Register an event handler
56
+ @client.ws.on("activity")
57
+ async def on_activity(event):
58
+ data = event['data']
59
+ print(f"[{event['type']}] New {data['type']} from {data['viewer_display_name']}")
60
+
61
+ # Connect and subscribe
62
+ await client.connect()
63
+ await client.ws.subscribe("activity", channel_id="YOUR_CHANNEL_UUID")
64
+
65
+ # Keep the client running
66
+ try:
67
+ while True:
68
+ await asyncio.sleep(1)
69
+ except KeyboardInterrupt:
70
+ await client.close()
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ## Development & Testing
76
+
77
+ The SDK includes a full test suite using `pytest`.
78
+
79
+ ```bash
80
+ # Install dev dependencies
81
+ pip install "synchra[dev]"
82
+
83
+ # Run tests
84
+ python -m pytest tests/
85
+ ```
86
+
87
+ ## API Groups
88
+
89
+ - **`client.channels`**: Main channel operations and provider listings.
90
+ - **`client.twitch`**: Twitch-specific features (Ban, Unban, Raid, Shoutout, Emulation).
91
+ - **`client.youtube`**: YouTube broadcasts and moderation.
92
+ - **`client.kick`**: Kick-specific moderation tools.
93
+
94
+ ## Documentation
95
+
96
+ For full API documentation and endpoint details, refer to the [Synchra OpenAPI Spec](https://api.synchra.net/api/2/docs).
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "synchra.py"
7
+ version = "0.1.0"
8
+ description = "A modern, async-first Python SDK for the Synchra API v2.0."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [
12
+ {name = "Bluscream", email = "blu.pypi@minopia.de"},
13
+ {name = "Antigravity.AI", email = "antigravity@google.com" }
14
+ ]
15
+ requires-python = ">=3.9"
16
+ dependencies = [
17
+ "aiohttp>=3.9.0",
18
+ "pydantic>=2.5.0",
19
+ "websockets>=12.0"
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest",
25
+ "pytest-asyncio",
26
+ "aioresponses",
27
+ "datamodel-code-generator",
28
+ "build",
29
+ "twine"
30
+ ]
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["synchra*"]
34
+ exclude = ["synchra_cli*", "tests*"]
35
+
36
+ [tool.pytest.ini_options]
37
+ asyncio_mode = "auto"
38
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .client import SynchraClient
2
+ from .http import SynchraAuth, SynchraError
3
+ from .ws import SynchraWSClient
4
+
5
+ __all__ = ["SynchraClient", "SynchraAuth", "SynchraError", "SynchraWSClient"]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,6 @@
1
+ from ..http import HTTPClient
2
+
3
+ class APIGroup:
4
+ """Base class for all Synchra API groups."""
5
+ def __init__(self, http: HTTPClient):
6
+ self._http = http
@@ -0,0 +1,78 @@
1
+ from typing import List, Optional, Any
2
+ from uuid import UUID
3
+
4
+ from .base import APIGroup
5
+ from ..models.resources import (
6
+ Channel,
7
+ ChannelProvider,
8
+ Activity,
9
+ PageCursorChannel,
10
+ PageCursorActivity
11
+ )
12
+
13
+ class ChannelsAPI(APIGroup):
14
+ """API for managing Synchra channels and providers."""
15
+
16
+ async def list(
17
+ self,
18
+ name: Optional[str] = None,
19
+ channel_id: Optional[UUID] = None,
20
+ provider: Optional[str] = None,
21
+ provider_channel_name: Optional[str] = None,
22
+ cursor: Optional[str] = None,
23
+ per_page: int = 25
24
+ ) -> List[Channel]:
25
+ """List and filter channels accessible to the user."""
26
+ params = {
27
+ "name": name,
28
+ "channel_id": channel_id,
29
+ "provider": provider,
30
+ "provider_channel_name": provider_channel_name,
31
+ "cursor": cursor,
32
+ "per_page": per_page
33
+ }
34
+ # Filter out None values
35
+ params = {k: v for k, v in params.items() if v is not None}
36
+
37
+ response = await self._http.get("/api/2/channels", params=params, response_model=PageCursorChannel)
38
+ return response.records
39
+
40
+ async def create(self, display_name: str, show_on_landing_page: bool = True) -> Channel:
41
+ """Create a new channel."""
42
+ data = {
43
+ "display_name": display_name,
44
+ "show_on_landing_page": show_on_landing_page
45
+ }
46
+ return await self._http.post("/api/2/channels", json=data, response_model=Channel)
47
+
48
+ async def get(self, channel_id: UUID) -> Channel:
49
+ """Get details for a specific channel."""
50
+ return await self._http.get(f"/api/2/channels/{channel_id}", response_model=Channel)
51
+
52
+ async def delete(self, channel_id: UUID) -> None:
53
+ """Delete a channel."""
54
+ await self._http.delete(f"/api/2/channels/{channel_id}")
55
+
56
+ async def list_providers(self, channel_id: UUID) -> List[ChannelProvider]:
57
+ """List providers linked to a channel."""
58
+ # Note: If this endpoint uses PageCursor but there's no PageCursorChannelProvider,
59
+ # we might need to handle it as a raw dict or find the correct model.
60
+ # Based on the spec, it returns PageCursor with ChannelProvider records.
61
+ # For now, let's assume it might be a list or we need to wait for a specific model.
62
+ # If it's paginated, it will fail validation again if we use List[ChannelProvider].
63
+ # Let's try to get it as a raw dict if we're unsure, or use a generic approach.
64
+ data = await self._http.get(f"/api/2/channels/{channel_id}/providers")
65
+ if isinstance(data, dict) and "records" in data:
66
+ # We can manually validate the records if needed
67
+ return [ChannelProvider.model_validate(r) for r in data["records"]]
68
+ return await self._http.get(f"/api/2/channels/{channel_id}/providers", response_model=List[ChannelProvider])
69
+
70
+ async def list_activities(self, channel_id: UUID, limit: int = 100, offset: int = 0) -> List[Activity]:
71
+ """List activities for a channel."""
72
+ params = {"limit": limit, "offset": offset}
73
+ response = await self._http.get(
74
+ f"/api/2/channels/{channel_id}/activities",
75
+ params=params,
76
+ response_model=PageCursorActivity
77
+ )
78
+ return response.records
@@ -0,0 +1,26 @@
1
+ from typing import List, Optional, Any
2
+ from uuid import UUID
3
+
4
+ from .base import APIGroup
5
+
6
+ class KickAPI(APIGroup):
7
+ """API for Kick-specific features in Synchra."""
8
+
9
+ async def ban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None, reason: Optional[str] = None):
10
+ """Ban a user from a Kick channel."""
11
+ data = {
12
+ "provider_viewer_id": provider_viewer_id,
13
+ "duration_seconds": duration,
14
+ "reason": reason
15
+ }
16
+ await self._http.post(
17
+ f"/api/2/channels/{channel_id}/kick/{provider_id}/ban",
18
+ json=data
19
+ )
20
+
21
+ async def unban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str):
22
+ """Unban a user from a Kick channel."""
23
+ await self._http.delete(
24
+ f"/api/2/channels/{channel_id}/kick/{provider_id}/ban",
25
+ params={"provider_viewer_id": provider_viewer_id}
26
+ )
@@ -0,0 +1,52 @@
1
+ from typing import List, Optional, Any
2
+ from uuid import UUID
3
+
4
+ from .base import APIGroup
5
+ from ..models.resources import (
6
+ BodyEmulateChannelChatMessageApi2TwitchEventsubEmulateChannelChatMessagePost as EmulateChatData,
7
+ BodyBanUserApi2ChannelsChannelIdTwitchChannelProviderIdBanPost as BanUserBody
8
+ )
9
+
10
+ class TwitchAPI(APIGroup):
11
+ """API for Twitch-specific features in Synchra."""
12
+
13
+ async def emulate_chat_message(self, data: EmulateChatData, channel_id: UUID) -> None:
14
+ """Emulate a Twitch channel chat message for testing."""
15
+ await self._http.post(
16
+ "/api/2/twitch/eventsub/emulate-channel-chat-message",
17
+ json=data.model_dump(exclude_none=True),
18
+ params={"channel_id": str(channel_id)}
19
+ )
20
+
21
+ async def ban_user(self, channel_id: UUID, channel_provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None, reason: Optional[str] = None):
22
+ """Ban a user from a Twitch channel."""
23
+ data = BanUserBody(
24
+ provider_viewer_id=provider_viewer_id,
25
+ duration_seconds=duration,
26
+ reason=reason
27
+ )
28
+ await self._http.post(
29
+ f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/ban",
30
+ json=data.model_dump(exclude_none=True)
31
+ )
32
+
33
+ async def unban_user(self, channel_id: UUID, channel_provider_id: UUID, provider_viewer_id: str):
34
+ """Unban a user from a Twitch channel."""
35
+ await self._http.delete(
36
+ f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/ban",
37
+ params={"provider_viewer_id": provider_viewer_id}
38
+ )
39
+
40
+ async def raid(self, channel_id: UUID, channel_provider_id: UUID, to_provider_channel_id: str):
41
+ """Start a raid on another Twitch channel."""
42
+ await self._http.post(
43
+ f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/raid",
44
+ json={"to_provider_channel_id": to_provider_channel_id}
45
+ )
46
+
47
+ async def shoutout(self, channel_id: UUID, channel_provider_id: UUID, to_provider_channel_id: str):
48
+ """Send a shoutout to another Twitch channel."""
49
+ await self._http.post(
50
+ f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/shoutout",
51
+ json={"to_provider_channel_id": to_provider_channel_id}
52
+ )
@@ -0,0 +1,48 @@
1
+ from typing import List, Optional, Any
2
+ from uuid import UUID
3
+
4
+ from .base import APIGroup
5
+ # Note: Specific YouTube/Kick models might be deep in resources.py
6
+ # Using Any or dict for complex nested structures if model names are not obvious
7
+
8
+ class YouTubeAPI(APIGroup):
9
+ """API for YouTube-specific features in Synchra."""
10
+
11
+ async def create_broadcast(self, channel_id: UUID, provider_id: UUID, snippet: dict, status: dict, content_details: Optional[dict] = None) -> Any:
12
+ """Create a YouTube broadcast."""
13
+ data = {
14
+ "snippet": snippet,
15
+ "status": status,
16
+ }
17
+ if content_details:
18
+ data["contentDetails"] = content_details
19
+
20
+ return await self._http.post(
21
+ f"/api/2/channels/{channel_id}/providers/{provider_id}/youtube/broadcast",
22
+ json=data
23
+ )
24
+
25
+ async def ban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None):
26
+ """Ban a user from a YouTube channel."""
27
+ data = {"provider_viewer_id": provider_viewer_id}
28
+ if duration:
29
+ data["duration_seconds"] = duration
30
+ await self._http.post(
31
+ f"/api/2/channels/{channel_id}/youtube/{provider_id}/ban",
32
+ json=data
33
+ )
34
+
35
+ class KickAPI(APIGroup):
36
+ """API for Kick-specific features in Synchra."""
37
+
38
+ async def ban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None, reason: Optional[str] = None):
39
+ """Ban a user from a Kick channel."""
40
+ data = {
41
+ "provider_viewer_id": provider_viewer_id,
42
+ "duration_seconds": duration,
43
+ "reason": reason
44
+ }
45
+ await self._http.post(
46
+ f"/api/2/channels/{channel_id}/kick/{provider_id}/ban",
47
+ json=data
48
+ )
@@ -0,0 +1,47 @@
1
+ import logging
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+ from .http import SynchraAuth, HTTPClient
6
+ from .ws import SynchraWSClient
7
+ from .api.channels import ChannelsAPI
8
+ from .api.twitch import TwitchAPI
9
+ from .api.youtube import YouTubeAPI
10
+ from .api.kick import KickAPI
11
+
12
+ logger = logging.getLogger("synchra")
13
+
14
+ class SynchraClient:
15
+ """The main client for the Synchra API."""
16
+ def __init__(
17
+ self,
18
+ access_token: Optional[str] = None,
19
+ client_id: Optional[str] = None,
20
+ client_secret: Optional[str] = None,
21
+ refresh_token: Optional[str] = None,
22
+ base_url: str = "https://api.synchra.net",
23
+ ws_url: str = "wss://api.synchra.net/api/2/ws"
24
+ ):
25
+ self.auth = SynchraAuth(
26
+ access_token=access_token,
27
+ client_id=client_id,
28
+ client_secret=client_secret,
29
+ refresh_token=refresh_token
30
+ )
31
+ self.http = HTTPClient(self.auth, base_url=base_url)
32
+ self.ws = SynchraWSClient(self.auth, base_url=ws_url)
33
+
34
+ # API Groups
35
+ self.channels = ChannelsAPI(self.http)
36
+ self.twitch = TwitchAPI(self.http)
37
+ self.youtube = YouTubeAPI(self.http)
38
+ self.kick = KickAPI(self.http)
39
+
40
+ async def connect(self):
41
+ """Connect the WebSocket client."""
42
+ await self.ws.connect()
43
+
44
+ async def close(self):
45
+ """Close both HTTP and WebSocket connections."""
46
+ await self.http.close()
47
+ await self.ws.close()
@@ -0,0 +1,141 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Any, Dict, Optional, Type, TypeVar, Union
4
+ from uuid import UUID
5
+
6
+ import aiohttp
7
+ from pydantic import BaseModel, ValidationError, TypeAdapter
8
+
9
+ from .models.resources import Error as SynchraErrorModel
10
+
11
+ logger = logging.getLogger("synchra.http")
12
+
13
+ T = TypeVar("T", bound=BaseModel)
14
+
15
+ class SynchraError(Exception):
16
+ """Base exception for Synchra SDK."""
17
+ def __init__(self, message: str, status: int = 0, data: Any = None):
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.status = status
21
+ self.data = data
22
+
23
+ class SynchraAuth:
24
+ """Handles authentication for Synchra API."""
25
+ def __init__(
26
+ self,
27
+ access_token: Optional[str] = None,
28
+ client_id: Optional[str] = None,
29
+ client_secret: Optional[str] = None,
30
+ refresh_token: Optional[str] = None,
31
+ token_url: str = "https://api.synchra.net/api/2/auth/token" # Placeholder
32
+ ):
33
+ self.access_token = access_token
34
+ self.client_id = client_id
35
+ self.client_secret = client_secret
36
+ self.refresh_token = refresh_token
37
+ self.token_url = token_url
38
+ self._lock = asyncio.Lock()
39
+
40
+ async def get_auth_header(self) -> Dict[str, str]:
41
+ """Returns the Authorization header, refreshing if necessary."""
42
+ if not self.access_token and self.refresh_token:
43
+ async with self._lock:
44
+ await self.refresh()
45
+
46
+ if self.access_token:
47
+ return {"Authorization": f"Bearer {self.access_token}"}
48
+ return {}
49
+
50
+ async def refresh(self):
51
+ """Refreshes the access token using the refresh token."""
52
+ if not self.refresh_token or not self.client_id or not self.client_secret:
53
+ raise SynchraError("Missing credentials for token refresh")
54
+
55
+ # Implementation of OAuth2 refresh flow
56
+ # This is a placeholder until the exact endpoint is confirmed
57
+ async with aiohttp.ClientSession() as session:
58
+ payload = {
59
+ "grant_type": "refresh_token",
60
+ "refresh_token": self.refresh_token,
61
+ "client_id": self.client_id,
62
+ "client_secret": self.client_secret,
63
+ }
64
+ async with session.post(self.token_url, data=payload) as resp:
65
+ if resp.status != 200:
66
+ text = await resp.text()
67
+ raise SynchraError(f"Failed to refresh token: {text}", status=resp.status)
68
+
69
+ data = await resp.json()
70
+ self.access_token = data.get("access_token")
71
+ self.refresh_token = data.get("refresh_token") or self.refresh_token
72
+
73
+ class HTTPClient:
74
+ """Low-level HTTP client for Synchra API."""
75
+ def __init__(self, auth: SynchraAuth, base_url: str = "https://api.synchra.net"):
76
+ self.auth = auth
77
+ self.base_url = base_url.rstrip("/")
78
+ self._session: Optional[aiohttp.ClientSession] = None
79
+
80
+ async def _get_session(self) -> aiohttp.ClientSession:
81
+ if self._session is None or self._session.closed:
82
+ self._session = aiohttp.ClientSession()
83
+ return self._session
84
+
85
+ async def close(self):
86
+ if self._session and not self._session.closed:
87
+ await self._session.close()
88
+
89
+ async def request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ *,
94
+ response_model: Optional[Type[T]] = None,
95
+ params: Optional[Dict[str, Any]] = None,
96
+ json: Optional[Any] = None,
97
+ **kwargs
98
+ ) -> Union[T, Any, None]:
99
+ session = await self._get_session()
100
+ url = f"{self.base_url}/{path.lstrip('/')}"
101
+ headers = await self.auth.get_auth_header()
102
+
103
+ if "headers" in kwargs:
104
+ headers.update(kwargs.pop("headers"))
105
+
106
+ async with session.request(method, url, params=params, json=json, headers=headers, **kwargs) as resp:
107
+ if resp.status == 204:
108
+ return None
109
+
110
+ data = await resp.json()
111
+
112
+ if resp.status >= 400:
113
+ try:
114
+ error_data = SynchraErrorModel.model_validate(data)
115
+ raise SynchraError(f"{error_data.type}: {error_data.message}", status=resp.status, data=error_data)
116
+ except Exception:
117
+ raise SynchraError(f"HTTP {resp.status}: {data}", status=resp.status, data=data)
118
+
119
+ if response_model:
120
+ try:
121
+ return TypeAdapter(response_model).validate_python(data)
122
+ except ValidationError as e:
123
+ logger.error(f"Validation error for {url}: {e}")
124
+ raise SynchraError(f"Failed to validate response: {e}", status=resp.status, data=data)
125
+
126
+ return data
127
+
128
+ async def get(self, path: str, **kwargs) -> Any:
129
+ return await self.request("GET", path, **kwargs)
130
+
131
+ async def post(self, path: str, **kwargs) -> Any:
132
+ return await self.request("POST", path, **kwargs)
133
+
134
+ async def put(self, path: str, **kwargs) -> Any:
135
+ return await self.request("PUT", path, **kwargs)
136
+
137
+ async def patch(self, path: str, **kwargs) -> Any:
138
+ return await self.request("PATCH", path, **kwargs)
139
+
140
+ async def delete(self, path: str, **kwargs) -> Any:
141
+ return await self.request("DELETE", path, **kwargs)