vws-python 2026.8.14__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.
- vws/__init__.py +21 -0
- vws/_async_vws_request.py +77 -0
- vws/_image_utils.py +18 -0
- vws/_model_targets.py +323 -0
- vws/_reco_counts.py +80 -0
- vws/_vws_request.py +76 -0
- vws/async_model_target_service.py +381 -0
- vws/async_query.py +224 -0
- vws/async_vumark_service.py +155 -0
- vws/async_vws.py +752 -0
- vws/exceptions/__init__.py +1 -0
- vws/exceptions/base_exceptions.py +74 -0
- vws/exceptions/cloud_reco_exceptions.py +44 -0
- vws/exceptions/custom_exceptions.py +107 -0
- vws/exceptions/model_target_exceptions.py +210 -0
- vws/exceptions/vws_exceptions.py +256 -0
- vws/include_target_data.py +18 -0
- vws/model_target_datasets.py +152 -0
- vws/model_target_service.py +366 -0
- vws/py.typed +0 -0
- vws/query.py +194 -0
- vws/reports.py +391 -0
- vws/response.py +19 -0
- vws/transports.py +315 -0
- vws/vumark_accept.py +18 -0
- vws/vumark_service.py +138 -0
- vws/vws.py +719 -0
- vws_python-2026.8.14.dist-info/METADATA +152 -0
- vws_python-2026.8.14.dist-info/RECORD +32 -0
- vws_python-2026.8.14.dist-info/WHEEL +5 -0
- vws_python-2026.8.14.dist-info/licenses/LICENSE +21 -0
- vws_python-2026.8.14.dist-info/top_level.txt +1 -0
vws/transports.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""HTTP transport implementations for VWS clients."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import requests
|
|
7
|
+
from beartype import BeartypeConf, beartype
|
|
8
|
+
|
|
9
|
+
from vws.response import Response
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Awaitable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class Transport(Protocol):
|
|
17
|
+
"""Protocol for HTTP transports used by VWS clients.
|
|
18
|
+
|
|
19
|
+
A transport is a callable that makes an HTTP request and
|
|
20
|
+
returns a ``Response``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
"""Close the transport and release resources."""
|
|
25
|
+
... # pylint: disable=unnecessary-ellipsis
|
|
26
|
+
|
|
27
|
+
def __call__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
method: str,
|
|
31
|
+
url: str,
|
|
32
|
+
headers: dict[str, str],
|
|
33
|
+
data: bytes,
|
|
34
|
+
request_timeout: float | tuple[float, float],
|
|
35
|
+
) -> Response:
|
|
36
|
+
"""Make an HTTP request.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
method: The HTTP method (e.g. "GET", "POST").
|
|
40
|
+
url: The full URL to request.
|
|
41
|
+
headers: Headers to send with the request.
|
|
42
|
+
data: The request body as bytes.
|
|
43
|
+
request_timeout: The timeout for the request. A float
|
|
44
|
+
sets both the connect and read timeouts. A
|
|
45
|
+
(connect, read) tuple sets them individually.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A Response populated from the HTTP response.
|
|
49
|
+
"""
|
|
50
|
+
... # pylint: disable=unnecessary-ellipsis
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
54
|
+
class RequestsTransport:
|
|
55
|
+
"""HTTP transport using the ``requests`` library.
|
|
56
|
+
|
|
57
|
+
This is the default transport.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
"""Close the transport.
|
|
62
|
+
|
|
63
|
+
This is a no-op for ``RequestsTransport`` as it does not
|
|
64
|
+
hold persistent connections.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __call__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
method: str,
|
|
71
|
+
url: str,
|
|
72
|
+
headers: dict[str, str],
|
|
73
|
+
data: bytes,
|
|
74
|
+
request_timeout: float | tuple[float, float],
|
|
75
|
+
) -> Response:
|
|
76
|
+
"""Make an HTTP request using ``requests``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
method: The HTTP method.
|
|
80
|
+
url: The full URL.
|
|
81
|
+
headers: Request headers.
|
|
82
|
+
data: The request body.
|
|
83
|
+
request_timeout: The request timeout.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A Response populated from the requests response.
|
|
87
|
+
"""
|
|
88
|
+
requests_response = requests.request(
|
|
89
|
+
method=method,
|
|
90
|
+
url=url,
|
|
91
|
+
headers=headers,
|
|
92
|
+
data=data,
|
|
93
|
+
timeout=request_timeout,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return Response(
|
|
97
|
+
text=requests_response.text,
|
|
98
|
+
url=requests_response.url,
|
|
99
|
+
status_code=requests_response.status_code,
|
|
100
|
+
headers=dict(requests_response.headers),
|
|
101
|
+
request_body=requests_response.request.body,
|
|
102
|
+
tell_position=requests_response.raw.tell(),
|
|
103
|
+
content=bytes(requests_response.content),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
108
|
+
class HTTPXTransport:
|
|
109
|
+
"""HTTP transport using the ``httpx`` library.
|
|
110
|
+
|
|
111
|
+
Use this transport for environments where ``httpx`` is
|
|
112
|
+
preferred over ``requests``.
|
|
113
|
+
A single ``httpx.Client`` is reused across requests
|
|
114
|
+
for connection pooling.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
"""Create an ``HTTPXTransport``."""
|
|
119
|
+
self._client = httpx.Client()
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
"""Close the underlying ``httpx.Client``."""
|
|
123
|
+
self._client.close()
|
|
124
|
+
|
|
125
|
+
def __enter__(self) -> Self:
|
|
126
|
+
"""Enter the context manager."""
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, *_args: object) -> None:
|
|
130
|
+
"""Exit the context manager and close the client."""
|
|
131
|
+
self.close()
|
|
132
|
+
|
|
133
|
+
def __call__(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
method: str,
|
|
137
|
+
url: str,
|
|
138
|
+
headers: dict[str, str],
|
|
139
|
+
data: bytes,
|
|
140
|
+
request_timeout: float | tuple[float, float],
|
|
141
|
+
) -> Response:
|
|
142
|
+
"""Make an HTTP request using ``httpx``.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
method: The HTTP method.
|
|
146
|
+
url: The full URL.
|
|
147
|
+
headers: Request headers.
|
|
148
|
+
data: The request body.
|
|
149
|
+
request_timeout: The request timeout.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A Response populated from the httpx response.
|
|
153
|
+
"""
|
|
154
|
+
match request_timeout:
|
|
155
|
+
case tuple() as timeout:
|
|
156
|
+
connect_timeout, read_timeout = timeout
|
|
157
|
+
httpx_timeout = httpx.Timeout(
|
|
158
|
+
connect=connect_timeout,
|
|
159
|
+
read=read_timeout,
|
|
160
|
+
write=None,
|
|
161
|
+
pool=None,
|
|
162
|
+
)
|
|
163
|
+
case timeout:
|
|
164
|
+
httpx_timeout = httpx.Timeout(
|
|
165
|
+
connect=timeout,
|
|
166
|
+
read=timeout,
|
|
167
|
+
write=None,
|
|
168
|
+
pool=None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
httpx_response = self._client.request(
|
|
172
|
+
method=method,
|
|
173
|
+
url=url,
|
|
174
|
+
headers=headers,
|
|
175
|
+
content=data,
|
|
176
|
+
timeout=httpx_timeout,
|
|
177
|
+
follow_redirects=True,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
content = bytes(httpx_response.content)
|
|
181
|
+
request_content = httpx_response.request.content
|
|
182
|
+
|
|
183
|
+
return Response(
|
|
184
|
+
text=httpx_response.text,
|
|
185
|
+
url=str(object=httpx_response.url),
|
|
186
|
+
status_code=httpx_response.status_code,
|
|
187
|
+
headers=dict(httpx_response.headers),
|
|
188
|
+
request_body=bytes(request_content) or None,
|
|
189
|
+
tell_position=len(content),
|
|
190
|
+
content=content,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@runtime_checkable
|
|
195
|
+
class AsyncTransport(Protocol):
|
|
196
|
+
"""Protocol for async HTTP transports used by VWS clients.
|
|
197
|
+
|
|
198
|
+
An async transport is a callable that makes an HTTP request
|
|
199
|
+
and returns a ``Response``.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
async def aclose(self) -> None:
|
|
203
|
+
"""Close the transport and release resources."""
|
|
204
|
+
... # pylint: disable=unnecessary-ellipsis
|
|
205
|
+
|
|
206
|
+
def __call__(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
method: str,
|
|
210
|
+
url: str,
|
|
211
|
+
headers: dict[str, str],
|
|
212
|
+
data: bytes,
|
|
213
|
+
request_timeout: float | tuple[float, float],
|
|
214
|
+
) -> Awaitable[Response]:
|
|
215
|
+
"""Make an async HTTP request.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
method: The HTTP method (e.g. "GET", "POST").
|
|
219
|
+
url: The full URL to request.
|
|
220
|
+
headers: Headers to send with the request.
|
|
221
|
+
data: The request body as bytes.
|
|
222
|
+
request_timeout: The timeout for the request. A float
|
|
223
|
+
sets both the connect and read timeouts. A
|
|
224
|
+
(connect, read) tuple sets them individually.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A Response populated from the HTTP response.
|
|
228
|
+
"""
|
|
229
|
+
... # pylint: disable=unnecessary-ellipsis
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
233
|
+
class AsyncHTTPXTransport:
|
|
234
|
+
"""Async HTTP transport using the ``httpx`` library.
|
|
235
|
+
|
|
236
|
+
This is the default transport for async VWS clients.
|
|
237
|
+
A single ``httpx.AsyncClient`` is reused across requests
|
|
238
|
+
for connection pooling.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(self) -> None:
|
|
242
|
+
"""Create an ``AsyncHTTPXTransport``."""
|
|
243
|
+
self._client = httpx.AsyncClient()
|
|
244
|
+
|
|
245
|
+
async def aclose(self) -> None:
|
|
246
|
+
"""Close the underlying ``httpx.AsyncClient``."""
|
|
247
|
+
await self._client.aclose()
|
|
248
|
+
|
|
249
|
+
async def __aenter__(self) -> Self:
|
|
250
|
+
"""Enter the async context manager."""
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
254
|
+
"""Exit the async context manager and close the client."""
|
|
255
|
+
await self.aclose()
|
|
256
|
+
|
|
257
|
+
async def __call__(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
method: str,
|
|
261
|
+
url: str,
|
|
262
|
+
headers: dict[str, str],
|
|
263
|
+
data: bytes,
|
|
264
|
+
request_timeout: float | tuple[float, float],
|
|
265
|
+
) -> Response:
|
|
266
|
+
"""Make an async HTTP request using ``httpx``.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
method: The HTTP method.
|
|
270
|
+
url: The full URL.
|
|
271
|
+
headers: Request headers.
|
|
272
|
+
data: The request body.
|
|
273
|
+
request_timeout: The request timeout.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
A Response populated from the httpx response.
|
|
277
|
+
"""
|
|
278
|
+
match request_timeout:
|
|
279
|
+
case tuple() as timeout:
|
|
280
|
+
connect_timeout, read_timeout = timeout
|
|
281
|
+
httpx_timeout = httpx.Timeout(
|
|
282
|
+
connect=connect_timeout,
|
|
283
|
+
read=read_timeout,
|
|
284
|
+
write=None,
|
|
285
|
+
pool=None,
|
|
286
|
+
)
|
|
287
|
+
case timeout:
|
|
288
|
+
httpx_timeout = httpx.Timeout(
|
|
289
|
+
connect=timeout,
|
|
290
|
+
read=timeout,
|
|
291
|
+
write=None,
|
|
292
|
+
pool=None,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
httpx_response = await self._client.request(
|
|
296
|
+
method=method,
|
|
297
|
+
url=url,
|
|
298
|
+
headers=headers,
|
|
299
|
+
content=data,
|
|
300
|
+
timeout=httpx_timeout,
|
|
301
|
+
follow_redirects=True,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
content = bytes(httpx_response.content)
|
|
305
|
+
request_content = httpx_response.request.content
|
|
306
|
+
|
|
307
|
+
return Response(
|
|
308
|
+
text=httpx_response.text,
|
|
309
|
+
url=str(object=httpx_response.url),
|
|
310
|
+
status_code=httpx_response.status_code,
|
|
311
|
+
headers=dict(httpx_response.headers),
|
|
312
|
+
request_body=bytes(request_content) or None,
|
|
313
|
+
tell_position=len(content),
|
|
314
|
+
content=content,
|
|
315
|
+
)
|
vws/vumark_accept.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Tools for managing ``VWS.generate_vumark_instance``'s ``accept``."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum, unique
|
|
4
|
+
|
|
5
|
+
from beartype import beartype
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@beartype
|
|
9
|
+
@unique
|
|
10
|
+
class VuMarkAccept(StrEnum):
|
|
11
|
+
"""
|
|
12
|
+
Options for the ``accept`` parameter of
|
|
13
|
+
``VWS.generate_vumark_instance``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
PNG = "image/png"
|
|
17
|
+
SVG = "image/svg+xml"
|
|
18
|
+
PDF = "application/pdf"
|
vws/vumark_service.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Interface to the Vuforia VuMark Generation Web API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from http import HTTPMethod, HTTPStatus
|
|
5
|
+
|
|
6
|
+
from beartype import BeartypeConf, beartype
|
|
7
|
+
|
|
8
|
+
from vws._vws_request import target_api_request
|
|
9
|
+
from vws.exceptions.base_exceptions import VWSError
|
|
10
|
+
from vws.exceptions.custom_exceptions import ServerError
|
|
11
|
+
from vws.exceptions.vws_exceptions import TooManyRequestsError
|
|
12
|
+
from vws.transports import RequestsTransport, Transport
|
|
13
|
+
from vws.vumark_accept import VuMarkAccept # noqa: TC001
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
17
|
+
class VuMarkService:
|
|
18
|
+
"""An interface to the Vuforia VuMark Generation Web API."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
server_access_key: str,
|
|
24
|
+
server_secret_key: str,
|
|
25
|
+
base_vws_url: str = "https://vws.vuforia.com",
|
|
26
|
+
request_timeout_seconds: float | tuple[float, float] = 30.0,
|
|
27
|
+
transport: Transport | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Args:
|
|
31
|
+
server_access_key: A VWS server access key.
|
|
32
|
+
server_secret_key: A VWS server secret key.
|
|
33
|
+
base_vws_url: The base URL for the VWS API.
|
|
34
|
+
request_timeout_seconds: The timeout for each
|
|
35
|
+
HTTP request. This can be a float to set both
|
|
36
|
+
the connect and read timeouts, or a
|
|
37
|
+
(connect, read) tuple.
|
|
38
|
+
transport: The HTTP transport to use for
|
|
39
|
+
requests. Defaults to
|
|
40
|
+
``RequestsTransport()``.
|
|
41
|
+
"""
|
|
42
|
+
self._server_access_key = server_access_key
|
|
43
|
+
self._server_secret_key = server_secret_key
|
|
44
|
+
self._base_vws_url = base_vws_url
|
|
45
|
+
self._request_timeout_seconds = request_timeout_seconds
|
|
46
|
+
self._transport = (
|
|
47
|
+
transport if transport is not None else RequestsTransport()
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def generate_vumark_instance(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
target_id: str,
|
|
54
|
+
instance_id: str,
|
|
55
|
+
accept: VuMarkAccept,
|
|
56
|
+
) -> bytes:
|
|
57
|
+
"""Generate a VuMark instance image.
|
|
58
|
+
|
|
59
|
+
See
|
|
60
|
+
https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/
|
|
61
|
+
for parameter details.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
target_id: The ID of the VuMark target.
|
|
65
|
+
instance_id: The instance ID to encode in the VuMark.
|
|
66
|
+
accept: The image format to return.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The VuMark instance image bytes.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
~vws.exceptions.vws_exceptions.AuthenticationFailureError: The
|
|
73
|
+
secret key is not correct.
|
|
74
|
+
~vws.exceptions.vws_exceptions.AuthorizationFailedError: There was
|
|
75
|
+
a general authentication problem.
|
|
76
|
+
~vws.exceptions.vws_exceptions.FailError: There was an error with
|
|
77
|
+
the request. For example, the given access key does not match a
|
|
78
|
+
known database.
|
|
79
|
+
~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The
|
|
80
|
+
Accept header value is not supported.
|
|
81
|
+
~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The
|
|
82
|
+
instance ID is invalid. For example, it may be empty.
|
|
83
|
+
~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target
|
|
84
|
+
is not a VuMark template target.
|
|
85
|
+
~vws.exceptions.vws_exceptions.LicenseCheckFailedError: The
|
|
86
|
+
license state and/or type does not allow this request.
|
|
87
|
+
~vws.exceptions.vws_exceptions.QuotaExceededError: No more
|
|
88
|
+
instances can be created for the associated license.
|
|
89
|
+
~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is
|
|
90
|
+
an error with the time sent to Vuforia.
|
|
91
|
+
~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The
|
|
92
|
+
target is not in the success state.
|
|
93
|
+
~vws.exceptions.vws_exceptions.UnknownTargetError: The given target
|
|
94
|
+
ID does not match a target in the database.
|
|
95
|
+
~vws.exceptions.custom_exceptions.ServerError: There is an error
|
|
96
|
+
with Vuforia's servers.
|
|
97
|
+
~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is
|
|
98
|
+
rate limiting access.
|
|
99
|
+
"""
|
|
100
|
+
request_path = f"/targets/{target_id}/instances"
|
|
101
|
+
content_type = "application/json"
|
|
102
|
+
request_data = json.dumps(obj={"instance_id": instance_id}).encode(
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
response = target_api_request(
|
|
107
|
+
content_type=content_type,
|
|
108
|
+
server_access_key=self._server_access_key,
|
|
109
|
+
server_secret_key=self._server_secret_key,
|
|
110
|
+
method=HTTPMethod.POST,
|
|
111
|
+
data=request_data,
|
|
112
|
+
request_path=request_path,
|
|
113
|
+
base_vws_url=self._base_vws_url,
|
|
114
|
+
request_timeout_seconds=self._request_timeout_seconds,
|
|
115
|
+
extra_headers={"Accept": accept},
|
|
116
|
+
transport=self._transport,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
response.status_code == HTTPStatus.TOO_MANY_REQUESTS
|
|
121
|
+
): # pragma: no cover
|
|
122
|
+
# The Vuforia API returns a 429 response with no JSON body.
|
|
123
|
+
raise TooManyRequestsError(response=response)
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
|
|
127
|
+
): # pragma: no cover
|
|
128
|
+
raise ServerError(response=response)
|
|
129
|
+
|
|
130
|
+
if response.status_code == HTTPStatus.OK:
|
|
131
|
+
return response.content
|
|
132
|
+
|
|
133
|
+
result_code = json.loads(s=response.text)["result_code"]
|
|
134
|
+
|
|
135
|
+
raise VWSError.from_result_code(
|
|
136
|
+
result_code=result_code,
|
|
137
|
+
response=response,
|
|
138
|
+
)
|