atif-sql 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
atif_cli/cron.py ADDED
@@ -0,0 +1,311 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The ``atif-sql cron`` subcommand group: install (print-only) + status.
4
+
5
+ Companion surface for ``scripts/atif-sql-refresh.sh``. Two commands:
6
+
7
+ * ``cron install`` prints the crontab block for the lanes and NEVER
8
+ writes the crontab (CONTRACT-V2 §Cron: "no silent crontab writes") — the
9
+ human pastes it after checking ``crontab -l``.
10
+ * ``cron status`` reports, per lane: whether the flock is currently held
11
+ (and by which pid), the last completed run parsed from the refresh log,
12
+ and the last skip. Plus the log tail.
13
+
14
+ Lean by construction: stdlib + cyclopts only, no atif_* imports beyond
15
+ :mod:`atif_cli.output` — this module loads eagerly with ``atif_cli.app``
16
+ (sub-apps must exist at registration time) and therefore sits on the fast
17
+ path pinned by the lean-import test.
18
+
19
+ The log/lock parsing is pure (:func:`parse_last_runs`, :func:`lock_state`
20
+ takes injected probe results) so the unit tests never need a live cron.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING, Annotated
29
+
30
+ import cyclopts
31
+
32
+ from atif_cli.errors import EXIT_CODES
33
+ from atif_cli.output import OutputFormat, emit_json, resolve_format
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Iterable
37
+
38
+ cron_app = cyclopts.App(
39
+ name="cron",
40
+ help="Inspect and (manually) install the atif-sql refresh cron lanes.",
41
+ )
42
+
43
+ #: The lanes and their crontab schedules — the single source the
44
+ #: ``install`` block is rendered from. Cadence rationale lives in the
45
+ #: refresh script's header (materialize is the cheap incremental lane; llm
46
+ #: is the one that spends; compact is the nightly lake maintenance).
47
+ LANES: tuple[tuple[str, str], ...] = (
48
+ ("materialize", "*/10 * * * *"),
49
+ ("llm", "20 10 * * *"),
50
+ ("compact", "40 3 * * *"),
51
+ )
52
+
53
+ #: Lanes cut from the refresh script, with the date. The script still accepts
54
+ #: each name and exits 0 with a one-line "lane removed" log, so a crontab
55
+ #: line nobody deleted yet stays quiet; ``cron install`` names them so the
56
+ #: human deletes the line.
57
+ REMOVED_LANES: tuple[tuple[str, str], ...] = (("structural", "2026-09-27"),)
58
+
59
+ #: ``date -Is`` prefix on every refresh-log line (fixed-offset ISO-8601).
60
+ _TS = r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})"
61
+ _COMPLETE_RE = re.compile(_TS + r" refresh complete \(mode=(?P<mode>[a-z]+), exit=(?P<exit>\d+)\)")
62
+ _SKIP_RE = re.compile(_TS + r" skip\[(?P<mode>[a-z]+)\]:")
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class LaneRun:
67
+ """One parsed lane event: the run's ISO timestamp plus its exit code."""
68
+
69
+ timestamp: str
70
+ exit_code: int
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class LaneStatus:
75
+ """Everything ``cron status`` reports for one lane."""
76
+
77
+ lane: str
78
+ schedule: str
79
+ lock_held: bool
80
+ holder_pid: int | None
81
+ last_complete: LaneRun | None
82
+ last_skip: str | None
83
+
84
+
85
+ def crontab_block(script: Path, log: Path) -> str:
86
+ """Render the crontab block for ``script`` logging to ``log``.
87
+
88
+ Pure. One line per lane, schedules from :data:`LANES`, stdout+stderr
89
+ appended to the cron-side log (the script keeps its own structured log
90
+ under ``scripts/.run/`` regardless).
91
+ """
92
+ width = max(len(schedule) for _, schedule in LANES)
93
+ lines = [f"{schedule:<{width}} {script} {lane} >> {log} 2>&1" for lane, schedule in LANES]
94
+ return "\n".join(lines)
95
+
96
+
97
+ def parse_last_runs(lines: Iterable[str]) -> dict[str, dict[str, LaneRun | str]]:
98
+ """Extract per-lane last-completion and last-skip events from log lines.
99
+
100
+ Pure. Later lines win (the log is append-only, so iteration order IS
101
+ chronological order). Returns ``{lane: {"complete": LaneRun, "skip": ts}}``
102
+ with keys present only when the event was seen.
103
+ """
104
+ out: dict[str, dict[str, LaneRun | str]] = {}
105
+ for line in lines:
106
+ if match := _COMPLETE_RE.match(line):
107
+ out.setdefault(match["mode"], {})["complete"] = LaneRun(
108
+ timestamp=match["ts"], exit_code=int(match["exit"])
109
+ )
110
+ elif match := _SKIP_RE.match(line):
111
+ out.setdefault(match["mode"], {})["skip"] = match["ts"]
112
+ return out
113
+
114
+
115
+ def lock_state(*, probe_acquired: bool, pidfile_text: str | None) -> tuple[bool, int | None]:
116
+ """Classify one lane's lock: ``(held, holder_pid)``.
117
+
118
+ Pure — the caller injects the nonblocking-flock probe result and the
119
+ pidfile's text. ``probe_acquired=True`` means WE got the lock, so no run
120
+ holds it. The pid is reported only for a held lock; a stale pidfile next
121
+ to a free lock is normal (the last run's pid, not a holder).
122
+ """
123
+ if probe_acquired:
124
+ return (False, None)
125
+ pid: int | None = None
126
+ if pidfile_text is not None:
127
+ stripped = pidfile_text.strip()
128
+ if stripped.isdigit():
129
+ pid = int(stripped)
130
+ return (True, pid)
131
+
132
+
133
+ def _probe_lock(lock_path: Path) -> bool:
134
+ """Try to acquire ``lock_path`` nonblocking; True = acquired (lane idle).
135
+
136
+ The kernel drops the flock when the fd closes, so a successful probe
137
+ perturbs nothing. An absent lock file means no run ever started — also
138
+ "acquired" for classification purposes.
139
+ """
140
+ import fcntl
141
+
142
+ if not lock_path.exists():
143
+ return True
144
+ with lock_path.open("w") as handle:
145
+ try:
146
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
147
+ except OSError:
148
+ return False
149
+ fcntl.flock(handle, fcntl.LOCK_UN)
150
+ return True
151
+
152
+
153
+ def _scripts_dir() -> Path | None:
154
+ """Locate ``scripts/atif-sql-refresh.sh`` by walking up from this file.
155
+
156
+ Works for the editable install this workspace runs (site-packages paths
157
+ resolve back into ``packages/atif-cli/src``). Returns ``None`` when the
158
+ tree isn't reachable — e.g. a future ``uv tool install`` — in which case
159
+ the commands require the explicit ``--script`` flag.
160
+ """
161
+ for parent in Path(__file__).resolve().parents:
162
+ candidate = parent / "scripts" / "atif-sql-refresh.sh"
163
+ if candidate.is_file():
164
+ return candidate.parent
165
+ return None
166
+
167
+
168
+ def _resolve_script(script: Path | None) -> Path:
169
+ """Resolve the refresh-script path from the flag or by discovery."""
170
+ if script is not None:
171
+ return script
172
+ scripts_dir = _scripts_dir()
173
+ if scripts_dir is None:
174
+ import sys
175
+
176
+ print(
177
+ "error: cannot locate scripts/atif-sql-refresh.sh from this install; "
178
+ "pass --script explicitly",
179
+ file=sys.stderr,
180
+ )
181
+ raise SystemExit(EXIT_CODES["invalid_input"])
182
+ return scripts_dir / "atif-sql-refresh.sh"
183
+
184
+
185
+ @cron_app.command
186
+ def install(*, script: Path | None = None) -> None:
187
+ """Print the crontab block for the refresh lanes — never write it.
188
+
189
+ CONTRACT-V2 §Cron: no silent crontab writes. Check ``crontab -l`` for an
190
+ existing block, then paste this one via ``crontab -e``.
191
+
192
+ Parameters
193
+ ----------
194
+ script
195
+ Path to ``atif-sql-refresh.sh``; default: discovered from the repo
196
+ this CLI is (editably) installed from.
197
+ """
198
+ resolved = _resolve_script(script)
199
+ log = resolved.parent / ".run" / "atif-sql-refresh.cron.log"
200
+ print("# atif-sql refresh lanes — paste into `crontab -e` (check `crontab -l` first)")
201
+ for lane, removed_on in REMOVED_LANES:
202
+ print(f"# the {lane} lane was removed {removed_on}: delete any `{lane}` line")
203
+ print(crontab_block(resolved, log))
204
+
205
+
206
+ @cron_app.command
207
+ def status(
208
+ *,
209
+ script: Path | None = None,
210
+ tail: int = 10,
211
+ fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
212
+ ) -> None:
213
+ """Report each lane's lock holder + last run/skip parsed from the log.
214
+
215
+ Read-only: the lock probe acquires-and-releases nonblocking, perturbing
216
+ no running lane.
217
+
218
+ Parameters
219
+ ----------
220
+ script
221
+ Path to ``atif-sql-refresh.sh`` (its ``.run/`` sibling holds the
222
+ locks and log); default: discovered from the repo.
223
+ tail
224
+ How many trailing log lines to include (0 disables).
225
+ fmt
226
+ ``auto`` = human lines on a TTY, JSON on a pipe.
227
+ """
228
+ resolved = _resolve_script(script)
229
+ run_dir = resolved.parent / ".run"
230
+ log_path = run_dir / "atif-sql-refresh.log"
231
+ log_lines = log_path.read_text().splitlines() if log_path.is_file() else []
232
+ runs = parse_last_runs(log_lines)
233
+
234
+ lanes: list[LaneStatus] = []
235
+ for lane, schedule in LANES:
236
+ pidfile = run_dir / f"atif-sql-refresh-{lane}.pid"
237
+ held, pid = lock_state(
238
+ probe_acquired=_probe_lock(run_dir / f"atif-sql-refresh-{lane}.lock"),
239
+ pidfile_text=pidfile.read_text() if pidfile.is_file() else None,
240
+ )
241
+ lane_runs = runs.get(lane, {})
242
+ complete = lane_runs.get("complete")
243
+ skip = lane_runs.get("skip")
244
+ lanes.append(
245
+ LaneStatus(
246
+ lane=lane,
247
+ schedule=schedule,
248
+ lock_held=held,
249
+ holder_pid=pid,
250
+ last_complete=complete if isinstance(complete, LaneRun) else None,
251
+ last_skip=skip if isinstance(skip, str) else None,
252
+ )
253
+ )
254
+
255
+ tail_lines = log_lines[-tail:] if tail > 0 else []
256
+ if resolve_format(fmt) is OutputFormat.TABLE:
257
+ for lane_status in lanes:
258
+ lock = (
259
+ f"RUNNING (pid {lane_status.holder_pid or '?'})"
260
+ if lane_status.lock_held
261
+ else "idle"
262
+ )
263
+ last = (
264
+ f"{lane_status.last_complete.timestamp} exit={lane_status.last_complete.exit_code}"
265
+ if lane_status.last_complete
266
+ else "never"
267
+ )
268
+ print(f"{lane_status.lane:<12} [{lane_status.schedule:<12}] {lock:<20} last: {last}")
269
+ if lane_status.last_skip:
270
+ print(f"{'':<12} last skip: {lane_status.last_skip}")
271
+ if tail_lines:
272
+ print(f"\n--- last {len(tail_lines)} log lines ({log_path}) ---")
273
+ for line in tail_lines:
274
+ print(line)
275
+ return
276
+ emit_json(
277
+ {
278
+ "log": str(log_path),
279
+ "lanes": [
280
+ {
281
+ "lane": s.lane,
282
+ "schedule": s.schedule,
283
+ "lock_held": s.lock_held,
284
+ "holder_pid": s.holder_pid,
285
+ "last_complete": (
286
+ {"timestamp": s.last_complete.timestamp, "exit": s.last_complete.exit_code}
287
+ if s.last_complete
288
+ else None
289
+ ),
290
+ "last_skip": s.last_skip,
291
+ }
292
+ for s in lanes
293
+ ],
294
+ "tail": tail_lines,
295
+ },
296
+ fmt,
297
+ )
298
+
299
+
300
+ __all__ = [
301
+ "LANES",
302
+ "REMOVED_LANES",
303
+ "LaneRun",
304
+ "LaneStatus",
305
+ "cron_app",
306
+ "crontab_block",
307
+ "install",
308
+ "lock_state",
309
+ "parse_last_runs",
310
+ "status",
311
+ ]
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """DuckDB error classification for the ``query`` and ``search`` commands.
4
+
5
+ The one piece of the exit-code taxonomy that needs ``import duckdb`` — kept
6
+ out of :mod:`atif_cli.errors` (pure) and :mod:`atif_cli.output` (rendering)
7
+ so the lean import path never touches the driver. Import this module ONLY
8
+ inside command bodies that already hold a DuckDB connection.
9
+
10
+ The mapping: ``ParserException`` -> parse_error (64), ``CatalogException`` ->
11
+ catalog_error (65), everything else under ``duckdb.Error`` -> runtime_error
12
+ (70). The distinction agents act on is 64 vs 65 — a 64 means rewrite the SQL,
13
+ a 65 means the object does not exist, so run ``atif-sql schema`` first.
14
+
15
+ :data:`REGISTRATION_ERRORS` widens the caught set on the registration path.
16
+ ``EmbeddingProviderMismatch`` derives from ``Exception``, not
17
+ ``duckdb.Error``, so a bare ``except duckdb.Error`` lets it escape as an
18
+ unhandled traceback with exit 1 — a code outside :data:`EXIT_CODES`
19
+ entirely.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import duckdb
25
+
26
+ from atif_cli.errors import EXIT_CODES, ClassifiedError
27
+ from atif_duck.domain.embedding_guard import EmbeddingProviderMismatch
28
+
29
+ #: What ``register`` can raise: DuckDB DDL failures plus the embedding-store
30
+ #: identity guard. Catch this tuple, then hand the exception to
31
+ #: :func:`classify_registration_error`.
32
+ REGISTRATION_ERRORS: tuple[type[Exception], ...] = (duckdb.Error, EmbeddingProviderMismatch)
33
+
34
+
35
+ def classify_duckdb_error(exc: duckdb.Error) -> ClassifiedError:
36
+ """Classify a ``duckdb.Error`` into a stable kind + exit code."""
37
+ message = str(exc)
38
+ if isinstance(exc, duckdb.ParserException):
39
+ return ClassifiedError(
40
+ kind="parse_error",
41
+ exit_code=EXIT_CODES["parse_error"],
42
+ message=message,
43
+ hint="check SQL syntax; try `atif-sql schema --format json` for view/macro names",
44
+ )
45
+ if isinstance(exc, duckdb.CatalogException):
46
+ return ClassifiedError(
47
+ kind="catalog_error",
48
+ exit_code=EXIT_CODES["catalog_error"],
49
+ message=message,
50
+ hint="unknown view or column; run `atif-sql schema --format json` for the catalog",
51
+ )
52
+ if isinstance(exc, duckdb.IOException):
53
+ return ClassifiedError(
54
+ kind="runtime_error",
55
+ exit_code=EXIT_CODES["runtime_error"],
56
+ message=message,
57
+ hint="is the corpus materialized? run `atif-sql materialize` then `atif-sql status`",
58
+ )
59
+ return ClassifiedError(
60
+ kind="runtime_error",
61
+ exit_code=EXIT_CODES["runtime_error"],
62
+ message=message,
63
+ hint=None,
64
+ )
65
+
66
+
67
+ def classify_registration_error(exc: Exception) -> ClassifiedError:
68
+ """Classify anything :data:`REGISTRATION_ERRORS` catches.
69
+
70
+ ``EmbeddingProviderMismatch`` maps to ``embedding_mismatch`` at exit 65
71
+ (the catalog-class code: the store's stamped identity does not match the
72
+ active embedder, so the requested object cannot be bound). Everything
73
+ else falls through to :func:`classify_duckdb_error`.
74
+ """
75
+ if isinstance(exc, EmbeddingProviderMismatch):
76
+ return ClassifiedError(
77
+ kind="embedding_mismatch",
78
+ exit_code=EXIT_CODES["embedding_mismatch"],
79
+ message=str(exc),
80
+ hint="delete the Lance store and re-run `atif-sql embed --all --no-dry-run`, "
81
+ "or point ATIF_SQL_EMBED_MODEL_ID at the model that wrote it",
82
+ )
83
+ if isinstance(exc, duckdb.Error):
84
+ return classify_duckdb_error(exc)
85
+ return ClassifiedError(
86
+ kind="runtime_error",
87
+ exit_code=EXIT_CODES["runtime_error"],
88
+ message=str(exc),
89
+ hint=None,
90
+ )
91
+
92
+
93
+ __all__ = [
94
+ "REGISTRATION_ERRORS",
95
+ "classify_duckdb_error",
96
+ "classify_registration_error",
97
+ ]
atif_cli/embed_lake.py ADDED
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """atif-embed's ``LakeStepsPort``, implemented over atif-duck's lake.
4
+
5
+ atif-embed and atif-duck may not import each other, so the composition root
6
+ joins them: :class:`DuckLakeSteps` opens the corpus's steps through
7
+ :func:`atif_duck.infrastructure.lake_steps.open_lake_steps` and hands them to
8
+ atif-embed in the port's own types. Imported only inside command bodies, so
9
+ the fast paths never load DuckDB.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING, Self
16
+
17
+ from loguru import logger
18
+
19
+ from atif_embed.domain.ports import LakePosition
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Iterator
23
+ from pathlib import Path
24
+ from types import TracebackType
25
+
26
+ from atif_duck.infrastructure.lake import LakeLayout
27
+ from atif_duck.infrastructure.lake_steps import LakeSteps
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class _Snapshot:
32
+ """One open :class:`~atif_duck.infrastructure.lake_steps.LakeSteps`, in the port's types."""
33
+
34
+ steps: LakeSteps
35
+
36
+ @property
37
+ def position(self) -> LakePosition:
38
+ return LakePosition(lineage=self.steps.lineage, snapshot_id=self.steps.snapshot_id)
39
+
40
+ def can_read_changes_since(self, since: LakePosition) -> bool:
41
+ return since.lineage == self.steps.lineage and self.steps.can_read_changes_since(
42
+ since.snapshot_id
43
+ )
44
+
45
+ def step_texts(
46
+ self, *, min_chars: int, changed_since: LakePosition | None
47
+ ) -> Iterator[tuple[str, str]]:
48
+ since = None if changed_since is None else changed_since.snapshot_id
49
+ return self.steps.step_texts(min_chars=min_chars, since_snapshot=since)
50
+
51
+ def step_keys(self) -> frozenset[str]:
52
+ return self.steps.step_keys()
53
+
54
+ def close(self) -> None:
55
+ self.steps.close()
56
+
57
+ def __enter__(self) -> Self:
58
+ return self
59
+
60
+ def __exit__(
61
+ self,
62
+ exc_type: type[BaseException] | None,
63
+ exc: BaseException | None,
64
+ tb: TracebackType | None,
65
+ ) -> None:
66
+ self.close()
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class DuckLakeSteps:
71
+ """``LakeStepsPort`` over the lake at ``layout``."""
72
+
73
+ layout: LakeLayout
74
+ memory_limit_bytes: int | None = None
75
+
76
+ def open_steps(self, corpus_root: Path) -> _Snapshot | None:
77
+ """The corpus's steps at the published snapshot, or ``None`` (reason logged) to fall back."""
78
+ from atif_duck.infrastructure.lake_steps import LakeSteps, open_lake_steps
79
+
80
+ opened = open_lake_steps(
81
+ self.layout, corpus_root, memory_limit_bytes=self.memory_limit_bytes
82
+ )
83
+ if not isinstance(opened, LakeSteps):
84
+ logger.info("embed: {}; reading the per-session artifacts instead", opened.reason)
85
+ return None
86
+ return _Snapshot(opened)
87
+
88
+
89
+ def lake_steps_port() -> DuckLakeSteps:
90
+ """The port over the lake ``ATIF_SQL_LAKE_ROOT`` names, capped like ``query``."""
91
+ from atif_cli.app import query_memory_limit_bytes
92
+ from atif_duck.infrastructure.lake import LakeLayout
93
+ from atif_duck.infrastructure.lake_settings import LakeSettings
94
+
95
+ return DuckLakeSteps(
96
+ layout=LakeLayout(LakeSettings().lake_root),
97
+ memory_limit_bytes=query_memory_limit_bytes(),
98
+ )
99
+
100
+
101
+ __all__ = ["DuckLakeSteps", "lake_steps_port"]
atif_cli/errors.py ADDED
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """CLI error taxonomy: stable exit codes + the classified-error shape.
4
+
5
+ :data:`EXIT_CODES` is the workspace's ONE exit-code table. The numbers follow
6
+ the BSD ``sysexits.h`` range so a shell reading them gets the conventional
7
+ meaning: 64 for malformed input or SQL, 65 for a catalog miss (unknown
8
+ view/macro/column), 70 for a runtime error. No other package owns a mapping —
9
+ atif-converter raises typed :class:`~atif_converter.domain.errors.DomainError`
10
+ subclasses and deliberately carries no codes, so ``convert`` translates them
11
+ here and every command exits from the same dict. A usage error cyclopts
12
+ refuses exits ``invalid_input``: ``atif_cli.app.main`` maps cyclopts 5's own
13
+ exit 2, which this table gives to ``empty_session``.
14
+
15
+ A number in this dict is a WIRE CONTRACT. An agent branches on it, so
16
+ reassigning an existing key to a different number is a breaking change even
17
+ though nothing in this repo fails.
18
+
19
+ Pure module: no duckdb, no atif_* imports — the concrete ``duckdb.Error``
20
+ classifier lives in :mod:`atif_cli.duck_errors` so this module stays on the
21
+ lean import path (pinned by the fresh-interpreter lean-import test).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ #: Exit codes agents can rely on. Add keys freely; never renumber one.
30
+ EXIT_CODES: dict[str, int] = {
31
+ "ok": 0,
32
+ "empty_session": 2, # convert: session parsed but produced no trajectory
33
+ "no_embeddings": 2, # search: store empty — run `atif-sql embed --all --no-dry-run`
34
+ "invalid_input": 64, # malformed user-supplied flags / paths
35
+ "parse_error": 64, # malformed SQL
36
+ "catalog_error": 65, # unknown view/macro/column
37
+ "validation_error": 65, # convert: trajectory failed TrajectoryValidator
38
+ "embedding_mismatch": 65, # query/search: Lance store written by another provider
39
+ "runtime_error": 70, # everything else duckdb (or the adapter) raises
40
+ "sandbox_refused": 70, # query: a statement kind the sandbox never runs (COPY, EXPORT,
41
+ # ATTACH, INSTALL, LOAD, PREPARE, ...). Same number as runtime_error, which is what
42
+ # every other sandbox refusal exits with; the distinct kind names the reason.
43
+ "root_refused": 77, # query/search/analyze: running as uid 0 without ATIF_SQL_ALLOW_ROOT=1.
44
+ # EX_NOPERM by convention: the process has too MUCH permission for the file modes
45
+ # that protect the corpus to mean anything.
46
+ "extension_missing": 78, # search: the Lance store exists but the lance DuckDB extension
47
+ # is not installed; `atif-sql embed --install-extension` is the operator action, so the
48
+ # same EX_CONFIG contract as terminal_state.
49
+ "terminal_state": 78, # embed: store/config state requires OPERATOR action (EX_CONFIG);
50
+ # retrying without intervention cannot succeed — unattended lanes suppress
51
+ # retries on this code instead of burning identical ticks
52
+ "suspicious_scan": 78, # materialize: the source scan found 0 sessions over a
53
+ # non-empty corpus, so marking them source-removed was refused. Same EX_CONFIG contract as
54
+ # terminal_state — a wrong source_root needs an operator, and a retry over the
55
+ # same wrong root cannot succeed — but a distinct `kind` string, because the
56
+ # remedy is a path, not a store. A separate code matters most for what it is
57
+ # NOT: exit 1 here is an uncaught traceback, indistinguishable from a crash.
58
+ "lake_mismatch": 65, # lake verify: a session's lake rows differ from its artifacts
59
+ # (EX_DATAERR). `atif-sql lake rebuild` reloads every corpus from its artifacts.
60
+ "lake_unavailable": 78, # lake verify/compact: no lake, a stale one, or no ducklake
61
+ # extension; an operator runs `atif-sql lake rebuild` (EX_CONFIG, like terminal_state).
62
+ "harbor_missing": 127, # RETIRED, kept so this table never renumbers. It named
63
+ # the loss of the private harbor method the converter used to be built on; the
64
+ # conversion is ours now (atif_converter.domain.*_conversion), so no code path
65
+ # raises it. EX_NOTFOUND by convention: a surface, not a datum.
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class ClassifiedError:
71
+ """The structured shape of a CLI error after classification."""
72
+
73
+ kind: str # "invalid_input" | "parse_error" | "catalog_error" | "runtime_error"
74
+ exit_code: int
75
+ message: str
76
+ hint: str | None = None
77
+
78
+ def to_payload(self) -> dict[str, Any]:
79
+ """The JSON error envelope emitted on non-TTY stderr."""
80
+ return {
81
+ "error": {
82
+ "kind": self.kind,
83
+ "message": self.message,
84
+ "hint": self.hint,
85
+ }
86
+ }
87
+
88
+
89
+ __all__ = ["EXIT_CODES", "ClassifiedError"]