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 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"