lbox-clients 1.1.2__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,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)