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,74 @@
|
|
|
1
|
+
"""Typed Python client for the EasyVista Service Manager REST API."""
|
|
2
|
+
|
|
3
|
+
from easyvista_python_client._async import AsyncEasyvistaClient
|
|
4
|
+
from easyvista_python_client._sync import EasyvistaClient
|
|
5
|
+
|
|
6
|
+
from .config import EasyvistaConfig
|
|
7
|
+
from .context import TicketContext
|
|
8
|
+
from .directory import DepartmentContext
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
EasyvistaAuthError,
|
|
11
|
+
EasyvistaConnectionError,
|
|
12
|
+
EasyvistaError,
|
|
13
|
+
EasyvistaNotFound,
|
|
14
|
+
EasyvistaRateLimitError,
|
|
15
|
+
EasyvistaServerError,
|
|
16
|
+
EasyvistaValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .field_model import FieldClassification
|
|
19
|
+
from .filters import (
|
|
20
|
+
escape_ev_value,
|
|
21
|
+
ev_equals_filter,
|
|
22
|
+
ev_in_filter,
|
|
23
|
+
is_safe_ev_value,
|
|
24
|
+
)
|
|
25
|
+
from .models.action import Action, PostAction
|
|
26
|
+
from .models.asset import Asset, PostAsset
|
|
27
|
+
from .models.department import Department, DepartmentUpdate, PostDepartment
|
|
28
|
+
from .models.document import Document
|
|
29
|
+
from .models.employee import Employee, EmployeeUpdate, PostEmployee
|
|
30
|
+
from .models.request import PostRequest, Request, RequestUpdate
|
|
31
|
+
from .pagination import SearchResult
|
|
32
|
+
from .references import Reference
|
|
33
|
+
from .reporting import TicketStatistics, aggregate_tickets
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Action",
|
|
39
|
+
"Asset",
|
|
40
|
+
"AsyncEasyvistaClient",
|
|
41
|
+
"Department",
|
|
42
|
+
"DepartmentContext",
|
|
43
|
+
"DepartmentUpdate",
|
|
44
|
+
"Document",
|
|
45
|
+
"EasyvistaAuthError",
|
|
46
|
+
"EasyvistaClient",
|
|
47
|
+
"EasyvistaConfig",
|
|
48
|
+
"EasyvistaConnectionError",
|
|
49
|
+
"EasyvistaError",
|
|
50
|
+
"EasyvistaNotFound",
|
|
51
|
+
"EasyvistaRateLimitError",
|
|
52
|
+
"EasyvistaServerError",
|
|
53
|
+
"EasyvistaValidationError",
|
|
54
|
+
"Employee",
|
|
55
|
+
"EmployeeUpdate",
|
|
56
|
+
"FieldClassification",
|
|
57
|
+
"PostAction",
|
|
58
|
+
"PostAsset",
|
|
59
|
+
"PostDepartment",
|
|
60
|
+
"PostEmployee",
|
|
61
|
+
"PostRequest",
|
|
62
|
+
"Reference",
|
|
63
|
+
"Request",
|
|
64
|
+
"RequestUpdate",
|
|
65
|
+
"SearchResult",
|
|
66
|
+
"TicketContext",
|
|
67
|
+
"TicketStatistics",
|
|
68
|
+
"__version__",
|
|
69
|
+
"aggregate_tickets",
|
|
70
|
+
"escape_ev_value",
|
|
71
|
+
"ev_equals_filter",
|
|
72
|
+
"ev_in_filter",
|
|
73
|
+
"is_safe_ev_value",
|
|
74
|
+
]
|
|
@@ -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._async.client import AsyncEasyvistaClient
|
|
12
|
+
|
|
13
|
+
__all__ = ["AsyncEasyvistaClient"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Asyncio concurrency primitives for the async client.
|
|
2
|
+
|
|
3
|
+
This module and its sync twin are the only files maintained by hand on both
|
|
4
|
+
sides of the codegen. Everything else in the sync tree is generated from the
|
|
5
|
+
async one by :mod:`unasync`, which strips ``async``/``await`` and substitutes
|
|
6
|
+
whole NAME tokens. That works for syntax; it cannot work here, because
|
|
7
|
+
``asyncio.gather`` and ``asyncio.Semaphore`` are **dotted** names. unasync
|
|
8
|
+
matches single NAME tokens only, and a dotted substitution key is accepted
|
|
9
|
+
*silently and never fires* -- so a generated twin would call
|
|
10
|
+
``asyncio.gather`` from synchronous code and break at runtime.
|
|
11
|
+
|
|
12
|
+
Keeping both twins tiny is deliberate: hand-maintained duplication is a
|
|
13
|
+
liability, so it is confined to the smallest possible surface.
|
|
14
|
+
|
|
15
|
+
**Ordering is load-bearing and unenforced.** The sync twin's ``settle``
|
|
16
|
+
returns already-evaluated arguments, so a fan-out's sync meaning is "evaluate
|
|
17
|
+
these expressions left to right". That reproduces the sequential client only
|
|
18
|
+
because each fan-out's arguments are written in the order the sequential code
|
|
19
|
+
used. A future fan-out written out of order will generate sync code that
|
|
20
|
+
issues requests in an order nobody intended, and no test will catch it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
#: Bounded-concurrency primitive for the async surface.
|
|
29
|
+
#:
|
|
30
|
+
#: An :class:`asyncio.Semaphore`, and the sync twin is a no-op context
|
|
31
|
+
#: manager. Neither substitutes for the other. Note that instances must be
|
|
32
|
+
#: built **per call**, never stored on a client or at module level: an
|
|
33
|
+
#: ``asyncio.Semaphore`` binds to the first event loop that *contends* it --
|
|
34
|
+
#: an uncontended acquire never touches the loop at all -- so a stored one
|
|
35
|
+
#: passes every low-traffic test and then raises ``RuntimeError: bound to a
|
|
36
|
+
#: different event loop`` the first time a second loop contends it, i.e. in
|
|
37
|
+
#: production under load (measured on 3.10).
|
|
38
|
+
Semaphore = asyncio.Semaphore
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def settle(*awaitables: Any) -> list[Any]:
|
|
42
|
+
"""Run ``awaitables`` concurrently; return results in **source** order.
|
|
43
|
+
|
|
44
|
+
``return_exceptions=True`` is load-bearing twice over, and removing it is
|
|
45
|
+
the tempting "simplification" this docstring exists to prevent.
|
|
46
|
+
|
|
47
|
+
First, orphans. A bare ``asyncio.gather`` propagates the first exception
|
|
48
|
+
while its siblings keep running -- it does not cancel them (measured).
|
|
49
|
+
Those orphaned requests outlive the call and can still be in flight when
|
|
50
|
+
``__aexit__`` closes the client, which surfaces as a bare ``RuntimeError``
|
|
51
|
+
inside a task nobody awaits. Settling every awaitable first means no
|
|
52
|
+
request outlives the method that issued it.
|
|
53
|
+
|
|
54
|
+
Second, *which* exception wins. Collecting the results and re-raising the
|
|
55
|
+
first failure in source order reproduces the exception the sequential
|
|
56
|
+
code would have raised. A bare gather instead raises whichever failed
|
|
57
|
+
soonest on the clock, so the error a caller sees would depend on server
|
|
58
|
+
timing.
|
|
59
|
+
|
|
60
|
+
The cost, accepted deliberately: on a failing bundle every sibling still
|
|
61
|
+
runs to completion, so an error path can issue more requests than a
|
|
62
|
+
sequential version would. They are bounded by the fan-out width and they
|
|
63
|
+
are all reads.
|
|
64
|
+
|
|
65
|
+
The sync twin takes already-computed values and returns them. That is not
|
|
66
|
+
a stub: once unasync strips the ``await`` from a call site, each argument
|
|
67
|
+
expression evaluates eagerly where it is written, which *is* sequential
|
|
68
|
+
execution. The same call shape means "concurrently" here and "one after
|
|
69
|
+
the other" there, with no change to the calling code.
|
|
70
|
+
"""
|
|
71
|
+
results = await asyncio.gather(*awaitables, return_exceptions=True)
|
|
72
|
+
for result in results:
|
|
73
|
+
if isinstance(result, BaseException):
|
|
74
|
+
raise result
|
|
75
|
+
return list(results)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__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
|
+
AsyncRetrying,
|
|
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.AsyncClient(
|
|
195
|
+
headers=self.headers(),
|
|
196
|
+
auth=self.auth(),
|
|
197
|
+
timeout=config.timeout,
|
|
198
|
+
verify=config.verify_ssl,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def __aenter__(self) -> Transport:
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
205
|
+
await self.aclose()
|
|
206
|
+
|
|
207
|
+
async def aclose(self) -> None:
|
|
208
|
+
await self._client.aclose()
|
|
209
|
+
|
|
210
|
+
async def _do_send(self, spec: RequestSpec) -> Any:
|
|
211
|
+
response = await 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
|
+
async def send(self, spec: RequestSpec) -> Any:
|
|
219
|
+
retryer = AsyncRetrying(
|
|
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 await 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
|
+
async def _do_get_bytes(self, path_or_url: str) -> bytes:
|
|
233
|
+
response = await 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
|
+
async 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 = AsyncRetrying(
|
|
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 = await 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
|