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.
- lbox_clients-1.1.2/.gitignore +10 -0
- lbox_clients-1.1.2/.python-version +1 -0
- lbox_clients-1.1.2/Dockerfile +44 -0
- lbox_clients-1.1.2/PKG-INFO +38 -0
- lbox_clients-1.1.2/README.md +9 -0
- lbox_clients-1.1.2/pyproject.toml +62 -0
- lbox_clients-1.1.2/src/lbox/call_info.py +55 -0
- lbox_clients-1.1.2/src/lbox/exceptions.py +201 -0
- lbox_clients-1.1.2/src/lbox/request_client.py +385 -0
- lbox_clients-1.1.2/tests/unit/lbox/test_client.py +46 -0
@@ -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)
|