queryforge-ai 1.1.2__py3-none-win_amd64.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.
queryforge/__init__.py ADDED
@@ -0,0 +1,467 @@
1
+ """QueryForge — natural language to database queries, as a native Python library.
2
+
3
+ from queryforge import QueryForge
4
+
5
+ qf = QueryForge.mysql("orders.config.json")
6
+ sql = qf.query("delivered orders over $100 last month").to_sql()
7
+
8
+ There is no server to run and no Go toolchain to install. The engine ships as a
9
+ platform binary inside this package; the SDK spawns it, hands it one JSON
10
+ request, and turns the reply into Python objects. Everything that decides what a
11
+ query means — parsing, the AST, validation, dialects, SQL and Mongo generation —
12
+ lives in that engine, so this SDK and the Java, Node and .NET ones all produce
13
+ byte-identical output for the same input.
14
+
15
+ The two halves of the API:
16
+
17
+ ``query(text)``
18
+ The full pipeline. Costs one model call, so it needs a ``model`` block in the
19
+ config and the API key exported under the name that block's ``apiKeyEnv``
20
+ gives.
21
+
22
+ ``generate(ast)`` / ``validate(ast)``
23
+ Deterministic. No model call, no network, no API key. Use these to re-compile
24
+ a stored AST for a second backend, and in tests.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from pathlib import Path
31
+ from typing import Any, Mapping, Sequence, Union
32
+
33
+ from ._binary import BINARY_ENV_VAR, find_binary, platform_tag
34
+ from ._result import Result, ScopeFilter
35
+ from ._transport import PROTOCOL_VERSION, run_request
36
+ from .errors import (
37
+ BinaryNotFoundError,
38
+ Detail,
39
+ GenerateError,
40
+ InvalidConfigError,
41
+ InvalidRequestError,
42
+ InvalidScopeError,
43
+ ModelOutputError,
44
+ ModelTransportError,
45
+ ProtocolError,
46
+ QueryForgeError,
47
+ TimeoutError,
48
+ UnknownBackendError,
49
+ UnsupportedRequestError,
50
+ ValidationError,
51
+ )
52
+
53
+ __version__ = "v1.1.2"
54
+
55
+ __all__ = [
56
+ "QueryForge",
57
+ "PendingQuery",
58
+ "Result",
59
+ "ScopeFilter",
60
+ "Detail",
61
+ "QueryForgeError",
62
+ "InvalidRequestError",
63
+ "InvalidConfigError",
64
+ "UnknownBackendError",
65
+ "InvalidScopeError",
66
+ "ValidationError",
67
+ "UnsupportedRequestError",
68
+ "ModelOutputError",
69
+ "ModelTransportError",
70
+ "GenerateError",
71
+ "TimeoutError",
72
+ "BinaryNotFoundError",
73
+ "ProtocolError",
74
+ "engine_version",
75
+ "binary_path",
76
+ "platform_tag",
77
+ "PROTOCOL_VERSION",
78
+ "BINARY_ENV_VAR",
79
+ "__version__",
80
+ ]
81
+
82
+ #: Config = a parsed dict, a path to a JSON file, or the JSON text itself.
83
+ #:
84
+ #: Spelled with ``Union`` rather than ``X | Y``. These two aliases are assigned at
85
+ #: import time, not annotations, so ``from __future__ import annotations`` does not
86
+ #: defer them — PEP 604 syntax here raises ``TypeError`` on 3.9, which is the floor
87
+ #: ``pyproject.toml`` promises.
88
+ Config = Union[Mapping[str, Any], str, Path]
89
+
90
+ #: Scope values the engine accepts: a literal, or a list of literals for an
91
+ #: ``in``-style filter.
92
+ ScopeValue = Union[str, int, float, bool, Sequence[Any]]
93
+
94
+
95
+ def _load_config(config: Config) -> dict[str, Any]:
96
+ """Normalize the several things a caller may reasonably pass as a config.
97
+
98
+ A ``dict`` is used as-is. A ``Path`` is always read as a file. A ``str`` is
99
+ read as a file unless it opens with ``{``, in which case it is JSON text —
100
+ unambiguous in practice, because no filesystem path begins with a brace.
101
+ """
102
+ if isinstance(config, Mapping):
103
+ return dict(config)
104
+
105
+ if isinstance(config, Path):
106
+ return _read_config_file(config)
107
+
108
+ if isinstance(config, str):
109
+ if config.lstrip().startswith("{"):
110
+ try:
111
+ parsed = json.loads(config)
112
+ except json.JSONDecodeError as exc:
113
+ raise InvalidConfigError(
114
+ f"The config string is not valid JSON: {exc}", code="INVALID_CONFIG"
115
+ ) from exc
116
+ if not isinstance(parsed, dict):
117
+ raise InvalidConfigError(
118
+ f"A config must be a JSON object, got {type(parsed).__name__}.",
119
+ code="INVALID_CONFIG",
120
+ )
121
+ return parsed
122
+ return _read_config_file(Path(config))
123
+
124
+ raise InvalidConfigError(
125
+ f"A config must be a dict, a path, or a JSON string — got {type(config).__name__}.",
126
+ code="INVALID_CONFIG",
127
+ )
128
+
129
+
130
+ def _read_config_file(path: Path) -> dict[str, Any]:
131
+ try:
132
+ text = path.expanduser().read_text(encoding="utf-8")
133
+ except OSError as exc:
134
+ raise InvalidConfigError(
135
+ f"Could not read the config file {str(path)!r}: {exc}", code="INVALID_CONFIG"
136
+ ) from exc
137
+ try:
138
+ parsed = json.loads(text)
139
+ except json.JSONDecodeError as exc:
140
+ raise InvalidConfigError(
141
+ f"The config file {str(path)!r} is not valid JSON: {exc}", code="INVALID_CONFIG"
142
+ ) from exc
143
+ if not isinstance(parsed, dict):
144
+ raise InvalidConfigError(
145
+ f"The config file {str(path)!r} must hold a JSON object, "
146
+ f"got {type(parsed).__name__}.",
147
+ code="INVALID_CONFIG",
148
+ )
149
+ return parsed
150
+
151
+
152
+ class PendingQuery:
153
+ """A question that has been described but not yet compiled.
154
+
155
+ Nothing runs until a terminal method (:meth:`to_sql`, :meth:`to_mongo`,
156
+ :meth:`result`, …) is called, and the answer is cached afterwards — so
157
+ reading ``.to_sql()`` and then ``.explain()`` off the same object costs one
158
+ model call, not two.
159
+
160
+ Every builder method returns a *new* ``PendingQuery`` rather than mutating
161
+ this one. That is what makes a partially-configured query safe to keep as a
162
+ template::
163
+
164
+ base = qf.query("open orders").scope({"tenantId": tenant})
165
+ sql = base.to_sql()
166
+ prose = base.explain() # same cached answer, no second model call
167
+ """
168
+
169
+ __slots__ = ("_forge", "_text", "_scope", "_options", "_result")
170
+
171
+ def __init__(
172
+ self,
173
+ forge: "QueryForge",
174
+ text: str,
175
+ scope: Mapping[str, ScopeValue] | None,
176
+ options: dict[str, Any],
177
+ ) -> None:
178
+ self._forge = forge
179
+ self._text = text
180
+ self._scope = dict(scope) if scope else None
181
+ self._options = dict(options)
182
+ self._result: Result | None = None
183
+
184
+ # --- builders -----------------------------------------------------------
185
+
186
+ def _derive(self, **changes: Any) -> "PendingQuery":
187
+ """Return a copy with some fields replaced, and no cached result."""
188
+ return PendingQuery(
189
+ forge=changes.get("forge", self._forge),
190
+ text=changes.get("text", self._text),
191
+ scope=changes.get("scope", self._scope),
192
+ options=changes.get("options", self._options),
193
+ )
194
+
195
+ def scope(self, scope: Mapping[str, ScopeValue]) -> "PendingQuery":
196
+ """Add filters the application imposes regardless of what was asked.
197
+
198
+ Subscription, tenant, user, enterprise ids — values that come from the
199
+ session, not from the question. They are AND-ed onto the query after
200
+ validation, so they can only narrow the result, and the model is never
201
+ told they exist.
202
+
203
+ Calling this more than once merges, so a base query's scope survives::
204
+
205
+ base = qf.query("open orders").scope({"tenantId": t})
206
+ mine = base.scope({"ownerId": u}) # both filters apply
207
+ """
208
+ merged = dict(self._scope or {})
209
+ merged.update(scope)
210
+ return self._derive(scope=merged)
211
+
212
+ def timeout(self, seconds: float) -> "PendingQuery":
213
+ """Bound the whole operation, model call included."""
214
+ if seconds <= 0:
215
+ raise InvalidRequestError(
216
+ f"timeout must be positive, got {seconds}", code="INVALID_REQUEST"
217
+ )
218
+ options = dict(self._options)
219
+ options["timeoutMs"] = int(seconds * 1000)
220
+ return self._derive(options=options)
221
+
222
+ def max_repairs(self, n: int) -> "PendingQuery":
223
+ """Bound the validation-repair retries. 0 means one attempt, no repairs."""
224
+ if n < 0:
225
+ raise InvalidRequestError(
226
+ f"max_repairs cannot be negative, got {n}", code="INVALID_REQUEST"
227
+ )
228
+ options = dict(self._options)
229
+ options["maxRepairs"] = n
230
+ return self._derive(options=options)
231
+
232
+ def include_raw(self, enabled: bool = True) -> "PendingQuery":
233
+ """Return the model's verbatim reply on :attr:`Result.raw`.
234
+
235
+ Off by default: the reply is derived from the user's question, and
236
+ results tend to end up in logs.
237
+ """
238
+ options = dict(self._options)
239
+ options["includeRaw"] = enabled
240
+ return self._derive(options=options)
241
+
242
+ def scope_in_ast(self, enabled: bool = True) -> "PendingQuery":
243
+ """Report the effective AST — scope predicates included — as the result's AST.
244
+
245
+ Off by default, in which case the AST is exactly what the model produced
246
+ and can be fed back to :meth:`QueryForge.generate` unchanged. Turn it on
247
+ for an audit log that must store one object proving what ran.
248
+ """
249
+ options = dict(self._options)
250
+ options["scopeInAst"] = enabled
251
+ return self._derive(options=options)
252
+
253
+ # --- terminals ----------------------------------------------------------
254
+
255
+ def result(self) -> Result:
256
+ """Compile the query and return everything the engine reported."""
257
+ if self._result is None:
258
+ self._result = self._forge._translate(self._text, self._scope, self._options)
259
+ return self._result
260
+
261
+ def to_sql(self) -> str:
262
+ """Return the parameterized SQL statement.
263
+
264
+ The values are **not** inlined — that is the injection guarantee this
265
+ library rests on. Pass :meth:`to_args` alongside it to your driver::
266
+
267
+ cursor.execute(pending.to_sql(), pending.to_args())
268
+ """
269
+ return self.result().require_sql()
270
+
271
+ def to_args(self) -> tuple[Any, ...]:
272
+ """Return the bound placeholder values, in the order the SQL expects."""
273
+ return self.result().args
274
+
275
+ def to_mongo(self) -> dict[str, Any]:
276
+ """Return the compiled query document for a document backend."""
277
+ return self.result().require_doc()
278
+
279
+ def to_ast(self) -> dict[str, Any]:
280
+ """Return the validated intermediate representation."""
281
+ ast = self.result().ast
282
+ return ast if ast is not None else {}
283
+
284
+ def explain(self) -> str:
285
+ """Return a deterministic prose rendering of what the query does."""
286
+ return self.result().explain
287
+
288
+ def warnings(self) -> tuple[str, ...]:
289
+ """Return non-fatal advisories, e.g. a filter on a non-indexed field."""
290
+ return self.result().warnings
291
+
292
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
293
+ state = "compiled" if self._result is not None else "pending"
294
+ return f"PendingQuery({self._text!r}, backend={self._forge.backend!r}, {state})"
295
+
296
+
297
+ class QueryForge:
298
+ """An engine bound to one config and one backend.
299
+
300
+ Construct it with the factory for the backend you want::
301
+
302
+ QueryForge.mysql(config) # MySQL dialect
303
+ QueryForge.postgres(config) # PostgreSQL dialect
304
+ QueryForge.mongo(config) # MongoDB query documents
305
+
306
+ Instances are immutable and cheap; the executable is not started until a
307
+ query is actually compiled, so building one costs nothing.
308
+ """
309
+
310
+ __slots__ = ("_config", "backend", "_scope", "_options")
311
+
312
+ #: Backend ids the shipped engine registers. Checked locally so a typo costs
313
+ #: an immediate error rather than a process spawn.
314
+ BACKENDS = ("sql", "mysql", "mongo")
315
+
316
+ def __init__(
317
+ self,
318
+ config: Config,
319
+ backend: str,
320
+ *,
321
+ scope: Mapping[str, ScopeValue] | None = None,
322
+ timeout: float | None = None,
323
+ max_repairs: int | None = None,
324
+ ) -> None:
325
+ if backend not in self.BACKENDS:
326
+ raise UnknownBackendError(
327
+ f"Unknown backend {backend!r}. Registered: {', '.join(self.BACKENDS)}.",
328
+ code="UNKNOWN_BACKEND",
329
+ )
330
+ self._config = _load_config(config)
331
+ self.backend = backend
332
+ self._scope = dict(scope) if scope else None
333
+
334
+ options: dict[str, Any] = {}
335
+ if timeout is not None:
336
+ if timeout <= 0:
337
+ raise InvalidRequestError(
338
+ f"timeout must be positive, got {timeout}", code="INVALID_REQUEST"
339
+ )
340
+ options["timeoutMs"] = int(timeout * 1000)
341
+ if max_repairs is not None:
342
+ if max_repairs < 0:
343
+ raise InvalidRequestError(
344
+ f"max_repairs cannot be negative, got {max_repairs}", code="INVALID_REQUEST"
345
+ )
346
+ options["maxRepairs"] = max_repairs
347
+ self._options = options
348
+
349
+ # --- factories ----------------------------------------------------------
350
+
351
+ @classmethod
352
+ def mysql(cls, config: Config, **kwargs: Any) -> "QueryForge":
353
+ """Bind to the MySQL dialect (``?`` placeholders, backtick quoting)."""
354
+ return cls(config, "mysql", **kwargs)
355
+
356
+ @classmethod
357
+ def postgres(cls, config: Config, **kwargs: Any) -> "QueryForge":
358
+ """Bind to the PostgreSQL dialect (``$1`` placeholders)."""
359
+ return cls(config, "sql", **kwargs)
360
+
361
+ #: ``sql`` is the engine's own id for the PostgreSQL dialect; both names are
362
+ #: offered because the config files use ``sql`` while callers think in terms
363
+ #: of the database they are actually pointed at.
364
+ sql = postgres
365
+
366
+ @classmethod
367
+ def mongo(cls, config: Config, **kwargs: Any) -> "QueryForge":
368
+ """Bind to MongoDB, producing a query document rather than SQL."""
369
+ return cls(config, "mongo", **kwargs)
370
+
371
+ # --- the model path -----------------------------------------------------
372
+
373
+ def query(self, text: str) -> PendingQuery:
374
+ """Describe a question. Nothing runs until a terminal method is called.
375
+
376
+ Costs one model call (more if a repair is needed) when it does run.
377
+ """
378
+ if not isinstance(text, str) or not text.strip():
379
+ raise InvalidRequestError(
380
+ "query text must be a non-empty string", code="INVALID_REQUEST"
381
+ )
382
+ return PendingQuery(self, text, self._scope, self._options)
383
+
384
+ # --- the deterministic path ---------------------------------------------
385
+
386
+ def generate(
387
+ self,
388
+ ast: Mapping[str, Any],
389
+ *,
390
+ scope: Mapping[str, ScopeValue] | None = None,
391
+ scope_in_ast: bool = False,
392
+ ) -> Result:
393
+ """Compile a pre-built AST. No model call, no network, no API key.
394
+
395
+ The AST is validated against the config first, so an AST from an
396
+ untrusted source cannot compile to something the config forbids.
397
+ """
398
+ options = dict(self._options)
399
+ if scope_in_ast:
400
+ options["scopeInAst"] = True
401
+ payload = self._request("generate", options, scope)
402
+ payload["ast"] = dict(ast)
403
+ return Result.from_json(run_request(payload, self._timeout_seconds(options)))
404
+
405
+ def validate(self, ast: Mapping[str, Any]) -> Result:
406
+ """Check an AST against the config without compiling it.
407
+
408
+ Returns the explanation on success; raises :class:`ValidationError` with
409
+ a populated ``details`` list otherwise.
410
+ """
411
+ payload = self._request("validate", dict(self._options), None)
412
+ payload["ast"] = dict(ast)
413
+ return Result.from_json(run_request(payload, self._timeout_seconds(self._options)))
414
+
415
+ # --- internals ----------------------------------------------------------
416
+
417
+ def _translate(
418
+ self,
419
+ text: str,
420
+ scope: Mapping[str, ScopeValue] | None,
421
+ options: dict[str, Any],
422
+ ) -> Result:
423
+ payload = self._request("translate", options, scope)
424
+ payload["query"] = text
425
+ return Result.from_json(run_request(payload, self._timeout_seconds(options)))
426
+
427
+ def _request(
428
+ self,
429
+ op: str,
430
+ options: dict[str, Any],
431
+ scope: Mapping[str, ScopeValue] | None,
432
+ ) -> dict[str, Any]:
433
+ payload: dict[str, Any] = {
434
+ "op": op,
435
+ "backend": self.backend,
436
+ "config": self._config,
437
+ }
438
+ effective_scope = scope if scope is not None else self._scope
439
+ if effective_scope:
440
+ payload["scope"] = dict(effective_scope)
441
+ if options:
442
+ payload["options"] = options
443
+ return payload
444
+
445
+ @staticmethod
446
+ def _timeout_seconds(options: Mapping[str, Any]) -> float | None:
447
+ """Convert the request's timeout into the subprocess kill deadline."""
448
+ ms = options.get("timeoutMs")
449
+ return None if not ms else ms / 1000.0
450
+
451
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
452
+ entity = self._config.get("entity", "?")
453
+ return f"QueryForge(entity={entity!r}, backend={self.backend!r})"
454
+
455
+
456
+ def engine_version() -> dict[str, Any]:
457
+ """Return the bundled engine's version, protocol version and backends.
458
+
459
+ Useful as an installation check: it exercises binary discovery, process
460
+ spawning and JSON decoding without needing a config or an API key.
461
+ """
462
+ return run_request({"op": "version"})
463
+
464
+
465
+ def binary_path() -> str:
466
+ """Return the path of the executable this SDK will run."""
467
+ return str(find_binary())
queryforge/_binary.py ADDED
@@ -0,0 +1,195 @@
1
+ """Locating the QueryForge executable.
2
+
3
+ The user installs ``queryforge`` from PyPI and calls a Python API; they never
4
+ learn there is a Go binary underneath. This module is what keeps that true: it
5
+ detects the platform, finds the executable that was packaged for it, and raises
6
+ a message that names the problem when it cannot.
7
+
8
+ Wheels are built per platform, so a correctly installed package contains exactly
9
+ one binary — the one for the machine it was installed on. The platform tag is
10
+ still computed and checked, because the failure it catches (a universal wheel
11
+ built by hand, a source install, a package copied between machines) otherwise
12
+ shows up as ``Exec format error`` from the OS, which tells the user nothing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import platform
19
+ import stat
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .errors import BinaryNotFoundError
24
+
25
+ #: Environment variable that overrides the search entirely. It exists for three
26
+ #: real cases: developing against a locally built binary, running in an
27
+ #: environment where the packaged one cannot execute (a hardened container with
28
+ #: a noexec site-packages mount), and pinning a specific engine version while
29
+ #: keeping the SDK where it is.
30
+ BINARY_ENV_VAR = "QUERYFORGE_BINARY"
31
+
32
+ #: Directory inside the installed package holding the bundled executable.
33
+ _BIN_DIR = "bin"
34
+
35
+
36
+ def platform_tag() -> str:
37
+ """Return the ``<os>-<arch>`` tag for the running interpreter.
38
+
39
+ The tags match the release artifact names, so the string in an error message
40
+ is the same string the user will look for on the releases page.
41
+ """
42
+ system = sys.platform
43
+ if system.startswith("linux"):
44
+ os_name = "linux"
45
+ elif system == "darwin":
46
+ os_name = "darwin"
47
+ elif system in ("win32", "cygwin"):
48
+ os_name = "windows"
49
+ else:
50
+ raise BinaryNotFoundError(
51
+ f"QueryForge has no build for this operating system ({system!r}). "
52
+ f"Supported: Linux, macOS, Windows. Set {BINARY_ENV_VAR} to point at "
53
+ f"a binary you built yourself.",
54
+ code="BINARY_NOT_FOUND",
55
+ )
56
+
57
+ # platform.machine() reports the kernel's idea of the architecture, which is
58
+ # what matters for choosing an executable. The aliases are all real: Linux
59
+ # says x86_64/aarch64, macOS says x86_64/arm64, Windows says AMD64/ARM64.
60
+ machine = platform.machine().lower()
61
+ if machine in ("x86_64", "amd64", "x64"):
62
+ arch = "amd64"
63
+ elif machine in ("arm64", "aarch64"):
64
+ arch = "arm64"
65
+ else:
66
+ raise BinaryNotFoundError(
67
+ f"QueryForge has no build for this CPU architecture ({machine!r}). "
68
+ f"Supported: amd64 (x86_64) and arm64 (aarch64). Set {BINARY_ENV_VAR} "
69
+ f"to point at a binary you built yourself.",
70
+ code="BINARY_NOT_FOUND",
71
+ )
72
+
73
+ return f"{os_name}-{arch}"
74
+
75
+
76
+ def _executable_name() -> str:
77
+ return "queryforge.exe" if sys.platform in ("win32", "cygwin") else "queryforge"
78
+
79
+
80
+ def _candidates() -> list[Path]:
81
+ """Return the paths to search, in priority order.
82
+
83
+ Both the bare name and the platform-tagged name are searched because the two
84
+ packaging modes produce different layouts: a per-platform wheel ships one
85
+ ``bin/queryforge``, while a developer checkout or a hand-built universal
86
+ package may keep several under ``bin/<os>-<arch>/``.
87
+ """
88
+ package_dir = Path(__file__).resolve().parent
89
+ name = _executable_name()
90
+ return [
91
+ package_dir / _BIN_DIR / name,
92
+ package_dir / _BIN_DIR / platform_tag() / name,
93
+ ]
94
+
95
+
96
+ def _is_executable(path: Path) -> bool:
97
+ """Report whether path is a file this process can execute.
98
+
99
+ On Windows the executable bit does not exist, so existence is the only
100
+ meaningful check; elsewhere a non-executable file is a real and common
101
+ failure (a wheel unpacked by a tool that dropped the mode bits), and
102
+ distinguishing it from "missing" is what makes the error message actionable.
103
+ """
104
+ if not path.is_file():
105
+ return False
106
+ if sys.platform in ("win32", "cygwin"):
107
+ return True
108
+ return os.access(path, os.X_OK)
109
+
110
+
111
+ def find_binary() -> Path:
112
+ """Locate the QueryForge executable, or raise :class:`BinaryNotFoundError`.
113
+
114
+ The result is not cached here — :func:`resolve_binary` does that — so this
115
+ function stays a pure lookup that tests can call repeatedly.
116
+ """
117
+ override = os.environ.get(BINARY_ENV_VAR)
118
+ if override:
119
+ # An explicit override that does not work is always reported, never
120
+ # silently fallen back from. Falling back would run a *different* engine
121
+ # than the one the user named, which is precisely the situation the
122
+ # variable exists to prevent.
123
+ path = Path(override).expanduser()
124
+ if not path.is_file():
125
+ raise BinaryNotFoundError(
126
+ f"{BINARY_ENV_VAR} points at {str(path)!r}, which is not a file.",
127
+ code="BINARY_NOT_FOUND",
128
+ )
129
+ if not _is_executable(path):
130
+ raise BinaryNotFoundError(
131
+ f"{BINARY_ENV_VAR} points at {str(path)!r}, which is not executable. "
132
+ f"Try: chmod +x {path}",
133
+ code="BINARY_NOT_FOUND",
134
+ )
135
+ return path
136
+
137
+ found_but_not_executable: list[Path] = []
138
+ for candidate in _candidates():
139
+ if candidate.is_file():
140
+ if _is_executable(candidate):
141
+ return candidate
142
+ found_but_not_executable.append(candidate)
143
+
144
+ # Separate the two failures. "Present but not executable" has a one-line fix
145
+ # and is common after a package is copied or unzipped by hand; "absent"
146
+ # means the wrong wheel is installed and needs a reinstall.
147
+ if found_but_not_executable:
148
+ paths = ", ".join(str(p) for p in found_but_not_executable)
149
+ raise BinaryNotFoundError(
150
+ f"The bundled QueryForge executable is present but not executable: {paths}. "
151
+ f"Try: chmod +x {found_but_not_executable[0]}",
152
+ code="BINARY_NOT_FOUND",
153
+ )
154
+
155
+ searched = ", ".join(str(p) for p in _candidates())
156
+ raise BinaryNotFoundError(
157
+ f"No QueryForge executable was found for this platform ({platform_tag()}). "
158
+ f"Searched: {searched}. This usually means the installed wheel was built "
159
+ f"for a different platform — reinstall with 'pip install --force-reinstall "
160
+ f"queryforge-ai' — or set {BINARY_ENV_VAR} to a binary you built yourself.",
161
+ code="BINARY_NOT_FOUND",
162
+ )
163
+
164
+
165
+ _cached: Path | None = None
166
+
167
+
168
+ def resolve_binary() -> Path:
169
+ """Return the executable path, resolving it at most once per process.
170
+
171
+ Caching matters because the default mode spawns one process per request: the
172
+ filesystem probing would otherwise be repeated on every single query. The
173
+ cache is skipped whenever the override variable is set, so a test or a
174
+ notebook can repoint it between calls without a stale hit.
175
+ """
176
+ global _cached
177
+ if os.environ.get(BINARY_ENV_VAR):
178
+ return find_binary()
179
+ if _cached is None:
180
+ _cached = find_binary()
181
+ return _cached
182
+
183
+
184
+ def ensure_executable(path: Path) -> None:
185
+ """Add the owner-execute bit to a bundled binary that is missing it.
186
+
187
+ Wheels do preserve mode bits, but the file can still arrive without them —
188
+ unpacked by a tool that ignores them, or restored from an archive built on
189
+ Windows. This is called at build/install time rather than on the query path,
190
+ because silently chmod-ing a file at query time would hide a broken install.
191
+ """
192
+ if sys.platform in ("win32", "cygwin"):
193
+ return
194
+ mode = path.stat().st_mode
195
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)