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 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
+ ("<", "&lt;"),
21
+ (">", "&gt;"),
22
+ ('"', "&quot;"),
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
+ )
@@ -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
@@ -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