qbd-agent-kit 0.2.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.
- qbd_agent_kit/__init__.py +3 -0
- qbd_agent_kit/__main__.py +3 -0
- qbd_agent_kit/bridge/__init__.py +0 -0
- qbd_agent_kit/bridge/com.py +162 -0
- qbd_agent_kit/cli.py +459 -0
- qbd_agent_kit/config.py +94 -0
- qbd_agent_kit/demo/__init__.py +0 -0
- qbd_agent_kit/demo/company.py +480 -0
- qbd_agent_kit/demo/transport.py +917 -0
- qbd_agent_kit/errors.py +130 -0
- qbd_agent_kit/jsutil.py +261 -0
- qbd_agent_kit/qb/__init__.py +0 -0
- qbd_agent_kit/qb/catalog.py +164 -0
- qbd_agent_kit/qb/client.py +164 -0
- qbd_agent_kit/qb/dates.py +161 -0
- qbd_agent_kit/qbxml/__init__.py +0 -0
- qbd_agent_kit/qbxml/build.py +147 -0
- qbd_agent_kit/qbxml/parse.py +226 -0
- qbd_agent_kit/qbxml/schema.json +1 -0
- qbd_agent_kit/qbxml/schema.py +63 -0
- qbd_agent_kit/qbxml/xml.py +150 -0
- qbd_agent_kit/safety/__init__.py +0 -0
- qbd_agent_kit/safety/audit.py +55 -0
- qbd_agent_kit/safety/privacy.py +52 -0
- qbd_agent_kit/tools/__init__.py +32 -0
- qbd_agent_kit/tools/args.py +95 -0
- qbd_agent_kit/tools/common.py +158 -0
- qbd_agent_kit/tools/files.py +453 -0
- qbd_agent_kit/tools/insights.py +2646 -0
- qbd_agent_kit/tools/read.py +963 -0
- qbd_agent_kit/tools/write.py +881 -0
- qbd_agent_kit-0.2.0.dist-info/METADATA +264 -0
- qbd_agent_kit-0.2.0.dist-info/RECORD +37 -0
- qbd_agent_kit-0.2.0.dist-info/WHEEL +5 -0
- qbd_agent_kit-0.2.0.dist-info/entry_points.txt +2 -0
- qbd_agent_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
- qbd_agent_kit-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The only part of the kit that talks to QuickBooks: the QuickBooks SDK's COM component (QBXMLRP2), through pywin32.
|
|
2
|
+
|
|
3
|
+
Every COM call runs on one worker thread. QuickBooks cannot cancel a request, so when one outlives its timeout the
|
|
4
|
+
caller gets QbTimeoutError while the worker stays inside QuickBooks. The transport then refuses further requests, which
|
|
5
|
+
would only queue behind the stuck one, and the process should exit without waiting for it."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import queue
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from ..errors import QbConnectionError, QbNoSessionError, QbTimeoutError
|
|
14
|
+
|
|
15
|
+
# pythoncom joins COM on the thread that first imports it, as a single-threaded apartment by default, and the first such
|
|
16
|
+
# apartment is the process's main one. The SDK hangs before it reaches QuickBooks when the main apartment's thread is
|
|
17
|
+
# blocked waiting, as the CLI's main thread is. So the main thread joins the multithreaded apartment instead, and the
|
|
18
|
+
# worker thread below becomes the main single-threaded apartment.
|
|
19
|
+
sys.coinit_flags = 0 # COINIT_MULTITHREADED
|
|
20
|
+
|
|
21
|
+
LOCAL_QBD = 1 # OpenConnection2: QuickBooks Desktop on this PC
|
|
22
|
+
OPEN_ANY_MODE = 2 # BeginSession: single- or multi-user, whichever the file is open in
|
|
23
|
+
XML_PARSE_ERROR = "0x80040400"
|
|
24
|
+
BUSY = "QuickBooks is still busy with an earlier request that did not answer in time."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def com_error(e: BaseException) -> QbConnectionError:
|
|
28
|
+
"""pywin32's com_error as QuickBooks' own code and message, which the SDK puts in the exception info."""
|
|
29
|
+
hr, message, info = (list(e.args) + [None, None, None])[:3]
|
|
30
|
+
if isinstance(info, tuple) and len(info) > 5 and info[5]:
|
|
31
|
+
hr, message = info[5], info[2] or message
|
|
32
|
+
code = f"0x{hr & 0xFFFFFFFF:08X}" if isinstance(hr, int) else str(hr)
|
|
33
|
+
return QbConnectionError(code, str(message or "QuickBooks returned an error").strip())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ComTransport:
|
|
37
|
+
kind = "com"
|
|
38
|
+
|
|
39
|
+
def __init__(self, app_id: str, app_name: str, company_file: str, open_timeout: float, timeout: float):
|
|
40
|
+
self.app_id = app_id
|
|
41
|
+
self.app_name = app_name
|
|
42
|
+
self.company_file = company_file # "" = the file open in QuickBooks
|
|
43
|
+
self.open_timeout = open_timeout # includes the time a person needs to answer the permission dialog
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.stuck = False # a call outlived its timeout and is still inside QuickBooks
|
|
46
|
+
self._jobs: queue.Queue = queue.Queue()
|
|
47
|
+
self._worker: threading.Thread | None = None
|
|
48
|
+
self._session: dict[str, Any] | None = None
|
|
49
|
+
self._rp: Any = None # the following three are used on the worker thread only
|
|
50
|
+
self._connected = False
|
|
51
|
+
self._ticket: Any = None
|
|
52
|
+
|
|
53
|
+
def open(self) -> dict[str, Any]:
|
|
54
|
+
if self._session is None:
|
|
55
|
+
self._session = self._call(self._begin, self.open_timeout, QbNoSessionError(self.open_timeout))
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
def process(self, qbxml: str) -> str:
|
|
59
|
+
self.open()
|
|
60
|
+
try:
|
|
61
|
+
return self._call(lambda: str(self._rp.ProcessRequest(self._ticket, qbxml)), self.timeout)
|
|
62
|
+
except QbConnectionError as e:
|
|
63
|
+
# Anything but a bad request may mean the session is gone (file closed, QuickBooks quit): start fresh next time.
|
|
64
|
+
if e.code != XML_PARSE_ERROR:
|
|
65
|
+
self.close()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
"""Ends the session and stops the worker. Does nothing while a call is stuck in QuickBooks."""
|
|
70
|
+
if self._worker is None or self.stuck:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
self._call(self._end, 15)
|
|
74
|
+
except QbConnectionError:
|
|
75
|
+
pass
|
|
76
|
+
if not self.stuck:
|
|
77
|
+
self._jobs.put(None)
|
|
78
|
+
self._worker = None
|
|
79
|
+
self._session = None
|
|
80
|
+
|
|
81
|
+
def _call(self, fn: Callable[[], Any], timeout: float, late: QbConnectionError | None = None) -> Any:
|
|
82
|
+
if self.stuck:
|
|
83
|
+
raise QbTimeoutError(timeout, BUSY)
|
|
84
|
+
self._start()
|
|
85
|
+
box: list[tuple[bool, Any]] = []
|
|
86
|
+
done = threading.Event()
|
|
87
|
+
self._jobs.put((fn, box, done))
|
|
88
|
+
if not done.wait(timeout):
|
|
89
|
+
self.stuck = True
|
|
90
|
+
raise late or QbTimeoutError(timeout)
|
|
91
|
+
ok, value = box[0]
|
|
92
|
+
if ok:
|
|
93
|
+
return value
|
|
94
|
+
raise value
|
|
95
|
+
|
|
96
|
+
def _start(self) -> None:
|
|
97
|
+
if self._worker is not None:
|
|
98
|
+
return
|
|
99
|
+
if sys.platform != "win32":
|
|
100
|
+
raise QbConnectionError(
|
|
101
|
+
"not-windows",
|
|
102
|
+
"QuickBooks Desktop can only be reached from the Windows PC it runs on.",
|
|
103
|
+
"Run qbd on that PC, or add --demo to try it with the sample company.",
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
import pythoncom # noqa: F401
|
|
107
|
+
import win32com.client # noqa: F401
|
|
108
|
+
except ImportError as e:
|
|
109
|
+
raise QbConnectionError("no-pywin32", f"pywin32 is missing ({e}).", "Install it with: python -m pip install pywin32") from e
|
|
110
|
+
self._worker = threading.Thread(target=self._serve, name="quickbooks", daemon=True)
|
|
111
|
+
self._worker.start()
|
|
112
|
+
|
|
113
|
+
def _serve(self) -> None:
|
|
114
|
+
import pythoncom
|
|
115
|
+
import pywintypes
|
|
116
|
+
|
|
117
|
+
pythoncom.CoInitialize()
|
|
118
|
+
try:
|
|
119
|
+
while True:
|
|
120
|
+
job = self._jobs.get()
|
|
121
|
+
if job is None:
|
|
122
|
+
return
|
|
123
|
+
fn, box, done = job
|
|
124
|
+
try:
|
|
125
|
+
box.append((True, fn()))
|
|
126
|
+
except pywintypes.com_error as e:
|
|
127
|
+
box.append((False, com_error(e)))
|
|
128
|
+
except Exception as e: # noqa: BLE001 - handed to the caller
|
|
129
|
+
box.append((False, e))
|
|
130
|
+
done.set()
|
|
131
|
+
finally:
|
|
132
|
+
pythoncom.CoUninitialize()
|
|
133
|
+
|
|
134
|
+
def _begin(self) -> dict[str, Any]:
|
|
135
|
+
import win32com.client
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
if self._rp is None:
|
|
139
|
+
self._rp = win32com.client.Dispatch("QBXMLRP2.RequestProcessor")
|
|
140
|
+
if not self._connected:
|
|
141
|
+
self._rp.OpenConnection2(self.app_id, self.app_name, LOCAL_QBD)
|
|
142
|
+
self._connected = True
|
|
143
|
+
self._ticket = self._rp.BeginSession(self.company_file, OPEN_ANY_MODE)
|
|
144
|
+
return {"companyFile": str(self._rp.GetCurrentCompanyFileName(self._ticket))}
|
|
145
|
+
except Exception:
|
|
146
|
+
self._end()
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _end(self) -> None:
|
|
150
|
+
if self._ticket is not None:
|
|
151
|
+
try:
|
|
152
|
+
self._rp.EndSession(self._ticket)
|
|
153
|
+
except Exception: # noqa: BLE001 - the session may already be gone
|
|
154
|
+
pass
|
|
155
|
+
self._ticket = None
|
|
156
|
+
if self._connected:
|
|
157
|
+
try:
|
|
158
|
+
self._rp.CloseConnection()
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
pass
|
|
161
|
+
self._connected = False
|
|
162
|
+
self._rp = None
|
qbd_agent_kit/cli.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""qbd: QuickBooks Desktop for AI agents, run on the Windows PC that hosts QuickBooks.
|
|
2
|
+
|
|
3
|
+
Each command runs one tool and prints its answer as one line of JSON. A failure prints {"error": {...}} and exits
|
|
4
|
+
with 2 (change the command), 3 (a person must act at the PC) or 1 (anything else)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import textwrap
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
from contextlib import contextmanager, nullcontext
|
|
19
|
+
from typing import Any, Iterator
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .bridge.com import ComTransport
|
|
23
|
+
from .config import Config, load_config
|
|
24
|
+
from .demo.transport import DemoTransport
|
|
25
|
+
from .errors import InvalidArguments, QbConnectionError, QbStatusError, QbxmlInputError, ToolError, WriteUnconfirmed
|
|
26
|
+
from .jsutil import MISSING, dumps, num_str, text
|
|
27
|
+
from .qb.client import QbClient
|
|
28
|
+
from .qb.dates import local_today
|
|
29
|
+
from .tools import error_text, run_tool
|
|
30
|
+
from .tools.args import Arg
|
|
31
|
+
from .tools.common import Ctx, Drafts, Tool, company_facts
|
|
32
|
+
from .tools.files import FILE_TOOLS
|
|
33
|
+
from .tools.insights import INSIGHT_TOOLS
|
|
34
|
+
from .tools.read import READ_TOOLS
|
|
35
|
+
from .tools.write import WRITE_TOOLS
|
|
36
|
+
|
|
37
|
+
GROUPS = [("Workflows", INSIGHT_TOOLS), ("Records", READ_TOOLS), ("The QuickBooks PC", FILE_TOOLS), ("Changes (when QBD_ALLOW_WRITES=true)", WRITE_TOOLS)]
|
|
38
|
+
RENAMED = {"qb_inventory_reorder": "reorder", "qb_write_log": "log"}
|
|
39
|
+
COMMANDS = {RENAMED.get(t.name, t.name[3:].replace("_", "-")): t for _, tools in GROUPS for t in tools}
|
|
40
|
+
POSITIONAL = {
|
|
41
|
+
"search": ["type"], "export": ["type"], "count": ["type"], "changes": ["since"], "coding": ["vendor"], "match-payment": ["customer", "amount"], "trace": ["ref"], "stock-history": ["item"], "reconcile-check": ["account"], "get": ["type", "id"], "report": ["report"], "schema": ["request"], "raw": ["request"],
|
|
42
|
+
"draft": ["action", "type"], "import": ["type"], "commit": ["token"], "undo": ["token"],
|
|
43
|
+
}
|
|
44
|
+
SETUP = {
|
|
45
|
+
"doctor": "Check this PC and the connection to QuickBooks",
|
|
46
|
+
"authorize": "Connect once so QuickBooks asks for permission (QuickBooks open, as Admin)",
|
|
47
|
+
}
|
|
48
|
+
META = {"int": "N", "date": "YYYY-MM-DD", "enum": "CHOICE", "str": "TEXT", "list": "A,B", "dict": "JSON|@FILE|-"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Usage(Exception):
|
|
52
|
+
"""A command line that cannot be run as typed."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- reading the command line
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def split_globals(argv: list[str]) -> tuple[dict[str, Any], list[str]]:
|
|
59
|
+
opts: dict[str, Any] = {"demo": False, "pretty": False, "version": False, "help": False, "out": None}
|
|
60
|
+
rest: list[str] = []
|
|
61
|
+
it = iter(argv)
|
|
62
|
+
for a in it:
|
|
63
|
+
if a in ("--demo", "--pretty", "--version"):
|
|
64
|
+
opts[a[2:]] = True
|
|
65
|
+
elif a in ("--help", "-h"):
|
|
66
|
+
opts["help"] = True
|
|
67
|
+
elif a == "--out" or a.startswith("--out="):
|
|
68
|
+
opts["out"] = a[6:] if a.startswith("--out=") else next(it, None)
|
|
69
|
+
if not opts["out"]:
|
|
70
|
+
raise Usage("--out needs a file name.")
|
|
71
|
+
else:
|
|
72
|
+
rest.append(a)
|
|
73
|
+
return opts, rest
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _no_constant(name: str) -> Any:
|
|
77
|
+
raise ValueError(f"{name} is not allowed in JSON")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_json(flag: str, v: str) -> Any:
|
|
81
|
+
"""JSON given inline, as @file, or as - for standard input."""
|
|
82
|
+
try:
|
|
83
|
+
if v == "-":
|
|
84
|
+
v = sys.stdin.buffer.read().decode("utf-8-sig")
|
|
85
|
+
elif v.startswith("@"):
|
|
86
|
+
with open(v[1:], encoding="utf-8-sig") as f:
|
|
87
|
+
v = f.read()
|
|
88
|
+
except OSError as e:
|
|
89
|
+
raise Usage(f"Cannot read {v[1:]}: {e.strerror}.") from e
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(v, parse_constant=_no_constant)
|
|
92
|
+
except ValueError as e:
|
|
93
|
+
raise Usage(f"{flag} must be JSON, inline or as @file: {e}.") from e
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_value(flag: str, arg: Arg, v: str) -> Any:
|
|
97
|
+
if arg.kind == "int":
|
|
98
|
+
if re.fullmatch(r"-?[0-9]+", v):
|
|
99
|
+
return int(v)
|
|
100
|
+
try:
|
|
101
|
+
f = float(v)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return v # the argument check says what is wrong
|
|
104
|
+
return f if math.isfinite(f) else v
|
|
105
|
+
if arg.kind == "dict":
|
|
106
|
+
return read_json(flag, v)
|
|
107
|
+
return v
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tool_args(t: Tool, positional: list[str], tokens: list[str]) -> dict[str, Any]:
|
|
111
|
+
names = {"--" + k.replace("_", "-"): k for k in t.input}
|
|
112
|
+
args: dict[str, Any] = {}
|
|
113
|
+
loose: list[str] = []
|
|
114
|
+
i = 0
|
|
115
|
+
while i < len(tokens):
|
|
116
|
+
token = tokens[i]
|
|
117
|
+
i += 1
|
|
118
|
+
if not token.startswith("--"):
|
|
119
|
+
loose.append(token)
|
|
120
|
+
continue
|
|
121
|
+
flag, eq, value = token.partition("=")
|
|
122
|
+
name = names.get(flag)
|
|
123
|
+
if name is None:
|
|
124
|
+
raise Usage(f"Unknown option {flag}.")
|
|
125
|
+
arg = t.input[name]
|
|
126
|
+
if arg.kind == "bool":
|
|
127
|
+
args[name] = {"true": True, "false": False}.get(value.lower(), value) if eq else True
|
|
128
|
+
continue
|
|
129
|
+
if not eq:
|
|
130
|
+
if i >= len(tokens) or tokens[i].startswith("--"):
|
|
131
|
+
raise Usage(f"{flag} needs a value.")
|
|
132
|
+
value = tokens[i]
|
|
133
|
+
i += 1
|
|
134
|
+
if arg.kind == "list":
|
|
135
|
+
args.setdefault(name, []).extend(x.strip() for x in value.split(",") if x.strip())
|
|
136
|
+
else:
|
|
137
|
+
args[name] = read_value(flag, arg, value)
|
|
138
|
+
if len(loose) > len(positional):
|
|
139
|
+
raise Usage(f"Unexpected argument {loose[len(positional)]!r}.")
|
|
140
|
+
for name, v in zip(positional, loose):
|
|
141
|
+
args[name] = read_value(f"<{name}>", t.input[name], v)
|
|
142
|
+
return args
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------- help
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def usage(cmd: str) -> str:
|
|
149
|
+
t = COMMANDS[cmd]
|
|
150
|
+
return " ".join([f"qbd {cmd}", *(f"<{p}>" if t.input[p].required else f"[<{p}>]" for p in POSITIONAL.get(cmd, []))])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _default(v: Any) -> str:
|
|
154
|
+
return ",".join(v) if isinstance(v, list) else text(v)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def describe_arg(a: Arg) -> str:
|
|
158
|
+
parts = [a.help.rstrip(".")] if a.help else []
|
|
159
|
+
if a.choices and not all(c in a.help for c in a.choices): # some help texts already list every choice
|
|
160
|
+
parts.append(("comma-separated, from: " if a.kind == "list" else "one of: ") + ", ".join(a.choices))
|
|
161
|
+
if a.min is not None and a.max is not None:
|
|
162
|
+
parts.append(f"{a.min} to {a.max}")
|
|
163
|
+
if a.required:
|
|
164
|
+
parts.append("required")
|
|
165
|
+
elif a.default is not MISSING and a.default not in ({}, False):
|
|
166
|
+
parts.append(f"default {_default(a.default)}")
|
|
167
|
+
return "; ".join(parts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def command_help(cmd: str) -> str:
|
|
171
|
+
t = COMMANDS[cmd]
|
|
172
|
+
pos = POSITIONAL.get(cmd, [])
|
|
173
|
+
rows = [
|
|
174
|
+
(f"<{k}>" if k in pos else "--" + k.replace("_", "-") + ("" if a.kind == "bool" else f" {META[a.kind]}"), describe_arg(a))
|
|
175
|
+
for k, a in t.input.items()
|
|
176
|
+
]
|
|
177
|
+
out = [f"Usage: {usage(cmd)}{' [options]' if len(rows) > len(pos) else ''}", "", f"{t.title}.", *textwrap.wrap(t.description, 100)]
|
|
178
|
+
if rows:
|
|
179
|
+
width = min(max(len(r[0]) for r in rows) + 2, 28)
|
|
180
|
+
out.append("")
|
|
181
|
+
for label, about in rows:
|
|
182
|
+
lines = textwrap.wrap(about, 100 - width) or [""]
|
|
183
|
+
out.append(f" {label:<{width}}{lines[0]}" if len(label) < width else f" {label}\n {'':<{width}}{lines[0]}")
|
|
184
|
+
out += [f" {'':<{width}}{x}" for x in lines[1:]]
|
|
185
|
+
return "\n".join(out)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def general_help() -> str:
|
|
189
|
+
out = [
|
|
190
|
+
f"qbd {__version__}: QuickBooks Desktop for AI agents, run on the Windows PC that hosts QuickBooks.",
|
|
191
|
+
"",
|
|
192
|
+
"Usage: qbd <command> [arguments] [options]",
|
|
193
|
+
"",
|
|
194
|
+
'Each command prints one line of JSON. A failure prints {"error": {"code", "message", "hint", "retryable"}}',
|
|
195
|
+
"and exits with 2 (change the command), 3 (a person must act at the PC) or 1 (anything else).",
|
|
196
|
+
]
|
|
197
|
+
for title, tools in GROUPS:
|
|
198
|
+
out += ["", title]
|
|
199
|
+
out += [f" {usage(c):<26} {t.title}" for c, t in COMMANDS.items() if t in tools]
|
|
200
|
+
out += ["", "Setup", *(f" {f'qbd {c}':<26} {about}" for c, about in SETUP.items())]
|
|
201
|
+
out += [
|
|
202
|
+
"",
|
|
203
|
+
"Options",
|
|
204
|
+
" --demo use the built-in sample company instead of QuickBooks",
|
|
205
|
+
" --pretty indent the JSON",
|
|
206
|
+
" --out FILE write the JSON to FILE as UTF-8 (in Windows PowerShell 5.1, > writes UTF-16)",
|
|
207
|
+
" --version print the version",
|
|
208
|
+
"",
|
|
209
|
+
"Run qbd help <command> for its options. Settings are environment variables (QBD_ALLOW_WRITES,",
|
|
210
|
+
"QBD_COMPANY_FILE, ...); see the README.",
|
|
211
|
+
]
|
|
212
|
+
return "\n".join(out)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def help_text(cmd: str) -> str:
|
|
216
|
+
if cmd in COMMANDS:
|
|
217
|
+
return command_help(cmd)
|
|
218
|
+
if cmd in SETUP:
|
|
219
|
+
return f"Usage: qbd {cmd} [--demo]\n\n{SETUP[cmd]}."
|
|
220
|
+
return general_help()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------- running a tool
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def make_context(config: Config, open_timeout: float | None = None) -> Ctx:
|
|
227
|
+
transport = (
|
|
228
|
+
DemoTransport(local_today())
|
|
229
|
+
if config.demo
|
|
230
|
+
else ComTransport(config.app_id, config.app_name, config.company_file, open_timeout or config.open_timeout_seconds, config.timeout_seconds)
|
|
231
|
+
)
|
|
232
|
+
drafts = Drafts(os.path.join(config.state_dir, "drafts"))
|
|
233
|
+
return Ctx(qb=QbClient(transport, config.qbxml_version), config=config, today=local_today, drafts=drafts, cache={})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _try_lock(f: Any) -> bool:
|
|
237
|
+
try:
|
|
238
|
+
if sys.platform == "win32":
|
|
239
|
+
import msvcrt
|
|
240
|
+
|
|
241
|
+
f.seek(0)
|
|
242
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
243
|
+
else:
|
|
244
|
+
import fcntl
|
|
245
|
+
|
|
246
|
+
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
247
|
+
return True
|
|
248
|
+
except OSError:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@contextmanager
|
|
253
|
+
def one_at_a_time(folder: str, wait: float) -> Iterator[None]:
|
|
254
|
+
"""QuickBooks answers one request at a time, so commands from several agents take turns."""
|
|
255
|
+
os.makedirs(folder, exist_ok=True)
|
|
256
|
+
with open(os.path.join(folder, "quickbooks.lock"), "a+b") as f:
|
|
257
|
+
deadline = time.monotonic() + wait
|
|
258
|
+
while not _try_lock(f):
|
|
259
|
+
if time.monotonic() > deadline:
|
|
260
|
+
raise QbConnectionError("busy", "Another qbd command is using QuickBooks right now.", "Run this command again in a moment.")
|
|
261
|
+
time.sleep(0.2)
|
|
262
|
+
yield # closing the file releases the lock
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def failure(e: BaseException, cmd: str) -> tuple[dict, int]:
|
|
266
|
+
code, message, hint, status = "internal", f"{type(e).__name__}: {e}", None, 1
|
|
267
|
+
if isinstance(e, (Usage, InvalidArguments)):
|
|
268
|
+
code, message, status = "invalid_arguments", error_text(e), 2
|
|
269
|
+
hint = f"Run: qbd help {cmd}" if cmd in COMMANDS else "Run: qbd help"
|
|
270
|
+
elif isinstance(e, QbxmlInputError):
|
|
271
|
+
code, message, status = "invalid_request", error_text(e), 2
|
|
272
|
+
elif isinstance(e, ToolError):
|
|
273
|
+
code, message, status = "refused", str(e), 2
|
|
274
|
+
elif isinstance(e, WriteUnconfirmed):
|
|
275
|
+
code, message = "unconfirmed", str(e)
|
|
276
|
+
elif isinstance(e, QbConnectionError):
|
|
277
|
+
code, message, hint, status = e.code, e.message, e.hint, 3 if e.needs_person else 1
|
|
278
|
+
elif isinstance(e, QbStatusError):
|
|
279
|
+
code, message, hint = f"qb_status_{num_str(e.status_code)}", e.message, e.hint
|
|
280
|
+
else:
|
|
281
|
+
traceback.print_exc()
|
|
282
|
+
# A change that timed out may still be saved: it is never repeated blindly.
|
|
283
|
+
retryable = code in ("timeout", "busy") and cmd != "commit"
|
|
284
|
+
return {"error": {"code": code, "message": message, **({"hint": hint} if hint else {}), "retryable": retryable}}, status
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def emit(value: Any, opts: dict[str, Any]) -> None:
|
|
288
|
+
out = dumps(value, pretty=opts["pretty"])
|
|
289
|
+
if opts["out"]:
|
|
290
|
+
with open(opts["out"], "w", encoding="utf-8", newline="\n") as f:
|
|
291
|
+
f.write(out + "\n")
|
|
292
|
+
else:
|
|
293
|
+
print(out)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def main(argv: list[str] | None = None) -> int:
|
|
297
|
+
for stream in (sys.stdout, sys.stderr):
|
|
298
|
+
try:
|
|
299
|
+
stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
300
|
+
except (AttributeError, ValueError):
|
|
301
|
+
pass
|
|
302
|
+
opts: dict[str, Any] = {"pretty": False, "out": None}
|
|
303
|
+
cmd = ""
|
|
304
|
+
transport: Any = None
|
|
305
|
+
try:
|
|
306
|
+
opts, rest = split_globals(sys.argv[1:] if argv is None else argv)
|
|
307
|
+
cmd = rest[0] if rest else ""
|
|
308
|
+
if opts["version"]:
|
|
309
|
+
print(__version__)
|
|
310
|
+
return 0
|
|
311
|
+
if opts["help"] or cmd in ("", "help"):
|
|
312
|
+
print(help_text(rest[1] if cmd == "help" and len(rest) > 1 else cmd))
|
|
313
|
+
return 0
|
|
314
|
+
config = load_config(demo=True) if opts["demo"] else load_config()
|
|
315
|
+
if cmd == "doctor":
|
|
316
|
+
return doctor(config)
|
|
317
|
+
if cmd == "authorize":
|
|
318
|
+
return authorize(config)
|
|
319
|
+
t = COMMANDS.get(cmd)
|
|
320
|
+
if t is None:
|
|
321
|
+
raise Usage(f"Unknown command {cmd}.")
|
|
322
|
+
if t.writes and not config.allow_writes:
|
|
323
|
+
raise ToolError("Changes are turned off on this PC (QBD_ALLOW_WRITES is not true). The owner can turn them on; see the README.")
|
|
324
|
+
args = tool_args(t, POSITIONAL.get(cmd, []), rest[1:])
|
|
325
|
+
ctx = make_context(config)
|
|
326
|
+
transport = ctx.qb.transport
|
|
327
|
+
# The sample company lives in this process, and schema only reads the kit's own files.
|
|
328
|
+
turn = nullcontext() if config.demo or cmd == "schema" else one_at_a_time(config.state_dir, min(config.timeout_seconds, 60))
|
|
329
|
+
with turn:
|
|
330
|
+
try:
|
|
331
|
+
result = run_tool(ctx, t, args)
|
|
332
|
+
finally:
|
|
333
|
+
transport.close()
|
|
334
|
+
emit(result, opts)
|
|
335
|
+
status = 0
|
|
336
|
+
except Exception as e: # noqa: BLE001 - every failure becomes a JSON error
|
|
337
|
+
body, status = failure(e, cmd)
|
|
338
|
+
print(dumps(body, pretty=opts["pretty"]))
|
|
339
|
+
if getattr(transport, "stuck", False):
|
|
340
|
+
# A request is still running inside QuickBooks on a thread that cannot be stopped: leave without waiting for it.
|
|
341
|
+
sys.stdout.flush()
|
|
342
|
+
os._exit(status)
|
|
343
|
+
return status
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------- setup commands
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def line(ok: bool | None, msg: str) -> None:
|
|
350
|
+
print(f"{' - ' if ok is None else 'OK ' if ok else '!! '} {msg}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def sdk_registrations() -> dict[str, str]:
|
|
354
|
+
"""Where the QuickBooks SDK component is registered: {"64-bit": dll, "32-bit": dll} and/or {"any": exe}."""
|
|
355
|
+
import winreg
|
|
356
|
+
|
|
357
|
+
found: dict[str, str] = {}
|
|
358
|
+
for bits, view in (("64-bit", winreg.KEY_WOW64_64KEY), ("32-bit", winreg.KEY_WOW64_32KEY)):
|
|
359
|
+
try:
|
|
360
|
+
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"QBXMLRP2.RequestProcessor\CLSID", 0, winreg.KEY_READ | view) as k:
|
|
361
|
+
clsid = winreg.QueryValueEx(k, "")[0]
|
|
362
|
+
except OSError:
|
|
363
|
+
continue
|
|
364
|
+
for server, key in (("InprocServer32", bits), ("LocalServer32", "any")):
|
|
365
|
+
try:
|
|
366
|
+
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\{server}", 0, winreg.KEY_READ | view) as k:
|
|
367
|
+
found[key] = winreg.QueryValueEx(k, "")[0]
|
|
368
|
+
except OSError:
|
|
369
|
+
pass
|
|
370
|
+
return found
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def quickbooks_running() -> bool | None:
|
|
374
|
+
try:
|
|
375
|
+
out = subprocess.run(
|
|
376
|
+
# errors="replace": tasklist writes in the console code page, which is not UTF-8 (Python 3.15's default).
|
|
377
|
+
["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, errors="replace", timeout=30,
|
|
378
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
379
|
+
).stdout
|
|
380
|
+
except (OSError, subprocess.SubprocessError):
|
|
381
|
+
return None # only a hint
|
|
382
|
+
return re.search(r'"QBW(32)?\.EXE"', out, re.I) is not None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def doctor(config: Config) -> int:
|
|
386
|
+
bits = f"{struct.calcsize('P') * 8}-bit"
|
|
387
|
+
print(f"qbd {__version__} doctor\n")
|
|
388
|
+
line(True, f"Python {platform.python_version()} ({bits}) on {platform.platform()}")
|
|
389
|
+
if not config.demo:
|
|
390
|
+
if sys.platform != "win32":
|
|
391
|
+
line(False, "QuickBooks Desktop runs on Windows. On this computer only --demo works.")
|
|
392
|
+
return 1
|
|
393
|
+
try:
|
|
394
|
+
import win32com.client # noqa: F401
|
|
395
|
+
|
|
396
|
+
line(True, "pywin32 is installed")
|
|
397
|
+
except ImportError:
|
|
398
|
+
line(False, "pywin32 is missing. Install it with: python -m pip install pywin32")
|
|
399
|
+
return 1
|
|
400
|
+
found = sdk_registrations()
|
|
401
|
+
for b in ("64-bit", "32-bit"):
|
|
402
|
+
line(True if b in found else None, f"{b} QuickBooks SDK: {f'registered ({found[b]})' if b in found else 'not registered'}")
|
|
403
|
+
if found and bits not in found and "any" not in found:
|
|
404
|
+
other = next(iter(found))
|
|
405
|
+
line(False, f"This Python is {bits}, but the QuickBooks SDK is registered for {other} programs only. Run qbd with a {other} Python from python.org.")
|
|
406
|
+
running = quickbooks_running()
|
|
407
|
+
if running is not None:
|
|
408
|
+
line(True if running else None, "QuickBooks is running" if running else "QuickBooks is not running (needed unless the app may log in without it)")
|
|
409
|
+
line(None, f'App name shown in QuickBooks: "{config.app_name}"')
|
|
410
|
+
if config.allow_writes:
|
|
411
|
+
line(None, f"Writes: allowed with approval ({', '.join(config.write_entities)}; up to {num_str(config.max_amount)}{'; delete/void on' if config.allow_delete else ''})")
|
|
412
|
+
else:
|
|
413
|
+
line(None, "Writes: off (read-only)")
|
|
414
|
+
line(None, f"Audit log: {config.audit_log}")
|
|
415
|
+
ctx = make_context(config, open_timeout=min(config.open_timeout_seconds, 45))
|
|
416
|
+
try:
|
|
417
|
+
host = ctx.qb.host_info()
|
|
418
|
+
facts = company_facts(ctx)
|
|
419
|
+
line(True, f"Connected: {facts.name} ({host.company_file})")
|
|
420
|
+
line(True, f"{host.product_name} {host.version}, qbXML {host.qbxml_version}{f', {host.country}' if host.country else ''}")
|
|
421
|
+
line(True if facts.closing_date else None, f"Closing date: {facts.closing_date or 'not set'}")
|
|
422
|
+
probe = ctx.qb.query("CustomerQueryRq", {"ActiveStatus": "ActiveOnly", "IncludeRetElement": ["ListID"]}, 1)
|
|
423
|
+
line(True, f"Read test: {'customers can be read' if probe.rows else 'no customers yet, but the query worked'}")
|
|
424
|
+
print("\nAll good. Try: qbd daily-brief --pretty")
|
|
425
|
+
return 0
|
|
426
|
+
except Exception as e: # noqa: BLE001 - reported to the person
|
|
427
|
+
line(False, error_text(e))
|
|
428
|
+
print("\nIf QuickBooks shows a permission dialog or this is the first run, use: qbd authorize")
|
|
429
|
+
return 1
|
|
430
|
+
finally:
|
|
431
|
+
ctx.qb.transport.close()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def authorize(config: Config) -> int:
|
|
435
|
+
if config.demo:
|
|
436
|
+
print("The demo company needs no permission.")
|
|
437
|
+
return 0
|
|
438
|
+
print(f"""Connecting to QuickBooks as "{config.app_name}".
|
|
439
|
+
|
|
440
|
+
The first time, QuickBooks shows an "Application permission" dialog (it may open behind other windows):
|
|
441
|
+
1. Choose "Yes, whenever my QuickBooks company file is open"
|
|
442
|
+
(or "Yes, always allow access; even when my QuickBooks isn't running" and pick the user to log in as).
|
|
443
|
+
2. Leave "Allow this application to access personal data" unticked unless you need it.
|
|
444
|
+
3. Type yes in the confirmation box if QuickBooks asks, then Continue and Done.
|
|
445
|
+
QuickBooks must be open with the company file, logged in as the Admin user.
|
|
446
|
+
|
|
447
|
+
Waiting up to 10 minutes...""", flush=True)
|
|
448
|
+
ctx = make_context(config, open_timeout=600)
|
|
449
|
+
try:
|
|
450
|
+
host = ctx.qb.host_info()
|
|
451
|
+
facts = company_facts(ctx)
|
|
452
|
+
print(f'\nDone: "{config.app_name}" can read {facts.name} ({host.company_file}).')
|
|
453
|
+
print("To change or remove this permission later: QuickBooks > Edit > Preferences > Integrated Applications > Company Preferences.")
|
|
454
|
+
return 0
|
|
455
|
+
except Exception as e: # noqa: BLE001 - reported to the person
|
|
456
|
+
print(f"\n{error_text(e)}")
|
|
457
|
+
return 1
|
|
458
|
+
finally:
|
|
459
|
+
ctx.qb.transport.close()
|