qubesync 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.
- qubesync/__init__.py +58 -0
- qubesync/client.py +134 -0
- qubesync/request_builder.py +194 -0
- qubesync-0.1.0.dist-info/METADATA +388 -0
- qubesync-0.1.0.dist-info/RECORD +8 -0
- qubesync-0.1.0.dist-info/WHEEL +5 -0
- qubesync-0.1.0.dist-info/licenses/LICENSE +21 -0
- qubesync-0.1.0.dist-info/top_level.txt +1 -0
qubesync/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QubeSync - Python library for QuickBooks integration via QubeSync API
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- QubeSync: Main API client for interacting with QubeSync service
|
|
6
|
+
- RequestBuilder: DSL for building QuickBooks XML requests
|
|
7
|
+
- Exception classes for error handling
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from qubesync import QubeSync, RequestBuilder
|
|
11
|
+
|
|
12
|
+
# API operations
|
|
13
|
+
connection = QubeSync.create_connection(options)
|
|
14
|
+
|
|
15
|
+
# Request building
|
|
16
|
+
request = RequestBuilder(version="16.0")
|
|
17
|
+
with request as r:
|
|
18
|
+
with r.QBXML() as qbxml:
|
|
19
|
+
# ... build request
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Import main classes for convenient access
|
|
23
|
+
from .client import (
|
|
24
|
+
QubeSync,
|
|
25
|
+
QubeSyncError,
|
|
26
|
+
StaleWebhookError,
|
|
27
|
+
InvalidWebhookSignatureError,
|
|
28
|
+
ConfigError
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .request_builder import (
|
|
32
|
+
RequestBuilder,
|
|
33
|
+
RequestElement,
|
|
34
|
+
FluentRequestBuilder
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Version info
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
# Public API
|
|
41
|
+
__all__ = [
|
|
42
|
+
# Main API client
|
|
43
|
+
"QubeSync",
|
|
44
|
+
|
|
45
|
+
# Request builders
|
|
46
|
+
"RequestBuilder",
|
|
47
|
+
"RequestElement",
|
|
48
|
+
"FluentRequestBuilder",
|
|
49
|
+
|
|
50
|
+
# Exceptions
|
|
51
|
+
"QubeSyncError",
|
|
52
|
+
"StaleWebhookError",
|
|
53
|
+
"InvalidWebhookSignatureError",
|
|
54
|
+
"ConfigError",
|
|
55
|
+
|
|
56
|
+
# Version
|
|
57
|
+
"__version__"
|
|
58
|
+
]
|
qubesync/client.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import requests
|
|
4
|
+
import hmac
|
|
5
|
+
import hashlib
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
class QubeSyncError(Exception): pass
|
|
9
|
+
class StaleWebhookError(QubeSyncError): pass
|
|
10
|
+
class InvalidWebhookSignatureError(QubeSyncError): pass
|
|
11
|
+
class ConfigError(QubeSyncError): pass
|
|
12
|
+
|
|
13
|
+
class QubeSync:
|
|
14
|
+
EXPECTED_SIGNATURE_SCHEMES = ["v1"]
|
|
15
|
+
|
|
16
|
+
def base_url():
|
|
17
|
+
return os.getenv("QUBE_URL", "https://qubesync.com/api/v1")
|
|
18
|
+
|
|
19
|
+
def default_headers():
|
|
20
|
+
return {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
"Accept": "application/json",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def api_key():
|
|
26
|
+
try:
|
|
27
|
+
return os.environ["QUBE_API_KEY"]
|
|
28
|
+
except KeyError:
|
|
29
|
+
raise ConfigError("QUBE_API_KEY not set in environment.")
|
|
30
|
+
|
|
31
|
+
def api_secret():
|
|
32
|
+
try:
|
|
33
|
+
return os.environ["QUBE_WEBHOOK_SECRET"]
|
|
34
|
+
except KeyError:
|
|
35
|
+
raise ConfigError("QUBE_WEBHOOK_SECRET not set in environment.")
|
|
36
|
+
|
|
37
|
+
def connection():
|
|
38
|
+
session = requests.Session()
|
|
39
|
+
session.auth = (QubeSync.api_key(), "")
|
|
40
|
+
return session
|
|
41
|
+
|
|
42
|
+
def get(url, headers={}):
|
|
43
|
+
resp = QubeSync.connection().get(f"{QubeSync.base_url()}/{url}", headers={**QubeSync.default_headers(), **headers})
|
|
44
|
+
if resp.status_code >= 200 and resp.status_code < 300:
|
|
45
|
+
return resp.json()
|
|
46
|
+
raise QubeSyncError(f"Unexpected response: {resp.status_code}\n{resp.text}")
|
|
47
|
+
|
|
48
|
+
def post(url, body=None, headers={}):
|
|
49
|
+
resp = QubeSync.connection().post(f"{QubeSync.base_url()}/{url}", headers={**QubeSync.default_headers(), **headers}, json=body)
|
|
50
|
+
if resp.status_code >= 200 and resp.status_code < 300:
|
|
51
|
+
return resp.json()
|
|
52
|
+
raise QubeSyncError(f"Unexpected response: {resp.status_code}\n{resp.text}")
|
|
53
|
+
|
|
54
|
+
def delete(url, headers={}):
|
|
55
|
+
resp = QubeSync.connection().delete(f"{QubeSync.base_url()}/{url}", headers={**QubeSync.default_headers(), **headers})
|
|
56
|
+
if resp.status_code >= 200 and resp.status_code < 300:
|
|
57
|
+
return True
|
|
58
|
+
raise QubeSyncError(f"Unexpected response: {resp.status_code}\n{resp.text}")
|
|
59
|
+
|
|
60
|
+
def create_connection(options={}):
|
|
61
|
+
resp = QubeSync.post("connections", body=options)
|
|
62
|
+
conn = resp.get("data", {})
|
|
63
|
+
if not conn.get("id"):
|
|
64
|
+
raise QubeSyncError(f"Could not create connection: {resp}")
|
|
65
|
+
return conn
|
|
66
|
+
|
|
67
|
+
def delete_connection(id):
|
|
68
|
+
return QubeSync.delete(f"connections/{id}")
|
|
69
|
+
|
|
70
|
+
def get_connection(id):
|
|
71
|
+
return QubeSync.get(f"connections/{id}").get("data")
|
|
72
|
+
|
|
73
|
+
def queue_request(connection_id, request):
|
|
74
|
+
if not (request.get("request_xml") or request.get("request_json")):
|
|
75
|
+
raise ValueError("must have either request_xml or request_json")
|
|
76
|
+
if not request.get("webhook_url"):
|
|
77
|
+
print("Warning: no webhook_url provided")
|
|
78
|
+
return QubeSync.post(f"connections/{connection_id}/queued_requests", {"queued_request": request}).get("data")
|
|
79
|
+
|
|
80
|
+
def get_request(id):
|
|
81
|
+
return QubeSync.get(f"queued_requests/{id}").get("data")
|
|
82
|
+
|
|
83
|
+
def get_requests(connection_id):
|
|
84
|
+
return QubeSync.get(f"connections/{connection_id}/queued_requests").get("data")
|
|
85
|
+
|
|
86
|
+
def delete_request(id):
|
|
87
|
+
return QubeSync.delete(f"queued_requests/{id}")
|
|
88
|
+
|
|
89
|
+
def get_qwc(connection_id):
|
|
90
|
+
return QubeSync.post(f"connections/{connection_id}/qwc").get("qwc")
|
|
91
|
+
|
|
92
|
+
def generate_password(connection_id):
|
|
93
|
+
resp = QubeSync.post(f"connections/{connection_id}/password")
|
|
94
|
+
password = resp.get("data", {}).get("password")
|
|
95
|
+
if not password:
|
|
96
|
+
raise QubeSyncError(f"Password not found: {resp}")
|
|
97
|
+
return password
|
|
98
|
+
|
|
99
|
+
def extract_signature_meta(header, expected_schemes=["v1"]):
|
|
100
|
+
# Example header: t=timestamp,v1=signature1,v1=signature2
|
|
101
|
+
# note: signatures may include '=' characters
|
|
102
|
+
parts = header.split(',')
|
|
103
|
+
timestamp = None
|
|
104
|
+
signatures = []
|
|
105
|
+
for part in parts:
|
|
106
|
+
k, v = part.split('=', 1)
|
|
107
|
+
if k == 't':
|
|
108
|
+
timestamp = int(v)
|
|
109
|
+
elif k in expected_schemes:
|
|
110
|
+
signatures.append(v)
|
|
111
|
+
if timestamp is None or not signatures:
|
|
112
|
+
raise InvalidWebhookSignatureError("Invalid signature header format.")
|
|
113
|
+
return timestamp, signatures
|
|
114
|
+
|
|
115
|
+
def sign_payload(payload):
|
|
116
|
+
return hmac.new(
|
|
117
|
+
QubeSync.api_secret().encode("utf-8"),
|
|
118
|
+
payload.encode("utf-8"),
|
|
119
|
+
hashlib.sha256
|
|
120
|
+
).hexdigest()
|
|
121
|
+
|
|
122
|
+
def verify_and_build_webhook(body, signature, max_age=500):
|
|
123
|
+
timestamp, signatures = QubeSync.extract_signature_meta(signature, QubeSync.EXPECTED_SIGNATURE_SCHEMES)
|
|
124
|
+
|
|
125
|
+
# print for debugging
|
|
126
|
+
print(f"Timestamp: {timestamp}, Signatures: {signatures}")
|
|
127
|
+
if timestamp < time.time() - max_age:
|
|
128
|
+
raise StaleWebhookError(f"Timestamp more than {max_age} seconds old.")
|
|
129
|
+
|
|
130
|
+
signed_payload = QubeSync.sign_payload(str(timestamp) + '.' + body)
|
|
131
|
+
if any(hmac.compare_digest(signed_payload, sig) for sig in signatures):
|
|
132
|
+
return json.loads(body)
|
|
133
|
+
|
|
134
|
+
raise InvalidWebhookSignatureError("Webhook signature mismatch")
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RequestBuilder - A Python DSL for building QuickBooks XML requests
|
|
3
|
+
|
|
4
|
+
This module provides a fluent interface for constructing QBXML requests
|
|
5
|
+
in a JSON format for API consumption. QuBeSync API will clean the data of any special
|
|
6
|
+
characters that cause QuickBooks errors and then convert this JSON back into
|
|
7
|
+
QBXML when sending requests to QuickBooks.
|
|
8
|
+
|
|
9
|
+
Example usage:
|
|
10
|
+
from qubesync import RequestBuilder
|
|
11
|
+
|
|
12
|
+
request = RequestBuilder(version="16.0")
|
|
13
|
+
with request as r:
|
|
14
|
+
with r.QBXML() as qbxml:
|
|
15
|
+
with qbxml.QBXMLMsgsRq(onError='stopOnError') as msgs:
|
|
16
|
+
with msgs.CustomerQueryRq(requestID='asdf1234', iterator='Start') as query:
|
|
17
|
+
query.MaxReturned(20)
|
|
18
|
+
query.IncludeRetElement('ListID')
|
|
19
|
+
query.IncludeRetElement('Name')
|
|
20
|
+
query.IncludeRetElement('FullName')
|
|
21
|
+
query.IncludeRetElement('IsActive')
|
|
22
|
+
|
|
23
|
+
json_data = request.as_json()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RequestElement:
|
|
28
|
+
"""Represents a single element in the request structure."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, name, attributes=None, text=None, parent=None):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.attributes = attributes or {}
|
|
33
|
+
self.text = text
|
|
34
|
+
self.children = []
|
|
35
|
+
self.parent = parent
|
|
36
|
+
|
|
37
|
+
def __enter__(self):
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def __getattr__(self, name):
|
|
44
|
+
"""Create a new child element with the given name."""
|
|
45
|
+
def element_creator(*args, **kwargs):
|
|
46
|
+
# Handle text content as first positional argument
|
|
47
|
+
text = args[0] if args else None
|
|
48
|
+
|
|
49
|
+
# Convert kwargs to attributes, handling Python reserved words
|
|
50
|
+
attributes = {}
|
|
51
|
+
for key, value in kwargs.items():
|
|
52
|
+
# Convert snake_case to camelCase if needed, but preserve exact case for QB fields
|
|
53
|
+
if key.endswith('_'):
|
|
54
|
+
key = key[:-1] # Remove trailing underscore for reserved words
|
|
55
|
+
attributes[key] = value
|
|
56
|
+
|
|
57
|
+
child = RequestElement(name, attributes, text, self)
|
|
58
|
+
self.children.append(child)
|
|
59
|
+
return child
|
|
60
|
+
|
|
61
|
+
return element_creator
|
|
62
|
+
|
|
63
|
+
def as_dict(self):
|
|
64
|
+
"""Convert this element to a dictionary representation."""
|
|
65
|
+
result = {"name": self.name}
|
|
66
|
+
|
|
67
|
+
if self.attributes:
|
|
68
|
+
result["attributes"] = self.attributes
|
|
69
|
+
|
|
70
|
+
if self.text is not None:
|
|
71
|
+
result["text"] = self.text
|
|
72
|
+
|
|
73
|
+
if self.children:
|
|
74
|
+
result["children"] = [child.as_dict() for child in self.children]
|
|
75
|
+
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RequestBuilder:
|
|
80
|
+
"""A fluent interface for building QuickBooks XML requests."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, version="16.0"):
|
|
83
|
+
self.version = version
|
|
84
|
+
self.root_elements = []
|
|
85
|
+
|
|
86
|
+
def __enter__(self):
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def __getattr__(self, name):
|
|
93
|
+
"""Create a new root element with the given name."""
|
|
94
|
+
def element_creator(*args, **kwargs):
|
|
95
|
+
# Handle text content as first positional argument
|
|
96
|
+
text = args[0] if args else None
|
|
97
|
+
|
|
98
|
+
# Convert kwargs to attributes
|
|
99
|
+
attributes = {}
|
|
100
|
+
for key, value in kwargs.items():
|
|
101
|
+
# Handle Python reserved words by removing trailing underscore
|
|
102
|
+
if key.endswith('_'):
|
|
103
|
+
key = key[:-1]
|
|
104
|
+
attributes[key] = value
|
|
105
|
+
|
|
106
|
+
element = RequestElement(name, attributes, text)
|
|
107
|
+
self.root_elements.append(element)
|
|
108
|
+
return element
|
|
109
|
+
|
|
110
|
+
return element_creator
|
|
111
|
+
|
|
112
|
+
def as_json(self):
|
|
113
|
+
"""Convert the request to JSON format."""
|
|
114
|
+
return {
|
|
115
|
+
"version": self.version,
|
|
116
|
+
"request": [element.as_dict() for element in self.root_elements]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def as_dict(self):
|
|
120
|
+
"""Alias for as_json() for consistency."""
|
|
121
|
+
return self.as_json()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class FluentRequestBuilder(RequestBuilder):
|
|
125
|
+
"""
|
|
126
|
+
Alternative builder with method chaining support for a more Ruby-like feel.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
request = FluentRequestBuilder(version="16.0")
|
|
130
|
+
request.QBXML().QBXMLMsgsRq(onError='stopOnError').CustomerQueryRq(
|
|
131
|
+
requestID='asdf1234', iterator='Start'
|
|
132
|
+
).add_children([
|
|
133
|
+
('MaxReturned', 20),
|
|
134
|
+
('IncludeRetElement', 'ListID'),
|
|
135
|
+
('IncludeRetElement', 'Name'),
|
|
136
|
+
('IncludeRetElement', 'FullName'),
|
|
137
|
+
('IncludeRetElement', 'IsActive')
|
|
138
|
+
])
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, version="16.0"):
|
|
142
|
+
super().__init__(version)
|
|
143
|
+
self._current_element = None
|
|
144
|
+
|
|
145
|
+
def __getattr__(self, name):
|
|
146
|
+
"""Create a new element and return self for chaining."""
|
|
147
|
+
def element_creator(*args, **kwargs):
|
|
148
|
+
text = args[0] if args else None
|
|
149
|
+
attributes = {k.rstrip('_'): v for k, v in kwargs.items()}
|
|
150
|
+
|
|
151
|
+
if self._current_element is None:
|
|
152
|
+
# Creating root element
|
|
153
|
+
element = RequestElement(name, attributes, text)
|
|
154
|
+
self.root_elements.append(element)
|
|
155
|
+
self._current_element = element
|
|
156
|
+
else:
|
|
157
|
+
# Creating child element
|
|
158
|
+
element = RequestElement(name, attributes, text, self._current_element)
|
|
159
|
+
self._current_element.children.append(element)
|
|
160
|
+
self._current_element = element
|
|
161
|
+
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
return element_creator
|
|
165
|
+
|
|
166
|
+
def add_children(self, children_data):
|
|
167
|
+
"""Add multiple children to the current element."""
|
|
168
|
+
if self._current_element is None:
|
|
169
|
+
raise ValueError("No current element to add children to")
|
|
170
|
+
|
|
171
|
+
for child_data in children_data:
|
|
172
|
+
if isinstance(child_data, tuple) and len(child_data) == 2:
|
|
173
|
+
name, text = child_data
|
|
174
|
+
child = RequestElement(name, text=text, parent=self._current_element)
|
|
175
|
+
self._current_element.children.append(child)
|
|
176
|
+
elif isinstance(child_data, dict):
|
|
177
|
+
name = child_data.get('name')
|
|
178
|
+
attributes = child_data.get('attributes', {})
|
|
179
|
+
text = child_data.get('text')
|
|
180
|
+
child = RequestElement(name, attributes, text, self._current_element)
|
|
181
|
+
self._current_element.children.append(child)
|
|
182
|
+
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def parent(self):
|
|
186
|
+
"""Move back to the parent element for continued chaining."""
|
|
187
|
+
if self._current_element and self._current_element.parent:
|
|
188
|
+
self._current_element = self._current_element.parent
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def root(self):
|
|
192
|
+
"""Return to the root level."""
|
|
193
|
+
self._current_element = None
|
|
194
|
+
return self
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qubesync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library for QuickBooks Desktop / Enterprise integration via QubeSync API with intuitive DSL for building QBXML requests.
|
|
5
|
+
Author-email: QubeSync <support@qubesync.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/qubeintegrations/qube_sync_py
|
|
8
|
+
Project-URL: Documentation, https://docs.qubesync.com
|
|
9
|
+
Project-URL: Repository, https://github.com/qubeintegrations/qube_sync_py
|
|
10
|
+
Project-URL: Issues, https://github.com/qubeintegrations/qube_sync_py/issues
|
|
11
|
+
Keywords: quickbooks,qbxml,accounting,integration,api,dsl
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: requests
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# QubeSync Python Library
|
|
29
|
+
|
|
30
|
+
A Python library for seamless QuickBooks integration via the QubeSync API. This library provides both a robust API client for QubeSync operations and a powerful DSL (Domain Specific Language) for building QuickBooks XML requests.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- 🔌 **Complete QubeSync API client** - Connect, queue requests, handle webhooks
|
|
35
|
+
- 🏗️ **Intuitive Request Builder DSL** - Build QBXML requests with Python syntax
|
|
36
|
+
- 🔒 **Webhook security** - Built-in signature verification for webhooks
|
|
37
|
+
- 🚀 **Multiple syntax styles** - Context managers, fluent chaining, or Ruby-like syntax
|
|
38
|
+
- ✅ **Fully tested** - comprehensive tests ensuring reliability
|
|
39
|
+
- 📦 **Clean imports** - Simple `from qubesync import ...` syntax
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install qubesync
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or install from source:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/qubeintegrations/qube_sync_py
|
|
51
|
+
cd qube_sync_py
|
|
52
|
+
pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
### Environment Setup
|
|
58
|
+
|
|
59
|
+
Set your QubeSync credentials:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export QUBE_API_KEY="your_api_key_here"
|
|
63
|
+
export QUBE_WEBHOOK_SECRET="your_webhook_secret_here"
|
|
64
|
+
export QUBE_URL="https://qubesync.com/api/v1" # Optional, defaults to this
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Basic Usage
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from qubesync import QubeSync, RequestBuilder
|
|
71
|
+
|
|
72
|
+
# 1. Create a connection
|
|
73
|
+
connection = QubeSync.create_connection({
|
|
74
|
+
"name": "My Customer Inc.", # optional (will populate from QuickBooks on first sync if blank)
|
|
75
|
+
"redirect_url": "https://your-app.com/qbd-setup/callback"
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
# 2. Build a request using the DSL
|
|
79
|
+
request = RequestBuilder(version="16.0")
|
|
80
|
+
with request as r:
|
|
81
|
+
with r.QBXML() as qbxml:
|
|
82
|
+
with qbxml.QBXMLMsgsRq(onError='stopOnError') as msgs:
|
|
83
|
+
with msgs.CustomerQueryRq(requestID='customer_123', iterator='Start') as query:
|
|
84
|
+
query.MaxReturned(50)
|
|
85
|
+
query.IncludeRetElement('ListID')
|
|
86
|
+
query.IncludeRetElement('Name')
|
|
87
|
+
query.IncludeRetElement('Email')
|
|
88
|
+
|
|
89
|
+
# 3. Queue the request
|
|
90
|
+
response = QubeSync.queue_request(connection['id'], {
|
|
91
|
+
'request_json': request.as_json(),
|
|
92
|
+
'webhook_url': 'https://your-app.com/webhooks/qb_response'
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
print(f"Request queued with ID: {response['id']}")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## API Client Reference
|
|
99
|
+
|
|
100
|
+
### Connection Management
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from qubesync import QubeSync
|
|
104
|
+
|
|
105
|
+
# Create a new connection
|
|
106
|
+
connection = QubeSync.create_connection({
|
|
107
|
+
"name": "My Customer Inc.", # optional (will populate from QuickBooks on first sync if blank)
|
|
108
|
+
"redirect_url": "https://your-app.com/qbd-setup/callback"
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
# Get connection details
|
|
112
|
+
connection = QubeSync.get_connection(connection_id)
|
|
113
|
+
|
|
114
|
+
# Delete a connection
|
|
115
|
+
QubeSync.delete_connection(connection_id)
|
|
116
|
+
|
|
117
|
+
# Generate QWC file for QuickBooks
|
|
118
|
+
qwc_content = QubeSync.get_qwc(connection_id)
|
|
119
|
+
|
|
120
|
+
# Generate password for QuickBooks connection
|
|
121
|
+
password = QubeSync.generate_password(connection_id)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Request Management
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Queue a request
|
|
128
|
+
request_data = {
|
|
129
|
+
'request_json': {...}, # Built using RequestBuilder
|
|
130
|
+
'webhook_url': 'https://your-app.com/webhook'
|
|
131
|
+
}
|
|
132
|
+
response = QubeSync.queue_request(connection_id, request_data)
|
|
133
|
+
|
|
134
|
+
# Get request status
|
|
135
|
+
request = QubeSync.get_request(request_id)
|
|
136
|
+
|
|
137
|
+
# Get all requests for a connection
|
|
138
|
+
requests = QubeSync.get_requests(connection_id)
|
|
139
|
+
|
|
140
|
+
# Delete a request
|
|
141
|
+
QubeSync.delete_request(request_id)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Webhook Handling
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# Verify and parse webhook payload
|
|
148
|
+
def handle_webhook(request):
|
|
149
|
+
signature = request.headers.get('X-Qube-Signature')
|
|
150
|
+
body = request.body.decode('utf-8')
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
webhook_data = QubeSync.verify_and_build_webhook(body, signature)
|
|
154
|
+
# Process the verified webhook data in the background
|
|
155
|
+
# to ensure a quick response
|
|
156
|
+
print(f"Received response for request: {webhook_data['request_id']}")
|
|
157
|
+
return {"status": "success"}
|
|
158
|
+
except (StaleWebhookError, InvalidWebhookSignatureError) as e:
|
|
159
|
+
print(f"Webhook verification failed: {e}")
|
|
160
|
+
return {"status": "error"}, 400
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## RequestBuilder DSL
|
|
164
|
+
|
|
165
|
+
The RequestBuilder provides an intuitive Python DSL for constructing QBXML requests that get converted to JSON format for the QubeSync API.
|
|
166
|
+
|
|
167
|
+
### Context Manager Style (Recommended)
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from qubesync import RequestBuilder
|
|
171
|
+
|
|
172
|
+
request = RequestBuilder(version="16.0")
|
|
173
|
+
with request as r:
|
|
174
|
+
with r.QBXML() as qbxml:
|
|
175
|
+
with qbxml.QBXMLMsgsRq(onError='stopOnError') as msgs:
|
|
176
|
+
with msgs.InvoiceQueryRq(requestID='inv_query_123') as query:
|
|
177
|
+
query.MaxReturned(25)
|
|
178
|
+
query.IncludeRetElement('TxnID')
|
|
179
|
+
query.IncludeRetElement('TxnNumber')
|
|
180
|
+
query.IncludeRetElement('CustomerRef')
|
|
181
|
+
query.IncludeRetElement('TxnDate')
|
|
182
|
+
query.IncludeRetElement('DueDate')
|
|
183
|
+
query.IncludeRetElement('BalanceRemaining')
|
|
184
|
+
|
|
185
|
+
json_data = request.as_json()
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Fluent Chaining Style
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from qubesync import FluentRequestBuilder
|
|
192
|
+
|
|
193
|
+
request = FluentRequestBuilder(version="16.0")
|
|
194
|
+
request.QBXML().QBXMLMsgsRq(onError='stopOnError').ItemQueryRq(
|
|
195
|
+
requestID='item_query_456'
|
|
196
|
+
).add_children([
|
|
197
|
+
('MaxReturned', 100),
|
|
198
|
+
('IncludeRetElement', 'ListID'),
|
|
199
|
+
('IncludeRetElement', 'Name'),
|
|
200
|
+
('IncludeRetElement', 'Type'),
|
|
201
|
+
('IncludeRetElement', 'SalePrice')
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
json_data = request.as_json()
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Ruby-like Style
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from qubesync import RequestBuilder
|
|
211
|
+
|
|
212
|
+
request = RequestBuilder(version="16.0")
|
|
213
|
+
qbxml = request.QBXML()
|
|
214
|
+
msgs = qbxml.QBXMLMsgsRq(onError='stopOnError')
|
|
215
|
+
query = msgs.CustomerQueryRq(requestID='customer_789', iterator='Start')
|
|
216
|
+
query.MaxReturned(30)
|
|
217
|
+
query.IncludeRetElement('ListID')
|
|
218
|
+
query.IncludeRetElement('Name')
|
|
219
|
+
query.IncludeRetElement('CompanyName')
|
|
220
|
+
|
|
221
|
+
json_data = request.as_json()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Handling Python Reserved Words
|
|
225
|
+
|
|
226
|
+
Use trailing underscores for Python reserved words:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
request = RequestBuilder(version="16.0")
|
|
230
|
+
with request as r:
|
|
231
|
+
with r.QBXML() as qbxml:
|
|
232
|
+
with qbxml.QBXMLMsgsRq(onError='stopOnError') as msgs:
|
|
233
|
+
# Use trailing underscore for 'class', 'for', 'if', etc.
|
|
234
|
+
with msgs.CustomQueryRq(class_='invoice', for_='reports') as query:
|
|
235
|
+
query.MaxReturned(100)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Complex Nested Structures
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
request = RequestBuilder(version="16.0")
|
|
242
|
+
with request as r:
|
|
243
|
+
with r.QBXML() as qbxml:
|
|
244
|
+
with qbxml.QBXMLMsgsRq(onError='stopOnError') as msgs:
|
|
245
|
+
with msgs.InvoiceAddRq(requestID='invoice_add_999') as add_req:
|
|
246
|
+
with add_req.InvoiceAdd() as invoice:
|
|
247
|
+
with invoice.CustomerRef() as customer_ref:
|
|
248
|
+
customer_ref.ListID('80000001-1234567890')
|
|
249
|
+
|
|
250
|
+
invoice.TxnDate('2024-01-15')
|
|
251
|
+
invoice.RefNumber('INV-2024-001')
|
|
252
|
+
|
|
253
|
+
with invoice.InvoiceLineAdd() as line:
|
|
254
|
+
with line.ItemRef() as item_ref:
|
|
255
|
+
item_ref.ListID('80000002-1234567890')
|
|
256
|
+
line.Desc('Professional Services')
|
|
257
|
+
line.Quantity(10)
|
|
258
|
+
line.Rate(150.00)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Example Output
|
|
262
|
+
|
|
263
|
+
The RequestBuilder generates clean JSON that the QubeSync API processes:
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"version": "16.0",
|
|
268
|
+
"request": [
|
|
269
|
+
{
|
|
270
|
+
"name": "QBXML",
|
|
271
|
+
"children": [
|
|
272
|
+
{
|
|
273
|
+
"name": "QBXMLMsgsRq",
|
|
274
|
+
"attributes": {"onError": "stopOnError"},
|
|
275
|
+
"children": [
|
|
276
|
+
{
|
|
277
|
+
"name": "CustomerQueryRq",
|
|
278
|
+
"attributes": {"requestID": "customer_123", "iterator": "Start"},
|
|
279
|
+
"children": [
|
|
280
|
+
{"name": "MaxReturned", "text": 50},
|
|
281
|
+
{"name": "IncludeRetElement", "text": "ListID"},
|
|
282
|
+
{"name": "IncludeRetElement", "text": "Name"},
|
|
283
|
+
{"name": "IncludeRetElement", "text": "Email"}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Error Handling
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from qubesync import (
|
|
298
|
+
QubeSync,
|
|
299
|
+
QubeSyncError,
|
|
300
|
+
ConfigError,
|
|
301
|
+
StaleWebhookError,
|
|
302
|
+
InvalidWebhookSignatureError
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
connection = QubeSync.create_connection({"name": "Test"})
|
|
307
|
+
except ConfigError as e:
|
|
308
|
+
print(f"Configuration error: {e}")
|
|
309
|
+
except QubeSyncError as e:
|
|
310
|
+
print(f"API error: {e}")
|
|
311
|
+
|
|
312
|
+
# Webhook verification
|
|
313
|
+
try:
|
|
314
|
+
webhook_data = QubeSync.verify_and_build_webhook(body, signature)
|
|
315
|
+
except StaleWebhookError:
|
|
316
|
+
print("Webhook is too old")
|
|
317
|
+
except InvalidWebhookSignatureError:
|
|
318
|
+
print("Invalid webhook signature")
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Available Classes
|
|
322
|
+
|
|
323
|
+
### API Client
|
|
324
|
+
- `QubeSync` - Main API client for all QubeSync operations
|
|
325
|
+
- `QubeSyncError` - Base exception class
|
|
326
|
+
- `ConfigError` - Configuration/environment variable errors
|
|
327
|
+
- `StaleWebhookError` - Webhook timestamp too old
|
|
328
|
+
- `InvalidWebhookSignatureError` - Webhook signature verification failed
|
|
329
|
+
|
|
330
|
+
### Request Building
|
|
331
|
+
- `RequestBuilder` - Context manager style DSL (recommended)
|
|
332
|
+
- `FluentRequestBuilder` - Method chaining style DSL
|
|
333
|
+
- `RequestElement` - Individual request element (advanced usage)
|
|
334
|
+
|
|
335
|
+
## Development
|
|
336
|
+
|
|
337
|
+
### Running Tests
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
# Run all tests
|
|
341
|
+
python run_tests.py
|
|
342
|
+
|
|
343
|
+
# Run with verbose output
|
|
344
|
+
python run_tests.py --verbose
|
|
345
|
+
|
|
346
|
+
# Run specific test file
|
|
347
|
+
python run_tests.py tests/test_request_builder.py
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Running Examples
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
# Run all examples
|
|
354
|
+
python examples.py
|
|
355
|
+
|
|
356
|
+
# Use virtual environment
|
|
357
|
+
./bin/python examples.py
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Contributing
|
|
361
|
+
|
|
362
|
+
1. Fork the repository
|
|
363
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
364
|
+
3. Make your changes
|
|
365
|
+
4. Run tests (`python run_tests.py`)
|
|
366
|
+
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
367
|
+
6. Push to the branch (`git push origin feature/amazing-feature`)
|
|
368
|
+
7. Open a Pull Request
|
|
369
|
+
|
|
370
|
+
## License
|
|
371
|
+
|
|
372
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
373
|
+
|
|
374
|
+
## Support
|
|
375
|
+
|
|
376
|
+
- 📧 Email: support@qubesync.com
|
|
377
|
+
- 📖 Documentation: [QubeSync Docs](https://docs.qubesync.com)
|
|
378
|
+
- 🐛 Issues: [GitHub Issues](https://github.com/qubeintegrations/qube_sync_py/issues)
|
|
379
|
+
|
|
380
|
+
## Changelog
|
|
381
|
+
|
|
382
|
+
### v0.1.0
|
|
383
|
+
- Initial release
|
|
384
|
+
- Complete QubeSync API client
|
|
385
|
+
- RequestBuilder DSL with multiple syntax styles
|
|
386
|
+
- Comprehensive test suite
|
|
387
|
+
- Webhook security verification
|
|
388
|
+
- Clean module structure with intuitive imports
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
qubesync/__init__.py,sha256=lTF0dXZlrvBB0swsDQRZGP5NWpsf_x1rznlM2a_3W6Q,1205
|
|
2
|
+
qubesync/client.py,sha256=mBZJZ9mO7rVdJVNJkoWZ4ktGKagR4VT1LfIOCVcUbBY,5128
|
|
3
|
+
qubesync/request_builder.py,sha256=ONxD2KQ7EWinnajE5fyy0JP_PDtF-rJmtjjic-MnHO4,6987
|
|
4
|
+
qubesync-0.1.0.dist-info/licenses/LICENSE,sha256=8JPn8nIUYBRz5p6R6QACgOXQHYRYRYctC2kV7hzS5jQ,1065
|
|
5
|
+
qubesync-0.1.0.dist-info/METADATA,sha256=O_P5ZQcwJT_6scCBSCcezHJnbW4uX4FlmEeN25QxGA4,11412
|
|
6
|
+
qubesync-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
qubesync-0.1.0.dist-info/top_level.txt,sha256=9iCcZOQbm8qaIUhIqiPo7G9hYVJCP6M2H50Rtie_8Vo,9
|
|
8
|
+
qubesync-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 QubeSync
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qubesync
|