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 +1 -0
- sqldoc/ai.py +311 -0
- sqldoc/cli.py +828 -0
- sqldoc/extractor.py +428 -0
- sqldoc/health.py +273 -0
- sqldoc/health_renderer.py +197 -0
- sqldoc/intel.py +332 -0
- sqldoc/intel_renderer.py +172 -0
- sqldoc/json_renderer.py +45 -0
- sqldoc/markdown_renderer.py +214 -0
- sqldoc/pdf_renderer.py +243 -0
- sqldoc/pii.py +418 -0
- sqldoc/pii_renderer.py +201 -0
- sqldoc/quality.py +210 -0
- sqldoc/quality_renderer.py +196 -0
- sqldoc/renderer.py +974 -0
- sqldoc/sarif.py +86 -0
- sqldoc/snapshot.py +227 -0
- sqldoc-1.3.0.dist-info/METADATA +325 -0
- sqldoc-1.3.0.dist-info/RECORD +23 -0
- sqldoc-1.3.0.dist-info/WHEEL +5 -0
- sqldoc-1.3.0.dist-info/entry_points.txt +2 -0
- sqldoc-1.3.0.dist-info/top_level.txt +1 -0
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
|