db-agent-kit 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.
- db_agent/__init__.py +3 -0
- db_agent/cli.py +32 -0
- db_agent/engines/__init__.py +36 -0
- db_agent/engines/databricks/__init__.py +1 -0
- db_agent/engines/databricks/checks.py +221 -0
- db_agent/engines/databricks/cli.py +61 -0
- db_agent/engines/databricks/client.py +86 -0
- db_agent/engines/databricks/cost.py +96 -0
- db_agent/engines/databricks/health.py +156 -0
- db_agent/engines/databricks/model.py +29 -0
- db_agent/engines/databricks/queries.py +98 -0
- db_agent/engines/databricks/render.py +78 -0
- db_agent/engines/databricks/runtimes.py +58 -0
- db_agent/engines/databricks/sqlguard.py +80 -0
- db_agent/engines/sqlserver/__init__.py +11 -0
- db_agent/engines/sqlserver/classify.py +102 -0
- db_agent/engines/sqlserver/cli.py +265 -0
- db_agent/engines/sqlserver/data/classification.yaml +29 -0
- db_agent/engines/sqlserver/db.py +183 -0
- db_agent/engines/sqlserver/guard.py +190 -0
- db_agent/engines/sqlserver/inventory.py +324 -0
- db_agent/engines/sqlserver/outputs.py +299 -0
- db_agent/engines/sqlserver/rdl.py +269 -0
- db_agent/engines/sqlserver/report.py +120 -0
- db_agent/engines/sqlserver/risks.py +184 -0
- db_agent/engines/sqlserver/rules.py +141 -0
- db_agent/engines/sqlserver/sqlrefs.py +103 -0
- db_agent/engines/sqlserver/ssrs.py +230 -0
- db_agent/engines/sqlserver/templates/summary.html.j2 +142 -0
- db_agent/engines/sqlserver/usage.py +127 -0
- db_agent/engines/sqlserver/validate.py +238 -0
- db_agent_kit-0.2.0.dist-info/METADATA +192 -0
- db_agent_kit-0.2.0.dist-info/RECORD +36 -0
- db_agent_kit-0.2.0.dist-info/WHEEL +4 -0
- db_agent_kit-0.2.0.dist-info/entry_points.txt +2 -0
- db_agent_kit-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Server and database inventory. Metadata only: no business table is ever read.
|
|
2
|
+
|
|
3
|
+
All SQL here is a fixed string and passes through the QueryGuard like everything else.
|
|
4
|
+
Row counts come from sys.dm_db_partition_stats.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from .db import ConnectionConfig, DatabaseError, SafeConnection
|
|
15
|
+
from .guard import GuardError, QueryGuard
|
|
16
|
+
|
|
17
|
+
SYSTEM_DATABASES = {"master", "model", "msdb", "tempdb"}
|
|
18
|
+
|
|
19
|
+
Q_SERVER = """
|
|
20
|
+
SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS product_version,
|
|
21
|
+
CAST(SERVERPROPERTY('Edition') AS nvarchar(128)) AS edition,
|
|
22
|
+
CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(128)) AS product_level,
|
|
23
|
+
CAST(SERVERPROPERTY('Collation') AS nvarchar(128)) AS collation,
|
|
24
|
+
CAST(SERVERPROPERTY('EngineEdition') AS int) AS engine_edition,
|
|
25
|
+
CAST(SERVERPROPERTY('ServerName') AS nvarchar(128)) AS server_name
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
Q_DATABASES = """
|
|
29
|
+
SELECT d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
|
|
30
|
+
d.recovery_model_desc, d.is_published, d.is_subscribed, d.is_merge_published, d.is_distributor,
|
|
31
|
+
CAST(SUM(CASE WHEN mf.type = 0 THEN CAST(mf.size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS data_mb,
|
|
32
|
+
CAST(SUM(CASE WHEN mf.type = 1 THEN CAST(mf.size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS log_mb
|
|
33
|
+
FROM sys.databases d
|
|
34
|
+
LEFT JOIN sys.master_files mf ON mf.database_id = d.database_id
|
|
35
|
+
GROUP BY d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
|
|
36
|
+
d.recovery_model_desc, d.is_published, d.is_subscribed, d.is_merge_published, d.is_distributor
|
|
37
|
+
ORDER BY d.name
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Azure SQL Database has no sys.master_files. Fall back to sys.databases alone (sizes unknown), then
|
|
41
|
+
# fill in the size of the connected database from sys.database_files.
|
|
42
|
+
Q_DATABASES_BASIC = """
|
|
43
|
+
SELECT d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
|
|
44
|
+
d.recovery_model_desc, d.is_published, d.is_subscribed, d.is_merge_published, d.is_distributor,
|
|
45
|
+
CAST(NULL AS bigint) AS data_mb, CAST(NULL AS bigint) AS log_mb
|
|
46
|
+
FROM sys.databases d
|
|
47
|
+
ORDER BY d.name
|
|
48
|
+
"""
|
|
49
|
+
Q_DATABASES_BASIC_MIN = """
|
|
50
|
+
SELECT d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
|
|
51
|
+
CAST(NULL AS nvarchar(60)) AS recovery_model_desc,
|
|
52
|
+
CAST(0 AS bit) AS is_published, CAST(0 AS bit) AS is_subscribed, CAST(0 AS bit) AS is_merge_published,
|
|
53
|
+
CAST(0 AS bit) AS is_distributor, CAST(NULL AS bigint) AS data_mb, CAST(NULL AS bigint) AS log_mb
|
|
54
|
+
FROM sys.databases d
|
|
55
|
+
ORDER BY d.name
|
|
56
|
+
"""
|
|
57
|
+
Q_OWN_FILES = """
|
|
58
|
+
SELECT CAST(SUM(CASE WHEN type = 0 THEN CAST(size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS data_mb,
|
|
59
|
+
CAST(SUM(CASE WHEN type = 1 THEN CAST(size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS log_mb
|
|
60
|
+
FROM sys.database_files
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
Q_LINKED = "SELECT name, product, provider, data_source FROM sys.servers WHERE is_linked = 1"
|
|
64
|
+
Q_DEPRECATED_COUNTERS = (
|
|
65
|
+
"SELECT RTRIM(instance_name) AS feature, cntr_value AS uses_since_restart "
|
|
66
|
+
"FROM sys.dm_os_performance_counters "
|
|
67
|
+
"WHERE object_name LIKE '%Deprecated Features%' AND cntr_value > 0 ORDER BY cntr_value DESC"
|
|
68
|
+
)
|
|
69
|
+
Q_MIRRORING = "SELECT DB_NAME(database_id) AS database_name FROM sys.database_mirroring WHERE mirroring_guid IS NOT NULL"
|
|
70
|
+
Q_SOAP = "SELECT name FROM sys.endpoints WHERE type_desc = 'SOAP'"
|
|
71
|
+
Q_AGENT_JOBS = """
|
|
72
|
+
SELECT j.name AS job_name, j.enabled, js.step_id, js.step_name, js.subsystem, js.database_name,
|
|
73
|
+
js.command
|
|
74
|
+
FROM msdb.dbo.sysjobs j
|
|
75
|
+
JOIN msdb.dbo.sysjobsteps js ON js.job_id = j.job_id
|
|
76
|
+
ORDER BY j.name, js.step_id
|
|
77
|
+
"""
|
|
78
|
+
Q_AGENT_SCHEDULES = """
|
|
79
|
+
SELECT j.name AS job_name, s.name AS schedule_name, s.enabled, s.freq_type, s.freq_interval
|
|
80
|
+
FROM msdb.dbo.sysjobs j
|
|
81
|
+
JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id
|
|
82
|
+
JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Per-database queries
|
|
86
|
+
Q_TABLES = """
|
|
87
|
+
SELECT s.name AS schema_name, t.name AS table_name, t.object_id, t.create_date, t.modify_date
|
|
88
|
+
FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
89
|
+
WHERE t.is_ms_shipped = 0
|
|
90
|
+
"""
|
|
91
|
+
Q_ROWCOUNTS = (
|
|
92
|
+
"SELECT object_id, SUM(row_count) AS row_count FROM sys.dm_db_partition_stats "
|
|
93
|
+
"WHERE index_id IN (0, 1) GROUP BY object_id"
|
|
94
|
+
)
|
|
95
|
+
Q_COLUMNS = """
|
|
96
|
+
SELECT o.object_id, s.name AS schema_name, o.name AS object_name, o.type AS object_type,
|
|
97
|
+
c.column_id, c.name AS column_name, ty.name AS type_name, c.max_length, c.precision, c.scale,
|
|
98
|
+
c.is_nullable, c.is_identity, c.is_computed
|
|
99
|
+
FROM sys.columns c
|
|
100
|
+
JOIN sys.objects o ON o.object_id = c.object_id
|
|
101
|
+
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
|
102
|
+
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
103
|
+
WHERE o.type IN ('U', 'V') AND o.is_ms_shipped = 0
|
|
104
|
+
ORDER BY o.object_id, c.column_id
|
|
105
|
+
"""
|
|
106
|
+
Q_INDEXES = """
|
|
107
|
+
SELECT i.object_id, i.name AS index_name, i.type_desc, i.is_primary_key, i.is_unique,
|
|
108
|
+
ic.key_ordinal, c.name AS column_name
|
|
109
|
+
FROM sys.indexes i
|
|
110
|
+
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
|
111
|
+
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
112
|
+
JOIN sys.objects o ON o.object_id = i.object_id
|
|
113
|
+
WHERE o.is_ms_shipped = 0 AND o.type = 'U' AND i.type > 0 AND ic.is_included_column = 0
|
|
114
|
+
ORDER BY i.object_id, i.index_id, ic.key_ordinal
|
|
115
|
+
"""
|
|
116
|
+
Q_MODULES = """
|
|
117
|
+
SELECT s.name AS schema_name, o.name AS object_name, o.type AS object_type, o.type_desc,
|
|
118
|
+
o.create_date, o.modify_date, m.uses_ansi_nulls, m.uses_quoted_identifier, m.definition
|
|
119
|
+
FROM sys.sql_modules m
|
|
120
|
+
JOIN sys.objects o ON o.object_id = m.object_id
|
|
121
|
+
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
|
122
|
+
WHERE o.is_ms_shipped = 0
|
|
123
|
+
ORDER BY s.name, o.name
|
|
124
|
+
"""
|
|
125
|
+
Q_DB_TRIGGERS = """
|
|
126
|
+
SELECT t.name AS object_name, m.uses_ansi_nulls, m.uses_quoted_identifier, m.definition
|
|
127
|
+
FROM sys.triggers t JOIN sys.sql_modules m ON m.object_id = t.object_id
|
|
128
|
+
WHERE t.parent_class = 0 AND t.is_ms_shipped = 0
|
|
129
|
+
"""
|
|
130
|
+
Q_TRIGGERS = """
|
|
131
|
+
SELECT t.name AS trigger_name, t.parent_class_desc, OBJECT_NAME(t.parent_id) AS parent_name,
|
|
132
|
+
t.is_disabled, t.is_instead_of_trigger
|
|
133
|
+
FROM sys.triggers t WHERE t.is_ms_shipped = 0
|
|
134
|
+
"""
|
|
135
|
+
Q_SYNONYMS = "SELECT name, base_object_name FROM sys.synonyms"
|
|
136
|
+
Q_XDEPS = """
|
|
137
|
+
SELECT OBJECT_SCHEMA_NAME(d.referencing_id) AS referencing_schema,
|
|
138
|
+
OBJECT_NAME(d.referencing_id) AS referencing_object,
|
|
139
|
+
d.referenced_server_name, d.referenced_database_name, d.referenced_schema_name, d.referenced_entity_name
|
|
140
|
+
FROM sys.sql_expression_dependencies d
|
|
141
|
+
WHERE d.referenced_server_name IS NOT NULL OR d.referenced_database_name IS NOT NULL
|
|
142
|
+
"""
|
|
143
|
+
Q_ASSEMBLIES = "SELECT name, permission_set_desc FROM sys.assemblies WHERE is_user_defined = 1"
|
|
144
|
+
Q_BROKER = "SELECT name FROM sys.service_queues WHERE is_ms_shipped = 0"
|
|
145
|
+
Q_FULLTEXT = "SELECT name FROM sys.fulltext_catalogs"
|
|
146
|
+
Q_STRETCH = """
|
|
147
|
+
SELECT s.name AS schema_name, t.name AS table_name
|
|
148
|
+
FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
149
|
+
WHERE t.is_remote_data_archive_enabled = 1
|
|
150
|
+
"""
|
|
151
|
+
Q_FILESTREAM = "SELECT COUNT(*) AS n FROM sys.columns WHERE is_filestream = 1"
|
|
152
|
+
Q_STANDALONE_DEFAULTS_RULES = (
|
|
153
|
+
"SELECT SCHEMA_NAME(schema_id) AS schema_name, name, type_desc FROM sys.objects "
|
|
154
|
+
"WHERE type IN ('D', 'R') AND parent_object_id = 0 AND is_ms_shipped = 0"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def major_version(product_version: str) -> int:
|
|
159
|
+
try:
|
|
160
|
+
return int(str(product_version).split(".")[0])
|
|
161
|
+
except (ValueError, IndexError):
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _optional(
|
|
166
|
+
warnings: list[str], label: str, fn: Callable[[], Any], default: Any
|
|
167
|
+
) -> Any:
|
|
168
|
+
"""Run a query that may be refused by permissions or missing in this version."""
|
|
169
|
+
try:
|
|
170
|
+
return fn()
|
|
171
|
+
except (DatabaseError, GuardError) as exc:
|
|
172
|
+
msg = str(exc)
|
|
173
|
+
m = re.search(r"Invalid object name '([^']+)'", msg)
|
|
174
|
+
if m:
|
|
175
|
+
warnings.append(f"{label}: skipped, {m.group(1)} does not exist on this server type (expected on Azure SQL Database)")
|
|
176
|
+
else:
|
|
177
|
+
warnings.append(f"{label}: skipped ({msg[:160]})")
|
|
178
|
+
return default
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def collect_database(conn: SafeConnection, name: str, warnings: list[str]) -> dict[str, Any]:
|
|
182
|
+
"""Collect objects for the database that `conn` is connected to."""
|
|
183
|
+
db: dict[str, Any] = {"name": name}
|
|
184
|
+
tables = _optional(warnings, f"{name}: tables", lambda: conn.query(Q_TABLES), [])
|
|
185
|
+
counts = {
|
|
186
|
+
r["object_id"]: int(r["row_count"] or 0)
|
|
187
|
+
for r in _optional(warnings, f"{name}: row counts", lambda: conn.query(Q_ROWCOUNTS), [])
|
|
188
|
+
}
|
|
189
|
+
cols_by_obj: dict[int, list[dict]] = defaultdict(list)
|
|
190
|
+
objects: dict[int, dict] = {}
|
|
191
|
+
for r in _optional(warnings, f"{name}: columns", lambda: conn.query(Q_COLUMNS), []):
|
|
192
|
+
cols_by_obj[r["object_id"]].append(
|
|
193
|
+
{k: r[k] for k in ("column_name", "type_name", "max_length", "precision", "scale", "is_nullable", "is_identity", "is_computed")}
|
|
194
|
+
)
|
|
195
|
+
objects[r["object_id"]] = {"schema": r["schema_name"], "name": r["object_name"], "type": (r["object_type"] or "").strip()}
|
|
196
|
+
idx: dict[int, dict[str, dict]] = defaultdict(dict)
|
|
197
|
+
for r in _optional(warnings, f"{name}: indexes", lambda: conn.query(Q_INDEXES), []):
|
|
198
|
+
e = idx[r["object_id"]].setdefault(
|
|
199
|
+
r["index_name"] or "(heap)",
|
|
200
|
+
{"type": r["type_desc"], "primary_key": bool(r["is_primary_key"]), "unique": bool(r["is_unique"]), "columns": []},
|
|
201
|
+
)
|
|
202
|
+
e["columns"].append(r["column_name"])
|
|
203
|
+
db["tables"] = [
|
|
204
|
+
{
|
|
205
|
+
"schema": t["schema_name"],
|
|
206
|
+
"name": t["table_name"],
|
|
207
|
+
"rows": counts.get(t["object_id"]),
|
|
208
|
+
"columns": cols_by_obj.get(t["object_id"], []),
|
|
209
|
+
"indexes": [{"name": k, **v} for k, v in idx.get(t["object_id"], {}).items()],
|
|
210
|
+
"created": str(t["create_date"]),
|
|
211
|
+
"modified": str(t["modify_date"]),
|
|
212
|
+
}
|
|
213
|
+
for t in tables
|
|
214
|
+
]
|
|
215
|
+
db["views"] = [
|
|
216
|
+
{"schema": o["schema"], "name": o["name"], "columns": cols_by_obj.get(oid, [])}
|
|
217
|
+
for oid, o in objects.items()
|
|
218
|
+
if o["type"] == "V"
|
|
219
|
+
]
|
|
220
|
+
modules = _optional(warnings, f"{name}: modules", lambda: conn.query(Q_MODULES), [])
|
|
221
|
+
modules += [
|
|
222
|
+
{**r, "schema_name": "(database trigger)", "object_type": "TR", "type_desc": "DATABASE_DDL_TRIGGER"}
|
|
223
|
+
for r in _optional(warnings, f"{name}: database triggers", lambda: conn.query(Q_DB_TRIGGERS), [])
|
|
224
|
+
]
|
|
225
|
+
db["modules"] = [
|
|
226
|
+
{
|
|
227
|
+
"schema": m["schema_name"],
|
|
228
|
+
"name": m["object_name"],
|
|
229
|
+
"type": m["type_desc"],
|
|
230
|
+
"created": str(m.get("create_date", "")),
|
|
231
|
+
"modified": str(m.get("modify_date", "")),
|
|
232
|
+
"uses_ansi_nulls": m["uses_ansi_nulls"],
|
|
233
|
+
"uses_quoted_identifier": m["uses_quoted_identifier"],
|
|
234
|
+
"encrypted": m["definition"] is None,
|
|
235
|
+
"definition": m["definition"],
|
|
236
|
+
}
|
|
237
|
+
for m in modules
|
|
238
|
+
]
|
|
239
|
+
db["triggers"] = _optional(warnings, f"{name}: triggers", lambda: conn.query(Q_TRIGGERS), [])
|
|
240
|
+
db["synonyms"] = _optional(warnings, f"{name}: synonyms", lambda: conn.query(Q_SYNONYMS), [])
|
|
241
|
+
db["cross_references"] = _optional(warnings, f"{name}: cross-database references", lambda: conn.query(Q_XDEPS), [])
|
|
242
|
+
db["assemblies"] = _optional(warnings, f"{name}: assemblies", lambda: conn.query(Q_ASSEMBLIES), [])
|
|
243
|
+
db["service_broker_queues"] = _optional(warnings, f"{name}: service broker", lambda: conn.query(Q_BROKER), [])
|
|
244
|
+
db["fulltext_catalogs"] = _optional(warnings, f"{name}: full-text", lambda: conn.query(Q_FULLTEXT), [])
|
|
245
|
+
db["stretch_tables"] = _optional(warnings, f"{name}: stretch database", lambda: conn.query(Q_STRETCH), [])
|
|
246
|
+
fs = _optional(warnings, f"{name}: filestream", lambda: conn.query(Q_FILESTREAM), [{"n": 0}])
|
|
247
|
+
db["filestream_columns"] = int(fs[0]["n"]) if fs else 0
|
|
248
|
+
db["standalone_defaults_rules"] = _optional(warnings, f"{name}: defaults and rules", lambda: conn.query(Q_STANDALONE_DEFAULTS_RULES), [])
|
|
249
|
+
return db
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def collect(
|
|
253
|
+
cfg: ConnectionConfig,
|
|
254
|
+
databases: list[str] | None = None,
|
|
255
|
+
include_system: bool = False,
|
|
256
|
+
guard: QueryGuard | None = None,
|
|
257
|
+
progress: Callable[[str], None] | None = None,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
"""Collect the full inventory. `databases` limits which ones are read."""
|
|
260
|
+
say = progress or (lambda _m: None)
|
|
261
|
+
warnings: list[str] = []
|
|
262
|
+
guard = guard or QueryGuard()
|
|
263
|
+
inv: dict[str, Any] = {"warnings": warnings}
|
|
264
|
+
with SafeConnection(cfg.with_database("master") if not cfg.database else cfg, guard) as master:
|
|
265
|
+
inv["server"] = master.query(Q_SERVER)[0]
|
|
266
|
+
try:
|
|
267
|
+
all_dbs = master.query(Q_DATABASES)
|
|
268
|
+
except DatabaseError as exc:
|
|
269
|
+
if "master_files" not in str(exc):
|
|
270
|
+
raise
|
|
271
|
+
warnings.append("database sizes: sys.master_files is not available here (Azure SQL Database), sizes are for the connected database only")
|
|
272
|
+
try:
|
|
273
|
+
all_dbs = master.query(Q_DATABASES_BASIC)
|
|
274
|
+
except DatabaseError:
|
|
275
|
+
all_dbs = master.query(Q_DATABASES_BASIC_MIN)
|
|
276
|
+
own = _optional(warnings, "own database size", lambda: master.query(Q_OWN_FILES), [])
|
|
277
|
+
cur = _optional(warnings, "current database name", lambda: master.query("SELECT DB_NAME() AS n"), [])
|
|
278
|
+
if own and cur:
|
|
279
|
+
for d in all_dbs:
|
|
280
|
+
if d["name"] == cur[0]["n"]:
|
|
281
|
+
d["data_mb"], d["log_mb"] = own[0]["data_mb"], own[0]["log_mb"]
|
|
282
|
+
inv["linked_servers"] = _optional(warnings, "linked servers", lambda: master.query(Q_LINKED), [])
|
|
283
|
+
azure_db = inv["server"].get("engine_edition") == 5 # Azure SQL Database
|
|
284
|
+
if azure_db:
|
|
285
|
+
# Platform tooling and sample-data loading use deprecated features on the service's own behalf.
|
|
286
|
+
inv["deprecated_feature_counters"] = []
|
|
287
|
+
warnings.append("deprecated feature counters: not read on Azure SQL Database (the counts reflect the platform's own use)")
|
|
288
|
+
else:
|
|
289
|
+
inv["deprecated_feature_counters"] = _optional(
|
|
290
|
+
warnings, "deprecated feature counters (needs VIEW SERVER STATE)", lambda: master.query(Q_DEPRECATED_COUNTERS), []
|
|
291
|
+
)
|
|
292
|
+
inv["mirroring"] = _optional(warnings, "database mirroring", lambda: master.query(Q_MIRRORING), [])
|
|
293
|
+
inv["soap_endpoints"] = _optional(warnings, "soap endpoints", lambda: master.query(Q_SOAP), [])
|
|
294
|
+
inv["databases"] = all_dbs
|
|
295
|
+
wanted = [
|
|
296
|
+
d["name"]
|
|
297
|
+
for d in all_dbs
|
|
298
|
+
if d["state_desc"] == "ONLINE"
|
|
299
|
+
and (databases is None or d["name"] in databases)
|
|
300
|
+
and (include_system or d["name"] not in SYSTEM_DATABASES)
|
|
301
|
+
]
|
|
302
|
+
inv["objects"] = {}
|
|
303
|
+
for name in wanted:
|
|
304
|
+
say(f"reading {name}")
|
|
305
|
+
try:
|
|
306
|
+
with SafeConnection(cfg.with_database(name), guard) as c:
|
|
307
|
+
inv["objects"][name] = collect_database(c, name, warnings)
|
|
308
|
+
except DatabaseError as exc:
|
|
309
|
+
warnings.append(f"{name}: skipped, cannot connect ({str(exc)[:160]})")
|
|
310
|
+
if inv["server"].get("engine_edition") == 5:
|
|
311
|
+
warnings.append("SQL Server Agent jobs: skipped, Azure SQL Database has no SQL Server Agent")
|
|
312
|
+
inv["agent_jobs"], inv["agent_schedules"] = [], []
|
|
313
|
+
return inv
|
|
314
|
+
say("reading SQL Server Agent jobs")
|
|
315
|
+
try:
|
|
316
|
+
with SafeConnection(cfg.with_database("msdb"), guard) as c:
|
|
317
|
+
jobs = c.query(Q_AGENT_JOBS)
|
|
318
|
+
sched = c.query(Q_AGENT_SCHEDULES)
|
|
319
|
+
inv["agent_jobs"] = jobs # command text is scanned for risks, then dropped before saving
|
|
320
|
+
inv["agent_schedules"] = sched
|
|
321
|
+
except (DatabaseError, GuardError) as exc:
|
|
322
|
+
warnings.append(f"SQL Server Agent jobs: skipped ({str(exc)[:160]})")
|
|
323
|
+
inv["agent_jobs"], inv["agent_schedules"] = [], []
|
|
324
|
+
return inv
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Write results to the output folder: Markdown, JSON, CSV, and tabs of one Excel workbook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from openpyxl import Workbook, load_workbook
|
|
14
|
+
from openpyxl.styles import Font, PatternFill
|
|
15
|
+
from openpyxl.utils import get_column_letter
|
|
16
|
+
|
|
17
|
+
from . import END_OF_SUPPORT
|
|
18
|
+
from .inventory import major_version
|
|
19
|
+
|
|
20
|
+
WORKBOOK = "db-agent-sqlserver.xlsx"
|
|
21
|
+
FORMATS = ("md", "json", "xlsx", "html")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default(o: Any) -> Any:
|
|
25
|
+
if isinstance(o, (datetime, date)):
|
|
26
|
+
return o.isoformat(sep=" ") if isinstance(o, datetime) else o.isoformat()
|
|
27
|
+
if isinstance(o, Decimal):
|
|
28
|
+
return float(o)
|
|
29
|
+
if isinstance(o, (bytes, bytearray)):
|
|
30
|
+
return f"<{len(o)} bytes>"
|
|
31
|
+
return str(o)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_json(path: Path, data: Any) -> None:
|
|
35
|
+
path.write_text(json.dumps(data, indent=2, default=_default, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_csv(path: Path, rows: list[dict[str, Any]], columns: list[str]) -> None:
|
|
39
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
40
|
+
w = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
|
|
41
|
+
w.writeheader()
|
|
42
|
+
for r in rows:
|
|
43
|
+
w.writerow({c: _cell(r.get(c)) for c in columns})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cell(v: Any) -> Any:
|
|
47
|
+
if isinstance(v, (list, tuple, set)):
|
|
48
|
+
return "; ".join(str(x) for x in v)
|
|
49
|
+
if isinstance(v, dict):
|
|
50
|
+
return json.dumps(v, default=_default)
|
|
51
|
+
if isinstance(v, bool):
|
|
52
|
+
return "yes" if v else "no"
|
|
53
|
+
return "" if v is None else v
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def update_sheet(out: Path, sheet: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
|
|
57
|
+
"""Create or replace one tab of the shared workbook."""
|
|
58
|
+
path = out / WORKBOOK
|
|
59
|
+
wb = load_workbook(path) if path.exists() else Workbook()
|
|
60
|
+
if sheet in wb.sheetnames:
|
|
61
|
+
del wb[sheet]
|
|
62
|
+
if "Sheet" in wb.sheetnames and len(wb.sheetnames) == 1:
|
|
63
|
+
del wb["Sheet"]
|
|
64
|
+
ws = wb.create_sheet(sheet)
|
|
65
|
+
ws.append(columns)
|
|
66
|
+
for c in ws[1]:
|
|
67
|
+
c.font = Font(bold=True, color="FFFFFF")
|
|
68
|
+
c.fill = PatternFill("solid", fgColor="1F3A5F")
|
|
69
|
+
for r in rows:
|
|
70
|
+
ws.append([_xl(_cell(r.get(c))) for c in columns])
|
|
71
|
+
for i, col in enumerate(columns, start=1):
|
|
72
|
+
width = max([len(str(col))] + [len(str(_cell(r.get(col)))) for r in rows[:200]])
|
|
73
|
+
ws.column_dimensions[get_column_letter(i)].width = min(max(width + 2, 8), 70)
|
|
74
|
+
ws.freeze_panes = "A2"
|
|
75
|
+
wb.save(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _xl(v: Any) -> Any:
|
|
79
|
+
if isinstance(v, str) and v and v[0] in "=+-@":
|
|
80
|
+
return "'" + v # stop Excel treating text as a formula
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def order_workbook(out: Path) -> None:
|
|
85
|
+
path = out / WORKBOOK
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return
|
|
88
|
+
wb = load_workbook(path)
|
|
89
|
+
order = ["Summary", "Reports", "Risks", "Inventory", "Validation"]
|
|
90
|
+
wb._sheets = sorted(wb._sheets, key=lambda s: order.index(s.title) if s.title in order else 99)
|
|
91
|
+
wb.save(path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def eos_info(product_version: str, today: date | None = None) -> dict[str, Any]:
|
|
95
|
+
major = major_version(product_version)
|
|
96
|
+
today = today or date.today()
|
|
97
|
+
if major in END_OF_SUPPORT:
|
|
98
|
+
name, d = END_OF_SUPPORT[major]
|
|
99
|
+
eos = date.fromisoformat(d)
|
|
100
|
+
days = (eos - today).days
|
|
101
|
+
return {"name": name, "date": d, "days": days,
|
|
102
|
+
"text": f"{name} end of support: {d} ({'ended ' + str(-days) + ' days ago' if days < 0 else str(days) + ' days left'})"}
|
|
103
|
+
return {"name": f"SQL Server {product_version}", "date": None, "days": None,
|
|
104
|
+
"text": "End-of-support date not in this kit's table. Check Microsoft's lifecycle page."}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def safe_filename(path: str) -> str:
|
|
108
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", path.strip("/")) or "report"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- inventory ------------------------------------------------------------------------------
|
|
112
|
+
def inventory_rows(inv: dict[str, Any]) -> list[dict[str, Any]]:
|
|
113
|
+
rows = []
|
|
114
|
+
for dbname, db in inv["objects"].items():
|
|
115
|
+
for t in db["tables"]:
|
|
116
|
+
rows.append({"database": dbname, "kind": "table", "schema": t["schema"], "name": t["name"], "rows": t["rows"],
|
|
117
|
+
"columns": len(t["columns"]), "indexes": len(t["indexes"]), "detail": ""})
|
|
118
|
+
for v in db["views"]:
|
|
119
|
+
rows.append({"database": dbname, "kind": "view", "schema": v["schema"], "name": v["name"], "rows": None,
|
|
120
|
+
"columns": len(v["columns"]), "indexes": None, "detail": ""})
|
|
121
|
+
for m in db["modules"]:
|
|
122
|
+
rows.append({"database": dbname, "kind": m["type"].lower(), "schema": m["schema"], "name": m["name"], "rows": None,
|
|
123
|
+
"columns": None, "indexes": None, "detail": "encrypted" if m["encrypted"] else ""})
|
|
124
|
+
return rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_inventory_md(inv: dict[str, Any], risks: list[dict[str, Any]]) -> str:
|
|
128
|
+
s = inv["server"]
|
|
129
|
+
eos = eos_info(s["product_version"])
|
|
130
|
+
L = ["# SQL Server inventory", "",
|
|
131
|
+
f"- Server: {s['server_name']}",
|
|
132
|
+
f"- Version: {s['product_version']} {s['product_level']}, {s['edition']}",
|
|
133
|
+
f"- Collation: {s['collation']}", f"- {eos['text']}", ""]
|
|
134
|
+
L += ["## Databases", "", "| Database | Compat level | Data MB | Log MB | State |", "|---|---|---|---|---|"]
|
|
135
|
+
for d in inv["databases"]:
|
|
136
|
+
L.append(f"| {d['name']} | {d['compatibility_level']} | {d['data_mb']} | {d['log_mb']} | {d['state_desc']} |")
|
|
137
|
+
L += ["", "## Objects per database", "", "| Database | Tables | Rows (metadata) | Views | Procedures, functions, triggers |", "|---|---|---|---|---|"]
|
|
138
|
+
for name, db in inv["objects"].items():
|
|
139
|
+
total = sum((t["rows"] or 0) for t in db["tables"])
|
|
140
|
+
L.append(f"| {name} | {len(db['tables'])} | {total:,} | {len(db['views'])} | {len(db['modules'])} |")
|
|
141
|
+
L += ["", f"Linked servers: {len(inv.get('linked_servers', []))}. SQL Server Agent jobs: {len({j['job_name'] for j in inv.get('agent_jobs', [])})}.", ""]
|
|
142
|
+
by_sev: dict[str, int] = {}
|
|
143
|
+
for r in risks:
|
|
144
|
+
by_sev[r["severity"]] = by_sev.get(r["severity"], 0) + 1
|
|
145
|
+
L += ["## Upgrade risks", "", ", ".join(f"{k}: {v}" for k, v in sorted(by_sev.items())) or "None found.", ""]
|
|
146
|
+
if risks:
|
|
147
|
+
L += ["| Severity | Database | Object | Finding | Source |", "|---|---|---|---|---|"]
|
|
148
|
+
for r in risks[:60]:
|
|
149
|
+
L.append(f"| {r['severity']} | {r['database']} | {r['object']} | {r['title']}. {r['detail'][:140]} | [doc]({r['doc']}) |")
|
|
150
|
+
if len(risks) > 60:
|
|
151
|
+
L.append(f"\n{len(risks) - 60} more in risks.csv.")
|
|
152
|
+
if inv["warnings"]:
|
|
153
|
+
L += ["", "## Not collected", ""] + [f"- {w}" for w in inv["warnings"]]
|
|
154
|
+
return "\n".join(L) + "\n"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
RISK_COLUMNS = ["severity", "category", "database", "object", "title", "detail", "azure_sql_database_only", "doc"]
|
|
158
|
+
INV_COLUMNS = ["database", "kind", "schema", "name", "rows", "columns", "indexes", "detail"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write_inventory(out: Path, inv: dict[str, Any], risks: list[dict[str, Any]], formats: set[str], include_code: bool) -> list[str]:
|
|
162
|
+
written = []
|
|
163
|
+
modules = {db: [{"schema": m["schema"], "name": m["name"], "type": m["type"], "definition": m["definition"]} for m in o["modules"]]
|
|
164
|
+
for db, o in inv["objects"].items()}
|
|
165
|
+
slim = json.loads(json.dumps(inv, default=_default))
|
|
166
|
+
for o in slim["objects"].values():
|
|
167
|
+
for m in o["modules"]:
|
|
168
|
+
m.pop("definition", None)
|
|
169
|
+
slim.pop("agent_jobs", None)
|
|
170
|
+
slim["agent_jobs"] = [{k: v for k, v in j.items() if k != "command"} for j in inv.get("agent_jobs", [])]
|
|
171
|
+
slim["linked_servers"] = [{k: v for k, v in ls.items() if k != "data_source"} for ls in inv.get("linked_servers", [])]
|
|
172
|
+
if "json" in formats:
|
|
173
|
+
write_json(out / "inventory.json", slim)
|
|
174
|
+
written.append("inventory.json")
|
|
175
|
+
if include_code:
|
|
176
|
+
write_json(out / "modules.json", modules)
|
|
177
|
+
written.append("modules.json (object code; may contain secrets, handle with care)")
|
|
178
|
+
if "md" in formats:
|
|
179
|
+
(out / "inventory.md").write_text(render_inventory_md(inv, risks), encoding="utf-8")
|
|
180
|
+
write_csv(out / "risks.csv", risks, RISK_COLUMNS)
|
|
181
|
+
written += ["inventory.md", "risks.csv"]
|
|
182
|
+
if "xlsx" in formats:
|
|
183
|
+
update_sheet(out, "Inventory", inventory_rows(inv), INV_COLUMNS)
|
|
184
|
+
update_sheet(out, "Risks", risks, RISK_COLUMNS)
|
|
185
|
+
written.append(f"{WORKBOOK} (Inventory, Risks)")
|
|
186
|
+
write_json(out / "risks.json", risks) # read by `report`
|
|
187
|
+
return written
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---- ssrs -----------------------------------------------------------------------------------
|
|
191
|
+
REPORT_COLUMNS = ["path", "name", "folder", "classification", "reasons", "layout_hint", "last_run", "runs_90", "runs_365",
|
|
192
|
+
"distinct_users", "avg_ms", "errors", "dataset_count", "parameter_count", "tables", "procedures", "ref_confidence",
|
|
193
|
+
"data_source_servers", "subreports", "drillthrough", "custom_code", "custom_assemblies", "subscriptions",
|
|
194
|
+
"expressions", "complex_expressions", "embedded_credentials", "rdl_version", "linked_report_of", "parse_error"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def render_ssrs_summary_md(res: dict[str, Any]) -> str:
|
|
198
|
+
c = res["classification_counts"]
|
|
199
|
+
u = res["usage"]
|
|
200
|
+
reps = res["reports"]
|
|
201
|
+
used = sum(1 for r in reps if (r["runs_365"] or 0) > 0)
|
|
202
|
+
L = ["# SSRS report inventory: summary", "",
|
|
203
|
+
f"Reports found: **{res['report_count']}** | Move: **{c.get('move', 0)}** | Review: **{c.get('review', 0)}** | Retire: **{c.get('retire', 0)}**", ""]
|
|
204
|
+
if u["source"]:
|
|
205
|
+
L += [f"Usage source: {u['source']}. The log reaches back {u['log_window_days']} days (oldest entry {u['oldest']}).",
|
|
206
|
+
f"Reports run in the last 365 days: {used}. Not run: {res['report_count'] - used}.", ""]
|
|
207
|
+
if (u["log_window_days"] or 0) < 365:
|
|
208
|
+
L += ["> **The usage log is shorter than a year.** SSRS keeps 60 days of log by default. A report that runs once a quarter or once a year can look unused. "
|
|
209
|
+
"No report is called retire until the log covers 365 days.", ""]
|
|
210
|
+
else:
|
|
211
|
+
L += ["No usage data was supplied, so no report is called retire. Supply the execution log (--reportserver-db) or a CSV (--usage-csv).", ""]
|
|
212
|
+
if not res["subscriptions_known"]:
|
|
213
|
+
L += ["Subscriptions were not read (folder mode without subscriptions.csv).", ""]
|
|
214
|
+
L += ["## What to do first", "",
|
|
215
|
+
f"1. **Retire ({c.get('retire', 0)}).** Confirm with the owners, then leave these out of the move.",
|
|
216
|
+
f"2. **Move ({c.get('move', 0)}).** Plain reports. Start here; check the results with `validate`.",
|
|
217
|
+
f"3. **Review ({c.get('review', 0)}).** Each has a reason below. Someone needs to look at it before it is moved.", ""]
|
|
218
|
+
for label in ("retire", "review", "move"):
|
|
219
|
+
group = [r for r in reps if r["classification"] == label]
|
|
220
|
+
if not group:
|
|
221
|
+
continue
|
|
222
|
+
L += [f"## {label.capitalize()} ({len(group)})", "", "| Report | Runs (365 days) | Why |", "|---|---|---|"]
|
|
223
|
+
for r in group:
|
|
224
|
+
L.append(f"| {r['path']} | {r['runs_365'] if r['runs_365'] is not None else 'n/a'} | {'; '.join(r['reasons'])} |")
|
|
225
|
+
L.append("")
|
|
226
|
+
if res["table_usage"]:
|
|
227
|
+
L += ["## Tables used most by reports", "", "| Table | Reports |", "|---|---|"] + [f"| {t} | {n} |" for t, n in res["table_usage"][:15]] + [""]
|
|
228
|
+
L += ["Table lists come from parsing report SQL and are best effort. See the confidence column in reports.csv.", ""]
|
|
229
|
+
return "\n".join(L)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render_report_detail_md(r: dict[str, Any]) -> str:
|
|
233
|
+
L = [f"# {r['path']}", "",
|
|
234
|
+
f"- Classification: **{r['classification']}**. {'; '.join(r['reasons'])}",
|
|
235
|
+
f"- Layout hint: {r['layout_hint']} (a hint only)",
|
|
236
|
+
f"- RDL version: {r['rdl_version']}",
|
|
237
|
+
f"- Runs: {r['runs_90']} in 90 days, {r['runs_365']} in 365 days, last run {r['last_run']}, distinct users {r['distinct_users']}"
|
|
238
|
+
if r["usage_known"] else "- Usage: not supplied", ""]
|
|
239
|
+
if r["data_sources"]:
|
|
240
|
+
L += ["## Data sources", ""]
|
|
241
|
+
for s in r["data_sources"]:
|
|
242
|
+
L.append(f"- {s['name']}: {s['provider'] or (s['shared_reference'] and 'shared ' + s['shared_reference']) or '?'}, server {s['server']}, database {s['database']}"
|
|
243
|
+
+ (" (**connection string contains a password**)" if s["embedded_credentials"] else ""))
|
|
244
|
+
L.append("")
|
|
245
|
+
if r["parameters"]:
|
|
246
|
+
L += ["## Parameters", "", "| Name | Type | Default | Multi-value |", "|---|---|---|---|"]
|
|
247
|
+
L += [f"| {p['name']} | {p['type']} | {p['default']} | {p['multi_value']} |" for p in r["parameters"]]
|
|
248
|
+
L.append("")
|
|
249
|
+
for d in r["datasets"]:
|
|
250
|
+
L += [f"## Dataset {d['name']}", "", f"- Command type: {d['command_type']}. Reference confidence: {d['confidence']}"
|
|
251
|
+
+ (f" ({d['parse_error']})" if d["parse_error"] else ""),
|
|
252
|
+
f"- Tables: {', '.join(d['tables']) or 'none found'}", f"- Procedures: {', '.join(d['procedures']) or 'none'}", "", "```sql", d["command_text"].strip(), "```", ""]
|
|
253
|
+
if r["subreports"]:
|
|
254
|
+
L += ["Subreports: " + ", ".join(r["subreports"]), ""]
|
|
255
|
+
return "\n".join(L)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def write_ssrs(out: Path, res: dict[str, Any], formats: set[str]) -> list[str]:
|
|
259
|
+
written = []
|
|
260
|
+
write_json(out / "reports.json", res) # read by validate and report
|
|
261
|
+
written.append("reports.json")
|
|
262
|
+
if "md" in formats:
|
|
263
|
+
write_csv(out / "reports.csv", res["reports"], REPORT_COLUMNS)
|
|
264
|
+
(out / "summary.md").write_text(render_ssrs_summary_md(res), encoding="utf-8")
|
|
265
|
+
d = out / "report-details"
|
|
266
|
+
d.mkdir(exist_ok=True)
|
|
267
|
+
for r in res["reports"]:
|
|
268
|
+
(d / f"{safe_filename(r['path'])}.md").write_text(render_report_detail_md(r), encoding="utf-8")
|
|
269
|
+
written += ["reports.csv", "summary.md", f"report-details/ ({len(res['reports'])} files)"]
|
|
270
|
+
if "xlsx" in formats:
|
|
271
|
+
update_sheet(out, "Reports", res["reports"], REPORT_COLUMNS)
|
|
272
|
+
written.append(f"{WORKBOOK} (Reports)")
|
|
273
|
+
return written
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---- validate -------------------------------------------------------------------------------
|
|
277
|
+
VALIDATION_COLUMNS = ["report", "dataset", "status", "reason", "source_rows", "target_rows", "source_ms", "target_ms", "differences"]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def write_validation(out: Path, rows: list[dict[str, Any]], formats: set[str]) -> list[str]:
|
|
281
|
+
write_json(out / "validation.json", rows)
|
|
282
|
+
written = ["validation.json"]
|
|
283
|
+
if "md" in formats:
|
|
284
|
+
write_csv(out / "validation.csv", rows, VALIDATION_COLUMNS)
|
|
285
|
+
counts: dict[str, int] = {}
|
|
286
|
+
for r in rows:
|
|
287
|
+
counts[r["status"]] = counts.get(r["status"], 0) + 1
|
|
288
|
+
L = ["# Validation", "", "Compared on row counts and totals of numeric columns. No row values are stored.", "",
|
|
289
|
+
", ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "Nothing was compared.", "",
|
|
290
|
+
"| Report | Dataset | Status | Source rows | Target rows | Note |", "|---|---|---|---|---|---|"]
|
|
291
|
+
for r in rows:
|
|
292
|
+
L.append(f"| {r['report']} | {r['dataset']} | {r['status']} | {r['source_rows'] if r['source_rows'] is not None else ''} | "
|
|
293
|
+
f"{r['target_rows'] if r['target_rows'] is not None else ''} | {(r['differences'] or r['reason'])[:160]} |")
|
|
294
|
+
(out / "validation.md").write_text("\n".join(L) + "\n", encoding="utf-8")
|
|
295
|
+
written += ["validation.csv", "validation.md"]
|
|
296
|
+
if "xlsx" in formats:
|
|
297
|
+
update_sheet(out, "Validation", rows, VALIDATION_COLUMNS)
|
|
298
|
+
written.append(f"{WORKBOOK} (Validation)")
|
|
299
|
+
return written
|