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 +467 -0
- queryforge/_binary.py +195 -0
- queryforge/_result.py +103 -0
- queryforge/_transport.py +164 -0
- queryforge/bin/queryforge +0 -0
- queryforge/bin/queryforge.exe +0 -0
- queryforge/errors.py +202 -0
- queryforge/py.typed +0 -0
- queryforge_ai-1.1.2.dist-info/METADATA +265 -0
- queryforge_ai-1.1.2.dist-info/RECORD +12 -0
- queryforge_ai-1.1.2.dist-info/WHEEL +5 -0
- queryforge_ai-1.1.2.dist-info/top_level.txt +1 -0
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)
|