lbox-clients 1.1.2__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ # python generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # venv
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.9.18
@@ -0,0 +1,44 @@
1
+ # https://github.com/ucyo/python-package-template/blob/master/Dockerfile
2
+ FROM python:3.9-slim as rye
3
+
4
+ ENV LANG="C.UTF-8" \
5
+ LC_ALL="C.UTF-8" \
6
+ PATH="/home/python/.local/bin:/home/python/.rye/shims:$PATH" \
7
+ PIP_NO_CACHE_DIR="false" \
8
+ RYE_VERSION="0.43.0" \
9
+ RYE_INSTALL_OPTION="--yes" \
10
+ LABELBOX_TEST_ENVIRON="prod"
11
+
12
+ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
13
+ ca-certificates \
14
+ curl \
15
+ inotify-tools \
16
+ make \
17
+ # cv2
18
+ libsm6 \
19
+ libxext6 \
20
+ ffmpeg \
21
+ libfontconfig1 \
22
+ libxrender1 \
23
+ libgl1-mesa-glx \
24
+ libgeos-dev \
25
+ gcc \
26
+ && rm -rf /var/lib/apt/lists/*
27
+
28
+ RUN groupadd --gid 1000 python && \
29
+ useradd --uid 1000 --gid python --shell /bin/bash --create-home python
30
+
31
+ USER 1000
32
+ WORKDIR /home/python/
33
+
34
+ RUN curl -sSf https://rye.astral.sh/get | bash -
35
+
36
+ COPY --chown=python:python . /home/python/labelbox-python/
37
+ WORKDIR /home/python/labelbox-python
38
+
39
+ RUN rye config --set-bool behavior.global-python=true && \
40
+ rye config --set-bool behavior.use-uv=true && \
41
+ rye pin 3.9 && \
42
+ rye sync
43
+
44
+ CMD rye run unit && rye integration
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: lbox-clients
3
+ Version: 1.1.2
4
+ Summary: This module contains client sdk uses to conntect to the Labelbox API and backends
5
+ Project-URL: Homepage, https://labelbox.com/
6
+ Project-URL: Documentation, https://labelbox-python.readthedocs.io/en/latest/
7
+ Project-URL: Repository, https://github.com/Labelbox/labelbox-python
8
+ Project-URL: Issues, https://github.com/Labelbox/labelbox-python/issues
9
+ Project-URL: Changelog, https://github.com/Labelbox/labelbox-python/blob/develop/libs/labelbox/CHANGELOG.md
10
+ Author-email: Labelbox <engineering@labelbox.com>
11
+ Keywords: ai,edu,labelbox,labeling,llm,machinelearning,ml
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: google-api-core>=1.22.1
27
+ Requires-Dist: requests>=2.22.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # lbox-clients
31
+
32
+ This is an example module which can be cloned and reused to develop modules under the `lbox` namespace.
33
+
34
+ ## Module Status: Experimental
35
+
36
+ **TLDR: This module may be removed or altered at any given time and there is no offical support.**
37
+
38
+ Please see [here](https://docs.labelbox.com/docs/product-release-phases) for the formal definition of `Experimental`.
@@ -0,0 +1,9 @@
1
+ # lbox-clients
2
+
3
+ This is an example module which can be cloned and reused to develop modules under the `lbox` namespace.
4
+
5
+ ## Module Status: Experimental
6
+
7
+ **TLDR: This module may be removed or altered at any given time and there is no offical support.**
8
+
9
+ Please see [here](https://docs.labelbox.com/docs/product-release-phases) for the formal definition of `Experimental`.
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "lbox-clients"
3
+ version = "1.1.2"
4
+ description = "This module contains client sdk uses to conntect to the Labelbox API and backends"
5
+ authors = [
6
+ { name = "Labelbox", email = "engineering@labelbox.com" }
7
+ ]
8
+ dependencies = [
9
+ "requests>=2.22.0",
10
+ "google-api-core>=1.22.1",
11
+ ]
12
+ readme = "README.md"
13
+ requires-python = ">= 3.9"
14
+
15
+ classifiers=[
16
+ # How mature is this project?
17
+ "Development Status :: 5 - Production/Stable",
18
+ # Indicate who your project is intended for
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Intended Audience :: Developers",
22
+ "Intended Audience :: Science/Research",
23
+ "Intended Audience :: Education",
24
+ # Pick your license as you wish
25
+ "License :: OSI Approved :: Apache Software License",
26
+ # Specify the Python versions you support here.
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.9",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ ]
34
+ keywords = ["ml", "ai", "labelbox", "labeling", "llm", "machinelearning", "edu"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://labelbox.com/"
38
+ Documentation = "https://labelbox-python.readthedocs.io/en/latest/"
39
+ Repository = "https://github.com/Labelbox/labelbox-python"
40
+ Issues = "https://github.com/Labelbox/labelbox-python/issues"
41
+ Changelog = "https://github.com/Labelbox/labelbox-python/blob/develop/libs/labelbox/CHANGELOG.md"
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.rye]
48
+ managed = true
49
+ dev-dependencies = []
50
+
51
+ [tool.rye.scripts]
52
+ unit = "pytest tests/unit"
53
+ integration = "python -c \"import sys; sys.exit(0)\""
54
+
55
+ [tool.hatch.metadata]
56
+ allow-direct-references = true
57
+
58
+ [tool.hatch.build.targets.wheel]
59
+ packages = ["src/lbox"]
60
+
61
+ [tool.pytest.ini_options]
62
+ addopts = "-rP -vvv --durations=20 --cov=lbox.example --import-mode=importlib"
@@ -0,0 +1,55 @@
1
+ import inspect
2
+ import re
3
+ import sys
4
+ from typing import TypedDict
5
+
6
+
7
+ def python_version_info():
8
+ version_info = sys.version_info
9
+
10
+ return f"{version_info.major}.{version_info.minor}.{version_info.micro}-{version_info.releaselevel}"
11
+
12
+
13
+ LABELBOX_CALL_PATTERN = re.compile(r"/labelbox/")
14
+ TEST_FILE_PATTERN = re.compile(r".*test.*\.py$")
15
+
16
+
17
+ class _RequestInfo(TypedDict):
18
+ prefix: str
19
+ class_name: str
20
+ method_name: str
21
+
22
+
23
+ def call_info():
24
+ method_name: str = "Unknown"
25
+ prefix = ""
26
+ class_name = ""
27
+ skip_methods = ["wrapper", "__init__", "execute"]
28
+ skip_classes = ["PaginatedCollection", "_CursorPagination", "_OffsetPagination"]
29
+
30
+ try:
31
+ call_info = None
32
+ for stack in reversed(inspect.stack()):
33
+ if LABELBOX_CALL_PATTERN.search(stack.filename):
34
+ call_info = stack
35
+ method_name: str = call_info.function
36
+ class_name = call_info.frame.f_locals.get(
37
+ "self", None
38
+ ).__class__.__name__
39
+ if method_name not in skip_methods:
40
+ if class_name not in skip_classes:
41
+ if TEST_FILE_PATTERN.search(call_info.filename):
42
+ prefix = "test:"
43
+ else:
44
+ if class_name == "NoneType":
45
+ class_name = ""
46
+ break
47
+
48
+ except Exception:
49
+ pass
50
+ return _RequestInfo(prefix=prefix, class_name=class_name, method_name=method_name)
51
+
52
+
53
+ def call_info_as_str():
54
+ info: _RequestInfo = call_info()
55
+ return f"{info['prefix']}{info['class_name']}:{info['method_name']}"
@@ -0,0 +1,201 @@
1
+ import re
2
+
3
+
4
+ class LabelboxError(Exception):
5
+ """Base class for exceptions."""
6
+
7
+ def __init__(self, message, cause=None):
8
+ """
9
+ Args:
10
+ message (str): Informative message about the exception.
11
+ cause (Exception): The cause of the exception (an Exception
12
+ raised by Python or another library). Optional.
13
+ """
14
+ super().__init__(message, cause)
15
+ self.message = message
16
+ self.cause = cause
17
+
18
+ def __str__(self):
19
+ exception_message = self.message
20
+ if self.cause is not None:
21
+ exception_message += " (caused by: %s)" % self.cause
22
+ return exception_message
23
+
24
+
25
+ class AuthenticationError(LabelboxError):
26
+ """Raised when an API key fails authentication."""
27
+
28
+ pass
29
+
30
+
31
+ class AuthorizationError(LabelboxError):
32
+ """Raised when a user is unauthorized to perform the given request."""
33
+
34
+ pass
35
+
36
+
37
+ class ResourceNotFoundError(LabelboxError):
38
+ """Exception raised when a given resource is not found."""
39
+
40
+ def __init__(self, db_object_type=None, params=None, message=None):
41
+ """Constructor for the ResourceNotFoundException class.
42
+
43
+ Args:
44
+ db_object_type (type): A subtype of labelbox.schema.DbObject.
45
+ params (dict): A dictionary of parameters identifying the sought resource.
46
+ message (str): An optional message to include in the exception.
47
+ """
48
+ if message is not None:
49
+ super().__init__(message)
50
+ else:
51
+ super().__init__(
52
+ "Resource '%s' not found for params: %r"
53
+ % (db_object_type.type_name(), params)
54
+ )
55
+ self.db_object_type = db_object_type
56
+ self.params = params
57
+
58
+
59
+ class ResourceConflict(LabelboxError):
60
+ """Exception raised when a given resource conflicts with another."""
61
+
62
+ pass
63
+
64
+
65
+ class ValidationFailedError(LabelboxError):
66
+ """Exception raised for when a GraphQL query fails validation (query cost,
67
+ etc.) E.g. a query that is too expensive, or depth is too deep.
68
+ """
69
+
70
+ pass
71
+
72
+
73
+ class InternalServerError(LabelboxError):
74
+ """Nondescript prisma or 502 related errors.
75
+
76
+ Meant to be retryable.
77
+
78
+ TODO: these errors need better messages from platform
79
+ """
80
+
81
+ pass
82
+
83
+
84
+ class InvalidQueryError(LabelboxError):
85
+ """Indicates a malconstructed or unsupported query (either by GraphQL in
86
+ general or by Labelbox specifically). This can be the result of either client
87
+ or server side query validation."""
88
+
89
+ pass
90
+
91
+
92
+ class UnprocessableEntityError(LabelboxError):
93
+ """Indicates that a resource could not be created in the server side
94
+ due to a validation or transaction error"""
95
+
96
+ pass
97
+
98
+
99
+ class ResourceCreationError(LabelboxError):
100
+ """Indicates that a resource could not be created in the server side
101
+ due to a validation or transaction error"""
102
+
103
+ pass
104
+
105
+
106
+ class NetworkError(LabelboxError):
107
+ """Raised when an HTTPError occurs."""
108
+
109
+ def __init__(self, cause):
110
+ super().__init__(str(cause), cause)
111
+ self.cause = cause
112
+
113
+
114
+ class TimeoutError(LabelboxError):
115
+ """Raised when a request times-out."""
116
+
117
+ pass
118
+
119
+
120
+ class InvalidAttributeError(LabelboxError):
121
+ """Raised when a field (name or Field instance) is not valid or found
122
+ for a specific DB object type."""
123
+
124
+ def __init__(self, db_object_type, field):
125
+ super().__init__(
126
+ "Field(s) '%r' not valid on DB type '%s'"
127
+ % (field, db_object_type.type_name())
128
+ )
129
+ self.db_object_type = db_object_type
130
+ self.field = field
131
+
132
+
133
+ class ApiLimitError(LabelboxError):
134
+ """Raised when the user performs too many requests in a short period
135
+ of time."""
136
+
137
+ pass
138
+
139
+
140
+ class MalformedQueryException(Exception):
141
+ """Raised when the user submits a malformed query."""
142
+
143
+ pass
144
+
145
+
146
+ class UuidError(LabelboxError):
147
+ """Raised when there are repeat Uuid's in bulk import request."""
148
+
149
+ pass
150
+
151
+
152
+ class InconsistentOntologyException(Exception):
153
+ pass
154
+
155
+
156
+ class MALValidationError(LabelboxError):
157
+ """Raised when user input is invalid for MAL imports."""
158
+
159
+ pass
160
+
161
+
162
+ class OperationNotAllowedException(Exception):
163
+ """Raised when user does not have permissions to a resource or has exceeded usage limit"""
164
+
165
+ pass
166
+
167
+
168
+ class OperationNotSupportedException(Exception):
169
+ """Raised when sdk does not support requested operation"""
170
+
171
+ pass
172
+
173
+
174
+ class ConfidenceNotSupportedException(Exception):
175
+ """Raised when confidence is specified for unsupported annotation type"""
176
+
177
+
178
+ class CustomMetricsNotSupportedException(Exception):
179
+ """Raised when custom_metrics is specified for unsupported annotation type"""
180
+
181
+
182
+ class ProcessingWaitTimeout(Exception):
183
+ """Raised when waiting for the data rows to be processed takes longer than allowed"""
184
+
185
+
186
+ def error_message_for_unparsed_graphql_error(error_string: str) -> str:
187
+ """
188
+ Since our client only parses certain graphql errors, this function is used to
189
+ extract the error message from the error string when the error is not
190
+ parsed by the client.
191
+ """
192
+ # Regex to find the message content
193
+ pattern = r"'message': '([^']+)'"
194
+ # Search for the pattern in the error string
195
+ match = re.search(pattern, error_string)
196
+ if match:
197
+ error_content = match.group(1)
198
+ else:
199
+ error_content = "Unknown error"
200
+
201
+ return error_content
@@ -0,0 +1,385 @@
1
+ # for the Labelbox Python SDK
2
+ import json
3
+ import logging
4
+ import os
5
+ from datetime import datetime, timezone
6
+ from types import MappingProxyType
7
+ from typing import Callable, Dict, Optional
8
+
9
+ import requests
10
+ import requests.exceptions
11
+ from google.api_core import retry
12
+ from lbox import exceptions
13
+ from lbox.call_info import call_info_as_str, python_version_info # type: ignore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _LABELBOX_API_KEY = "LABELBOX_API_KEY"
18
+
19
+
20
+ class RequestClient:
21
+ """A Labelbox request client.
22
+
23
+ Contains info necessary for connecting to a Labelbox server (URL,
24
+ authentication key).
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ sdk_version,
30
+ api_key=None,
31
+ endpoint="https://api.labelbox.com/graphql",
32
+ enable_experimental=False,
33
+ app_url="https://app.labelbox.com",
34
+ rest_endpoint="https://api.labelbox.com/api/v1",
35
+ ):
36
+ """Creates and initializes a RequestClient.
37
+ This class executes graphql and rest requests to the Labelbox server.
38
+
39
+ Args:
40
+ api_key (str): API key. If None, the key is obtained from the "LABELBOX_API_KEY" environment variable.
41
+ endpoint (str): URL of the Labelbox server to connect to.
42
+ enable_experimental (bool): Indicates whether or not to use experimental features
43
+ app_url (str) : host url for all links to the web app
44
+ Raises:
45
+ exceptions.AuthenticationError: If no `api_key`
46
+ is provided as an argument or via the environment
47
+ variable.
48
+ """
49
+ if api_key is None:
50
+ if _LABELBOX_API_KEY not in os.environ:
51
+ raise exceptions.AuthenticationError("Labelbox API key not provided")
52
+ api_key = os.environ[_LABELBOX_API_KEY]
53
+ self.api_key = api_key
54
+
55
+ self.enable_experimental = enable_experimental
56
+ if enable_experimental:
57
+ logger.info("Experimental features have been enabled")
58
+
59
+ logger.info("Initializing Labelbox client at '%s'", endpoint)
60
+ self.app_url = app_url
61
+ self.endpoint = endpoint
62
+ self.rest_endpoint = rest_endpoint
63
+ self.sdk_version = sdk_version
64
+ self._sdk_method = None
65
+ self._connection: requests.Session = self._init_connection()
66
+
67
+ def _init_connection(self) -> requests.Session:
68
+ connection = requests.Session() # using default connection pool size of 10
69
+ connection.headers.update(self._default_headers())
70
+
71
+ return connection
72
+
73
+ @property
74
+ def headers(self) -> MappingProxyType:
75
+ return self._connection.headers
76
+
77
+ @property
78
+ def sdk_method(self):
79
+ return self._sdk_method
80
+
81
+ @sdk_method.setter
82
+ def sdk_method(self, value):
83
+ self._sdk_method = value
84
+
85
+ def _default_headers(self):
86
+ return {
87
+ "Authorization": "Bearer %s" % self.api_key,
88
+ "Accept": "application/json",
89
+ "Content-Type": "application/json",
90
+ "X-User-Agent": f"python-sdk {self.sdk_version}",
91
+ "X-Python-Version": f"{python_version_info()}",
92
+ }
93
+
94
+ @retry.Retry(
95
+ predicate=retry.if_exception_type(
96
+ exceptions.InternalServerError,
97
+ exceptions.TimeoutError,
98
+ )
99
+ )
100
+ def execute(
101
+ self,
102
+ query=None,
103
+ params=None,
104
+ data=None,
105
+ files=None,
106
+ timeout=60.0,
107
+ experimental=False,
108
+ error_log_key="message",
109
+ raise_return_resource_not_found=False,
110
+ error_handlers: Optional[
111
+ Dict[str, Callable[[requests.models.Response], None]]
112
+ ] = None,
113
+ ):
114
+ """Sends a request to the server for the execution of the
115
+ given query.
116
+
117
+ Checks the response for errors and wraps errors
118
+ in appropriate `exceptions.LabelboxError` subtypes.
119
+
120
+ Args:
121
+ query (str): The query to execute.
122
+ params (dict): Query parameters referenced within the query.
123
+ data (str): json string containing the query to execute
124
+ files (dict): file arguments for request
125
+ timeout (float): Max allowed time for query execution,
126
+ in seconds.
127
+ raise_return_resource_not_found: By default the client relies on the caller to raise the correct exception when a resource is not found.
128
+ If this is set to True, the client will raise a ResourceNotFoundError exception automatically.
129
+ This simplifies processing.
130
+ We recommend to use it only of api returns a clear and well-formed error when a resource not found for a given query.
131
+ error_handlers (dict): A dictionary mapping graphql error code to handler functions.
132
+ Allows a caller to handle specific errors reporting in a custom way or produce more user-friendly readable messages.
133
+
134
+ Example - custom error handler:
135
+ >>> def _raise_readable_errors(self, response):
136
+ >>> errors = response.json().get('errors', [])
137
+ >>> if errors:
138
+ >>> message = errors[0].get(
139
+ >>> 'message', json.dumps([{
140
+ >>> "errorMessage": "Unknown error"
141
+ >>> }]))
142
+ >>> errors = json.loads(message)
143
+ >>> error_messages = [error['errorMessage'] for error in errors]
144
+ >>> else:
145
+ >>> error_messages = ["Uknown error"]
146
+ >>> raise LabelboxError(". ".join(error_messages))
147
+
148
+ Returns:
149
+ dict, parsed JSON response.
150
+ Raises:
151
+ exceptions.AuthenticationError: If authentication
152
+ failed.
153
+ exceptions.InvalidQueryError: If `query` is not
154
+ syntactically or semantically valid (checked server-side).
155
+ exceptions.ApiLimitError: If the server API limit was
156
+ exceeded. See "How to import data" in the online documentation
157
+ to see API limits.
158
+ exceptions.TimeoutError: If response was not received
159
+ in `timeout` seconds.
160
+ exceptions.NetworkError: If an unknown error occurred
161
+ most likely due to connection issues.
162
+ exceptions.LabelboxError: If an unknown error of any
163
+ kind occurred.
164
+ ValueError: If query and data are both None.
165
+ """
166
+ logger.debug("Query: %s, params: %r, data %r", query, params, data)
167
+
168
+ # Convert datetimes to UTC strings.
169
+ def convert_value(value):
170
+ if isinstance(value, datetime):
171
+ value = value.astimezone(timezone.utc)
172
+ value = value.strftime("%Y-%m-%dT%H:%M:%SZ")
173
+ return value
174
+
175
+ if query is not None:
176
+ if params is not None:
177
+ params = {key: convert_value(value) for key, value in params.items()}
178
+ data = json.dumps({"query": query, "variables": params}).encode("utf-8")
179
+ elif data is None:
180
+ raise ValueError("query and data cannot both be none")
181
+
182
+ endpoint = (
183
+ self.endpoint
184
+ if not experimental
185
+ else self.endpoint.replace("/graphql", "/_gql")
186
+ )
187
+
188
+ try:
189
+ headers = self._connection.headers.copy()
190
+ if files:
191
+ del headers["Content-Type"]
192
+ del headers["Accept"]
193
+ headers["X-SDK-Method"] = (
194
+ self.sdk_method if self.sdk_method else call_info_as_str()
195
+ )
196
+
197
+ request = requests.Request(
198
+ "POST",
199
+ endpoint,
200
+ headers=headers,
201
+ data=data,
202
+ files=files if files else None,
203
+ )
204
+
205
+ prepped: requests.PreparedRequest = request.prepare()
206
+
207
+ settings = self._connection.merge_environment_settings(
208
+ prepped.url, {}, None, None, None
209
+ )
210
+ response = self._connection.send(prepped, timeout=timeout, **settings)
211
+ logger.debug("Response: %s", response.text)
212
+ except requests.exceptions.Timeout as e:
213
+ raise exceptions.TimeoutError(str(e))
214
+ except requests.exceptions.RequestException as e:
215
+ logger.error("Unknown error: %s", str(e))
216
+ raise exceptions.NetworkError(e)
217
+ except Exception as e:
218
+ raise exceptions.LabelboxError(
219
+ "Unknown error during Client.query(): " + str(e), e
220
+ )
221
+
222
+ if (
223
+ 200 <= response.status_code < 300
224
+ or response.status_code < 500
225
+ or response.status_code >= 600
226
+ ):
227
+ try:
228
+ r_json = response.json()
229
+ except Exception:
230
+ raise exceptions.LabelboxError(
231
+ "Failed to parse response as JSON: %s" % response.text
232
+ )
233
+ else:
234
+ if (
235
+ "upstream connect error or disconnect/reset before headers"
236
+ in response.text
237
+ ):
238
+ raise exceptions.InternalServerError("Connection reset")
239
+ elif response.status_code == 502:
240
+ error_502 = "502 Bad Gateway"
241
+ raise exceptions.InternalServerError(error_502)
242
+ elif 500 <= response.status_code < 600:
243
+ error_500 = f"Internal server http error {response.status_code}"
244
+ raise exceptions.InternalServerError(error_500)
245
+
246
+ errors = r_json.get("errors", [])
247
+
248
+ def check_errors(keywords, *path):
249
+ """Helper that looks for any of the given `keywords` in any of
250
+ current errors on paths (like error[path][component][to][keyword]).
251
+ """
252
+ for error in errors:
253
+ obj = error
254
+ for path_elem in path:
255
+ obj = obj.get(path_elem, {})
256
+ if obj in keywords:
257
+ return error
258
+ return None
259
+
260
+ def get_error_status_code(error: dict) -> int:
261
+ try:
262
+ return int(error["extensions"].get("exception").get("status"))
263
+ except Exception:
264
+ return 500
265
+
266
+ if check_errors(["AUTHENTICATION_ERROR"], "extensions", "code") is not None:
267
+ raise exceptions.AuthenticationError("Invalid API key")
268
+
269
+ authorization_error = check_errors(
270
+ ["AUTHORIZATION_ERROR"], "extensions", "code"
271
+ )
272
+ if authorization_error is not None:
273
+ raise exceptions.AuthorizationError(authorization_error["message"])
274
+
275
+ validation_error = check_errors(
276
+ ["GRAPHQL_VALIDATION_FAILED"], "extensions", "code"
277
+ )
278
+
279
+ if validation_error is not None:
280
+ message = validation_error["message"]
281
+ if message == "Query complexity limit exceeded":
282
+ raise exceptions.ValidationFailedError(message)
283
+ else:
284
+ raise exceptions.InvalidQueryError(message)
285
+
286
+ graphql_error = check_errors(["GRAPHQL_PARSE_FAILED"], "extensions", "code")
287
+ if graphql_error is not None:
288
+ raise exceptions.InvalidQueryError(graphql_error["message"])
289
+
290
+ # Check if API limit was exceeded
291
+ response_msg = r_json.get("message", "")
292
+
293
+ if response_msg.startswith("You have exceeded"):
294
+ raise exceptions.ApiLimitError(response_msg)
295
+
296
+ resource_not_found_error = check_errors(
297
+ ["RESOURCE_NOT_FOUND"], "extensions", "code"
298
+ )
299
+ if resource_not_found_error is not None:
300
+ if raise_return_resource_not_found:
301
+ raise exceptions.ResourceNotFoundError(
302
+ message=resource_not_found_error["message"]
303
+ )
304
+ else:
305
+ # Return None and let the caller methods raise an exception
306
+ # as they already know which resource type and ID was requested
307
+ return None
308
+
309
+ resource_conflict_error = check_errors(
310
+ ["RESOURCE_CONFLICT"], "extensions", "code"
311
+ )
312
+ if resource_conflict_error is not None:
313
+ raise exceptions.ResourceConflict(resource_conflict_error["message"])
314
+
315
+ malformed_request_error = check_errors(
316
+ ["MALFORMED_REQUEST"], "extensions", "code"
317
+ )
318
+
319
+ error_code = "MALFORMED_REQUEST"
320
+ if malformed_request_error is not None:
321
+ if error_handlers and error_code in error_handlers:
322
+ handler = error_handlers[error_code]
323
+ handler(response)
324
+ return None
325
+ raise exceptions.MalformedQueryException(
326
+ malformed_request_error[error_log_key]
327
+ )
328
+
329
+ # A lot of different error situations are now labeled serverside
330
+ # as INTERNAL_SERVER_ERROR, when they are actually client errors.
331
+ # TODO: fix this in the server API
332
+ internal_server_error = check_errors(
333
+ ["INTERNAL_SERVER_ERROR"], "extensions", "code"
334
+ )
335
+ error_code = "INTERNAL_SERVER_ERROR"
336
+
337
+ if internal_server_error is not None:
338
+ if error_handlers and error_code in error_handlers:
339
+ handler = error_handlers[error_code]
340
+ handler(response)
341
+ return None
342
+ message = internal_server_error.get("message")
343
+ error_status_code = get_error_status_code(internal_server_error)
344
+ if error_status_code == 400:
345
+ raise exceptions.InvalidQueryError(message)
346
+ elif error_status_code == 422:
347
+ raise exceptions.UnprocessableEntityError(message)
348
+ elif error_status_code == 426:
349
+ raise exceptions.OperationNotAllowedException(message)
350
+ elif error_status_code == 500:
351
+ raise exceptions.LabelboxError(message)
352
+ else:
353
+ raise exceptions.InternalServerError(message)
354
+
355
+ not_allowed_error = check_errors(
356
+ ["OPERATION_NOT_ALLOWED"], "extensions", "code"
357
+ )
358
+ if not_allowed_error is not None:
359
+ message = not_allowed_error.get("message")
360
+ raise exceptions.OperationNotAllowedException(message)
361
+
362
+ if len(errors) > 0:
363
+ logger.warning("Unparsed errors on query execution: %r", errors)
364
+ messages = list(
365
+ map(
366
+ lambda x: {
367
+ "message": x["message"],
368
+ "code": x["extensions"]["code"],
369
+ },
370
+ errors,
371
+ )
372
+ )
373
+ raise exceptions.LabelboxError("Unknown error: %s" % str(messages))
374
+
375
+ # if we do return a proper error code, and didn't catch this above
376
+ # reraise
377
+ # this mainly catches a 401 for API access disabled for free tier
378
+ # TODO: need to unify API errors to handle things more uniformly
379
+ # in the SDK
380
+ if response.status_code != requests.codes.ok:
381
+ message = f"{response.status_code} {response.reason}"
382
+ cause = r_json.get("message")
383
+ raise exceptions.LabelboxError(message, cause)
384
+
385
+ return r_json["data"]
@@ -0,0 +1,46 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ from lbox.request_client import RequestClient
4
+
5
+
6
+ # @patch.dict(os.environ, {'LABELBOX_API_KEY': 'bar'})
7
+ def test_headers():
8
+ client = RequestClient(
9
+ sdk_version="foo", api_key="api_key", endpoint="http://localhost:8080/_gql"
10
+ )
11
+ assert client.headers
12
+ assert client.headers["Authorization"] == "Bearer api_key"
13
+ assert client.headers["Content-Type"] == "application/json"
14
+ assert client.headers["User-Agent"]
15
+ assert client.headers["X-Python-Version"]
16
+
17
+
18
+ def test_custom_error_handling():
19
+ mock_raise_error = MagicMock()
20
+
21
+ response_dict = {
22
+ "errors": [
23
+ {
24
+ "message": "Internal server error",
25
+ "extensions": {"code": "INTERNAL_SERVER_ERROR"},
26
+ }
27
+ ],
28
+ }
29
+ response = MagicMock()
30
+ response.json.return_value = response_dict
31
+ response.status_code = 200
32
+
33
+ client = RequestClient(
34
+ sdk_version="foo", api_key="api_key", endpoint="http://localhost:8080/_gql"
35
+ )
36
+ connection_mock = MagicMock()
37
+ connection_mock.send.return_value = response
38
+ client._connection = connection_mock
39
+
40
+ client.execute(
41
+ "query_str",
42
+ {"projectId": "project_id"},
43
+ raise_return_resource_not_found=True,
44
+ error_handlers={"INTERNAL_SERVER_ERROR": mock_raise_error},
45
+ )
46
+ mock_raise_error.assert_called_once_with(response)