bytesmap 0.1.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.
bytesmap/_limits.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "max_batch_bytes": 1048576,
3
+ "max_batch_events": 1000,
4
+ "max_data_bytes": 65536,
5
+ "max_depth": 16,
6
+ "max_dimensions": 64,
7
+ "dimension_pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$",
8
+ "label_pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$"
9
+ }
bytesmap/_transport.py ADDED
@@ -0,0 +1,120 @@
1
+ """Standard-library transport with verified TLS and no redirect replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import math
7
+ import ssl
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from http.client import HTTPException
11
+ from typing import Protocol
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import urlsplit
14
+ from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Response:
19
+ status: int
20
+ headers: Mapping[str, str]
21
+ body: bytes
22
+
23
+
24
+ class Transport(Protocol):
25
+ def request(
26
+ self,
27
+ method: str,
28
+ url: str,
29
+ headers: Mapping[str, str],
30
+ body: bytes | None,
31
+ timeout: float,
32
+ ) -> Response: ...
33
+ def close(self) -> None: ...
34
+
35
+
36
+ class TransportError(Exception):
37
+ """Network delivery could not be confirmed; credentials are never included."""
38
+
39
+
40
+ def validate_origin(value: str) -> tuple[str, bool]:
41
+ if not isinstance(value, str) or any(
42
+ ord(char) < 33 or ord(char) == 127 for char in value
43
+ ):
44
+ raise ValueError("Endpoint must be an HTTPS origin or HTTP loopback")
45
+ try:
46
+ parsed = urlsplit(value)
47
+ port = parsed.port
48
+ host = parsed.hostname
49
+ if not host or port is not None and not 1 <= port <= 65535:
50
+ raise ValueError()
51
+ try:
52
+ loopback = ipaddress.ip_address(host).is_loopback
53
+ except ValueError:
54
+ loopback = host.lower() == "localhost"
55
+ if (
56
+ parsed.scheme != "https"
57
+ and not (parsed.scheme == "http" and loopback)
58
+ or parsed.username is not None
59
+ or parsed.password is not None
60
+ or parsed.path not in ("", "/")
61
+ or parsed.query
62
+ or parsed.fragment
63
+ ):
64
+ raise ValueError()
65
+ except ValueError:
66
+ raise ValueError(
67
+ "Endpoint must be an HTTPS origin or HTTP loopback without credentials, path, query or fragment"
68
+ ) from None
69
+ return value.rstrip("/"), parsed.scheme == "http"
70
+
71
+
72
+ def validate_timeout(value: float) -> None:
73
+ if (
74
+ isinstance(value, bool)
75
+ or not isinstance(value, (int, float))
76
+ or not math.isfinite(value)
77
+ or value <= 0
78
+ ):
79
+ raise ValueError("Timeout must be a finite positive number")
80
+
81
+
82
+ class _NoRedirect(HTTPRedirectHandler):
83
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
84
+ return None
85
+
86
+
87
+ class UrllibTransport:
88
+ def __init__(self) -> None:
89
+ self._opener = build_opener(
90
+ _NoRedirect(), HTTPSHandler(context=ssl.create_default_context())
91
+ )
92
+
93
+ def request(
94
+ self,
95
+ method: str,
96
+ url: str,
97
+ headers: Mapping[str, str],
98
+ body: bytes | None,
99
+ timeout: float,
100
+ ) -> Response:
101
+ request = Request(url, data=body, headers=dict(headers), method=method)
102
+ try:
103
+ try:
104
+ response = self._opener.open(request, timeout=timeout)
105
+ except HTTPError as error:
106
+ response = error
107
+ with response:
108
+ payload = response.read(64 * 1024 * 1024 + 1)
109
+ if len(payload) > 64 * 1024 * 1024:
110
+ raise TransportError("Response exceeded the SDK size limit")
111
+ return Response(
112
+ response.status,
113
+ {key.lower(): value for key, value in response.headers.items()},
114
+ payload,
115
+ )
116
+ except (URLError, OSError, ValueError, HTTPException):
117
+ raise TransportError("BytesMap connection failed") from None
118
+
119
+ def close(self) -> None:
120
+ self._opener.close()
bytesmap/client.py ADDED
@@ -0,0 +1,214 @@
1
+ """Workspace-scoped operations generated from BytesMap's shared catalogue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from importlib.resources import files
11
+ from typing import Any, Self
12
+ from urllib.parse import quote, urlencode
13
+
14
+ from ._generated import GeneratedClient
15
+ from ._transport import Transport, UrllibTransport, validate_origin, validate_timeout
16
+
17
+ _CATALOG = json.loads(files(__package__).joinpath("_catalog.json").read_text("utf-8"))
18
+
19
+
20
+ class BytesMapError(Exception):
21
+ def __init__(
22
+ self, code: str, params: Mapping[str, Any] | None = None, status: int = 0
23
+ ) -> None:
24
+ super().__init__(code)
25
+ self.code, self.params, self.status = code, dict(params or {}), status
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CallOptions:
30
+ project_id: str | None = None
31
+
32
+
33
+ def _json(value: Any) -> bytes:
34
+ return json.dumps(
35
+ value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
36
+ ).encode("utf-8")
37
+
38
+
39
+ def _header(value: str) -> str:
40
+ if not isinstance(value, str) or any(
41
+ ord(char) < 32 or ord(char) == 127 for char in value
42
+ ):
43
+ raise ValueError("Invalid authentication or scope header")
44
+ return value
45
+
46
+
47
+ class BytesMapClient(GeneratedClient):
48
+ def __init__(
49
+ self,
50
+ base_url: str,
51
+ *,
52
+ token: str | Callable[[], str | None] | None = None,
53
+ organization_id: str | None = None,
54
+ project_id: str | Callable[[], str | None] | None = None,
55
+ timeout: float = 30,
56
+ transport: Transport | None = None,
57
+ ) -> None:
58
+ self._base_url, _ = validate_origin(base_url)
59
+ validate_timeout(timeout)
60
+ self._token, self._organization_id, self._project_id = (
61
+ token,
62
+ organization_id,
63
+ project_id,
64
+ )
65
+ self._timeout = timeout
66
+ self._transport = transport or UrllibTransport()
67
+ self._owns_transport = transport is None
68
+ self._closed = False
69
+ self._owner: BytesMapClient | None = None
70
+ self._initialize_modules()
71
+
72
+ def scope(self, organization_id: str) -> BytesMapClient:
73
+ self._check_open()
74
+ if not organization_id:
75
+ raise ValueError("Workspace ID is required")
76
+ scoped = BytesMapClient(
77
+ self._base_url,
78
+ token=self._token,
79
+ organization_id=_header(organization_id),
80
+ project_id=self._project_id,
81
+ timeout=self._timeout,
82
+ transport=self._transport,
83
+ )
84
+ scoped._owner = self._owner or self
85
+ return scoped
86
+
87
+ def invoke(
88
+ self,
89
+ operation: str,
90
+ input: Mapping[str, Any] | None = None,
91
+ *,
92
+ call_options: CallOptions | None = None,
93
+ **kwargs: Any,
94
+ ) -> Any:
95
+ self._check_open()
96
+ if operation not in _CATALOG:
97
+ raise ValueError("Unknown BytesMap operation")
98
+ spec = _CATALOG[operation]
99
+ if input is not None and not isinstance(input, Mapping):
100
+ raise TypeError("Operation input must be a mapping")
101
+ values = dict(input or {})
102
+ for key, value in kwargs.items():
103
+ wire_key = spec["aliases"].get(key, key)
104
+ if wire_key in values:
105
+ raise ValueError("Duplicate operation input field")
106
+ values[wire_key] = value
107
+ values = json.loads(_json(values))
108
+ # Resolve project selection before a token callback can change application state.
109
+ project = (
110
+ call_options.project_id
111
+ if call_options and call_options.project_id is not None
112
+ else (
113
+ self._project_id() if callable(self._project_id) else self._project_id
114
+ )
115
+ )
116
+
117
+ def segment(match):
118
+ key = match.group(1)
119
+ if (
120
+ key not in values
121
+ or values[key] is None
122
+ or isinstance(values[key], (dict, list))
123
+ ):
124
+ raise ValueError("Missing or invalid path parameter")
125
+ value = values.pop(key)
126
+ text = value if isinstance(value, str) else _json(value).decode("utf-8")
127
+ if not text or text in (".", ".."):
128
+ raise ValueError("Invalid path parameter")
129
+ return quote(text, safe="")
130
+
131
+ path = re.sub(r":([A-Za-z0-9_]+)", segment, spec["path"])
132
+ url = self._base_url + path
133
+ headers = {"accept": "application/json"}
134
+ token = self._token() if callable(self._token) else self._token
135
+ if token:
136
+ headers["authorization"] = "Bearer " + _header(token)
137
+ if self._organization_id:
138
+ headers["x-organization-id"] = _header(self._organization_id)
139
+ if project:
140
+ headers["x-bytesmap-project"] = _header(project)
141
+ body = None
142
+ if spec["method"] == "GET":
143
+ if values:
144
+ query = {
145
+ key: value
146
+ if isinstance(value, str)
147
+ else _json(value).decode("utf-8")
148
+ for key, value in values.items()
149
+ }
150
+ url += "?" + urlencode(query)
151
+ else:
152
+ body = _json(values)
153
+ headers["content-type"] = "application/json"
154
+ response = self._transport.request(
155
+ spec["method"], url, headers, body, self._timeout
156
+ )
157
+ if not 200 <= response.status < 300:
158
+ try:
159
+ error = json.loads(response.body)
160
+ except (ValueError, UnicodeError):
161
+ error = {}
162
+ if not isinstance(error, dict):
163
+ error = {}
164
+ raise BytesMapError(
165
+ error.get("code")
166
+ if isinstance(error.get("code"), str)
167
+ else f"http.{response.status}",
168
+ error.get("params") if isinstance(error.get("params"), dict) else {},
169
+ response.status,
170
+ )
171
+ if spec["response"] == "file":
172
+ headers = {key.lower(): value for key, value in response.headers.items()}
173
+ disposition = headers.get("content-disposition", "")
174
+ from urllib.parse import unquote
175
+
176
+ filename = re.search(
177
+ r"filename\*=UTF-8''([^;]+)", disposition, re.IGNORECASE
178
+ )
179
+ plain = re.search(r'filename="?([^";]+)', disposition, re.IGNORECASE)
180
+ return {
181
+ "filename": unquote(filename.group(1))
182
+ if filename
183
+ else plain.group(1)
184
+ if plain
185
+ else "report",
186
+ "contentType": headers.get("content-type", "application/octet-stream"),
187
+ "contentBase64": base64.b64encode(response.body).decode("ascii"),
188
+ }
189
+ try:
190
+ result = json.loads(response.body)
191
+ except (ValueError, UnicodeError):
192
+ raise BytesMapError("response.invalid", status=response.status) from None
193
+ if spec["response"] == "raw":
194
+ return result
195
+ if not isinstance(result, dict) or "data" not in result:
196
+ raise BytesMapError("response.invalid", status=response.status)
197
+ return result["data"]
198
+
199
+ def _check_open(self) -> None:
200
+ if self._closed or self._owner is not None and self._owner._closed:
201
+ raise RuntimeError("BytesMap client is closed")
202
+
203
+ def close(self) -> None:
204
+ if not self._closed:
205
+ self._closed = True
206
+ if self._owns_transport:
207
+ self._transport.close()
208
+
209
+ def __enter__(self) -> Self:
210
+ self._check_open()
211
+ return self
212
+
213
+ def __exit__(self, *_: object) -> None:
214
+ self.close()