sg-python-api 0.1.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.
@@ -0,0 +1,162 @@
1
+ """Async transport vocabulary and streaming response handling."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator, Awaitable, Callable
5
+ from contextlib import suppress
6
+ from typing import Protocol
7
+
8
+ from ._transport import (
9
+ CaseInsensitiveHeaders,
10
+ TransportError,
11
+ TransportRequest,
12
+ TransportResponse,
13
+ )
14
+
15
+
16
+ class AsyncStreamingResponse:
17
+ """A response whose body is consumed incrementally exactly once.
18
+
19
+ Fully consuming the body releases response resources. Call `await close()`
20
+ when leaving the body unconsumed or stopping early. Explicit closure is
21
+ idempotent.
22
+
23
+ Attributes:
24
+ status: HTTP status code.
25
+ reason: HTTP reason phrase.
26
+ headers: Response headers.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ status: int,
32
+ reason: str,
33
+ headers: CaseInsensitiveHeaders,
34
+ stream: Callable[[int], AsyncIterator[bytes]],
35
+ close_fn: Callable[[], Awaitable[None]],
36
+ ) -> None:
37
+ """Initialize a one-use asynchronous streaming response.
38
+
39
+ Args:
40
+ status: HTTP status code.
41
+ reason: HTTP reason phrase.
42
+ headers: Response headers.
43
+ stream: Factory accepting a chunk size and returning the body
44
+ iterator.
45
+ close_fn: Awaitable callback that releases response resources.
46
+ """
47
+ self.status = status
48
+ self.reason = reason
49
+ self.headers = headers
50
+ self._stream = stream
51
+ self._close_fn: Callable[[], Awaitable[None]] | None = close_fn
52
+ self._claimed = False
53
+
54
+ def _require_open(self) -> None:
55
+ """Raise when response resources were already released."""
56
+ if self._close_fn is None:
57
+ raise TransportError('streaming response is closed')
58
+
59
+ async def close(self) -> None:
60
+ """Release resources associated with the streaming response."""
61
+ close_fn = self._close_fn
62
+ self._close_fn = None
63
+ if close_fn is not None:
64
+ await close_fn()
65
+
66
+ async def _iterate(self, size: int) -> AsyncIterator[bytes]:
67
+ """Yield chunks while closing on every terminal iterator path."""
68
+ try:
69
+ async for chunk in self._stream(size):
70
+ self._require_open()
71
+ yield chunk
72
+ except BaseException:
73
+ with suppress(BaseException):
74
+ await self.close()
75
+ raise
76
+ else:
77
+ await self.close()
78
+
79
+ def aiter_chunks(self, size: int = 65536) -> AsyncIterator[bytes]:
80
+ """Claim and return the single-use response-body iterator.
81
+
82
+ Args:
83
+ size: Maximum bytes per yielded chunk.
84
+
85
+ Returns:
86
+ An async iterator over the response body.
87
+
88
+ Raises:
89
+ TransportError: If `size` is not positive, the response is closed,
90
+ or its stream has already been claimed.
91
+ """
92
+ if size <= 0:
93
+ raise TransportError('streaming chunk size must be positive')
94
+ self._require_open()
95
+ if self._claimed:
96
+ raise TransportError('streaming response was already consumed')
97
+ self._claimed = True
98
+ return self._iterate(size)
99
+
100
+
101
+ class AsyncTransport(Protocol):
102
+ """HTTP backend operations required by the async driver.
103
+
104
+ Task cancellation replaces the sync transport's `abort` operation. The
105
+ synchronous lease pair brackets one complete logical RPC so draining close
106
+ operations cannot interrupt a retry loop between attempts.
107
+ """
108
+
109
+ def checkin(self) -> None:
110
+ """Release one logical RPC lease."""
111
+ ...
112
+
113
+ def checkout(self) -> None:
114
+ """Acquire one logical RPC lease."""
115
+ ...
116
+
117
+ async def close(self) -> None:
118
+ """Release resources owned by the transport."""
119
+ ...
120
+
121
+ def reserve(self, task: asyncio.Task[object]) -> None:
122
+ """Retain one child task until its done callback runs.
123
+
124
+ Args:
125
+ task: Child task whose scheduled work must survive close.
126
+
127
+ Raises:
128
+ TransportError: If close rejects the reserving owner or `task` is
129
+ already reserved.
130
+ """
131
+ ...
132
+
133
+ async def send(self, request: TransportRequest) -> TransportResponse:
134
+ """Send one request and return its complete response.
135
+
136
+ Args:
137
+ request: Transport request to send.
138
+
139
+ Returns:
140
+ The complete transport response.
141
+
142
+ Raises:
143
+ TransportError: If the transport operation fails.
144
+ """
145
+ ...
146
+
147
+ async def send_streaming(
148
+ self,
149
+ request: TransportRequest,
150
+ ) -> AsyncStreamingResponse:
151
+ """Send one request and return its single-use streaming response.
152
+
153
+ Args:
154
+ request: Transport request to send.
155
+
156
+ Returns:
157
+ The streaming transport response.
158
+
159
+ Raises:
160
+ TransportError: If the transport operation fails.
161
+ """
162
+ ...
@@ -0,0 +1,165 @@
1
+ """Server and client capability containers."""
2
+
3
+ import copy
4
+ import ssl
5
+ import sys
6
+ from typing import Literal, TypeAlias, cast
7
+
8
+ from ._errors import ShotgunError
9
+
10
+ _Platform: TypeAlias = Literal['linux', 'mac', 'windows']
11
+
12
+ _DEV_VERSION_INDEX = 3
13
+ _SERVER_VERSION_FLOOR = (7, 10, 0)
14
+ _VERSION_PARTS = 3
15
+
16
+
17
+ class ClientCapabilities:
18
+ """Represent local platform details used by compatibility helpers.
19
+
20
+ Attributes:
21
+ platform: Operating-system name.
22
+ py_version: Python version.
23
+ ssl_version: SSL library version.
24
+ local_path_field: Platform-specific local-file field name.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ """Capture local platform capabilities for compatibility checks."""
29
+ system = sys.platform
30
+ if system == 'darwin':
31
+ platform: _Platform | None = 'mac'
32
+ elif system.startswith('linux'):
33
+ platform = 'linux'
34
+ elif system == 'win32':
35
+ platform = 'windows'
36
+ else:
37
+ platform = None
38
+ self.platform = platform
39
+ self.local_path_field = f'local_path_{platform}' if platform is not None else None
40
+ self.py_version = f'{sys.version_info.major}.{sys.version_info.minor}'
41
+ self.ssl_version = ssl.OPENSSL_VERSION
42
+
43
+
44
+ class ServerCapabilities:
45
+ """Represent parsed server version and capability metadata.
46
+
47
+ Attributes:
48
+ host: Server host name.
49
+ version: Parsed server version.
50
+ is_dev: Whether the server reports a development build.
51
+ server_info: Detached raw server metadata.
52
+ """
53
+
54
+ def __init__(self, host: str, meta: dict[str, object]) -> None:
55
+ """Parse one server's capability metadata.
56
+
57
+ Args:
58
+ host: Server host name represented by the metadata.
59
+ meta: Server metadata containing a version sequence.
60
+
61
+ Raises:
62
+ ShotgunError: If server version metadata is missing, short,
63
+ malformed, or below the supported floor.
64
+ """
65
+ owned_meta = copy.deepcopy(meta)
66
+ self.host = host
67
+ self._server_info = owned_meta
68
+ raw = owned_meta.get('version')
69
+ if isinstance(raw, list | tuple):
70
+ sequence = cast('list[object] | tuple[object, ...]', raw)
71
+ self.is_dev = (
72
+ len(sequence) > _DEV_VERSION_INDEX and sequence[_DEV_VERSION_INDEX] == 'Dev'
73
+ )
74
+ else:
75
+ self.is_dev = False
76
+ self.version = _version(owned_meta)
77
+ if self.version < _SERVER_VERSION_FLOOR:
78
+ floor = '.'.join(str(part) for part in _SERVER_VERSION_FLOOR)
79
+ current = '.'.join(str(part) for part in self.version)
80
+ raise ShotgunError(
81
+ f'This client requires server version {floor} or higher; server is {current}.'
82
+ )
83
+
84
+ def __str__(self) -> str:
85
+ """Return a concise capability summary."""
86
+ return (
87
+ f'ServerCapabilities: host {self.host}, version {self.version}, is_dev {self.is_dev}'
88
+ )
89
+
90
+ def ensure_include_archived_projects(self) -> bool:
91
+ """Report archived-project filtering support.
92
+
93
+ Returns:
94
+ Always `True`; the server floor guarantees this feature.
95
+ """
96
+ return True
97
+
98
+ def ensure_paging_info_without_counts_support(self) -> bool:
99
+ """Report count-free paging-info support.
100
+
101
+ Returns:
102
+ Always `True`; the server floor guarantees this feature.
103
+ """
104
+ return True
105
+
106
+ def ensure_per_project_customization(self) -> bool:
107
+ """Report per-project customization support.
108
+
109
+ Returns:
110
+ Always `True`; the server floor guarantees this feature.
111
+ """
112
+ return True
113
+
114
+ def ensure_return_image_urls_support(self) -> bool:
115
+ """Report image-URL return support.
116
+
117
+ Returns:
118
+ Always `True`; the server floor guarantees this feature.
119
+ """
120
+ return True
121
+
122
+ def ensure_support_for_additional_filter_presets(self) -> bool:
123
+ """Report additional-filter-preset support.
124
+
125
+ Returns:
126
+ Always `True`; the server floor guarantees this feature.
127
+ """
128
+ return True
129
+
130
+ def ensure_user_following_support(self) -> bool:
131
+ """Report user-following support.
132
+
133
+ Returns:
134
+ Always `True`; the server floor guarantees this feature.
135
+ """
136
+ return True
137
+
138
+ @property
139
+ def server_info(self) -> dict[str, object]:
140
+ """Return detached raw server metadata.
141
+
142
+ Returns:
143
+ A deep copy of the metadata owned by this capability container.
144
+ """
145
+ return copy.deepcopy(self._server_info)
146
+
147
+
148
+ def _version(meta: dict[str, object]) -> tuple[int, int, int]:
149
+ """Parse the supported three-part server version."""
150
+ raw = meta.get('version')
151
+ if not isinstance(raw, list | tuple):
152
+ raise ShotgunError(
153
+ 'The Flow Production Tracking server did not report a version; '
154
+ 'it may be too old for this client.'
155
+ )
156
+ sequence = cast('list[object] | tuple[object, ...]', raw)
157
+ if len(sequence) < _VERSION_PARTS:
158
+ raise ShotgunError(
159
+ 'The Flow Production Tracking server did not report a version; '
160
+ 'it may be too old for this client.'
161
+ )
162
+ parts = sequence[:_VERSION_PARTS]
163
+ if any(not isinstance(part, int) or isinstance(part, bool) for part in parts):
164
+ raise ShotgunError('The Flow Production Tracking server reported an invalid version.')
165
+ return cast('tuple[int, int, int]', tuple(parts))