kbforge-sql 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """A relational database as a kbforge source, through configuration alone."""
2
+
3
+ __version__ = "0.1.0"
kbforge_sql/config.py ADDED
@@ -0,0 +1,182 @@
1
+ """Config model for a SQL source, and the offline validation the CLI runs
2
+ before any connection (spec §3.1). `check_columns` is the one check that needs
3
+ the query result (§3.2)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import re
9
+ from collections import Counter
10
+ from collections.abc import Mapping, Sequence
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+
14
+ from kbforge.canonical import is_blank
15
+ from kbforge.synthesize import OKF_OWNED
16
+ from kbforge_sql.errors import SqlSourceError
17
+
18
+ # Same shapes as kbforge-mcp's config: an ALL_CAPS env var name, and a `system`
19
+ # that is safe inside a doc_id and a `sync/<system>` branch name.
20
+ _ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]*\Z")
21
+ _SYSTEM_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*\Z")
22
+
23
+
24
+ class _Strict(BaseModel):
25
+ # A typo in a source config must be an error, not a silently ignored key.
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+
29
+ class GroupSpec(_Strict):
30
+ children: list[str] = Field(min_length=1)
31
+ order_by: list[str] = Field(default_factory=list)
32
+ heading: str | None = None
33
+
34
+
35
+ class SqlSourceConfig(_Strict):
36
+ system: str
37
+ url_env: str
38
+ password_env: str | None = None
39
+ query: str
40
+ id: list[str] = Field(min_length=1)
41
+ title: str
42
+ text: str | None = None
43
+ facets: list[str] = Field(default_factory=list)
44
+ exclude: list[str] = Field(default_factory=list)
45
+ type: str = "concept"
46
+ url_template: str | None = None
47
+ group: GroupSpec | None = None
48
+ retries: int = Field(default=2, ge=0)
49
+ max_removed_fraction: float = Field(default=0.5, gt=0, le=1)
50
+
51
+ def configured_columns(self) -> list[str]:
52
+ """Every column the config names, first mention first, no repeats."""
53
+ cols = [*self.id, self.title]
54
+ if self.text:
55
+ cols.append(self.text)
56
+ cols += [*self.facets, *self.exclude]
57
+ if self.group is not None:
58
+ cols += [*self.group.children, *self.group.order_by]
59
+ return list(dict.fromkeys(cols))
60
+
61
+
62
+ # `url_template` is plain `{column}` substitution, deliberately not str.format:
63
+ # format() would read `{a.b}` as an attribute, `{a[0]}` as an index, `{0}` as a
64
+ # positional argument and `{a:{w}}` as a nested field, so a template could pass
65
+ # validation and still fail mid-fetch. One pattern serves both sides.
66
+ _TEMPLATE_FIELD = re.compile(r"\{([^{}]*)\}")
67
+
68
+
69
+ def template_fields(template: str) -> list[str] | None:
70
+ """Column names a url_template references, or None if its braces don't
71
+ pair up into `{column}` fields."""
72
+ rest = _TEMPLATE_FIELD.sub("", template)
73
+ if "{" in rest or "}" in rest:
74
+ return None
75
+ return _TEMPLATE_FIELD.findall(template)
76
+
77
+
78
+ def render_template(template: str, values: Mapping[str, str]) -> str:
79
+ return _TEMPLATE_FIELD.sub(lambda m: values[m.group(1)], template)
80
+
81
+
82
+ def problems_for(config: dict) -> list[str]:
83
+ """Human-readable problems; `[]` means the config is usable. No connection."""
84
+ try:
85
+ cfg = SqlSourceConfig.model_validate(config)
86
+ except ValidationError as exc:
87
+ return [
88
+ f"config {'.'.join(str(p) for p in e['loc']) or '<root>'}: {e['msg']}"
89
+ for e in exc.errors()
90
+ ]
91
+
92
+ problems: list[str] = []
93
+ if not _SYSTEM_NAME.match(cfg.system):
94
+ problems.append(
95
+ "config 'system' must be a short identifier -- letters, digits, '_' "
96
+ "and '-', starting with a letter or digit -- because it is part of "
97
+ f"every doc_id and the publish branch: {cfg.system!r}"
98
+ )
99
+ for key, name in (("url_env", cfg.url_env), ("password_env", cfg.password_env)):
100
+ if name is None:
101
+ continue
102
+ if not _ENV_NAME.match(name):
103
+ # The value is NOT echoed: a name that fails this shape is most
104
+ # likely a pasted URL or password.
105
+ problems.append(
106
+ f"config '{key}' must name an environment variable (ALL_CAPS), "
107
+ "not hold its value"
108
+ )
109
+ elif name not in os.environ:
110
+ problems.append(f"config '{key}': environment variable {name} is not set")
111
+ if is_blank(cfg.query):
112
+ problems.append("config 'query' is blank")
113
+ if is_blank(cfg.type):
114
+ problems.append("config 'type' is blank; OKF requires a non-empty type")
115
+ if is_blank(cfg.title):
116
+ problems.append("config 'title' is blank")
117
+ if any(is_blank(c) for c in cfg.id):
118
+ problems.append("config 'id' has a blank column name")
119
+
120
+ used = {*cfg.id, cfg.title, *cfg.facets}
121
+ if cfg.text:
122
+ used.add(cfg.text)
123
+ if cfg.group is not None:
124
+ # Excluded columns are dropped from every row before grouping, so a
125
+ # child or order_by column in `exclude` would fail mid-fetch instead.
126
+ used |= {*cfg.group.children, *cfg.group.order_by}
127
+ if clash := used & set(cfg.exclude):
128
+ problems.append(
129
+ f"config 'exclude' names column(s) the source also uses: {sorted(clash)}"
130
+ )
131
+ if owned := sorted(set(cfg.facets) & OKF_OWNED):
132
+ problems.append(
133
+ f"config 'facets' must not use OKF-owned key(s) {owned}: the emitter "
134
+ "drops them, so the facet would silently never appear"
135
+ )
136
+
137
+ if cfg.group is not None:
138
+ children = set(cfg.group.children)
139
+ if ids := sorted(children & set(cfg.id)):
140
+ problems.append(
141
+ f"config 'group.children' must not include id column(s) {ids}"
142
+ )
143
+ entity = {cfg.title, *cfg.facets} | ({cfg.text} if cfg.text else set())
144
+ if both := sorted(children & entity):
145
+ problems.append(
146
+ "config 'group.children' must not include title, text or facet "
147
+ f"column(s) {both}: they describe the entity, not a child"
148
+ )
149
+ if extra := [c for c in cfg.group.order_by if c not in children]:
150
+ problems.append(
151
+ f"config 'group.order_by' must be a subset of 'group.children': {extra}"
152
+ )
153
+
154
+ if cfg.url_template is not None:
155
+ fields = template_fields(cfg.url_template)
156
+ if fields is None:
157
+ problems.append(
158
+ "config 'url_template' has unpaired braces; fields are `{column}`"
159
+ )
160
+ elif bad := sorted({f for f in fields if f not in cfg.id}):
161
+ problems.append(
162
+ f"config 'url_template' may only reference id column(s): {bad}"
163
+ )
164
+ return problems
165
+
166
+
167
+ def check_columns(cfg: SqlSourceConfig, columns: Sequence[str]) -> None:
168
+ """Spec §3.2: every configured column must be in the result, and the
169
+ result must not name one column twice -- a duplicate makes 'which one did
170
+ the config mean' ambiguous, silently picking whichever `result.keys()`
171
+ binds a lookup to."""
172
+ missing = [c for c in cfg.configured_columns() if c not in columns]
173
+ if missing:
174
+ raise SqlSourceError(
175
+ f"configured column(s) {missing} are not in the query result; "
176
+ f"the query returned: {list(columns)}"
177
+ )
178
+ dupes = sorted(c for c, n in Counter(columns).items() if n > 1)
179
+ if dupes:
180
+ raise SqlSourceError(
181
+ f"the query returned duplicate column name(s) {dupes}; alias them apart"
182
+ )
@@ -0,0 +1,385 @@
1
+ """The kbforge connector: four hookimpls over one rolled-back query.
2
+
3
+ `kbforge_fetch` may use a clock and the network; `kbforge_normalize` may not
4
+ (architecture §4.3). `retrieved_at` is stamped in fetch, into `anchor_hint`,
5
+ and normalize only reads it back. Everything normalize needs travels in the
6
+ record -- it never sees config."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from decimal import Decimal
17
+ from urllib.parse import quote
18
+
19
+ from sqlalchemy import URL, create_engine, make_url
20
+ from sqlalchemy.exc import ArgumentError, DBAPIError, InterfaceError, OperationalError
21
+ from sqlalchemy.pool import NullPool
22
+
23
+ from kbforge.canonical import content_hash, is_blank
24
+ from kbforge.hookspecs import hookimpl
25
+ from kbforge.models import (
26
+ CanonicalDocument,
27
+ ConnectorInfo,
28
+ Cursor,
29
+ FetchResult,
30
+ RawRecord,
31
+ ResourceAnchor,
32
+ )
33
+ from kbforge_sql.config import (
34
+ SqlSourceConfig,
35
+ check_columns,
36
+ problems_for,
37
+ render_template,
38
+ )
39
+ from kbforge_sql.errors import SqlSourceError
40
+ from kbforge_sql.identity import native_id_for
41
+ from kbforge_sql.render import render_text
42
+ from kbforge_sql.values import Scalar, canonical
43
+
44
+ NAME = "sql"
45
+ TOMBSTONE = "application/vnd.kbforge.tombstone"
46
+
47
+ # Indirection so tests can observe backoff without waiting (Task 6).
48
+ _sleep = time.sleep
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class _Entity:
53
+ native_id: str
54
+ payload: dict
55
+ url: str | None
56
+
57
+
58
+ def _query_once(url: URL, query: str) -> tuple[list[str], list[tuple]]:
59
+ """Run the one configured statement and roll back. `exec_driver_sql`, not
60
+ `text()`: text() parses `:name` as a bind parameter, which breaks `'10:30'`
61
+ literals and Postgres `::` casts in an operator's query."""
62
+ engine = create_engine(url, poolclass=NullPool)
63
+ try:
64
+ with engine.connect() as conn:
65
+ try:
66
+ # no_parameters: a driver's paramstyle (psycopg/psycopg2/pymysql,
67
+ # and Denodo's dialect, are all pyformat-family) otherwise treats
68
+ # `%` in the statement as a parameter marker, and a bare `LIKE
69
+ # 'EV-%'` breaks with an empty parameter collection to fill it.
70
+ result = conn.exec_driver_sql(
71
+ query, execution_options={"no_parameters": True}
72
+ )
73
+ if not result.returns_rows:
74
+ raise SqlSourceError(
75
+ "the query returned no result set; a source query must "
76
+ "be a SELECT"
77
+ )
78
+ columns = list(result.keys())
79
+ rows = [tuple(r) for r in result.fetchall()]
80
+ finally:
81
+ # No commit exists anywhere in this package. Whatever the
82
+ # statement did, this discards it (spec §7). SQLAlchemy also
83
+ # rolls back on close, so this line is belt-and-braces; no
84
+ # test can observe it, and the property tests pin is that
85
+ # nothing is ever committed.
86
+ conn.rollback()
87
+ finally:
88
+ engine.dispose()
89
+ return columns, rows
90
+
91
+
92
+ # Retried: the connection failed, not the statement. Re-running a SELECT is
93
+ # safe. Everything else -- bad SQL, a missing view, no permission -- fails at
94
+ # once, because retrying a typo only delays the message. The classification is
95
+ # the driver's: sqlite3 and pymysql raise OperationalError for some statement
96
+ # errors too, so on those a bad query is retried before it fails. That costs
97
+ # time, never correctness; `retries: 0` opts out.
98
+ _TRANSIENT = (OperationalError, InterfaceError)
99
+
100
+
101
+ def _url(cfg: SqlSourceConfig) -> URL:
102
+ try:
103
+ url = make_url(os.environ[cfg.url_env])
104
+ except ArgumentError:
105
+ # Never echo the value: it may carry a password.
106
+ raise SqlSourceError(
107
+ f"the value of {cfg.url_env} is not a SQLAlchemy URL"
108
+ ) from None
109
+ if cfg.password_env:
110
+ # URL.set takes the raw password: no percent-escaping for `@:/%`.
111
+ url = url.set(password=os.environ[cfg.password_env])
112
+ return url
113
+
114
+
115
+ def _redact(message: str, url: URL) -> str:
116
+ secret = url.password
117
+ return message.replace(str(secret), "***") if secret else message
118
+
119
+
120
+ def _query(cfg: SqlSourceConfig) -> tuple[list[str], list[tuple]]:
121
+ url = _url(cfg)
122
+ attempts = cfg.retries + 1
123
+ for attempt in range(1, attempts + 1):
124
+ try:
125
+ return _query_once(url, cfg.query)
126
+ except _TRANSIENT as exc:
127
+ if attempt == attempts:
128
+ raise SqlSourceError(
129
+ _redact(
130
+ f"{type(exc).__name__} after {attempts} attempt(s): {exc.orig}",
131
+ url,
132
+ )
133
+ ) from None
134
+ _sleep(min(2 ** (attempt - 1), 30))
135
+ except DBAPIError as exc:
136
+ raise SqlSourceError(
137
+ _redact(f"{type(exc).__name__}: {exc.orig}", url)
138
+ ) from None
139
+ except (ArgumentError, ImportError) as exc:
140
+ # NoSuchModuleError is an ArgumentError; a dialect whose DBAPI
141
+ # module is absent raises ImportError from create_engine.
142
+ raise SqlSourceError(
143
+ _redact(
144
+ "no SQLAlchemy dialect or driver for this URL is installed "
145
+ f"({exc}); install the driver for your database",
146
+ url,
147
+ )
148
+ ) from None
149
+ raise AssertionError("unreachable: the loop returns or raises")
150
+
151
+
152
+ def _sort_key(value: object) -> tuple:
153
+ """Order for a RAW database value in `group.order_by`. Raw, because the
154
+ canonical form is text for decimals and floats, and "10" < "9.5" as text.
155
+ Numbers compare by value across int/float/Decimal; other types by type name
156
+ first, so a column mixing types still sorts instead of raising; NULLs last."""
157
+ if value is None:
158
+ return (1,)
159
+ if isinstance(value, int | float | Decimal) and not isinstance(value, bool):
160
+ return (0, 0, Decimal(str(value)))
161
+ return (0, 1, type(value).__name__, value)
162
+
163
+
164
+ def _entities(
165
+ cfg: SqlSourceConfig, columns: list[str], rows: list[tuple]
166
+ ) -> list[_Entity]:
167
+ index = {c: i for i, c in enumerate(columns)}
168
+ keep = [c for c in columns if c not in cfg.exclude]
169
+ children = list(cfg.group.children) if cfg.group else []
170
+
171
+ # Each canonical row keeps its raw tuple: group.order_by sorts on raw values.
172
+ by_id: dict[str, list[tuple[dict[str, Scalar], tuple]]] = {}
173
+ for raw in rows:
174
+ row = {c: canonical(raw[index[c]], c) for c in keep}
175
+ nid = native_id_for([row[c] for c in cfg.id], cfg.id)
176
+ by_id.setdefault(nid, []).append((row, raw))
177
+
178
+ entity_cols = [c for c in keep if c not in children]
179
+ entities: list[_Entity] = []
180
+ for nid in sorted(by_id):
181
+ group_rows = [row for row, _ in by_id[nid]]
182
+ if cfg.group is None and len(group_rows) > 1:
183
+ raise SqlSourceError(
184
+ f"{len(group_rows)} rows share the id {nid!r}; configure 'group' "
185
+ "to fold them, or make the id unique"
186
+ )
187
+ for c in entity_cols:
188
+ if len({json.dumps(r[c]) for r in group_rows}) > 1:
189
+ raise SqlSourceError(
190
+ f"rows for id {nid!r} disagree on column {c!r}; only "
191
+ "'group.children' columns may vary within an entity"
192
+ )
193
+ first = group_rows[0]
194
+
195
+ title = first[cfg.title]
196
+ lead = first[cfg.text] if cfg.text else None
197
+ attributes = [[c, first[c]] for c in cfg.id] + [
198
+ [c, first[c]]
199
+ for c in entity_cols
200
+ if c not in cfg.id and c != cfg.title and c != cfg.text
201
+ ]
202
+ group = None
203
+ if cfg.group is not None:
204
+ keyed = [
205
+ (
206
+ [_sort_key(raw[index[c]]) for c in cfg.group.order_by],
207
+ [row[c] for c in children],
208
+ )
209
+ for row, raw in by_id[nid]
210
+ ]
211
+ # A LEFT JOIN's all-NULL row means "no children", not a child.
212
+ keyed = [k for k in keyed if any(v is not None for v in k[1])]
213
+ keyed.sort(key=lambda k: (k[0], json.dumps(k[1])))
214
+ child_rows = [child for _, child in keyed]
215
+ group = {
216
+ "heading": cfg.group.heading or cfg.system,
217
+ "columns": children,
218
+ "rows": child_rows,
219
+ }
220
+ url = None
221
+ if cfg.url_template is not None:
222
+ url = render_template(
223
+ cfg.url_template, {c: quote(str(first[c]), safe="") for c in cfg.id}
224
+ )
225
+ entities.append(
226
+ _Entity(
227
+ native_id=nid,
228
+ url=url,
229
+ payload={
230
+ "title": (
231
+ nid if title is None or is_blank(str(title)) else str(title)
232
+ ),
233
+ "lead": None if lead is None else str(lead),
234
+ "attributes": attributes,
235
+ "facets": {f: first[f] for f in cfg.facets if first[f] is not None},
236
+ "type": cfg.type,
237
+ "group": group,
238
+ },
239
+ )
240
+ )
241
+ return entities
242
+
243
+
244
+ _ALLOW_REMOVALS_ENV = "KBFORGE_SQL_ALLOW_REMOVALS"
245
+
246
+
247
+ def _removals_allowed(system: str) -> bool:
248
+ """`KBFORGE_SQL_ALLOW_REMOVALS` is a comma-separated list of source
249
+ `system` names, out of band from the config on purpose:
250
+ `pipeline._instance_key` hashes the *whole* connector config into the
251
+ cursor slot name, so raising `max_removed_fraction` -- or editing any
252
+ other config key -- makes the next run find no prior manifest at all
253
+ (no tombstones, a silent NoOp) rather than performing the cleanup. This
254
+ is read here, at fetch time; `normalize` never reads the environment."""
255
+ raw = os.environ.get(_ALLOW_REMOVALS_ENV, "")
256
+ return system in {name.strip() for name in raw.split(",") if name.strip()}
257
+
258
+
259
+ def _removed(cfg: SqlSourceConfig, prior: list[str], current: list[str]) -> list[str]:
260
+ """Ids seen at the last published run and missing now (spec §6).
261
+
262
+ The empty-result guard always applies. The deletion ceiling is skipped
263
+ when the source's `system` is listed in `KBFORGE_SQL_ALLOW_REMOVALS`,
264
+ the deliberate-cleanup override (spec §6.3). An empty result is refused
265
+ even at max_removed_fraction=1.0 or with the override set: a view
266
+ mid-refresh returns zero rows without an error, and 'delete the
267
+ knowledge base' must never be the default reading of that."""
268
+ if not prior:
269
+ return []
270
+ if not current:
271
+ raise SqlSourceError(
272
+ "the query returned no rows, but the last published run saw "
273
+ f"{len(prior)}; refusing to delete every concept. If the source "
274
+ "is meant to be empty, remove its config instead"
275
+ )
276
+ gone = sorted(set(prior) - set(current))
277
+ if _removals_allowed(cfg.system):
278
+ return gone
279
+ fraction = len(gone) / len(prior)
280
+ if fraction > cfg.max_removed_fraction:
281
+ raise SqlSourceError(
282
+ f"{len(gone)} of {len(prior)} previously seen ids ({fraction:.1%}) "
283
+ f"are missing, above max_removed_fraction={cfg.max_removed_fraction}; "
284
+ f"for a deliberate cleanup, rerun with {_ALLOW_REMOVALS_ENV}={cfg.system} "
285
+ "(editing the config resets deletion memory instead)"
286
+ )
287
+ return gone
288
+
289
+
290
+ class SqlConnector:
291
+ @hookimpl
292
+ def kbforge_connector_info(self) -> ConnectorInfo:
293
+ return ConnectorInfo(
294
+ name=NAME,
295
+ version="0.1.0",
296
+ source_system="any database SQLAlchemy can reach",
297
+ info_types=["entity"],
298
+ )
299
+
300
+ @hookimpl
301
+ def kbforge_validate_config(self, config: dict) -> list[str]:
302
+ return problems_for(config)
303
+
304
+ @hookimpl
305
+ def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
306
+ cfg = SqlSourceConfig.model_validate(config)
307
+ try:
308
+ columns, rows = _query(cfg)
309
+ check_columns(cfg, columns)
310
+ entities = _entities(cfg, columns, rows)
311
+ current = [e.native_id for e in entities]
312
+ prior = list((cursor.payload.get("ids") if cursor else None) or [])
313
+ removed = _removed(cfg, prior, current)
314
+ except SqlSourceError as exc:
315
+ raise SqlSourceError(f"sql source {cfg.system!r}: {exc}") from None
316
+
317
+ stamped = datetime.now(tz=UTC).isoformat()
318
+
319
+ def hint(native_id: str, url: str | None) -> dict:
320
+ return {
321
+ "system": cfg.system,
322
+ "native_id": native_id,
323
+ "url": url,
324
+ "retrieved_at": stamped,
325
+ }
326
+
327
+ records = [
328
+ RawRecord(
329
+ anchor_hint=hint(e.native_id, e.url),
330
+ media_type="application/json",
331
+ payload=json.dumps(
332
+ e.payload, sort_keys=True, ensure_ascii=False
333
+ ).encode(),
334
+ )
335
+ for e in entities
336
+ ]
337
+ records += [
338
+ RawRecord(anchor_hint=hint(nid, None), media_type=TOMBSTONE, payload=b"")
339
+ for nid in removed
340
+ ]
341
+ return FetchResult(
342
+ records=records,
343
+ cursor=Cursor(connector=NAME, payload={"ids": sorted(current)}),
344
+ complete=True,
345
+ )
346
+
347
+ @hookimpl
348
+ def kbforge_normalize(
349
+ self, records: Sequence[RawRecord]
350
+ ) -> list[CanonicalDocument]:
351
+ docs: list[CanonicalDocument] = []
352
+ for rec in records:
353
+ h = rec.anchor_hint
354
+ system, native_id = h["system"], h["native_id"]
355
+ anchor = ResourceAnchor(
356
+ system=system,
357
+ native_id=native_id,
358
+ url=h.get("url"),
359
+ retrieved_at=datetime.fromisoformat(h["retrieved_at"]),
360
+ content_hash="",
361
+ )
362
+ doc_id = f"{system}:{native_id}"
363
+ if rec.media_type == TOMBSTONE:
364
+ doc = CanonicalDocument(
365
+ anchor=anchor,
366
+ doc_id=doc_id,
367
+ title=native_id,
368
+ text="",
369
+ deleted=True,
370
+ )
371
+ else:
372
+ p = json.loads(rec.payload)
373
+ doc = CanonicalDocument(
374
+ anchor=anchor,
375
+ doc_id=doc_id,
376
+ title=p["title"],
377
+ text=render_text(p["lead"], p["attributes"], p["group"]),
378
+ structured={**p["facets"], "type": p["type"]},
379
+ )
380
+ doc.anchor.content_hash = content_hash(doc)
381
+ docs.append(doc)
382
+ return docs
383
+
384
+
385
+ CONNECTOR = SqlConnector()
kbforge_sql/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ """The one exception kbforge-sql raises."""
2
+
3
+
4
+ class SqlSourceError(RuntimeError):
5
+ """A SQL source failed in a way an operator must fix or retry.
6
+
7
+ Raised unprefixed by the helpers; `kbforge_fetch` re-raises it once with
8
+ `sql source '<system>': ` in front, so every message names its source
9
+ exactly once."""
@@ -0,0 +1,46 @@
1
+ """Id column values -> a path-safe, injective native_id (spec §4.2).
2
+
3
+ The escape set is kbforge-mcp's (`kbforge_mcp/slug.py`, which documents each
4
+ character). It is copied rather than imported: kbforge-sql must not depend on
5
+ kbforge-mcp, and a shared helper belongs in core, which this release does not
6
+ touch. Each id value is ONE path segment, so none of slug.py's URL and path
7
+ handling applies -- only the per-segment escape and the two tail rules."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Sequence
13
+
14
+ from kbforge.canonical import is_blank
15
+ from kbforge_sql.errors import SqlSourceError
16
+ from kbforge_sql.values import Scalar
17
+
18
+ # `%` makes the escape injective; `/` keeps a value from becoming structure;
19
+ # the rest are illegal in an NTFS filename.
20
+ _ESCAPE = re.compile(r'[\x00-\x1f\x7f%/<>:"|?*\\]')
21
+ _UNSAFE_TAIL = (".", " ")
22
+
23
+
24
+ def _escape(part: str) -> str:
25
+ escaped = _ESCAPE.sub(lambda m: f"%{ord(m.group()):02X}", part)
26
+ # Windows refuses, and sometimes silently trims, a trailing dot or space.
27
+ if escaped[-1:] in _UNSAFE_TAIL:
28
+ escaped = f"{escaped[:-1]}%{ord(escaped[-1]):02X}"
29
+ return escaped
30
+
31
+
32
+ def native_id_for(values: Sequence[Scalar], columns: Sequence[str]) -> str:
33
+ parts: list[str] = []
34
+ for column, value in zip(columns, values, strict=True):
35
+ if value is None or (isinstance(value, str) and is_blank(value)):
36
+ raise SqlSourceError(
37
+ f"id column {column!r} is empty in a returned row; every row "
38
+ "needs an id"
39
+ )
40
+ parts.append(_escape(str(value)))
41
+ slug = "/".join(parts)
42
+ # `concept_path` strips a `.md` suffix a second time downstream, which would
43
+ # publish `x.md` and `x` to one file. Escaping the dot ends it.
44
+ if slug.endswith(".md"):
45
+ slug = f"{slug[:-3]}%2Emd"
46
+ return slug
kbforge_sql/render.py ADDED
@@ -0,0 +1,52 @@
1
+ """Canonical entity -> fixed-format markdown (spec §4.5).
2
+
3
+ No templating: wording is the synthesizer's job. This only has to present the
4
+ row faithfully and byte-stably, because this text is ALL a grounding reader
5
+ sees of a row (`llm_synthesizer._grounding_block` passes `text`, not
6
+ `structured`)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from kbforge_sql.values import Scalar
13
+
14
+ _NULL = "—"
15
+
16
+
17
+ def _value(v: Scalar) -> str:
18
+ if v is None:
19
+ return _NULL
20
+ if isinstance(v, bool):
21
+ return "true" if v else "false"
22
+ return str(v).replace("\n", "<br>")
23
+
24
+
25
+ def _cell(v: Scalar) -> str:
26
+ return _value(v).replace("|", "\\|")
27
+
28
+
29
+ def render_text(
30
+ lead: str | None, attributes: Sequence[Sequence], group: dict | None
31
+ ) -> str:
32
+ blocks: list[str] = []
33
+ if lead:
34
+ blocks.append(lead)
35
+ blocks.append(
36
+ "\n".join(
37
+ [
38
+ "## Attributes",
39
+ *(f"- **{k}:** {_value(v)}" for k, v in attributes),
40
+ ]
41
+ )
42
+ )
43
+ if group is not None:
44
+ columns = group["columns"]
45
+ lines = [
46
+ f"## {group['heading']}",
47
+ "| " + " | ".join(_cell(c) for c in columns) + " |",
48
+ "|" + "---|" * len(columns),
49
+ *("| " + " | ".join(_cell(v) for v in row) + " |" for row in group["rows"]),
50
+ ]
51
+ blocks.append("\n".join(lines))
52
+ return "\n\n".join(blocks)
kbforge_sql/values.py ADDED
@@ -0,0 +1,54 @@
1
+ """Database value -> canonical JSON-safe form (spec §4.3).
2
+
3
+ Everything the diff hashes passes through here, so this is where §4.3 law 1
4
+ (determinism) is won or lost for a SQL source. Unknown types are rejected
5
+ rather than str()-ed: a repr can embed memory addresses or driver-specific
6
+ formatting, and a silently dropped column is a fact synthesis never sees."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import unicodedata
12
+ from datetime import UTC, date, datetime
13
+ from decimal import Decimal
14
+ from uuid import UUID
15
+
16
+ from kbforge_sql.errors import SqlSourceError
17
+
18
+ Scalar = str | int | bool | None
19
+
20
+
21
+ def canonical(value: object, column: str) -> Scalar:
22
+ if value is None:
23
+ return None
24
+ # bool before int: bool is an int subclass, and True must not become 1.
25
+ if isinstance(value, bool | int):
26
+ return value
27
+ if isinstance(value, Decimal):
28
+ if not value.is_finite():
29
+ msg = f"column {column!r} holds a non-finite decimal {value}"
30
+ raise SqlSourceError(msg)
31
+ # normalize() drops trailing zeros (1.50 -> 1.5); format "f" undoes the
32
+ # exponent normalize() may introduce (100 -> 1E+2 -> "100").
33
+ return format(value.normalize(), "f")
34
+ if isinstance(value, float):
35
+ if not math.isfinite(value):
36
+ raise SqlSourceError(f"column {column!r} holds a non-finite float {value}")
37
+ return repr(value)
38
+ if isinstance(value, str):
39
+ text = value.replace("\r\n", "\n").replace("\r", "\n")
40
+ return unicodedata.normalize("NFC", text).rstrip()
41
+ # datetime before date: datetime is a date subclass.
42
+ if isinstance(value, datetime):
43
+ if value.utcoffset() is not None:
44
+ return value.astimezone(UTC).isoformat()
45
+ # Naive stays naive: guessing a timezone would invent a fact.
46
+ return value.isoformat()
47
+ if isinstance(value, date):
48
+ return value.isoformat()
49
+ if isinstance(value, UUID):
50
+ return str(value)
51
+ raise SqlSourceError(
52
+ f"column {column!r} holds a {type(value).__name__} value, which has no "
53
+ "canonical text form; add it to 'exclude' or cast it in the query"
54
+ )
@@ -0,0 +1,242 @@
1
+ Metadata-Version: 2.5
2
+ Name: kbforge-sql
3
+ Version: 0.1.0
4
+ Summary: SQL source connector for kbforge
5
+ Author-email: Qing <qingye779@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: kbforge>=0.8.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: sqlalchemy>=2.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # kbforge-sql
14
+
15
+ `kbforge-sql` lets any database [SQLAlchemy](https://www.sqlalchemy.org/) can reach be a
16
+ [kbforge](https://github.com/flyersworder/kbforge) source. Install it alongside kbforge and
17
+ a source becomes one scoped `SELECT` instead of a Python package: each row the query
18
+ returns (or each group of rows sharing an id) becomes one canonical document, rendered as
19
+ fixed-format markdown. It registers itself under the `kbforge.connectors` entry-point
20
+ group, so `kbforge list` shows `sql` with no further wiring. Every run is a full snapshot,
21
+ which makes deletions derivable — an id seen last run and missing now becomes an explicit
22
+ tombstone — so this is the first kbforge connector that emits tombstones at all.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install kbforge-sql
28
+ ```
29
+
30
+ `kbforge-sql` ships **no database driver**; the operator installs the one their engine
31
+ needs, for example:
32
+
33
+ ```bash
34
+ pip install denodo-sqlalchemy # Denodo
35
+ pip install "psycopg[binary]" # PostgreSQL
36
+ pip install oracledb # Oracle
37
+ pip install pyodbc # SQL Server and other ODBC targets
38
+ ```
39
+
40
+ ## Configure a source
41
+
42
+ A flat source — one row, one document:
43
+
44
+ ```yaml
45
+ system: products # per-instance identity; prefixes every doc_id
46
+ url_env: DENODO_URL # env var NAME: denodo://kb_reader@host:9996/product_vdb
47
+ password_env: DENODO_PASSWORD # optional env var NAME; injected via URL.set()
48
+ query: |
49
+ SELECT product_id, product_name, family, status, description, last_refreshed
50
+ FROM iv_product
51
+ WHERE app_id = 'EV-TRACTION'
52
+ id: [product_id] # one or more columns -> native_id
53
+ title: product_name
54
+ text: description # optional free-text column, placed verbatim
55
+ facets: [family, status] # scalar columns -> filterable frontmatter
56
+ exclude: [last_refreshed] # volatile columns, dropped before hashing
57
+ type: product # OKF `type` for every concept; default "concept"
58
+ url_template: https://portal.example/products/{product_id} # optional; {column} fields, id columns only
59
+ retries: 2 # transient-error retries; default 2
60
+ max_removed_fraction: 0.5 # deletion ceiling, see below; default 0.5
61
+ ```
62
+
63
+ A grouped source — a joined view where one application spans many rows, folded into one
64
+ document per application:
65
+
66
+ ```yaml
67
+ system: applications
68
+ url_env: DENODO_URL
69
+ password_env: DENODO_PASSWORD
70
+ query: |
71
+ SELECT app_id, app_name, segment, app_description,
72
+ product_id, product_name, product_status
73
+ FROM iv_product_application
74
+ WHERE app_id = 'EV-TRACTION'
75
+ id: [app_id]
76
+ title: app_name
77
+ text: app_description
78
+ facets: [segment]
79
+ type: application
80
+ group:
81
+ children: [product_id, product_name, product_status]
82
+ order_by: [product_id]
83
+ heading: Products # optional; defaults to the source `system`
84
+ ```
85
+
86
+ Columns outside `id` and `group.children` must be constant across an entity's rows; a
87
+ grouped entity whose rows disagree on one is a fetch error naming the id and the column.
88
+
89
+ kbforge takes connector config as repeated YAML-typed `--set` pairs, so that is one key
90
+ per flag:
91
+
92
+ ```bash
93
+ kbforge run --connector sql \
94
+ --set system=products \
95
+ --set url_env=DENODO_URL \
96
+ --set password_env=DENODO_PASSWORD \
97
+ --set 'query=SELECT product_id, product_name, family, status, description, last_refreshed FROM iv_product' \
98
+ --set 'id=[product_id]' \
99
+ --set title=product_name \
100
+ --set text=description \
101
+ --set 'facets=[family, status]' \
102
+ --set 'exclude=[last_refreshed]' \
103
+ --set type=product \
104
+ --mirror .kbforge/mirror --out .kbforge/out --state .kbforge/state
105
+ ```
106
+
107
+ Because `--set` values are YAML-typed, a query with its own quoted string literal or a
108
+ multi-line `WHERE` clause is easier to get right in a shell variable or script than typed
109
+ inline; quote the whole `key=value` pair once and let the query itself carry its quotes.
110
+
111
+ The query reaches the driver exactly as written, with no parameter binding, so `LIKE 'EV-%'`,
112
+ Postgres `::` casts and `'10:30'` literals are safe on every driver. On the first result
113
+ the connector checks that every configured column exists (listing the real ones if not)
114
+ and that no two result columns share a name; `SELECT a.id, b.id` must alias them apart.
115
+
116
+ ## First run: `dry-run` and a throwaway mirror
117
+
118
+ No tool proves a query executable across every dialect, so try a new source against a
119
+ mirror you can throw away before pointing it at the real one. kbforge's default publisher
120
+ is `dry-run`, so the query runs and every concept renders to local files with nothing
121
+ opened anywhere:
122
+
123
+ ```bash
124
+ kbforge run --connector sql --set … \
125
+ --mirror /tmp/try --state /tmp/try-state --out /tmp/try-out
126
+ ```
127
+
128
+ Read the rendered files under `/tmp/try-out` before wiring in a real `--mirror` and
129
+ `--publisher`.
130
+
131
+ ## Credentials
132
+
133
+ `url_env` and `password_env` hold environment variable **names**, never values. A
134
+ credential never appears in config, on a command line, or in an error message — errors
135
+ name the env var, not its content. With `password_env` set, the URL carries no password
136
+ and the raw value is injected with SQLAlchemy's `URL.set(password=...)`, so a password
137
+ containing `@`, `:`, `/` or `%` needs no percent-escaping.
138
+
139
+ The expected deployment is a service account, and the recommended one is a **dedicated
140
+ read-only account** granted `SELECT` only on the views its queries use. The connector
141
+ rolls back every transaction and never commits — no commit exists anywhere in the
142
+ package — but that is a bound on accidents, not a guarantee: kbforge cannot prove a SQL
143
+ string is free of side effects through a function call, a procedure, or a dialect
144
+ extension, so the database's grants are what actually prevent a write. (The connector
145
+ issues an explicit rollback even though SQLAlchemy also rolls back on connection close;
146
+ belt-and-braces, since the account's grants are what really carry the guarantee.)
147
+
148
+ `kbforge_validate_config` also rejects a blank `title` or a blank `id` column name before
149
+ any connection is attempted, alongside the checks in §3.1 of the design note.
150
+
151
+ A dropped or refused connection is retried up to `retries` times (default 2) with
152
+ exponential backoff capped at 30 seconds; bad SQL, a missing view, or a missing grant fails
153
+ at once, because retrying a typo only delays the message — on drivers that classify it that
154
+ way (see "Known limits").
155
+
156
+ ## Deletions
157
+
158
+ Every run is a full snapshot: the connector keeps the id set from the last published run
159
+ in the cursor manifest, and any id missing from this run's result becomes an explicit
160
+ tombstone. Two guards sit in front of that, because a view that returns nothing — a cache
161
+ mid-refresh, a failed upstream load, a filter changed upstream — is not an error to the
162
+ database:
163
+
164
+ - **Empty result with a non-empty prior manifest fails the run** and emits no tombstones,
165
+ rather than reading "the source is empty" as "delete every concept". An intentionally
166
+ empty source is rare enough to be handled by removing its config instead.
167
+ - **Deletion ceiling.** If the tombstones would exceed `max_removed_fraction` (default
168
+ `0.5`) of the prior manifest, the run fails and states the count and the fraction. For a
169
+ deliberate large cleanup, rerun with `KBFORGE_SQL_ALLOW_REMOVALS` set (see below) rather
170
+ than raising `max_removed_fraction` — see "Known limits" for why.
171
+
172
+ ## Known limits
173
+
174
+ **Whether an error is retried is the driver's call.** The connector retries SQLAlchemy's
175
+ `OperationalError` and `InterfaceError`, which most drivers reserve for connection
176
+ failures. Some also raise `OperationalError` for statement errors — sqlite3 for a missing
177
+ table or a syntax error, pymysql for access denied and unmapped server errors — so on those
178
+ drivers a bad query is retried before it fails, and the message reads "OperationalError
179
+ after N attempt(s)". That costs time, never correctness. PostgreSQL (psycopg) reports these
180
+ as `ProgrammingError` and fails at once; check your driver with a deliberately misspelled
181
+ view on a first run, and set `retries: 0` if it misclassifies.
182
+
183
+ **Editing ANY config key resets deletion memory, not just the query.** Cursor slots are
184
+ keyed by a digest of the *whole* connector config (`pipeline._instance_key`), so editing
185
+ `query` — narrowing its `WHERE`, say — or any other key, including `max_removed_fraction`
186
+ itself, means the next run finds no prior cursor: `NoOp`, no tombstones, and the old slot
187
+ trips the ceiling again if the edit is ever reverted. The connector cannot fix this; it is
188
+ deliberately mirror-blind. After narrowing a query, remove the stale concepts by hand in
189
+ the review repository.
190
+
191
+ **A tripped deletion ceiling is cleared with `KBFORGE_SQL_ALLOW_REMOVALS`, not by raising
192
+ `max_removed_fraction`.** Raising the fraction is a config edit, so it hits the limit
193
+ above: it resets deletion memory instead of performing the cleanup, and the old cursor
194
+ slot trips the ceiling again once the fraction is reverted. Instead, set the environment
195
+ variable `KBFORGE_SQL_ALLOW_REMOVALS` to a comma-separated list of source `system` names
196
+ (for example `KBFORGE_SQL_ALLOW_REMOVALS=products`) and rerun with the config unchanged —
197
+ it is read at fetch time, out of band from the config, so the cursor slot and the rest of
198
+ `max_removed_fraction`'s guard stay intact. The empty-result guard always applies, even
199
+ with the override set.
200
+
201
+ **Ids can collide with another source's.** `concept_path` drops the system prefix, so a
202
+ product id `42` and an application id `42` render the same file, and the pipeline aborts
203
+ on the collision — numeric keys make this likely. Until bundle paths are system-qualified,
204
+ give ids a kind prefix in the query itself (`'product-' || product_id AS kb_id`, or the
205
+ `CONCAT` your dialect prefers) and use that column as `id`. No connector config is needed
206
+ for this.
207
+
208
+ **Ids differing only in case are one directory on a case-insensitive checkout.** `Foo` and
209
+ `foo` render distinct `native_id`s but the same path on the default macOS or Windows
210
+ filesystem, so a case-only id pair collides in a local checkout even though it would not
211
+ on Linux CI.
212
+
213
+ **Canonicalization can fold two raw ids into one entity.** NFC normalization and
214
+ trailing-whitespace stripping (§4.3) run before the duplicate-id check, so two raw id
215
+ values that canonicalize to the same string collapse onto one id: without `group`, that is
216
+ the duplicate-id error; with `group`, it is a silent merge into one entity's rows.
217
+
218
+ ## Testing against your own database
219
+
220
+ The package ships a live test that is skipped unless you ask for it. Point it at a
221
+ read-only view and run it with your driver installed:
222
+
223
+ ```bash
224
+ pip install kbforge-sql denodo-sqlalchemy pytest # or psycopg[binary], oracledb, ...
225
+ export KBFORGE_SQL_LIVE_URL='denodo://kb_reader@denodo.example:9996/product_vdb'
226
+ export KBFORGE_SQL_LIVE_PASSWORD=... # optional
227
+ export KBFORGE_SQL_LIVE_QUERY="SELECT product_id, product_name FROM iv_product WHERE product_name LIKE '%a%'"
228
+ export KBFORGE_SQL_LIVE_ID=product_id KBFORGE_SQL_LIVE_TITLE=product_name
229
+ pytest packages/kbforge-sql/tests/test_sql_live.py --run-live
230
+ ```
231
+
232
+ It fetches twice and requires identical content hashes, which is the check that matters
233
+ for a new source: anything volatile in the result (a refresh timestamp, a computed
234
+ column) fails it, and belongs in `exclude`. Without the `KBFORGE_SQL_LIVE_QUERY` variables
235
+ it queries `information_schema`, which any PostgreSQL accepts.
236
+
237
+ ## Design
238
+
239
+ The [design note](https://github.com/flyersworder/kbforge/blob/main/docs/design/2026-09-18-sql-source-connector-design.md)
240
+ holds the full rationale and what remains deferred (incremental fetch, relations between
241
+ rows, deletion memory across a query edit). The shipped design is in
242
+ [`docs/architecture.md`](https://github.com/flyersworder/kbforge/blob/main/docs/architecture.md) §4.1.
@@ -0,0 +1,11 @@
1
+ kbforge_sql/__init__.py,sha256=ypEAJs517pwYG0Ujisr_yvzyK0IrpQ0w3bTELGql6mI,101
2
+ kbforge_sql/config.py,sha256=0dNd7WG07b-oMJ-an9URI7Xwn3UoTZXca_-Iql1Y0Fw,7221
3
+ kbforge_sql/connector.py,sha256=gyNJWuudZrwjRDHbusYm97dSI6tK9DsXmmXhAmRTeHA,14805
4
+ kbforge_sql/errors.py,sha256=IZ8l9-8AuceTj28MrdtcSlW8N6h_e6giZ9qWML3oULE,322
5
+ kbforge_sql/identity.py,sha256=xmMsvRSDiCMl0MznPu3iaGrmbWg5TWPpQfMT0wEt7-c,1850
6
+ kbforge_sql/render.py,sha256=t23kwWBFyFMw8gKuVwAioJURouqDDcvX4DPmrwFOLt8,1427
7
+ kbforge_sql/values.py,sha256=grkugIo7c-WQd9BUSpgFo5nTZDWJdTU54tZucd5CZCA,2152
8
+ kbforge_sql-0.1.0.dist-info/METADATA,sha256=Wa8Ge_xyF5yOq97pd_LKQW2g0BSRF6bHIzEty3un-KY,12113
9
+ kbforge_sql-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
10
+ kbforge_sql-0.1.0.dist-info/entry_points.txt,sha256=GIXw1n48voMiVEYEEArEs8hWAlaMgTub06PlOsNihBM,59
11
+ kbforge_sql-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [kbforge.connectors]
2
+ sql = kbforge_sql.connector:CONNECTOR