sqldoc 1.3.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.
sqldoc/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
sqldoc/ai.py ADDED
@@ -0,0 +1,311 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import random
5
+ import hashlib
6
+ import threading
7
+ import requests
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from anthropic import Anthropic
10
+ from sqldoc.extractor import Table, View, StoredProcedure
11
+
12
+ DEFAULT_CONCURRENCY = 8
13
+ MAX_ATTEMPTS = 4 # 1 try + 3 retries
14
+ CACHE_VERSION = 1
15
+
16
+
17
+ def _retry(fn, what: str):
18
+ """Call fn(), retrying transient failures with exponential backoff + jitter."""
19
+ delay = 1.0
20
+ for attempt in range(1, MAX_ATTEMPTS + 1):
21
+ try:
22
+ return fn()
23
+ except Exception as e:
24
+ if attempt == MAX_ATTEMPTS:
25
+ raise
26
+ wait = delay + random.uniform(0, 0.4)
27
+ print(f" retry {attempt}/{MAX_ATTEMPTS - 1} for {what}: {type(e).__name__}: {e} (waiting {wait:.1f}s)")
28
+ time.sleep(wait)
29
+ delay *= 2
30
+
31
+
32
+ # --- Description cache -----------------------------------------------------
33
+ # Descriptions are keyed by (model, kind, structural signature). If an object's
34
+ # structure is unchanged since the last run, its description is reused instead of
35
+ # calling the LLM again — saving cost and making incremental runs fast.
36
+
37
+ def _def_sig(*definitions) -> str:
38
+ """A short digest of one or more SQL bodies, folded into a cache signature
39
+ when --include-definitions is on so a changed body invalidates the cache."""
40
+ joined = "\x1e".join(d for d in definitions if d)
41
+ if not joined:
42
+ return ""
43
+ return "|def:" + hashlib.sha1(joined.encode("utf-8")).hexdigest()[:12]
44
+
45
+ def _sig_table(t, include_definitions=False) -> str:
46
+ cols = "|".join(f"{c.name}:{c.data_type}:{int(c.is_primary_key)}{int(c.is_foreign_key)}" for c in t.columns)
47
+ sig = f"{t.schema}.{t.name}|{cols}"
48
+ if include_definitions:
49
+ sig += _def_sig(*(tg.definition for tg in t.triggers))
50
+ return sig
51
+
52
+ def _sig_view(v, include_definitions=False) -> str:
53
+ cols = "|".join(f"{c.name}:{c.data_type}" for c in v.columns)
54
+ sig = f"{v.schema}.{v.name}|{cols}"
55
+ if include_definitions:
56
+ sig += _def_sig(v.definition)
57
+ return sig
58
+
59
+ def _sig_proc(p, include_definitions=False) -> str:
60
+ params = "|".join(f"{pm.name}:{pm.data_type}:{int(pm.is_output)}" for pm in p.parameters)
61
+ sig = f"{p.schema}.{p.name}|{params}"
62
+ if include_definitions:
63
+ sig += _def_sig(p.definition)
64
+ return sig
65
+
66
+ def _sig_col(container: str, col) -> str:
67
+ return f"{container}.{col.name}:{col.data_type}"
68
+
69
+ def _key(model: str, kind: str, sig: str) -> str:
70
+ return hashlib.sha1(f"{model}\x1f{kind}\x1f{sig}".encode("utf-8")).hexdigest()
71
+
72
+ def load_cache(path: str) -> dict:
73
+ if not path or not os.path.exists(path):
74
+ return {"version": CACHE_VERSION, "entries": {}}
75
+ try:
76
+ with open(path, encoding="utf-8") as f:
77
+ data = json.load(f)
78
+ except (json.JSONDecodeError, OSError):
79
+ data = {}
80
+ if not isinstance(data, dict) or not isinstance(data.get("entries"), dict):
81
+ data = {"version": CACHE_VERSION, "entries": {}}
82
+ return data
83
+
84
+ def save_cache(cache: dict, path: str):
85
+ if cache is None or not path:
86
+ return
87
+ parent = os.path.dirname(path)
88
+ if parent:
89
+ os.makedirs(parent, exist_ok=True)
90
+ with open(path, "w", encoding="utf-8") as f:
91
+ json.dump(cache, f, indent=2, sort_keys=True)
92
+
93
+ # One shared Anthropic client, created lazily. The SDK client is thread-safe and
94
+ # reuses its connection pool, so sharing it across worker threads is both correct
95
+ # and faster than constructing a client per call.
96
+ _anthropic_client = None
97
+ _anthropic_lock = threading.Lock()
98
+
99
+ def _get_anthropic_client() -> Anthropic:
100
+ global _anthropic_client
101
+ if _anthropic_client is None:
102
+ with _anthropic_lock:
103
+ if _anthropic_client is None:
104
+ _anthropic_client = Anthropic()
105
+ return _anthropic_client
106
+
107
+ def generate_table_description(table: Table, mode: str = "local", model: str = "llama3.1:8b",
108
+ include_definitions: bool = False) -> str:
109
+ column_info = "\n".join([
110
+ f" - {col.name} ({col.data_type})"
111
+ f"{'[PK]' if col.is_primary_key else ''}"
112
+ f"{'[FK -> ' + col.references_table + ']' if col.is_foreign_key else ''}"
113
+ f"{': ' + col.description if col.description else ''}"
114
+ for col in table.columns
115
+ ])
116
+
117
+ prompt = f"""You are documenting a SQL Server database table. Based on the table name, schema, and column names, write a clear 2-3 sentence description of what this table likely stores and its business purpose.
118
+
119
+ Table: {table.schema}.{table.name}
120
+ Row count: {table.row_count}
121
+ Columns:
122
+ {column_info}"""
123
+
124
+ # Opt-in: include trigger bodies so the AI can reason about side effects.
125
+ if include_definitions:
126
+ trig = "\n\n".join(f"-- trigger {tg.name}\n{tg.definition}"
127
+ for tg in table.triggers if tg.definition)
128
+ if trig:
129
+ prompt += f"\n\nTrigger definitions:\n{trig}"
130
+
131
+ prompt += "\n\nRespond with only the description, no preamble."
132
+
133
+ if mode == "local":
134
+ return _call_ollama(prompt, model)
135
+ else:
136
+ return _call_anthropic(prompt, model)
137
+
138
+ def generate_column_description(table_name: str, col, mode: str = "local", model: str = "llama3.1:8b") -> str:
139
+ prompt = f"""In one sentence, describe what the column '{col.name}' ({col.data_type}) likely stores in the '{table_name}' table. Respond with only the description, no preamble."""
140
+
141
+ if mode == "local":
142
+ return _call_ollama(prompt, model)
143
+ else:
144
+ return _call_anthropic(prompt, model)
145
+
146
+ def generate_view_description(view: View, mode: str = "local", model: str = "llama3.1:8b",
147
+ include_definitions: bool = False) -> str:
148
+ # Metadata only by default: name + column names/types. The view's SQL
149
+ # definition is NOT sent unless --include-definitions is set, keeping the
150
+ # default cloud data boundary limited to schema metadata (the definition is
151
+ # rendered locally regardless).
152
+ column_info = "\n".join(f" - {col.name} ({col.data_type})" for col in view.columns)
153
+ prompt = f"""You are documenting a SQL Server view. Based on the view name, schema, and its output columns, write a clear 2-3 sentence description of what this view likely presents and its business purpose.
154
+
155
+ View: {view.schema}.{view.name}
156
+ Columns:
157
+ {column_info}"""
158
+
159
+ if include_definitions and view.definition:
160
+ prompt += f"\n\nSQL definition:\n{view.definition}"
161
+
162
+ prompt += "\n\nRespond with only the description, no preamble."
163
+
164
+ if mode == "local":
165
+ return _call_ollama(prompt, model)
166
+ else:
167
+ return _call_anthropic(prompt, model)
168
+
169
+ def generate_procedure_description(proc: StoredProcedure, mode: str = "local", model: str = "llama3.1:8b",
170
+ include_definitions: bool = False) -> str:
171
+ # Metadata only by default: name + parameter names/types/direction. The proc
172
+ # body is NOT sent unless --include-definitions is set (rendered locally
173
+ # regardless).
174
+ if proc.parameters:
175
+ param_info = "\n".join(
176
+ f" - {p.name} ({p.data_type}){' OUTPUT' if p.is_output else ''}"
177
+ for p in proc.parameters
178
+ )
179
+ else:
180
+ param_info = " (no parameters)"
181
+ prompt = f"""You are documenting a SQL Server stored procedure. Based on the procedure name, schema, and its parameters, write a clear 2-3 sentence description of what this procedure likely does and its business purpose.
182
+
183
+ Procedure: {proc.schema}.{proc.name}
184
+ Parameters:
185
+ {param_info}"""
186
+
187
+ if include_definitions and proc.definition:
188
+ prompt += f"\n\nSQL definition:\n{proc.definition}"
189
+
190
+ prompt += "\n\nRespond with only the description, no preamble."
191
+
192
+ if mode == "local":
193
+ return _call_ollama(prompt, model)
194
+ else:
195
+ return _call_anthropic(prompt, model)
196
+
197
+ def _call_ollama(prompt: str, model: str = "llama3.1:8b") -> str:
198
+ def do():
199
+ response = requests.post(
200
+ "http://localhost:11434/api/generate",
201
+ json={"model": model, "prompt": prompt, "stream": False},
202
+ timeout=120,
203
+ )
204
+ response.raise_for_status()
205
+ return response.json()["response"].strip()
206
+ return _retry(do, f"ollama:{model}")
207
+
208
+ def _call_anthropic(prompt: str, model: str = "claude-haiku-4-5") -> str:
209
+ def do():
210
+ client = _get_anthropic_client()
211
+ message = client.messages.create(
212
+ model=model,
213
+ max_tokens=200,
214
+ messages=[{"role": "user", "content": prompt}],
215
+ )
216
+ return message.content[0].text
217
+ return _retry(do, f"anthropic:{model}")
218
+
219
+ def _run_tasks(tasks: list, concurrency: int, label: str):
220
+ """Run independent, zero-argument LLM-call tasks across a thread pool.
221
+
222
+ Each task performs one blocking model call and writes its result onto its
223
+ own target object's `.description`, so tasks never touch shared state and
224
+ can run fully in parallel. A failed task logs and is skipped rather than
225
+ aborting the whole run; progress is reported from a single locked counter.
226
+ """
227
+ if not tasks:
228
+ return
229
+ total = len(tasks)
230
+ state = {"done": 0}
231
+ lock = threading.Lock()
232
+
233
+ def worker(fn):
234
+ try:
235
+ fn()
236
+ except Exception as e:
237
+ with lock:
238
+ print(f" ! {label} description failed: {e}")
239
+ finally:
240
+ with lock:
241
+ state["done"] += 1
242
+ d = state["done"]
243
+ if d % 10 == 0 or d == total:
244
+ print(f" [{d}/{total}] {label} descriptions generated")
245
+
246
+ with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
247
+ list(pool.map(worker, tasks))
248
+
249
+
250
+ def _cache_or_task(cache, key, target, genfn, tasks, stats):
251
+ """If a cached description exists for key, apply it and count a hit; else
252
+ queue a task that generates the description and writes it back to cache."""
253
+ cached = cache["entries"].get(key) if cache is not None else None
254
+ if cached is not None:
255
+ target.description = cached
256
+ stats["hits"] += 1
257
+ return
258
+ def task():
259
+ val = genfn()
260
+ target.description = val
261
+ if cache is not None:
262
+ cache["entries"][key] = val
263
+ tasks.append(task)
264
+
265
+ def _report(label, tasks, stats, cache):
266
+ if cache is not None:
267
+ print(f" {label}: {stats['hits']} reused from cache, {len(tasks)} generated")
268
+
269
+
270
+ def enrich_tables(tables: list[Table], mode: str = "local", model: str = "llama3.1:8b",
271
+ concurrency: int = DEFAULT_CONCURRENCY, cache: dict = None,
272
+ include_definitions: bool = False) -> list[Table]:
273
+ tasks, stats = [], {"hits": 0}
274
+ for table in tables:
275
+ _cache_or_task(cache, _key(model, "table", _sig_table(table, include_definitions)), table,
276
+ (lambda t=table: generate_table_description(t, mode, model, include_definitions)), tasks, stats)
277
+ for col in table.columns:
278
+ if col.description:
279
+ continue
280
+ _cache_or_task(cache, _key(model, "column", _sig_col(table.name, col)), col,
281
+ (lambda tn=table.name, c=col: generate_column_description(tn, c, mode, model)), tasks, stats)
282
+ _run_tasks(tasks, concurrency, "table")
283
+ _report("tables", tasks, stats, cache)
284
+ return tables
285
+
286
+ def enrich_views(views: list[View], mode: str = "local", model: str = "llama3.1:8b",
287
+ concurrency: int = DEFAULT_CONCURRENCY, cache: dict = None,
288
+ include_definitions: bool = False) -> list[View]:
289
+ tasks, stats = [], {"hits": 0}
290
+ for view in views:
291
+ _cache_or_task(cache, _key(model, "view", _sig_view(view, include_definitions)), view,
292
+ (lambda v=view: generate_view_description(v, mode, model, include_definitions)), tasks, stats)
293
+ for col in view.columns:
294
+ if col.description:
295
+ continue
296
+ _cache_or_task(cache, _key(model, "column", _sig_col(view.name, col)), col,
297
+ (lambda vn=view.name, c=col: generate_column_description(vn, c, mode, model)), tasks, stats)
298
+ _run_tasks(tasks, concurrency, "view")
299
+ _report("views", tasks, stats, cache)
300
+ return views
301
+
302
+ def enrich_procedures(procedures: list[StoredProcedure], mode: str = "local", model: str = "llama3.1:8b",
303
+ concurrency: int = DEFAULT_CONCURRENCY, cache: dict = None,
304
+ include_definitions: bool = False) -> list[StoredProcedure]:
305
+ tasks, stats = [], {"hits": 0}
306
+ for proc in procedures:
307
+ _cache_or_task(cache, _key(model, "proc", _sig_proc(proc, include_definitions)), proc,
308
+ (lambda p=proc: generate_procedure_description(p, mode, model, include_definitions)), tasks, stats)
309
+ _run_tasks(tasks, concurrency, "procedure")
310
+ _report("procedures", tasks, stats, cache)
311
+ return procedures