qbwc-kit 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.
- qbwc_kit/__init__.py +69 -0
- qbwc_kit/qbxml/__init__.py +40 -0
- qbwc_kit/qbxml/builder.py +202 -0
- qbwc_kit/qbxml/parser.py +210 -0
- qbwc_kit/qbxml/types.py +63 -0
- qbwc_kit/server.py +59 -0
- qbwc_kit/service.py +221 -0
- qbwc_kit/session.py +300 -0
- qbwc_kit/soap.py +151 -0
- qbwc_kit/testing.py +305 -0
- qbwc_kit/wsdl.py +180 -0
- qbwc_kit-0.1.0.dist-info/METADATA +209 -0
- qbwc_kit-0.1.0.dist-info/RECORD +16 -0
- qbwc_kit-0.1.0.dist-info/WHEEL +5 -0
- qbwc_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- qbwc_kit-0.1.0.dist-info/top_level.txt +1 -0
qbwc_kit/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""qbwc-kit: talk to QuickBooks Desktop over the Web Connector.
|
|
2
|
+
|
|
3
|
+
QuickBooks Desktop has no HTTP API. The only supported way in is the Web
|
|
4
|
+
Connector: a Windows service that polls *your* SOAP endpoint on a schedule,
|
|
5
|
+
asks it for qbXML, hands that to QuickBooks over COM, and posts the response
|
|
6
|
+
back. Every integration therefore has to implement the same eight callbacks and
|
|
7
|
+
the same request/response loop before it can read a single invoice.
|
|
8
|
+
|
|
9
|
+
This package is that plumbing:
|
|
10
|
+
|
|
11
|
+
* :mod:`qbwc_kit.soap` - the small SOAP slice QBWC actually uses
|
|
12
|
+
* :mod:`qbwc_kit.qbxml` - qbXML request building and status-aware parsing
|
|
13
|
+
* :mod:`qbwc_kit.session` - generator-based tasks spanning many round trips
|
|
14
|
+
* :mod:`qbwc_kit.service` - the eight callbacks, framework-agnostic
|
|
15
|
+
* :mod:`qbwc_kit.server` - optional FastAPI adapter and WSDL hosting
|
|
16
|
+
* :mod:`qbwc_kit.testing` - a fake Web Connector and a fake QuickBooks
|
|
17
|
+
|
|
18
|
+
The core has no dependencies outside the standard library.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from . import qbxml
|
|
22
|
+
from .qbxml import (
|
|
23
|
+
QBXMLRequest,
|
|
24
|
+
QBXMLStatusError,
|
|
25
|
+
Request,
|
|
26
|
+
Response,
|
|
27
|
+
ResponseSet,
|
|
28
|
+
add,
|
|
29
|
+
mod,
|
|
30
|
+
parse_response,
|
|
31
|
+
query,
|
|
32
|
+
)
|
|
33
|
+
from .service import QBWCService
|
|
34
|
+
from .session import (
|
|
35
|
+
Authenticator,
|
|
36
|
+
Session,
|
|
37
|
+
SessionStore,
|
|
38
|
+
SimpleTask,
|
|
39
|
+
StaticAuthenticator,
|
|
40
|
+
Task,
|
|
41
|
+
TaskContext,
|
|
42
|
+
)
|
|
43
|
+
from .wsdl import build_qwc, build_wsdl
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Authenticator",
|
|
49
|
+
"QBWCService",
|
|
50
|
+
"QBXMLRequest",
|
|
51
|
+
"QBXMLStatusError",
|
|
52
|
+
"Request",
|
|
53
|
+
"Response",
|
|
54
|
+
"ResponseSet",
|
|
55
|
+
"Session",
|
|
56
|
+
"SessionStore",
|
|
57
|
+
"SimpleTask",
|
|
58
|
+
"StaticAuthenticator",
|
|
59
|
+
"Task",
|
|
60
|
+
"TaskContext",
|
|
61
|
+
"__version__",
|
|
62
|
+
"add",
|
|
63
|
+
"build_qwc",
|
|
64
|
+
"build_wsdl",
|
|
65
|
+
"mod",
|
|
66
|
+
"parse_response",
|
|
67
|
+
"qbxml",
|
|
68
|
+
"query",
|
|
69
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""qbXML request building and response parsing."""
|
|
2
|
+
|
|
3
|
+
from .builder import QBXMLRequest, Request, add, element, elements, mod, query, ref
|
|
4
|
+
from .parser import (
|
|
5
|
+
QBXMLParseError,
|
|
6
|
+
QBXMLStatusError,
|
|
7
|
+
Response,
|
|
8
|
+
ResponseSet,
|
|
9
|
+
parse_response,
|
|
10
|
+
)
|
|
11
|
+
from .types import (
|
|
12
|
+
ITERATOR_ENTITIES,
|
|
13
|
+
STATUS_NOTHING_FOUND,
|
|
14
|
+
STATUS_OK,
|
|
15
|
+
STATUS_UNSUPPORTED_REQUEST,
|
|
16
|
+
OnError,
|
|
17
|
+
Severity,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ITERATOR_ENTITIES",
|
|
22
|
+
"OnError",
|
|
23
|
+
"QBXMLParseError",
|
|
24
|
+
"QBXMLRequest",
|
|
25
|
+
"QBXMLStatusError",
|
|
26
|
+
"Request",
|
|
27
|
+
"Response",
|
|
28
|
+
"ResponseSet",
|
|
29
|
+
"STATUS_NOTHING_FOUND",
|
|
30
|
+
"STATUS_OK",
|
|
31
|
+
"STATUS_UNSUPPORTED_REQUEST",
|
|
32
|
+
"Severity",
|
|
33
|
+
"add",
|
|
34
|
+
"element",
|
|
35
|
+
"elements",
|
|
36
|
+
"mod",
|
|
37
|
+
"parse_response",
|
|
38
|
+
"query",
|
|
39
|
+
"ref",
|
|
40
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""qbXML request construction.
|
|
2
|
+
|
|
3
|
+
QuickBooks Desktop only accepts a very particular document: a ``?qbxml``
|
|
4
|
+
processing instruction, a ``QBXML`` root, and a ``QBXMLMsgsRq`` element whose
|
|
5
|
+
``onError`` attribute decides whether the whole batch aborts on the first bad
|
|
6
|
+
request. Getting any of that wrong produces an unhelpful parse error from
|
|
7
|
+
QuickBooks, so it is worth building rather than templating by hand.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .types import OnError, iterator_supported
|
|
17
|
+
|
|
18
|
+
_ESCAPES = (
|
|
19
|
+
("&", "&"),
|
|
20
|
+
("<", "<"),
|
|
21
|
+
(">", ">"),
|
|
22
|
+
('"', """),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def escape(value: Any) -> str:
|
|
27
|
+
text = "" if value is None else str(value)
|
|
28
|
+
for char, replacement in _ESCAPES:
|
|
29
|
+
text = text.replace(char, replacement)
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def element(name: str, value: Any) -> str:
|
|
34
|
+
"""One leaf element. ``None`` renders nothing so optional fields can be passed through."""
|
|
35
|
+
if value is None:
|
|
36
|
+
return ""
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
value = "true" if value else "false"
|
|
39
|
+
return f"<{name}>{escape(value)}</{name}>"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def elements(fields: Mapping[str, Any] | Sequence[tuple[str, Any]]) -> str:
|
|
43
|
+
"""Render an ordered mapping of leaf elements.
|
|
44
|
+
|
|
45
|
+
qbXML is order-sensitive: the schema is a sequence, not a set. Python dicts
|
|
46
|
+
preserve insertion order, which is exactly the guarantee this relies on.
|
|
47
|
+
"""
|
|
48
|
+
items = fields.items() if isinstance(fields, Mapping) else fields
|
|
49
|
+
return "".join(element(name, value) for name, value in items)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ref(name: str, full_name: str | None = None, list_id: str | None = None) -> str:
|
|
53
|
+
"""A ``*Ref`` aggregate. QuickBooks accepts either a ListID or a FullName."""
|
|
54
|
+
if full_name is None and list_id is None:
|
|
55
|
+
return ""
|
|
56
|
+
body = element("ListID", list_id) + element("FullName", full_name)
|
|
57
|
+
return f"<{name}>{body}</{name}>"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Request:
|
|
62
|
+
"""A single ``*Rq`` element inside the batch."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
body: str = ""
|
|
66
|
+
request_id: str | None = None
|
|
67
|
+
iterator: str | None = None
|
|
68
|
+
iterator_id: str | None = None
|
|
69
|
+
max_returned: int | None = None
|
|
70
|
+
|
|
71
|
+
def render(self) -> str:
|
|
72
|
+
attrs = ""
|
|
73
|
+
if self.request_id is not None:
|
|
74
|
+
attrs += f' requestID="{escape(self.request_id)}"'
|
|
75
|
+
if self.iterator is not None:
|
|
76
|
+
if not iterator_supported(self.name):
|
|
77
|
+
raise ValueError(f"{self.name} does not support iterators")
|
|
78
|
+
attrs += f' iterator="{escape(self.iterator)}"'
|
|
79
|
+
if self.iterator_id is not None:
|
|
80
|
+
attrs += f' iteratorID="{escape(self.iterator_id)}"'
|
|
81
|
+
|
|
82
|
+
body = self.body
|
|
83
|
+
if self.max_returned is not None:
|
|
84
|
+
# MaxReturned must lead the query body per the schema sequence.
|
|
85
|
+
body = element("MaxReturned", self.max_returned) + body
|
|
86
|
+
|
|
87
|
+
return f"<{self.name}{attrs}>{body}</{self.name}>"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class QBXMLRequest:
|
|
92
|
+
"""A qbXML batch, ready to hand back from ``sendRequestXML``."""
|
|
93
|
+
|
|
94
|
+
requests: list[Request] = field(default_factory=list)
|
|
95
|
+
on_error: OnError = OnError.STOP
|
|
96
|
+
version: str = "13.0"
|
|
97
|
+
|
|
98
|
+
def add(self, request: Request) -> QBXMLRequest:
|
|
99
|
+
self.requests.append(request)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def extend(self, requests: Iterable[Request]) -> QBXMLRequest:
|
|
103
|
+
self.requests.extend(requests)
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def render(self) -> str:
|
|
107
|
+
if not self.requests:
|
|
108
|
+
raise ValueError("a qbXML batch needs at least one request")
|
|
109
|
+
body = "".join(request.render() for request in self.requests)
|
|
110
|
+
return (
|
|
111
|
+
'<?xml version="1.0" encoding="utf-8"?>'
|
|
112
|
+
f'<?qbxml version="{escape(self.version)}"?>'
|
|
113
|
+
"<QBXML>"
|
|
114
|
+
f'<QBXMLMsgsRq onError="{self.on_error.value}">'
|
|
115
|
+
f"{body}"
|
|
116
|
+
"</QBXMLMsgsRq>"
|
|
117
|
+
"</QBXML>"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def __str__(self) -> str: # pragma: no cover - convenience only
|
|
121
|
+
return self.render()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def query(
|
|
125
|
+
entity: str,
|
|
126
|
+
*,
|
|
127
|
+
request_id: str | None = None,
|
|
128
|
+
max_returned: int | None = None,
|
|
129
|
+
iterator: str | None = None,
|
|
130
|
+
iterator_id: str | None = None,
|
|
131
|
+
modified_after: str | None = None,
|
|
132
|
+
modified_before: str | None = None,
|
|
133
|
+
active_status: str | None = None,
|
|
134
|
+
include_fields: Sequence[str] | None = None,
|
|
135
|
+
owner_id: str | None = None,
|
|
136
|
+
extra: Mapping[str, Any] | None = None,
|
|
137
|
+
) -> Request:
|
|
138
|
+
"""Build a ``<Entity>QueryRq``.
|
|
139
|
+
|
|
140
|
+
``modified_after`` is the workhorse for incremental syncs: pair it with the
|
|
141
|
+
last successful sync timestamp and QuickBooks returns only what changed.
|
|
142
|
+
"""
|
|
143
|
+
body = ""
|
|
144
|
+
if modified_after is not None or modified_before is not None:
|
|
145
|
+
body += (
|
|
146
|
+
"<ModifiedDateRangeFilter>"
|
|
147
|
+
+ element("FromModifiedDate", modified_after)
|
|
148
|
+
+ element("ToModifiedDate", modified_before)
|
|
149
|
+
+ "</ModifiedDateRangeFilter>"
|
|
150
|
+
)
|
|
151
|
+
body += element("ActiveStatus", active_status)
|
|
152
|
+
if extra:
|
|
153
|
+
body += elements(extra)
|
|
154
|
+
if owner_id is not None:
|
|
155
|
+
body += element("OwnerID", owner_id)
|
|
156
|
+
if include_fields:
|
|
157
|
+
body += "".join(element("IncludeRetElement", name) for name in include_fields)
|
|
158
|
+
|
|
159
|
+
return Request(
|
|
160
|
+
name=f"{entity}QueryRq",
|
|
161
|
+
body=body,
|
|
162
|
+
request_id=request_id,
|
|
163
|
+
iterator=iterator,
|
|
164
|
+
iterator_id=iterator_id,
|
|
165
|
+
max_returned=max_returned,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def add(entity: str, fields: Mapping[str, Any], *, request_id: str | None = None) -> Request:
|
|
170
|
+
"""Build an ``<Entity>AddRq`` wrapping an ``<Entity>Add`` aggregate."""
|
|
171
|
+
inner = elements(fields) if isinstance(fields, Mapping) else str(fields)
|
|
172
|
+
return Request(
|
|
173
|
+
name=f"{entity}AddRq",
|
|
174
|
+
body=f"<{entity}Add>{inner}</{entity}Add>",
|
|
175
|
+
request_id=request_id,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def mod(
|
|
180
|
+
entity: str,
|
|
181
|
+
fields: Mapping[str, Any],
|
|
182
|
+
*,
|
|
183
|
+
list_id: str | None = None,
|
|
184
|
+
txn_id: str | None = None,
|
|
185
|
+
edit_sequence: str,
|
|
186
|
+
request_id: str | None = None,
|
|
187
|
+
) -> Request:
|
|
188
|
+
"""Build an ``<Entity>ModRq``.
|
|
189
|
+
|
|
190
|
+
QuickBooks uses optimistic concurrency: every modification must carry the
|
|
191
|
+
``EditSequence`` returned by the last read, and a stale one is rejected
|
|
192
|
+
rather than silently overwriting somebody else's edit.
|
|
193
|
+
"""
|
|
194
|
+
if list_id is None and txn_id is None:
|
|
195
|
+
raise ValueError("a Mod request needs either a ListID or a TxnID")
|
|
196
|
+
head = element("ListID", list_id) + element("TxnID", txn_id)
|
|
197
|
+
head += element("EditSequence", edit_sequence)
|
|
198
|
+
return Request(
|
|
199
|
+
name=f"{entity}ModRq",
|
|
200
|
+
body=f"<{entity}Mod>{head}{elements(fields)}</{entity}Mod>",
|
|
201
|
+
request_id=request_id,
|
|
202
|
+
)
|
qbwc_kit/qbxml/parser.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""qbXML response parsing.
|
|
2
|
+
|
|
3
|
+
The response side is where integrations quietly break. A request that returns
|
|
4
|
+
``statusCode="1"`` ("nothing found") looks structurally identical to one that
|
|
5
|
+
succeeded with zero rows, and an unsupported request comes back as a *success*
|
|
6
|
+
envelope carrying a non-zero status. Treating the response as "parse the XML,
|
|
7
|
+
take the rows" is how a cache silently degrades into empty results, so this
|
|
8
|
+
parser surfaces status on every response and refuses to hand back rows without
|
|
9
|
+
it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
from xml.etree import ElementTree as ET
|
|
18
|
+
|
|
19
|
+
from .types import STATUS_NOTHING_FOUND, STATUS_OK, Severity
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QBXMLParseError(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QBXMLStatusError(RuntimeError):
|
|
27
|
+
"""Raised by :meth:`Response.raise_for_status` on a non-OK response."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, response: Response) -> None:
|
|
30
|
+
super().__init__(f"{response.name}: [{response.status_code}] {response.status_message}")
|
|
31
|
+
self.response = response
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _text(node: ET.Element) -> str:
|
|
35
|
+
return (node.text or "").strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _to_dict(node: ET.Element) -> Any:
|
|
39
|
+
"""Convert a qbXML aggregate into plain Python.
|
|
40
|
+
|
|
41
|
+
Repeated sibling tags become lists, which is how line items, addresses with
|
|
42
|
+
multiple lines, and custom fields all arrive.
|
|
43
|
+
"""
|
|
44
|
+
children = list(node)
|
|
45
|
+
if not children:
|
|
46
|
+
return _text(node)
|
|
47
|
+
|
|
48
|
+
result: dict[str, Any] = {}
|
|
49
|
+
for child in children:
|
|
50
|
+
value = _to_dict(child)
|
|
51
|
+
tag = child.tag
|
|
52
|
+
if tag in result:
|
|
53
|
+
existing = result[tag]
|
|
54
|
+
if isinstance(existing, list):
|
|
55
|
+
existing.append(value)
|
|
56
|
+
else:
|
|
57
|
+
result[tag] = [existing, value]
|
|
58
|
+
else:
|
|
59
|
+
result[tag] = value
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Response:
|
|
65
|
+
"""One ``*Rs`` element out of the batch."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
status_code: int
|
|
69
|
+
status_severity: str
|
|
70
|
+
status_message: str
|
|
71
|
+
request_id: str | None = None
|
|
72
|
+
records: list[dict[str, Any]] = field(default_factory=list)
|
|
73
|
+
iterator_remaining_count: int | None = None
|
|
74
|
+
iterator_id: str | None = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def ok(self) -> bool:
|
|
78
|
+
"""True when QuickBooks actually processed the request.
|
|
79
|
+
|
|
80
|
+
Status 1 ("nothing found") counts as OK: an empty result set is a valid
|
|
81
|
+
answer, unlike status 3100 which means the request never ran.
|
|
82
|
+
"""
|
|
83
|
+
return self.status_code in (STATUS_OK, STATUS_NOTHING_FOUND)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def empty(self) -> bool:
|
|
87
|
+
return self.status_code == STATUS_NOTHING_FOUND or not self.records
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def has_more(self) -> bool:
|
|
91
|
+
return bool(self.iterator_remaining_count)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def entity(self) -> str:
|
|
95
|
+
"""``CustomerQueryRs`` -> ``Customer``."""
|
|
96
|
+
name = self.name
|
|
97
|
+
for suffix in ("QueryRs", "AddRs", "ModRs", "DelRs", "VoidRs", "Rs"):
|
|
98
|
+
if name.endswith(suffix):
|
|
99
|
+
return name[: -len(suffix)]
|
|
100
|
+
return name
|
|
101
|
+
|
|
102
|
+
def raise_for_status(self) -> Response:
|
|
103
|
+
if not self.ok:
|
|
104
|
+
raise QBXMLStatusError(self)
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
108
|
+
return iter(self.records)
|
|
109
|
+
|
|
110
|
+
def __len__(self) -> int:
|
|
111
|
+
return len(self.records)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class ResponseSet:
|
|
116
|
+
"""The whole ``QBXMLMsgsRs`` batch."""
|
|
117
|
+
|
|
118
|
+
responses: list[Response] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
def __iter__(self) -> Iterator[Response]:
|
|
121
|
+
return iter(self.responses)
|
|
122
|
+
|
|
123
|
+
def __len__(self) -> int:
|
|
124
|
+
return len(self.responses)
|
|
125
|
+
|
|
126
|
+
def __getitem__(self, index: int) -> Response:
|
|
127
|
+
return self.responses[index]
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def ok(self) -> bool:
|
|
131
|
+
return all(response.ok for response in self.responses)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def failures(self) -> list[Response]:
|
|
135
|
+
return [response for response in self.responses if not response.ok]
|
|
136
|
+
|
|
137
|
+
def by_request_id(self, request_id: str) -> Response | None:
|
|
138
|
+
for response in self.responses:
|
|
139
|
+
if response.request_id == request_id:
|
|
140
|
+
return response
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def first(self, name: str | None = None) -> Response:
|
|
144
|
+
for response in self.responses:
|
|
145
|
+
if name is None or response.name == name or response.entity == name:
|
|
146
|
+
return response
|
|
147
|
+
raise KeyError(name)
|
|
148
|
+
|
|
149
|
+
def raise_for_status(self) -> ResponseSet:
|
|
150
|
+
for response in self.responses:
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: Elements inside a ``*Rs`` that are metadata rather than returned records.
|
|
156
|
+
_NON_RECORD_TAGS = frozenset({"ErrorRecovery"})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_response(payload: str | bytes) -> ResponseSet:
|
|
160
|
+
"""Parse a full qbXML response document.
|
|
161
|
+
|
|
162
|
+
An empty payload is not an error: QBWC passes an empty string through
|
|
163
|
+
``receiveResponseXML`` when a request was skipped.
|
|
164
|
+
"""
|
|
165
|
+
if isinstance(payload, bytes):
|
|
166
|
+
payload = payload.decode("utf-8")
|
|
167
|
+
payload = payload.strip()
|
|
168
|
+
if not payload:
|
|
169
|
+
return ResponseSet()
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
root = ET.fromstring(payload)
|
|
173
|
+
except ET.ParseError as exc:
|
|
174
|
+
raise QBXMLParseError(f"malformed qbXML: {exc}") from exc
|
|
175
|
+
|
|
176
|
+
if root.tag != "QBXML":
|
|
177
|
+
raise QBXMLParseError(f"expected a QBXML root, got {root.tag!r}")
|
|
178
|
+
|
|
179
|
+
msgs = root.find("QBXMLMsgsRs")
|
|
180
|
+
if msgs is None:
|
|
181
|
+
raise QBXMLParseError("response has no QBXMLMsgsRs")
|
|
182
|
+
|
|
183
|
+
responses = [_parse_one(node) for node in msgs]
|
|
184
|
+
return ResponseSet(responses=responses)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_one(node: ET.Element) -> Response:
|
|
188
|
+
try:
|
|
189
|
+
status_code = int(node.get("statusCode", "0"))
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
raise QBXMLParseError(f"non-numeric statusCode on {node.tag}") from exc
|
|
192
|
+
|
|
193
|
+
remaining = node.get("iteratorRemainingCount")
|
|
194
|
+
response = Response(
|
|
195
|
+
name=node.tag,
|
|
196
|
+
status_code=status_code,
|
|
197
|
+
status_severity=node.get("statusSeverity", Severity.INFO.value),
|
|
198
|
+
status_message=node.get("statusMessage", ""),
|
|
199
|
+
request_id=node.get("requestID"),
|
|
200
|
+
iterator_remaining_count=int(remaining) if remaining is not None else None,
|
|
201
|
+
iterator_id=node.get("iteratorID"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
for child in node:
|
|
205
|
+
if child.tag in _NON_RECORD_TAGS:
|
|
206
|
+
continue
|
|
207
|
+
value = _to_dict(child)
|
|
208
|
+
response.records.append(value if isinstance(value, dict) else {child.tag: value})
|
|
209
|
+
|
|
210
|
+
return response
|
qbwc_kit/qbxml/types.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Shared qbXML vocabulary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OnError(str, Enum):
|
|
9
|
+
"""``QBXMLMsgsRq/@onError``.
|
|
10
|
+
|
|
11
|
+
``STOP`` aborts the batch at the first failing request. ``CONTINUE`` runs
|
|
12
|
+
every request and reports per-request status, which is what you want for a
|
|
13
|
+
read-only sync where one unsupported entity should not blank the rest.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
STOP = "stopOnError"
|
|
17
|
+
CONTINUE = "continueOnError"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Severity(str, Enum):
|
|
21
|
+
INFO = "Info"
|
|
22
|
+
WARN = "Warn"
|
|
23
|
+
ERROR = "Error"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
#: Status codes worth branching on. QuickBooks defines several hundred; these
|
|
27
|
+
#: are the ones that change control flow rather than just being logged.
|
|
28
|
+
STATUS_OK = 0
|
|
29
|
+
STATUS_NOTHING_FOUND = 1
|
|
30
|
+
STATUS_UNSUPPORTED_REQUEST = 3100
|
|
31
|
+
STATUS_INSUFFICIENT_PERMISSION = 3260
|
|
32
|
+
STATUS_STALE_EDIT_SEQUENCE = 3200
|
|
33
|
+
STATUS_OBJECT_NOT_FOUND = 500
|
|
34
|
+
|
|
35
|
+
#: Entities whose Query requests accept ``iterator``/``iteratorID`` attributes.
|
|
36
|
+
#: Asking for an iterator on anything else is a parse error from QuickBooks,
|
|
37
|
+
#: so it is checked at build time instead.
|
|
38
|
+
ITERATOR_ENTITIES = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"AccountQueryRq",
|
|
41
|
+
"BillQueryRq",
|
|
42
|
+
"CheckQueryRq",
|
|
43
|
+
"CreditMemoQueryRq",
|
|
44
|
+
"CustomerQueryRq",
|
|
45
|
+
"DepositQueryRq",
|
|
46
|
+
"EstimateQueryRq",
|
|
47
|
+
"InvoiceQueryRq",
|
|
48
|
+
"ItemInventoryQueryRq",
|
|
49
|
+
"ItemQueryRq",
|
|
50
|
+
"ItemReceiptQueryRq",
|
|
51
|
+
"JournalEntryQueryRq",
|
|
52
|
+
"PurchaseOrderQueryRq",
|
|
53
|
+
"ReceivePaymentQueryRq",
|
|
54
|
+
"SalesOrderQueryRq",
|
|
55
|
+
"SalesReceiptQueryRq",
|
|
56
|
+
"TimeTrackingQueryRq",
|
|
57
|
+
"VendorQueryRq",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def iterator_supported(request_name: str) -> bool:
|
|
63
|
+
return request_name in ITERATOR_ENTITIES
|
qbwc_kit/server.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""FastAPI adapter.
|
|
2
|
+
|
|
3
|
+
Kept deliberately thin: the protocol lives in :mod:`qbwc_kit.service`, and this
|
|
4
|
+
module only maps HTTP verbs onto it. FastAPI is an optional dependency, so
|
|
5
|
+
importing this module without it raises a clear error instead of an
|
|
6
|
+
``ImportError`` from three frames down.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .service import QBWCService
|
|
14
|
+
from .wsdl import build_wsdl
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from fastapi import FastAPI, Request, Response
|
|
21
|
+
except ModuleNotFoundError as exc: # pragma: no cover - exercised by install shape, not tests
|
|
22
|
+
raise ModuleNotFoundError(
|
|
23
|
+
"qbwc_kit.server needs FastAPI. Install it with: pip install fastapi"
|
|
24
|
+
) from exc
|
|
25
|
+
|
|
26
|
+
#: QBWC sends this exact content type and expects it back.
|
|
27
|
+
SOAP_CONTENT_TYPE = "text/xml; charset=utf-8"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_app(
|
|
31
|
+
service: QBWCService,
|
|
32
|
+
*,
|
|
33
|
+
endpoint_url: str,
|
|
34
|
+
path: str = "/qbwc",
|
|
35
|
+
app: FastAPI | None = None,
|
|
36
|
+
service_name: str = "QBWebConnectorSvc",
|
|
37
|
+
) -> FastAPI:
|
|
38
|
+
"""Mount ``service`` on a FastAPI app.
|
|
39
|
+
|
|
40
|
+
``endpoint_url`` is the externally reachable URL of ``path``. QBWC reads it
|
|
41
|
+
out of the WSDL and posts there, so behind a reverse proxy it must be the
|
|
42
|
+
public URL, not ``http://localhost``.
|
|
43
|
+
"""
|
|
44
|
+
app = app or FastAPI(title="QuickBooks Web Connector service", docs_url=None, redoc_url=None)
|
|
45
|
+
wsdl = build_wsdl(endpoint_url, service_name=service_name)
|
|
46
|
+
|
|
47
|
+
@app.get(path)
|
|
48
|
+
async def get_wsdl(request: Request) -> Response:
|
|
49
|
+
# QBWC asks for the WSDL as "?wsdl"; browsers and health checks hit the
|
|
50
|
+
# bare path. Serving the WSDL for both is harmless and saves a support
|
|
51
|
+
# round trip when someone pastes the URL into a browser to check it.
|
|
52
|
+
return Response(content=wsdl, media_type=SOAP_CONTENT_TYPE)
|
|
53
|
+
|
|
54
|
+
@app.post(path)
|
|
55
|
+
async def handle_soap(request: Request) -> Response:
|
|
56
|
+
body = await request.body()
|
|
57
|
+
return Response(content=service.dispatch(body), media_type=SOAP_CONTENT_TYPE)
|
|
58
|
+
|
|
59
|
+
return app
|