nullprotocol 0.2.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.
- nullprotocol/__init__.py +29 -0
- nullprotocol/_agent.py +518 -0
- nullprotocol/_client.py +108 -0
- nullprotocol/_common.py +292 -0
- nullprotocol/_errors.py +43 -0
- nullprotocol/_knowledge.py +163 -0
- nullprotocol/_stores.py +189 -0
- nullprotocol/_transport.py +276 -0
- nullprotocol/_types.py +175 -0
- nullprotocol/py.typed +0 -0
- nullprotocol-0.2.0.dist-info/METADATA +268 -0
- nullprotocol-0.2.0.dist-info/RECORD +14 -0
- nullprotocol-0.2.0.dist-info/WHEEL +4 -0
- nullprotocol-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, NoReturn
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._errors import APIError, ProtocolError, TransportError
|
|
15
|
+
|
|
16
|
+
_UNSET = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def json_body(value: Any) -> bytes:
|
|
20
|
+
try:
|
|
21
|
+
return json.dumps(
|
|
22
|
+
value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
23
|
+
).encode()
|
|
24
|
+
except (TypeError, ValueError, UnicodeError) as exc:
|
|
25
|
+
raise ValueError("Request data must be valid UTF-8 JSON (no NaN or Infinity).") from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def utf16_length(value: str) -> int:
|
|
29
|
+
try:
|
|
30
|
+
return len(value.encode("utf-16-le")) // 2
|
|
31
|
+
except UnicodeError as exc:
|
|
32
|
+
raise ValueError("Text must not contain unpaired surrogates.") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def identifier(value: str, kind: str) -> str:
|
|
36
|
+
pattern = r"[a-z0-9][a-z0-9._-]{0,63}" if kind == "agent ID" else r"[a-z][a-z0-9._-]{0,63}"
|
|
37
|
+
if not isinstance(value, str) or not re.fullmatch(pattern, value):
|
|
38
|
+
raise ValueError(f"Invalid {kind}.")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scope_id(value: str | None) -> str | None:
|
|
43
|
+
if value is not None and (
|
|
44
|
+
not isinstance(value, str)
|
|
45
|
+
or not 1 <= utf16_length(value) <= 128
|
|
46
|
+
or re.search(r"[\x00-\x1f\x7f-\x9f]", value)
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("scope must be 1–128 UTF-16 characters without control characters.")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def request_id(value: str) -> str:
|
|
53
|
+
if not isinstance(value, str) or not re.fullmatch(r"[\x21-\x7e]{1,128}", value):
|
|
54
|
+
raise ValueError("idempotency_key must be 1–128 visible ASCII characters without spaces.")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Config:
|
|
59
|
+
def __init__(
|
|
60
|
+
self, key: str | None, endpoint: str | None, timeout: float, max_retries: int
|
|
61
|
+
) -> None:
|
|
62
|
+
actual = key if key is not None else os.environ.get("NULLPROTOCOL_KEY")
|
|
63
|
+
if (
|
|
64
|
+
not isinstance(actual, str)
|
|
65
|
+
or not actual.strip()
|
|
66
|
+
or re.search(r"[\s\x00-\x1f\x7f]", actual)
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Set key or NULLPROTOCOL_KEY to an active Space SDK key.")
|
|
69
|
+
address = (
|
|
70
|
+
endpoint or os.environ.get("NULLPROTOCOL_API_URL") or "https://api.nullprotocol.ai"
|
|
71
|
+
)
|
|
72
|
+
parsed = urlsplit(address)
|
|
73
|
+
if (
|
|
74
|
+
parsed.scheme not in ("http", "https")
|
|
75
|
+
or not parsed.netloc
|
|
76
|
+
or parsed.username is not None
|
|
77
|
+
or parsed.password is not None
|
|
78
|
+
or parsed.query
|
|
79
|
+
or parsed.fragment
|
|
80
|
+
or parsed.path.rstrip("/") not in ("", "/v1")
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("endpoint must be an HTTP(S) origin, optionally ending in /v1.")
|
|
83
|
+
if (
|
|
84
|
+
not isinstance(timeout, (int, float))
|
|
85
|
+
or isinstance(timeout, bool)
|
|
86
|
+
or not math.isfinite(timeout)
|
|
87
|
+
or timeout <= 0
|
|
88
|
+
):
|
|
89
|
+
raise ValueError("timeout must be a positive number of seconds.")
|
|
90
|
+
if type(max_retries) is not int or not 0 <= max_retries <= 5:
|
|
91
|
+
raise ValueError("max_retries must be an integer from 0 to 5.")
|
|
92
|
+
self.base = address.rstrip("/").removesuffix("/v1") + "/v1"
|
|
93
|
+
self.key = actual
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.max_retries = max_retries
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _invalid_constant(_value: str) -> NoReturn:
|
|
99
|
+
raise ValueError("Non-finite numbers are not valid JSON.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _decode(response: httpx.Response, task_id: str | None) -> dict[str, Any]:
|
|
103
|
+
try:
|
|
104
|
+
body = response.json(parse_constant=_invalid_constant)
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
if not response.is_success:
|
|
107
|
+
raise APIError(
|
|
108
|
+
response.status_code,
|
|
109
|
+
"non_json_response",
|
|
110
|
+
request_id=task_id,
|
|
111
|
+
retry_after=response.headers.get("retry-after"),
|
|
112
|
+
) from exc
|
|
113
|
+
raise ProtocolError("Expected a JSON response.", request_id=task_id) from exc
|
|
114
|
+
if not response.is_success:
|
|
115
|
+
detail = body if isinstance(body, dict) else {}
|
|
116
|
+
error = detail.get("error", "http_error")
|
|
117
|
+
raise APIError(
|
|
118
|
+
response.status_code,
|
|
119
|
+
error if isinstance(error, str) else "http_error",
|
|
120
|
+
request_id=task_id,
|
|
121
|
+
details=detail,
|
|
122
|
+
retry_after=response.headers.get("retry-after"),
|
|
123
|
+
)
|
|
124
|
+
if not isinstance(body, dict):
|
|
125
|
+
raise ProtocolError("Expected a JSON object response.", request_id=task_id)
|
|
126
|
+
return body
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _delay(response: httpx.Response | None, attempt: int) -> float | None:
|
|
130
|
+
if response is not None:
|
|
131
|
+
retry_after = response.headers.get("retry-after")
|
|
132
|
+
if retry_after is not None:
|
|
133
|
+
try:
|
|
134
|
+
value = float(retry_after)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return None # Do not guess when a server supplies an unsupported date format.
|
|
137
|
+
if not math.isfinite(value) or value < 0 or value > 30:
|
|
138
|
+
return None # Return the response instead of ignoring a long delay.
|
|
139
|
+
return value
|
|
140
|
+
return float(min(0.25 * (2**attempt), 2.0))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _retryable(response: httpx.Response) -> bool:
|
|
144
|
+
if response.status_code == 429:
|
|
145
|
+
return True
|
|
146
|
+
if response.status_code == 409:
|
|
147
|
+
try:
|
|
148
|
+
return bool(response.json().get("error") == "in_progress")
|
|
149
|
+
except (ValueError, AttributeError):
|
|
150
|
+
return False
|
|
151
|
+
if response.status_code == 503:
|
|
152
|
+
try:
|
|
153
|
+
return bool(response.json().get("error") == "busy")
|
|
154
|
+
except (ValueError, AttributeError):
|
|
155
|
+
return False
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Transport:
|
|
160
|
+
def __init__(self, config: _Config, client: httpx.Client | None = None) -> None:
|
|
161
|
+
self.config = config
|
|
162
|
+
self.client = client if client is not None else httpx.Client(follow_redirects=False)
|
|
163
|
+
self.owned = client is None
|
|
164
|
+
self.closed = False
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
if not self.closed:
|
|
168
|
+
self.closed = True
|
|
169
|
+
if self.owned:
|
|
170
|
+
self.client.close()
|
|
171
|
+
|
|
172
|
+
def request(
|
|
173
|
+
self,
|
|
174
|
+
method: str,
|
|
175
|
+
path: str,
|
|
176
|
+
*,
|
|
177
|
+
body: Any = _UNSET,
|
|
178
|
+
params: dict[str, str] | None = None,
|
|
179
|
+
task_id: str | None = None,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
if self.closed:
|
|
182
|
+
raise TransportError("This NullProtocol client is closed.", request_id=task_id)
|
|
183
|
+
content = None if body is _UNSET else json_body(body)
|
|
184
|
+
headers = {"Authorization": f"Bearer {self.config.key}", "Accept": "application/json"}
|
|
185
|
+
if content is not None:
|
|
186
|
+
headers["Content-Type"] = "application/json"
|
|
187
|
+
if task_id is not None:
|
|
188
|
+
headers["Idempotency-Key"] = task_id
|
|
189
|
+
retries = self.config.max_retries if method == "GET" or task_id is not None else 0
|
|
190
|
+
for attempt in range(retries + 1):
|
|
191
|
+
response = None
|
|
192
|
+
try:
|
|
193
|
+
response = self.client.request(
|
|
194
|
+
method,
|
|
195
|
+
self.config.base + path,
|
|
196
|
+
headers=headers,
|
|
197
|
+
content=content,
|
|
198
|
+
params=params,
|
|
199
|
+
timeout=self.config.timeout,
|
|
200
|
+
follow_redirects=False,
|
|
201
|
+
)
|
|
202
|
+
except httpx.TransportError as exc:
|
|
203
|
+
if attempt == retries:
|
|
204
|
+
raise TransportError(
|
|
205
|
+
"NullProtocol could not be reached. "
|
|
206
|
+
"Preserve request_id before retrying a run.",
|
|
207
|
+
request_id=task_id,
|
|
208
|
+
) from exc
|
|
209
|
+
if response is not None and (attempt == retries or not _retryable(response)):
|
|
210
|
+
return _decode(response, task_id)
|
|
211
|
+
delay = _delay(response, attempt)
|
|
212
|
+
if delay is None:
|
|
213
|
+
assert response is not None
|
|
214
|
+
return _decode(response, task_id)
|
|
215
|
+
time.sleep(delay)
|
|
216
|
+
raise AssertionError("Unreachable transport state")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class AsyncTransport:
|
|
220
|
+
def __init__(self, config: _Config, client: httpx.AsyncClient | None = None) -> None:
|
|
221
|
+
self.config = config
|
|
222
|
+
self.client = client if client is not None else httpx.AsyncClient(follow_redirects=False)
|
|
223
|
+
self.owned = client is None
|
|
224
|
+
self.closed = False
|
|
225
|
+
|
|
226
|
+
async def close(self) -> None:
|
|
227
|
+
if not self.closed:
|
|
228
|
+
self.closed = True
|
|
229
|
+
if self.owned:
|
|
230
|
+
await self.client.aclose()
|
|
231
|
+
|
|
232
|
+
async def request(
|
|
233
|
+
self,
|
|
234
|
+
method: str,
|
|
235
|
+
path: str,
|
|
236
|
+
*,
|
|
237
|
+
body: Any = _UNSET,
|
|
238
|
+
params: dict[str, str] | None = None,
|
|
239
|
+
task_id: str | None = None,
|
|
240
|
+
) -> dict[str, Any]:
|
|
241
|
+
if self.closed:
|
|
242
|
+
raise TransportError("This NullProtocol client is closed.", request_id=task_id)
|
|
243
|
+
content = None if body is _UNSET else json_body(body)
|
|
244
|
+
headers = {"Authorization": f"Bearer {self.config.key}", "Accept": "application/json"}
|
|
245
|
+
if content is not None:
|
|
246
|
+
headers["Content-Type"] = "application/json"
|
|
247
|
+
if task_id is not None:
|
|
248
|
+
headers["Idempotency-Key"] = task_id
|
|
249
|
+
retries = self.config.max_retries if method == "GET" or task_id is not None else 0
|
|
250
|
+
for attempt in range(retries + 1):
|
|
251
|
+
response = None
|
|
252
|
+
try:
|
|
253
|
+
response = await self.client.request(
|
|
254
|
+
method,
|
|
255
|
+
self.config.base + path,
|
|
256
|
+
headers=headers,
|
|
257
|
+
content=content,
|
|
258
|
+
params=params,
|
|
259
|
+
timeout=self.config.timeout,
|
|
260
|
+
follow_redirects=False,
|
|
261
|
+
)
|
|
262
|
+
except httpx.TransportError as exc:
|
|
263
|
+
if attempt == retries:
|
|
264
|
+
raise TransportError(
|
|
265
|
+
"NullProtocol could not be reached. "
|
|
266
|
+
"Preserve request_id before retrying a run.",
|
|
267
|
+
request_id=task_id,
|
|
268
|
+
) from exc
|
|
269
|
+
if response is not None and (attempt == retries or not _retryable(response)):
|
|
270
|
+
return _decode(response, task_id)
|
|
271
|
+
delay = _delay(response, attempt)
|
|
272
|
+
if delay is None:
|
|
273
|
+
assert response is not None
|
|
274
|
+
return _decode(response, task_id)
|
|
275
|
+
await asyncio.sleep(delay)
|
|
276
|
+
raise AssertionError("Unreachable transport state")
|
nullprotocol/_types.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from ._errors import ProtocolError
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _count(value: Any, name: str) -> int:
|
|
14
|
+
if type(value) is not int or value < 0:
|
|
15
|
+
raise ProtocolError(f"Invalid {name} in the server response.")
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Usage:
|
|
21
|
+
input_tokens: int
|
|
22
|
+
output_tokens: int
|
|
23
|
+
uncounted_requests: int | None = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, value: Any) -> Usage | None:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
if not isinstance(value, dict):
|
|
30
|
+
raise ProtocolError("Invalid generation usage in the server response.")
|
|
31
|
+
uncounted = value.get("uncountedRequests")
|
|
32
|
+
if uncounted is not None:
|
|
33
|
+
uncounted = _count(uncounted, "uncountedRequests")
|
|
34
|
+
if uncounted == 0:
|
|
35
|
+
raise ProtocolError("uncountedRequests must be positive when reported.")
|
|
36
|
+
return cls(
|
|
37
|
+
_count(value.get("inputTokens"), "inputTokens"),
|
|
38
|
+
_count(value.get("outputTokens"), "outputTokens"),
|
|
39
|
+
uncounted,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class KnowledgeUsage:
|
|
45
|
+
input_tokens: int | None = None
|
|
46
|
+
uncounted_requests: int | None = None
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, value: Any) -> KnowledgeUsage | None:
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
raise ProtocolError("Invalid Knowledge usage in the server response.")
|
|
54
|
+
counted, uncounted = value.get("inputTokens"), value.get("uncountedRequests")
|
|
55
|
+
if counted is not None:
|
|
56
|
+
counted = _count(counted, "Knowledge inputTokens")
|
|
57
|
+
if uncounted is not None:
|
|
58
|
+
uncounted = _count(uncounted, "Knowledge uncountedRequests")
|
|
59
|
+
if uncounted == 0:
|
|
60
|
+
raise ProtocolError("Knowledge uncountedRequests must be positive.")
|
|
61
|
+
return cls(counted, uncounted)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, eq=False)
|
|
65
|
+
class Result(Generic[T]):
|
|
66
|
+
"""A model-operation result. raw preserves all server fields without renaming JSON data."""
|
|
67
|
+
|
|
68
|
+
success: bool
|
|
69
|
+
operation_id: str | None
|
|
70
|
+
request_id: str
|
|
71
|
+
data: T | None = None
|
|
72
|
+
summary: str | None = None
|
|
73
|
+
key_points: list[str] | None = None
|
|
74
|
+
recommendation: str | None = None
|
|
75
|
+
score: float | None = None
|
|
76
|
+
reasoning: str | None = None
|
|
77
|
+
action: str | None = None
|
|
78
|
+
parameters: dict[str, Any] | None = None
|
|
79
|
+
error_code: str | None = None
|
|
80
|
+
error: str | None = None
|
|
81
|
+
attempts: int | None = None
|
|
82
|
+
repaired: bool | None = None
|
|
83
|
+
usage: Usage | None = None
|
|
84
|
+
knowledge_usage: KnowledgeUsage | None = None
|
|
85
|
+
knowledge: dict[str, Any] | None = None
|
|
86
|
+
history_error: str | None = None
|
|
87
|
+
_raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def raw(self) -> dict[str, Any]:
|
|
91
|
+
return deepcopy(self._raw)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_response(cls, response: dict[str, Any], request_id: str) -> Result[Any]:
|
|
95
|
+
value = response.get("result")
|
|
96
|
+
if not isinstance(value, dict) or type(value.get("success")) is not bool:
|
|
97
|
+
raise ProtocolError(
|
|
98
|
+
"Missing operation result in the server response.", request_id=request_id
|
|
99
|
+
)
|
|
100
|
+
if "operationId" not in response:
|
|
101
|
+
raise ProtocolError(
|
|
102
|
+
"Missing operationId in the server response.", request_id=request_id
|
|
103
|
+
)
|
|
104
|
+
operation_id = response.get("operationId")
|
|
105
|
+
if operation_id is not None and not isinstance(operation_id, str):
|
|
106
|
+
raise ProtocolError(
|
|
107
|
+
"Invalid operationId in the server response.", request_id=request_id
|
|
108
|
+
)
|
|
109
|
+
if operation_id is not None:
|
|
110
|
+
try:
|
|
111
|
+
UUID(operation_id)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise ProtocolError("Invalid operationId UUID.", request_id=request_id) from exc
|
|
114
|
+
for name in ("attempts",):
|
|
115
|
+
if name in value:
|
|
116
|
+
try:
|
|
117
|
+
_count(value[name], name)
|
|
118
|
+
except ProtocolError as exc:
|
|
119
|
+
exc.request_id = request_id
|
|
120
|
+
raise
|
|
121
|
+
if "repaired" in value and type(value["repaired"]) is not bool:
|
|
122
|
+
raise ProtocolError("Invalid repaired flag.", request_id=request_id)
|
|
123
|
+
if "historyError" in value and not isinstance(value["historyError"], str):
|
|
124
|
+
raise ProtocolError("Invalid historyError.", request_id=request_id)
|
|
125
|
+
if "knowledge" in value and not isinstance(value["knowledge"], dict):
|
|
126
|
+
raise ProtocolError("Invalid Knowledge evidence.", request_id=request_id)
|
|
127
|
+
if not value["success"] and not all(
|
|
128
|
+
isinstance(value.get(key), str) for key in ("errorCode", "error")
|
|
129
|
+
):
|
|
130
|
+
raise ProtocolError("Missing operation error details.", request_id=request_id)
|
|
131
|
+
try:
|
|
132
|
+
usage = Usage.from_dict(value.get("usage"))
|
|
133
|
+
knowledge_usage = KnowledgeUsage.from_dict(value.get("knowledgeUsage"))
|
|
134
|
+
except ProtocolError as exc:
|
|
135
|
+
exc.request_id = request_id
|
|
136
|
+
raise
|
|
137
|
+
return cls(
|
|
138
|
+
success=value["success"],
|
|
139
|
+
operation_id=operation_id,
|
|
140
|
+
request_id=request_id,
|
|
141
|
+
data=deepcopy(value.get("data")),
|
|
142
|
+
summary=value.get("summary"),
|
|
143
|
+
key_points=deepcopy(value.get("keyPoints")),
|
|
144
|
+
recommendation=value.get("recommendation"),
|
|
145
|
+
score=value.get("score"),
|
|
146
|
+
reasoning=value.get("reasoning"),
|
|
147
|
+
action=value.get("action"),
|
|
148
|
+
parameters=deepcopy(value.get("parameters")),
|
|
149
|
+
error_code=value.get("errorCode"),
|
|
150
|
+
error=value.get("error"),
|
|
151
|
+
attempts=value.get("attempts"),
|
|
152
|
+
repaired=value.get("repaired"),
|
|
153
|
+
usage=usage,
|
|
154
|
+
knowledge_usage=knowledge_usage,
|
|
155
|
+
knowledge=deepcopy(value.get("knowledge")),
|
|
156
|
+
history_error=value.get("historyError"),
|
|
157
|
+
_raw=deepcopy(value),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class Execution:
|
|
163
|
+
success: bool
|
|
164
|
+
outcome: str
|
|
165
|
+
action: str | None = None
|
|
166
|
+
result: Any = None
|
|
167
|
+
error_code: str | None = None
|
|
168
|
+
error: str | None = None
|
|
169
|
+
history_error: str | None = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class ActionContext:
|
|
174
|
+
decision: Result[Any]
|
|
175
|
+
input: Any
|
nullprotocol/py.typed
ADDED
|
File without changes
|