pybc365 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.
- pybc365/__init__.py +83 -0
- pybc365/_reader.py +199 -0
- pybc365/_surface.py +111 -0
- pybc365/_transport.py +140 -0
- pybc365/_version.py +9 -0
- pybc365/client.py +316 -0
- pybc365/errors.py +167 -0
- pybc365/filters.py +811 -0
- pybc365/py.typed +0 -0
- pybc365/query.py +393 -0
- pybc365/retry.py +87 -0
- pybc365-0.1.0.dist-info/METADATA +197 -0
- pybc365-0.1.0.dist-info/RECORD +15 -0
- pybc365-0.1.0.dist-info/WHEEL +4 -0
- pybc365-0.1.0.dist-info/licenses/LICENSE +21 -0
pybc365/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A modern, fully typed Python client for Microsoft Dynamics 365 Business Central.
|
|
3
|
+
|
|
4
|
+
The package reads published OData V4 web services from one company on one
|
|
5
|
+
server. Every read starts at Client and runs through Query.
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
>>> import httpx
|
|
9
|
+
>>> import pybc365
|
|
10
|
+
>>> client = pybc365.Client(
|
|
11
|
+
... base_url="https://bc.example.com/BC",
|
|
12
|
+
... company="CRONUS",
|
|
13
|
+
... auth=httpx.BasicAuth("user", "token"),
|
|
14
|
+
... )
|
|
15
|
+
>>> rows = client.service("Customers").all() # doctest: +SKIP
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from httpx import Auth
|
|
19
|
+
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .errors import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
AuthorizationError,
|
|
25
|
+
BadRequestError,
|
|
26
|
+
BC365Error,
|
|
27
|
+
ConfigurationError,
|
|
28
|
+
FilterError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
NotSupportedError,
|
|
31
|
+
ProtocolError,
|
|
32
|
+
ResponseError,
|
|
33
|
+
ServerError,
|
|
34
|
+
ThrottledError,
|
|
35
|
+
TransportError,
|
|
36
|
+
)
|
|
37
|
+
from .filters import (
|
|
38
|
+
And,
|
|
39
|
+
Comparison,
|
|
40
|
+
F,
|
|
41
|
+
Field,
|
|
42
|
+
FilterExpression,
|
|
43
|
+
InList,
|
|
44
|
+
Or,
|
|
45
|
+
Raw,
|
|
46
|
+
raw,
|
|
47
|
+
)
|
|
48
|
+
from .query import Converter, Order, Query, asc, desc
|
|
49
|
+
from .retry import Retry
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"And",
|
|
53
|
+
"Auth",
|
|
54
|
+
"AuthenticationError",
|
|
55
|
+
"AuthorizationError",
|
|
56
|
+
"BC365Error",
|
|
57
|
+
"BadRequestError",
|
|
58
|
+
"Client",
|
|
59
|
+
"Comparison",
|
|
60
|
+
"ConfigurationError",
|
|
61
|
+
"Converter",
|
|
62
|
+
"F",
|
|
63
|
+
"Field",
|
|
64
|
+
"FilterError",
|
|
65
|
+
"FilterExpression",
|
|
66
|
+
"InList",
|
|
67
|
+
"NotFoundError",
|
|
68
|
+
"NotSupportedError",
|
|
69
|
+
"Or",
|
|
70
|
+
"Order",
|
|
71
|
+
"ProtocolError",
|
|
72
|
+
"Query",
|
|
73
|
+
"Raw",
|
|
74
|
+
"ResponseError",
|
|
75
|
+
"Retry",
|
|
76
|
+
"ServerError",
|
|
77
|
+
"ThrottledError",
|
|
78
|
+
"TransportError",
|
|
79
|
+
"__version__",
|
|
80
|
+
"asc",
|
|
81
|
+
"desc",
|
|
82
|
+
"raw",
|
|
83
|
+
]
|
pybc365/_reader.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Internal. ResponseReader turns one response into rows, a count, or an error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ._transport import ATTEMPTS_KEY, retry_after_seconds
|
|
9
|
+
from .errors import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
AuthorizationError,
|
|
12
|
+
BadRequestError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
NotSupportedError,
|
|
15
|
+
ProtocolError,
|
|
16
|
+
ResponseError,
|
|
17
|
+
ServerError,
|
|
18
|
+
ThrottledError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
# The parser reads the float token as the string Business Central wrote, so it
|
|
27
|
+
# sees every digit. None leaves the standard float in place.
|
|
28
|
+
type _FloatParser = Callable[[str], Any] | None
|
|
29
|
+
|
|
30
|
+
# An error body is cut to this many characters, so an HTML error page never
|
|
31
|
+
# becomes the whole exception message.
|
|
32
|
+
_MESSAGE_LIMIT = 200
|
|
33
|
+
|
|
34
|
+
_CLIENT_ERROR = 400
|
|
35
|
+
_SERVER_ERROR = 500
|
|
36
|
+
_ABOVE_SERVER_ERROR = 600
|
|
37
|
+
|
|
38
|
+
# 413 is here even though the retry layer never retries it: resending an
|
|
39
|
+
# identical GET cannot shrink the response.
|
|
40
|
+
_THROTTLED_STATUSES = frozenset({408, 413, 429, 503, 504})
|
|
41
|
+
|
|
42
|
+
_BY_STATUS: dict[int, type[ResponseError]] = {
|
|
43
|
+
401: AuthenticationError,
|
|
44
|
+
403: AuthorizationError,
|
|
45
|
+
404: NotFoundError,
|
|
46
|
+
501: NotSupportedError,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ResponseReader:
|
|
51
|
+
"""
|
|
52
|
+
What reads one Business Central response.
|
|
53
|
+
|
|
54
|
+
The reader holds the caller's float parser and nothing else. It sends no
|
|
55
|
+
request, and it never closes a response it did not open.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, parse_float: _FloatParser) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Keep the float parser every body decodes with.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
parse_float: How to build a number from a JSON float token. None
|
|
64
|
+
keeps the standard float.
|
|
65
|
+
"""
|
|
66
|
+
self._parse_float = parse_float
|
|
67
|
+
|
|
68
|
+
def page(self, response: httpx.Response) -> tuple[list[dict[str, Any]], str | None]:
|
|
69
|
+
"""
|
|
70
|
+
Return the rows of one page, and the link to the next page.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
response: The response to read.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The rows, and the next link, or None when the page is the last one.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ResponseError: If the status is 400 or above.
|
|
80
|
+
ProtocolError: If the body is not an OData collection, or carries a
|
|
81
|
+
next link the library cannot follow.
|
|
82
|
+
"""
|
|
83
|
+
self._raise_for_status(response)
|
|
84
|
+
body = self._json_or_none(response)
|
|
85
|
+
if not isinstance(body, dict) or not isinstance(body.get("value"), list):
|
|
86
|
+
msg = "the response is not an OData collection: no JSON 'value' array"
|
|
87
|
+
raise ProtocolError(msg, request=response.request, response=response)
|
|
88
|
+
|
|
89
|
+
value: list[dict[str, Any]] = body["value"]
|
|
90
|
+
link = body.get("@odata.nextLink")
|
|
91
|
+
if link is None:
|
|
92
|
+
return value, None
|
|
93
|
+
# A next link is sent byte for byte, so the library resolves nothing and
|
|
94
|
+
# only follows one it can send as it stands.
|
|
95
|
+
if not isinstance(link, str) or not link.startswith(("http://", "https://")):
|
|
96
|
+
msg = (
|
|
97
|
+
"the response carries an @odata.nextLink the library cannot "
|
|
98
|
+
f"follow: {link!r}"
|
|
99
|
+
)
|
|
100
|
+
raise ProtocolError(msg, request=response.request, response=response)
|
|
101
|
+
return value, link
|
|
102
|
+
|
|
103
|
+
def count(self, response: httpx.Response) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Return how many rows match, from the OData count beside the page.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
response: The response to read.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The row count.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ResponseError: If the status is 400 or above.
|
|
115
|
+
ProtocolError: If the body carries no numeric count.
|
|
116
|
+
"""
|
|
117
|
+
self._raise_for_status(response)
|
|
118
|
+
body = self._json_or_none(response)
|
|
119
|
+
count = body.get("@odata.count") if isinstance(body, dict) else None
|
|
120
|
+
# A bool is an int in Python, and true is not a count.
|
|
121
|
+
if isinstance(count, int) and not isinstance(count, bool):
|
|
122
|
+
return count
|
|
123
|
+
if isinstance(count, str):
|
|
124
|
+
try:
|
|
125
|
+
return int(count)
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
msg = (
|
|
129
|
+
"the response carries no numeric @odata.count, so the library cannot "
|
|
130
|
+
f"say how many rows match: {count!r}"
|
|
131
|
+
)
|
|
132
|
+
raise ProtocolError(msg, request=response.request, response=response)
|
|
133
|
+
|
|
134
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
135
|
+
if response.status_code >= _CLIENT_ERROR:
|
|
136
|
+
raise self._response_error(response)
|
|
137
|
+
|
|
138
|
+
def _response_error(self, response: httpx.Response) -> ResponseError:
|
|
139
|
+
status = response.status_code
|
|
140
|
+
code, message = self._error_code_and_message(response)
|
|
141
|
+
# The retry transport reports its count on a private extension key.
|
|
142
|
+
attempts = int(response.extensions.get(ATTEMPTS_KEY, 1))
|
|
143
|
+
if status in _THROTTLED_STATUSES:
|
|
144
|
+
return ThrottledError(
|
|
145
|
+
status=status,
|
|
146
|
+
code=code,
|
|
147
|
+
message=message,
|
|
148
|
+
request=response.request,
|
|
149
|
+
response=response,
|
|
150
|
+
attempts=attempts,
|
|
151
|
+
retry_after=retry_after_seconds(response),
|
|
152
|
+
)
|
|
153
|
+
return _BY_STATUS.get(status, _catch_all(status))(
|
|
154
|
+
status=status,
|
|
155
|
+
code=code,
|
|
156
|
+
message=message,
|
|
157
|
+
request=response.request,
|
|
158
|
+
response=response,
|
|
159
|
+
attempts=attempts,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _error_code_and_message(
|
|
163
|
+
self, response: httpx.Response
|
|
164
|
+
) -> tuple[str | None, str]:
|
|
165
|
+
body = self._json_or_none(response)
|
|
166
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
167
|
+
if not isinstance(error, dict):
|
|
168
|
+
return None, _body_text(response)
|
|
169
|
+
|
|
170
|
+
code = error.get("code")
|
|
171
|
+
message = error.get("message")
|
|
172
|
+
return (
|
|
173
|
+
code if isinstance(code, str) else None,
|
|
174
|
+
message if isinstance(message, str) else _body_text(response),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _json_or_none(self, response: httpx.Response) -> object:
|
|
178
|
+
# The catch names the two ways a body can fail to be JSON, and no wider,
|
|
179
|
+
# because a caller's parse_float runs inside this call and its own
|
|
180
|
+
# ValueError must reach the caller rather than read as a malformed body.
|
|
181
|
+
try:
|
|
182
|
+
return response.json(parse_float=self._parse_float)
|
|
183
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _catch_all(status: int) -> type[ResponseError]:
|
|
188
|
+
if _CLIENT_ERROR <= status < _SERVER_ERROR:
|
|
189
|
+
return BadRequestError
|
|
190
|
+
if _SERVER_ERROR <= status < _ABOVE_SERVER_ERROR:
|
|
191
|
+
return ServerError
|
|
192
|
+
return ResponseError
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _body_text(response: httpx.Response) -> str:
|
|
196
|
+
text = response.text.strip()
|
|
197
|
+
if len(text) > _MESSAGE_LIMIT:
|
|
198
|
+
return text[:_MESSAGE_LIMIT] + "..."
|
|
199
|
+
return text
|
pybc365/_surface.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Internal. ODataSurface builds published web service URLs."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from .errors import ConfigurationError
|
|
7
|
+
|
|
8
|
+
_logger = logging.getLogger("pybc365")
|
|
9
|
+
|
|
10
|
+
_ROOT = "base_url is the server root, stopping before the ODataV4 or api segment."
|
|
11
|
+
_NOT_REST = "The library reads published web services, not the REST API."
|
|
12
|
+
|
|
13
|
+
# Each entry is (the lowercased substring to look for, the part to name back,
|
|
14
|
+
# the reason to give).
|
|
15
|
+
_REJECTED = (
|
|
16
|
+
("/api/", "/api", _NOT_REST),
|
|
17
|
+
("companies(", "companies(", _NOT_REST),
|
|
18
|
+
("/odatav4/", "/ODataV4", _ROOT),
|
|
19
|
+
("company(", "Company(", _ROOT),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ODataSurface:
|
|
24
|
+
"""
|
|
25
|
+
The one URL form v0.1 reads: <base_url>/ODataV4/Company('<name>')/<service>.
|
|
26
|
+
|
|
27
|
+
The surface owns every cleaning rule, so a bad base_url, company or web
|
|
28
|
+
service name raises ConfigurationError from here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, base_url: str, company: str) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Clean and keep the two parts of the URL that never change.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
base_url: The server root, stopping before the ODataV4 segment.
|
|
37
|
+
company: The company name, unescaped.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ConfigurationError: If either argument is malformed.
|
|
41
|
+
"""
|
|
42
|
+
self._base_url = _clean_base_url(base_url)
|
|
43
|
+
self._company = _clean_company(company)
|
|
44
|
+
|
|
45
|
+
def service_url(self, name: str) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Return the URL of one published web service.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
name: The published name of the web service.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The full URL to read.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ConfigurationError: If name is empty.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> surface = ODataSurface(
|
|
60
|
+
... base_url="https://bc.example.com/BC", company="CRONUS"
|
|
61
|
+
... )
|
|
62
|
+
>>> surface.service_url("Customers")
|
|
63
|
+
"https://bc.example.com/BC/ODataV4/Company('CRONUS')/Customers"
|
|
64
|
+
"""
|
|
65
|
+
service = _clean_service(name)
|
|
66
|
+
return f"{self._base_url}/ODataV4/Company('{self._company}')/{service}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clean_base_url(base_url: str) -> str:
|
|
70
|
+
cleaned = base_url.strip().rstrip("/")
|
|
71
|
+
scheme = cleaned.split("://", 1)[0].lower() if "://" in cleaned else ""
|
|
72
|
+
if scheme not in {"http", "https"}:
|
|
73
|
+
msg = f"base_url must start with http:// or https://, got {base_url!r}"
|
|
74
|
+
raise ConfigurationError(msg)
|
|
75
|
+
|
|
76
|
+
# The trailing slash lets one substring match both "/api/" and a bare "/api"
|
|
77
|
+
# at the end.
|
|
78
|
+
probe = f"{cleaned.lower()}/"
|
|
79
|
+
for substring, named, reason in _REJECTED:
|
|
80
|
+
if substring in probe:
|
|
81
|
+
msg = (
|
|
82
|
+
f"base_url must not contain {named!r}: delete it and everything "
|
|
83
|
+
f"after it. {reason}"
|
|
84
|
+
)
|
|
85
|
+
raise ConfigurationError(msg)
|
|
86
|
+
|
|
87
|
+
if scheme == "http":
|
|
88
|
+
_logger.warning(
|
|
89
|
+
"base_url uses http, so credentials and rows cross the network in "
|
|
90
|
+
"clear: %s",
|
|
91
|
+
cleaned,
|
|
92
|
+
)
|
|
93
|
+
return cleaned
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _clean_company(company: str) -> str:
|
|
97
|
+
cleaned = company.strip()
|
|
98
|
+
if not cleaned:
|
|
99
|
+
msg = "company must not be empty"
|
|
100
|
+
raise ConfigurationError(msg)
|
|
101
|
+
# OData escapes a single quote by doubling it, so the doubled pair must
|
|
102
|
+
# survive quote() unencoded.
|
|
103
|
+
return quote(cleaned.replace("'", "''"), safe="'")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _clean_service(name: str) -> str:
|
|
107
|
+
cleaned = name.strip().strip("/").strip()
|
|
108
|
+
if not cleaned:
|
|
109
|
+
msg = f"the web service name must not be empty, got {name!r}"
|
|
110
|
+
raise ConfigurationError(msg)
|
|
111
|
+
return quote(cleaned, safe="")
|
pybc365/_transport.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Internal. The transport wrapper that applies the Retry policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import TransportError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .retry import Retry
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger("pybc365")
|
|
19
|
+
|
|
20
|
+
# The wrapper reports its attempt count to the error layer on this private
|
|
21
|
+
# extension key. It is never public API.
|
|
22
|
+
ATTEMPTS_KEY = "pybc365_attempts"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RetryTransport(httpx.BaseTransport):
|
|
26
|
+
"""
|
|
27
|
+
A transport that sends a failed request again, following a Retry policy.
|
|
28
|
+
|
|
29
|
+
Client always installs it, so a caller-supplied transport retries too.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, inner: httpx.BaseTransport, retry: Retry) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Wrap one transport with one policy.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
inner: The transport that sends the request.
|
|
38
|
+
retry: The policy deciding what to send again, and when.
|
|
39
|
+
"""
|
|
40
|
+
self._inner = inner
|
|
41
|
+
self._retry = retry
|
|
42
|
+
|
|
43
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
44
|
+
"""
|
|
45
|
+
Send the request, and send it again while the policy allows it.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
request: The request to send.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The final response, unread, carrying the attempt count on its
|
|
52
|
+
extensions.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
TransportError: If no attempt got a response.
|
|
56
|
+
"""
|
|
57
|
+
attempt = 0
|
|
58
|
+
while True:
|
|
59
|
+
attempt += 1
|
|
60
|
+
final = attempt >= self._retry.max_attempts
|
|
61
|
+
try:
|
|
62
|
+
response = self._inner.handle_request(request)
|
|
63
|
+
except httpx.TransportError as exc:
|
|
64
|
+
if final:
|
|
65
|
+
msg = f"{request.method} {request.url} failed: {exc}"
|
|
66
|
+
raise TransportError(
|
|
67
|
+
msg, request=request, attempts=attempt
|
|
68
|
+
) from exc
|
|
69
|
+
self._wait(attempt, type(exc).__name__, None)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if final or response.status_code not in self._retry.retry_statuses:
|
|
73
|
+
response.extensions[ATTEMPTS_KEY] = attempt
|
|
74
|
+
return response
|
|
75
|
+
|
|
76
|
+
response.close()
|
|
77
|
+
self._wait(attempt, response.status_code, retry_after_seconds(response))
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
"""Close the transport this wrapper sends through."""
|
|
81
|
+
self._inner.close()
|
|
82
|
+
|
|
83
|
+
def _wait(self, attempt: int, cause: object, retry_after: float | None) -> None:
|
|
84
|
+
delay = self._delay(attempt, retry_after)
|
|
85
|
+
_logger.warning(
|
|
86
|
+
"attempt %d of %d failed with %s, retrying in %.3f seconds",
|
|
87
|
+
attempt,
|
|
88
|
+
self._retry.max_attempts,
|
|
89
|
+
cause,
|
|
90
|
+
delay,
|
|
91
|
+
)
|
|
92
|
+
# Retry owns the two seams; the wrapper is the only reader of them.
|
|
93
|
+
self._retry._sleep(delay) # ruff: ignore[private-member-access]
|
|
94
|
+
|
|
95
|
+
def _delay(self, attempt: int, retry_after: float | None) -> float:
|
|
96
|
+
retry = self._retry
|
|
97
|
+
if retry_after is not None:
|
|
98
|
+
return min(retry_after, retry.max_delay)
|
|
99
|
+
# An exponent past 1024 overflows, and any exponent this large is far
|
|
100
|
+
# past max_delay already.
|
|
101
|
+
exponent = min(attempt - 1, 512)
|
|
102
|
+
ceiling = min(retry.max_delay, retry.base_delay * 2.0**exponent)
|
|
103
|
+
# Full jitter: several workers against one server otherwise retry in
|
|
104
|
+
# lockstep.
|
|
105
|
+
return retry._random() * ceiling # ruff: ignore[private-member-access]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def retry_after_seconds(response: httpx.Response) -> float | None:
|
|
109
|
+
"""
|
|
110
|
+
Return the seconds the Retry-After header asks for, if it is readable.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
response: The response to read the header from.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The seconds to wait, never negative, or None when the header is absent
|
|
117
|
+
or unparseable.
|
|
118
|
+
"""
|
|
119
|
+
raw = response.headers.get("Retry-After")
|
|
120
|
+
if raw is None:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Retry-After comes in two forms: delta-seconds, or an HTTP date.
|
|
124
|
+
value = raw.strip()
|
|
125
|
+
try:
|
|
126
|
+
seconds = float(value)
|
|
127
|
+
except ValueError:
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
# A server sending a negative or a non-finite delay is telling the
|
|
131
|
+
# library nothing it can wait for.
|
|
132
|
+
return max(seconds, 0.0) if math.isfinite(seconds) else None
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
when = parsedate_to_datetime(value)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return None
|
|
138
|
+
if when.tzinfo is None:
|
|
139
|
+
when = when.replace(tzinfo=UTC)
|
|
140
|
+
return max((when - datetime.now(tz=UTC)).total_seconds(), 0.0)
|
pybc365/_version.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Internal. The installed version of the package."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = metadata.version("pybc365")
|
|
7
|
+
except metadata.PackageNotFoundError: # pragma: no cover
|
|
8
|
+
# The package is running from a source tree that was never installed.
|
|
9
|
+
__version__ = "0.0.0"
|