easyvista-python-client 0.1.0__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.
- easyvista_python_client/__init__.py +74 -0
- easyvista_python_client/_async/__init__.py +13 -0
- easyvista_python_client/_async/_concurrency.py +78 -0
- easyvista_python_client/_async/_transport.py +266 -0
- easyvista_python_client/_async/client.py +790 -0
- easyvista_python_client/_fields.py +26 -0
- easyvista_python_client/_html.py +41 -0
- easyvista_python_client/_sync/__init__.py +13 -0
- easyvista_python_client/_sync/_concurrency.py +50 -0
- easyvista_python_client/_sync/_transport.py +266 -0
- easyvista_python_client/_sync/client.py +790 -0
- easyvista_python_client/_transport.py +29 -0
- easyvista_python_client/config.py +71 -0
- easyvista_python_client/context.py +116 -0
- easyvista_python_client/directory.py +53 -0
- easyvista_python_client/exceptions.py +53 -0
- easyvista_python_client/field_model.py +74 -0
- easyvista_python_client/filters.py +82 -0
- easyvista_python_client/models/__init__.py +1 -0
- easyvista_python_client/models/action.py +65 -0
- easyvista_python_client/models/asset.py +36 -0
- easyvista_python_client/models/common.py +78 -0
- easyvista_python_client/models/department.py +68 -0
- easyvista_python_client/models/document.py +32 -0
- easyvista_python_client/models/employee.py +67 -0
- easyvista_python_client/models/request.py +172 -0
- easyvista_python_client/pagination.py +84 -0
- easyvista_python_client/py.typed +0 -0
- easyvista_python_client/references.py +146 -0
- easyvista_python_client/reporting.py +143 -0
- easyvista_python_client/resources/__init__.py +1 -0
- easyvista_python_client/resources/actions.py +72 -0
- easyvista_python_client/resources/assets.py +47 -0
- easyvista_python_client/resources/departments.py +57 -0
- easyvista_python_client/resources/descriptor.py +99 -0
- easyvista_python_client/resources/documents.py +78 -0
- easyvista_python_client/resources/employees.py +57 -0
- easyvista_python_client/resources/requests.py +91 -0
- easyvista_python_client-0.1.0.dist-info/METADATA +178 -0
- easyvista_python_client-0.1.0.dist-info/RECORD +42 -0
- easyvista_python_client-0.1.0.dist-info/WHEEL +4 -0
- easyvista_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared helpers for extracting human labels from EasyVista field objects.
|
|
2
|
+
|
|
3
|
+
EasyVista nests label+href objects (``STATUS``, ``DEPARTMENT``, ``CATALOG_REQUEST``,
|
|
4
|
+
``URGENCY``, ``IMPACT``). Both the Markdown renderer (:mod:`context`) and the
|
|
5
|
+
statistics aggregator (:mod:`reporting`) need to pick the human label and never an
|
|
6
|
+
href, so the logic lives here once.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _text(value: Any) -> str:
|
|
15
|
+
"""First stripped string form of ``value``; ``""`` if it is not a string."""
|
|
16
|
+
return value.strip() if isinstance(value, str) else ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _label(obj: Any, keys: tuple[str, ...]) -> str:
|
|
20
|
+
"""First non-empty string among ``keys`` of a nested object; never an href."""
|
|
21
|
+
if isinstance(obj, dict):
|
|
22
|
+
for key in keys:
|
|
23
|
+
value = obj.get(key)
|
|
24
|
+
if isinstance(value, str) and value.strip():
|
|
25
|
+
return value.strip()
|
|
26
|
+
return ""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Reduce HTML (EasyVista rich-text fields) to plain text, stdlib only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
|
|
8
|
+
_BLOCK_TAGS = {"p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _TextExtractor(HTMLParser):
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
super().__init__(convert_charrefs=True) # entities -> unicode in handle_data
|
|
14
|
+
self._parts: list[str] = []
|
|
15
|
+
|
|
16
|
+
def handle_data(self, data: str) -> None:
|
|
17
|
+
self._parts.append(data)
|
|
18
|
+
|
|
19
|
+
def handle_starttag(self, tag: str, attrs: object) -> None:
|
|
20
|
+
if tag == "br":
|
|
21
|
+
self._parts.append("\n")
|
|
22
|
+
|
|
23
|
+
def handle_endtag(self, tag: str) -> None:
|
|
24
|
+
if tag in _BLOCK_TAGS:
|
|
25
|
+
self._parts.append("\n")
|
|
26
|
+
|
|
27
|
+
def text(self) -> str:
|
|
28
|
+
return "".join(self._parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def html_to_text(value: str | None) -> str:
|
|
32
|
+
"""Strip HTML tags and unescape entities; plain text passes through; None -> ''."""
|
|
33
|
+
if value is None:
|
|
34
|
+
return ""
|
|
35
|
+
parser = _TextExtractor()
|
|
36
|
+
parser.feed(value)
|
|
37
|
+
text = parser.text()
|
|
38
|
+
# Trim trailing spaces on each line, then collapse 3+ newlines to a blank line.
|
|
39
|
+
text = "\n".join(line.strip() for line in text.split("\n"))
|
|
40
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
41
|
+
return text.strip()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The client tree: one hand-written async surface, one generated sync twin.
|
|
2
|
+
|
|
3
|
+
This package exists twice, once per surface. Every module in it except
|
|
4
|
+
``_concurrency.py`` and ``tests/test_concurrency.py`` has a counterpart in the
|
|
5
|
+
other tree that ``unasync_build.py`` produces from the async one -- so the
|
|
6
|
+
async copy is the only place a change belongs, and the sync copy must never be
|
|
7
|
+
edited by hand. Run ``python unasync_build.py`` after any edit; CI's
|
|
8
|
+
``--check`` fails if the two trees have drifted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from easyvista_python_client._sync.client import EasyvistaClient
|
|
12
|
+
|
|
13
|
+
__all__ = ["EasyvistaClient"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Sequential concurrency primitives for the sync client.
|
|
2
|
+
|
|
3
|
+
Hand-written twin of the async ``_concurrency`` module -- see that module for
|
|
4
|
+
why these two files are the only ones not generated.
|
|
5
|
+
|
|
6
|
+
**This file is not produced by the codegen and must be edited alongside its
|
|
7
|
+
async twin.** ``unasync_build.py`` excludes it by name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Semaphore:
|
|
16
|
+
"""A no-op context manager standing in for :class:`asyncio.Semaphore`.
|
|
17
|
+
|
|
18
|
+
The async surface bounds its fan-out width because every branch is in
|
|
19
|
+
flight at once. Here the branches have already run, one after another, by
|
|
20
|
+
the time the enclosing ``settle`` is entered -- concurrency is one, so
|
|
21
|
+
there is nothing to bound. Accepting and ignoring the width keeps the
|
|
22
|
+
call site identical on both surfaces.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, value: int) -> None:
|
|
26
|
+
self.value = value
|
|
27
|
+
|
|
28
|
+
def __enter__(self) -> Semaphore:
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def settle(*values: Any) -> list[Any]:
|
|
36
|
+
"""Return ``values`` unchanged, preserving order.
|
|
37
|
+
|
|
38
|
+
This looks like a no-op and is doing real work. On the async side the call
|
|
39
|
+
reads ``await settle(self.a(), self.b())`` and runs the two concurrently.
|
|
40
|
+
Stripping the ``await`` leaves ``settle(self.a(), self.b())``, where each
|
|
41
|
+
argument has already been evaluated -- in order -- by the time this is
|
|
42
|
+
entered. Collecting them is therefore the correct and complete
|
|
43
|
+
synchronous meaning of the same expression, including its failure
|
|
44
|
+
behaviour: a raise in an earlier argument prevents the later ones from
|
|
45
|
+
being evaluated at all.
|
|
46
|
+
"""
|
|
47
|
+
return list(values)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["Semaphore", "settle"]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""HTTP executor for the EasyVista client.
|
|
2
|
+
|
|
3
|
+
Owns everything EasyVista-specific about talking to the API -- URL building,
|
|
4
|
+
auth headers, error mapping, and the executor itself -- except
|
|
5
|
+
``RequestSpec``, which stays at the package root because the shared resource
|
|
6
|
+
builders and the probe scripts consume it without executing anything.
|
|
7
|
+
|
|
8
|
+
This module exists once per client tree, and only the async copy is written by
|
|
9
|
+
hand: ``unasync_build.py`` generates the sync one from it, and CI's
|
|
10
|
+
``--check`` fails if the two have drifted. Edit the async copy and regenerate;
|
|
11
|
+
hand-editing the generated one is a mistake the check will catch. Prose here
|
|
12
|
+
must therefore read true on both surfaces -- never "see the other transport",
|
|
13
|
+
and never a claim that holds on only one of them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any, NoReturn
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from tenacity import (
|
|
24
|
+
Retrying,
|
|
25
|
+
retry_if_exception_type,
|
|
26
|
+
stop_after_attempt,
|
|
27
|
+
wait_exponential,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from easyvista_python_client._transport import RequestSpec
|
|
31
|
+
from easyvista_python_client.config import EasyvistaConfig
|
|
32
|
+
from easyvista_python_client.exceptions import (
|
|
33
|
+
EasyvistaAuthError,
|
|
34
|
+
EasyvistaConnectionError,
|
|
35
|
+
EasyvistaError,
|
|
36
|
+
EasyvistaNotFound,
|
|
37
|
+
EasyvistaRateLimitError,
|
|
38
|
+
EasyvistaServerError,
|
|
39
|
+
EasyvistaValidationError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BaseTransport:
|
|
44
|
+
"""Pure transport logic, independent of how a request is executed (no I/O)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: EasyvistaConfig) -> None:
|
|
47
|
+
self.config = config
|
|
48
|
+
|
|
49
|
+
def build_url(self, path: str) -> str:
|
|
50
|
+
return f"{self.config.api_root}/{path.lstrip('/')}"
|
|
51
|
+
|
|
52
|
+
def resolve_url(self, path_or_url: str) -> str:
|
|
53
|
+
"""Return an absolute URL for a resource path or an API-supplied URL.
|
|
54
|
+
|
|
55
|
+
Relative paths join to ``api_root`` exactly as :meth:`build_url` does.
|
|
56
|
+
An absolute URL is passed through **only when its scheme and host match
|
|
57
|
+
``config.server``**, and raises otherwise.
|
|
58
|
+
|
|
59
|
+
That check is load-bearing, not decoration. Every request this transport
|
|
60
|
+
makes carries the instance's Bearer token, so following an absolute URL
|
|
61
|
+
taken out of a response body (an attachment's ``DDL_HREF``, say) would
|
|
62
|
+
hand that credential to whatever host the body named. The API is trusted
|
|
63
|
+
to describe its own instance, not to redirect us off it.
|
|
64
|
+
"""
|
|
65
|
+
parsed = urlsplit(path_or_url)
|
|
66
|
+
if not parsed.scheme and not parsed.netloc:
|
|
67
|
+
return self.build_url(path_or_url)
|
|
68
|
+
server = urlsplit(self.config.server)
|
|
69
|
+
# Host comparison is case-folded because DNS is case-insensitive and
|
|
70
|
+
# this API returns mixed-case URLs (live: an upper-case "HTTPS://"
|
|
71
|
+
# scheme on DDL_HREF). Compare the RAW netloc, not .hostname: that
|
|
72
|
+
# keeps "https://attacker.test@ev.test/x" rejected, because its netloc
|
|
73
|
+
# is "attacker.test@ev.test", not "ev.test".
|
|
74
|
+
if (parsed.scheme, parsed.netloc.lower()) != (
|
|
75
|
+
server.scheme,
|
|
76
|
+
server.netloc.lower(),
|
|
77
|
+
):
|
|
78
|
+
raise EasyvistaError(
|
|
79
|
+
f"refusing to fetch {parsed.scheme}://{parsed.netloc} — it is "
|
|
80
|
+
f"outside the configured instance "
|
|
81
|
+
f"({server.scheme}://{server.netloc})"
|
|
82
|
+
)
|
|
83
|
+
return path_or_url
|
|
84
|
+
|
|
85
|
+
def headers(self) -> dict[str, str]:
|
|
86
|
+
base = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
87
|
+
if self.config.token:
|
|
88
|
+
base["Authorization"] = f"Bearer {self.config.token}"
|
|
89
|
+
return base
|
|
90
|
+
|
|
91
|
+
def auth(self) -> httpx.Auth | None:
|
|
92
|
+
if self.config.uses_basic_auth:
|
|
93
|
+
return httpx.BasicAuth(self.config.login or "", self.config.password or "")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def is_retryable_status(status_code: int) -> bool:
|
|
98
|
+
# 590 is EasyVista's "Internal Easyvista Error" — in practice a *rejected
|
|
99
|
+
# request* (bad catalog, missing mandatory field), so it is deterministic.
|
|
100
|
+
if status_code == 590:
|
|
101
|
+
return False
|
|
102
|
+
return status_code == 429 or status_code >= 500
|
|
103
|
+
|
|
104
|
+
def finish(self, response: httpx.Response) -> Any:
|
|
105
|
+
"""Return parsed JSON for a success, or raise a mapped exception."""
|
|
106
|
+
if response.is_success:
|
|
107
|
+
if not response.content:
|
|
108
|
+
return {}
|
|
109
|
+
return response.json()
|
|
110
|
+
return self._raise_for_response(response)
|
|
111
|
+
|
|
112
|
+
def _raise_for_response(self, response: httpx.Response) -> NoReturn:
|
|
113
|
+
status = response.status_code
|
|
114
|
+
ev_code, ev_message = self._extract_error(response)
|
|
115
|
+
# Never interpolate the raw body: no layer redacts exception TEXT, and a
|
|
116
|
+
# short traceback still emits `E <Type>: <msg>` while the `-r` summary
|
|
117
|
+
# reuses it verbatim -- so an unrecognized body prints wherever the
|
|
118
|
+
# exception surfaces. The byte count keeps the diagnostic ("the server
|
|
119
|
+
# said something we do not parse, and it was this big") without the
|
|
120
|
+
# content; `.ev_message` and `.ev_code` carry the parsed values, and
|
|
121
|
+
# `.body` (below) carries the raw bytes themselves, for a caller with no
|
|
122
|
+
# other way to see what an unrecognized response actually said.
|
|
123
|
+
detail = ev_message or (
|
|
124
|
+
f"<{len(response.content)}-byte body with no recognized error key>"
|
|
125
|
+
)
|
|
126
|
+
message = f"EasyVista request failed ({status}): {detail}"
|
|
127
|
+
kwargs: dict[str, Any] = {
|
|
128
|
+
"status_code": status,
|
|
129
|
+
"ev_code": ev_code,
|
|
130
|
+
"ev_message": ev_message,
|
|
131
|
+
"body": response.content,
|
|
132
|
+
}
|
|
133
|
+
if status in (401, 403):
|
|
134
|
+
raise EasyvistaAuthError(message, **kwargs)
|
|
135
|
+
if status == 404:
|
|
136
|
+
raise EasyvistaNotFound(message, **kwargs)
|
|
137
|
+
if status == 400:
|
|
138
|
+
raise EasyvistaValidationError(message, **kwargs)
|
|
139
|
+
if status == 590:
|
|
140
|
+
raise EasyvistaValidationError(
|
|
141
|
+
f"{message} — EasyVista rejected the request (HTTP 590, code "
|
|
142
|
+
f"{ev_code}); this usually means a missing mandatory field or an "
|
|
143
|
+
f"invalid catalog reference for this catalog.",
|
|
144
|
+
**kwargs,
|
|
145
|
+
)
|
|
146
|
+
if status == 429:
|
|
147
|
+
raise EasyvistaRateLimitError(message, **kwargs)
|
|
148
|
+
if status >= 500:
|
|
149
|
+
raise EasyvistaServerError(message, **kwargs)
|
|
150
|
+
raise EasyvistaError(message, **kwargs)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _extract_error(response: httpx.Response) -> tuple[str | None, str | None]:
|
|
154
|
+
try:
|
|
155
|
+
data = response.json()
|
|
156
|
+
except ValueError:
|
|
157
|
+
return None, None
|
|
158
|
+
# EasyVista sometimes wraps the real error as a JSON string under "message".
|
|
159
|
+
if isinstance(data, dict) and isinstance(data.get("message"), str):
|
|
160
|
+
inner = data["message"].strip()
|
|
161
|
+
if inner.startswith("{"):
|
|
162
|
+
try:
|
|
163
|
+
data = {**data, **json.loads(inner)}
|
|
164
|
+
except ValueError:
|
|
165
|
+
pass
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
return None, None
|
|
168
|
+
code = data.get("error_code") or data.get("code")
|
|
169
|
+
message = data.get("error") or data.get("error_message") or data.get("message")
|
|
170
|
+
return (
|
|
171
|
+
str(code) if code is not None else None,
|
|
172
|
+
str(message) if message is not None else None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _RetryableResponse(Exception): # internal control-flow signal
|
|
177
|
+
"""Raised internally to trigger a tenacity retry on a 429/5xx response."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
180
|
+
super().__init__(f"retryable status {response.status_code}")
|
|
181
|
+
self.response = response
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Transport(BaseTransport):
|
|
185
|
+
"""The executor: runs a :class:`RequestSpec` against the configured instance.
|
|
186
|
+
|
|
187
|
+
Blocking in the sync tree and coroutine-returning in the async tree, each
|
|
188
|
+
backed by the matching ``httpx`` client. Same methods, same arguments,
|
|
189
|
+
same results.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, config: EasyvistaConfig) -> None:
|
|
193
|
+
super().__init__(config)
|
|
194
|
+
self._client = httpx.Client(
|
|
195
|
+
headers=self.headers(),
|
|
196
|
+
auth=self.auth(),
|
|
197
|
+
timeout=config.timeout,
|
|
198
|
+
verify=config.verify_ssl,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def __enter__(self) -> Transport:
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
205
|
+
self.close()
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
self._client.close()
|
|
209
|
+
|
|
210
|
+
def _do_send(self, spec: RequestSpec) -> Any:
|
|
211
|
+
response = self._client.request(
|
|
212
|
+
spec.method, self.build_url(spec.path), params=spec.params, json=spec.json
|
|
213
|
+
)
|
|
214
|
+
if self.is_retryable_status(response.status_code):
|
|
215
|
+
raise _RetryableResponse(response)
|
|
216
|
+
return self.finish(response)
|
|
217
|
+
|
|
218
|
+
def send(self, spec: RequestSpec) -> Any:
|
|
219
|
+
retryer = Retrying(
|
|
220
|
+
stop=stop_after_attempt(self.config.max_retries + 1),
|
|
221
|
+
wait=wait_exponential(multiplier=0.5, max=10),
|
|
222
|
+
retry=retry_if_exception_type((_RetryableResponse, httpx.TransportError)),
|
|
223
|
+
reraise=True,
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
return retryer(self._do_send, spec)
|
|
227
|
+
except _RetryableResponse as exc:
|
|
228
|
+
return self.finish(exc.response)
|
|
229
|
+
except httpx.TransportError as exc:
|
|
230
|
+
raise EasyvistaConnectionError(f"connection failed: {exc}") from exc
|
|
231
|
+
|
|
232
|
+
def _do_get_bytes(self, path_or_url: str) -> bytes:
|
|
233
|
+
response = self._client.get(
|
|
234
|
+
self.resolve_url(path_or_url), follow_redirects=True
|
|
235
|
+
)
|
|
236
|
+
if self.is_retryable_status(response.status_code):
|
|
237
|
+
raise _RetryableResponse(response)
|
|
238
|
+
if not response.is_success:
|
|
239
|
+
self._raise_for_response(response)
|
|
240
|
+
return response.content
|
|
241
|
+
|
|
242
|
+
def get_bytes(self, path_or_url: str) -> bytes:
|
|
243
|
+
"""GET raw bytes (an attachment), not JSON.
|
|
244
|
+
|
|
245
|
+
:meth:`BaseTransport.finish` always calls ``response.json()``, so binary
|
|
246
|
+
responses need their own path. This one reuses the same retry policy and
|
|
247
|
+
the same error mapping, so a 403 on an attachment still surfaces as
|
|
248
|
+
:class:`EasyvistaAuthError`. ``follow_redirects`` is on because a
|
|
249
|
+
download URL commonly redirects to a signed location; httpx strips the
|
|
250
|
+
``Authorization`` header on a cross-origin redirect, so a foreign
|
|
251
|
+
redirect degrades to an unauthenticated fetch rather than leaking the
|
|
252
|
+
instance token.
|
|
253
|
+
"""
|
|
254
|
+
retryer = Retrying(
|
|
255
|
+
stop=stop_after_attempt(self.config.max_retries + 1),
|
|
256
|
+
wait=wait_exponential(multiplier=0.5, max=10),
|
|
257
|
+
retry=retry_if_exception_type((_RetryableResponse, httpx.TransportError)),
|
|
258
|
+
reraise=True,
|
|
259
|
+
)
|
|
260
|
+
try:
|
|
261
|
+
result: bytes = retryer(self._do_get_bytes, path_or_url)
|
|
262
|
+
return result
|
|
263
|
+
except _RetryableResponse as exc:
|
|
264
|
+
self._raise_for_response(exc.response)
|
|
265
|
+
except httpx.TransportError as exc:
|
|
266
|
+
raise EasyvistaConnectionError(f"connection failed: {exc}") from exc
|