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.
@@ -0,0 +1,3 @@
1
+ """QuickBooks Desktop for AI agents: a command line that runs on the Windows PC that hosts QuickBooks."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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()