analyticsreceipts 0.0.2__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.
- analyticsreceipts/__init__.py +3 -0
- analyticsreceipts/broken.py +25 -0
- analyticsreceipts/compare.py +67 -0
- analyticsreceipts/compiler.py +216 -0
- analyticsreceipts/contract.py +50 -0
- analyticsreceipts/control_server.py +26 -0
- analyticsreceipts/data/OSSIE-NOTICE.md +7 -0
- analyticsreceipts/data/online_retail.ossie.yaml +128 -0
- analyticsreceipts/data/ossie-schema.json +374 -0
- analyticsreceipts/execution.py +185 -0
- analyticsreceipts/fixtures.py +181 -0
- analyticsreceipts/grader.py +348 -0
- analyticsreceipts/live.py +423 -0
- analyticsreceipts/load.py +74 -0
- analyticsreceipts/materialise.py +86 -0
- analyticsreceipts/model.py +122 -0
- analyticsreceipts/numbers.py +93 -0
- analyticsreceipts/paths.py +31 -0
- analyticsreceipts/rawtools.py +105 -0
- analyticsreceipts/receipt.schema.json +56 -0
- analyticsreceipts/receipts.py +17 -0
- analyticsreceipts/report.py +162 -0
- analyticsreceipts/server.py +165 -0
- analyticsreceipts-0.0.2.dist-info/METADATA +313 -0
- analyticsreceipts-0.0.2.dist-info/RECORD +29 -0
- analyticsreceipts-0.0.2.dist-info/WHEEL +4 -0
- analyticsreceipts-0.0.2.dist-info/licenses/LICENSE +21 -0
- analyticsreceipts-0.0.2.dist-info/licenses/vendor/ossie/LICENSE +201 -0
- analyticsreceipts-0.0.2.dist-info/licenses/vendor/ossie/NOTICE +5 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/apache/ossie/core-spec/ossie-schema.json",
|
|
4
|
+
"title": "Apache Ossie Core Metadata Specification",
|
|
5
|
+
"description": "JSON Schema for validating a single Apache Ossie semantic model document",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"const": "0.2.0.dev0",
|
|
11
|
+
"description": "Apache Ossie specification version"
|
|
12
|
+
},
|
|
13
|
+
"name": {
|
|
14
|
+
"$ref": "#/$defs/SemanticModel/properties/name"
|
|
15
|
+
},
|
|
16
|
+
"description": {
|
|
17
|
+
"$ref": "#/$defs/SemanticModel/properties/description"
|
|
18
|
+
},
|
|
19
|
+
"ai_context": {
|
|
20
|
+
"$ref": "#/$defs/SemanticModel/properties/ai_context"
|
|
21
|
+
},
|
|
22
|
+
"datasets": {
|
|
23
|
+
"$ref": "#/$defs/SemanticModel/properties/datasets"
|
|
24
|
+
},
|
|
25
|
+
"relationships": {
|
|
26
|
+
"$ref": "#/$defs/SemanticModel/properties/relationships"
|
|
27
|
+
},
|
|
28
|
+
"metrics": {
|
|
29
|
+
"$ref": "#/$defs/SemanticModel/properties/metrics"
|
|
30
|
+
},
|
|
31
|
+
"custom_extensions": {
|
|
32
|
+
"$ref": "#/$defs/SemanticModel/properties/custom_extensions"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": ["version", "name", "datasets"],
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"$defs": {
|
|
38
|
+
"Dialect": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "BIGQUERY", "SIGMA", "THOUGHTSPOT", "DAX", "OSSIE_SQL_2026"],
|
|
41
|
+
"description": "Supported SQL and expression language dialects"
|
|
42
|
+
},
|
|
43
|
+
"Vendor": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA", "WISDOM", "POWER_BI"],
|
|
46
|
+
"description": "Vendor name for custom extensions. Any string value is accepted."
|
|
47
|
+
},
|
|
48
|
+
"AIContext": {
|
|
49
|
+
"description": "Additional context for AI tools",
|
|
50
|
+
"oneOf": [
|
|
51
|
+
{
|
|
52
|
+
"type": "string"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"instructions": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Instructions for AI on how to use this entity"
|
|
60
|
+
},
|
|
61
|
+
"synonyms": {
|
|
62
|
+
"type": "array",
|
|
63
|
+
"items": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
},
|
|
66
|
+
"description": "Alternative names and terms"
|
|
67
|
+
},
|
|
68
|
+
"examples": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {
|
|
71
|
+
"type": "string"
|
|
72
|
+
},
|
|
73
|
+
"description": "Sample questions or use cases"
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"additionalProperties": true
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"CustomExtension": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"description": "Vendor-specific attributes for extensibility",
|
|
83
|
+
"properties": {
|
|
84
|
+
"vendor_name": {
|
|
85
|
+
"$ref": "#/$defs/Vendor"
|
|
86
|
+
},
|
|
87
|
+
"data": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "JSON string containing vendor-specific data"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"required": ["vendor_name", "data"],
|
|
93
|
+
"additionalProperties": false
|
|
94
|
+
},
|
|
95
|
+
"DialectExpression": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"description": "Expression in a specific dialect",
|
|
98
|
+
"properties": {
|
|
99
|
+
"dialect": {
|
|
100
|
+
"$ref": "#/$defs/Dialect"
|
|
101
|
+
},
|
|
102
|
+
"expression": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "SQL or dialect-specific expression"
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"required": ["dialect", "expression"],
|
|
108
|
+
"additionalProperties": false
|
|
109
|
+
},
|
|
110
|
+
"Expression": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"description": "Expression definition with multi-dialect support",
|
|
113
|
+
"properties": {
|
|
114
|
+
"dialects": {
|
|
115
|
+
"type": "array",
|
|
116
|
+
"items": {
|
|
117
|
+
"$ref": "#/$defs/DialectExpression"
|
|
118
|
+
},
|
|
119
|
+
"minItems": 1
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"required": ["dialects"],
|
|
123
|
+
"additionalProperties": false
|
|
124
|
+
},
|
|
125
|
+
"DataType": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"enum": [
|
|
128
|
+
"String",
|
|
129
|
+
"Integer",
|
|
130
|
+
"Decimal",
|
|
131
|
+
"Float",
|
|
132
|
+
"Boolean",
|
|
133
|
+
"Date",
|
|
134
|
+
"Time",
|
|
135
|
+
"DateTime",
|
|
136
|
+
"DateTimeTz",
|
|
137
|
+
"Opaque"
|
|
138
|
+
],
|
|
139
|
+
"description": "Logical data type for fields and metrics, independent of role (e.g. dimension vs fact) and physical representation. `Decimal` is exact base-10 with unspecified precision and scale; `Float` is approximate. `DateTime` has no timezone or offset, while `DateTimeTz` identifies an instant using offset or timezone context but does not guarantee preservation of a named timezone. Omit `datatype` when unknown; use `Opaque` plus `custom_extensions` for a known type outside the portable vocabulary."
|
|
140
|
+
},
|
|
141
|
+
"Dimension": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"description": "Dimension metadata",
|
|
144
|
+
"properties": {
|
|
145
|
+
"is_time": {
|
|
146
|
+
"type": "boolean",
|
|
147
|
+
"description": "Temporal-role marker. When true, consumers that distinguish time dimensions (e.g. for time-series analysis or temporal filtering) should treat this field as a time dimension. This is a *role* flag, independent of the field's data type: a field with `is_time: true` may carry any `datatype` (e.g. `Integer` for a year grain, `String` for a month name, as well as temporal data types). When `is_time` is unset, it defaults to `true` if `datatype` is one of `Date`, `Time`, `DateTime`, or `DateTimeTz`, and `false` otherwise. Set `is_time: false` explicitly to opt a temporal-typed column (such as an audit timestamp) out of time-dimension treatment."
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"additionalProperties": false
|
|
151
|
+
},
|
|
152
|
+
"Field": {
|
|
153
|
+
"type": "object",
|
|
154
|
+
"description": "Row-level attribute for grouping, filtering, and metric expressions",
|
|
155
|
+
"properties": {
|
|
156
|
+
"name": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"minLength": 1,
|
|
159
|
+
"description": "Unique identifier for the field within the dataset"
|
|
160
|
+
},
|
|
161
|
+
"expression": {
|
|
162
|
+
"$ref": "#/$defs/Expression"
|
|
163
|
+
},
|
|
164
|
+
"dimension": {
|
|
165
|
+
"$ref": "#/$defs/Dimension"
|
|
166
|
+
},
|
|
167
|
+
"label": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"description": "Label for categorization"
|
|
170
|
+
},
|
|
171
|
+
"description": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"description": "Human-readable description"
|
|
174
|
+
},
|
|
175
|
+
"datatype": {
|
|
176
|
+
"$ref": "#/$defs/DataType"
|
|
177
|
+
},
|
|
178
|
+
"ai_context": {
|
|
179
|
+
"$ref": "#/$defs/AIContext"
|
|
180
|
+
},
|
|
181
|
+
"custom_extensions": {
|
|
182
|
+
"type": "array",
|
|
183
|
+
"items": {
|
|
184
|
+
"$ref": "#/$defs/CustomExtension"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"required": ["name", "expression"],
|
|
189
|
+
"additionalProperties": false
|
|
190
|
+
},
|
|
191
|
+
"Dataset": {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"description": "Logical dataset representing a business entity (fact or dimension table)",
|
|
194
|
+
"properties": {
|
|
195
|
+
"name": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"minLength": 1,
|
|
198
|
+
"description": "Unique identifier for the dataset"
|
|
199
|
+
},
|
|
200
|
+
"source": {
|
|
201
|
+
"type": "string",
|
|
202
|
+
"minLength": 1,
|
|
203
|
+
"description": "Reference to underlying physical table/view (database.schema.table) or query"
|
|
204
|
+
},
|
|
205
|
+
"primary_key": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"items": {
|
|
208
|
+
"type": "string"
|
|
209
|
+
},
|
|
210
|
+
"description": "Primary key columns (single or composite)"
|
|
211
|
+
},
|
|
212
|
+
"unique_keys": {
|
|
213
|
+
"type": "array",
|
|
214
|
+
"items": {
|
|
215
|
+
"type": "array",
|
|
216
|
+
"items": {
|
|
217
|
+
"type": "string"
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
"description": "Array of unique key definitions (each can be single or composite)"
|
|
221
|
+
},
|
|
222
|
+
"description": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"description": "Human-readable description"
|
|
225
|
+
},
|
|
226
|
+
"ai_context": {
|
|
227
|
+
"$ref": "#/$defs/AIContext"
|
|
228
|
+
},
|
|
229
|
+
"fields": {
|
|
230
|
+
"type": "array",
|
|
231
|
+
"items": {
|
|
232
|
+
"$ref": "#/$defs/Field"
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"custom_extensions": {
|
|
236
|
+
"type": "array",
|
|
237
|
+
"items": {
|
|
238
|
+
"$ref": "#/$defs/CustomExtension"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
"required": ["name", "source"],
|
|
243
|
+
"additionalProperties": false
|
|
244
|
+
},
|
|
245
|
+
"Relationship": {
|
|
246
|
+
"type": "object",
|
|
247
|
+
"description": "Foreign key relationship between datasets",
|
|
248
|
+
"properties": {
|
|
249
|
+
"name": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"minLength": 1,
|
|
252
|
+
"description": "Unique identifier for the relationship"
|
|
253
|
+
},
|
|
254
|
+
"from": {
|
|
255
|
+
"type": "string",
|
|
256
|
+
"minLength": 1,
|
|
257
|
+
"description": "Dataset on the many side of the relationship"
|
|
258
|
+
},
|
|
259
|
+
"to": {
|
|
260
|
+
"type": "string",
|
|
261
|
+
"minLength": 1,
|
|
262
|
+
"description": "Dataset on the one side of the relationship"
|
|
263
|
+
},
|
|
264
|
+
"from_columns": {
|
|
265
|
+
"type": "array",
|
|
266
|
+
"items": {
|
|
267
|
+
"type": "string"
|
|
268
|
+
},
|
|
269
|
+
"minItems": 1,
|
|
270
|
+
"description": "Foreign key columns in the 'from' dataset"
|
|
271
|
+
},
|
|
272
|
+
"to_columns": {
|
|
273
|
+
"type": "array",
|
|
274
|
+
"items": {
|
|
275
|
+
"type": "string"
|
|
276
|
+
},
|
|
277
|
+
"minItems": 1,
|
|
278
|
+
"description": "Primary/unique key columns in the 'to' dataset"
|
|
279
|
+
},
|
|
280
|
+
"ai_context": {
|
|
281
|
+
"$ref": "#/$defs/AIContext"
|
|
282
|
+
},
|
|
283
|
+
"custom_extensions": {
|
|
284
|
+
"type": "array",
|
|
285
|
+
"items": {
|
|
286
|
+
"$ref": "#/$defs/CustomExtension"
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
"required": ["name", "from", "to", "from_columns", "to_columns"],
|
|
291
|
+
"additionalProperties": false
|
|
292
|
+
},
|
|
293
|
+
"Metric": {
|
|
294
|
+
"type": "object",
|
|
295
|
+
"description": "Quantitative measure defined on business data",
|
|
296
|
+
"properties": {
|
|
297
|
+
"name": {
|
|
298
|
+
"type": "string",
|
|
299
|
+
"minLength": 1,
|
|
300
|
+
"description": "Unique identifier for the metric"
|
|
301
|
+
},
|
|
302
|
+
"expression": {
|
|
303
|
+
"$ref": "#/$defs/Expression"
|
|
304
|
+
},
|
|
305
|
+
"description": {
|
|
306
|
+
"type": "string",
|
|
307
|
+
"description": "Human-readable description of what the metric measures"
|
|
308
|
+
},
|
|
309
|
+
"datatype": {
|
|
310
|
+
"$ref": "#/$defs/DataType"
|
|
311
|
+
},
|
|
312
|
+
"ai_context": {
|
|
313
|
+
"$ref": "#/$defs/AIContext"
|
|
314
|
+
},
|
|
315
|
+
"custom_extensions": {
|
|
316
|
+
"type": "array",
|
|
317
|
+
"items": {
|
|
318
|
+
"$ref": "#/$defs/CustomExtension"
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
"required": ["name", "expression"],
|
|
323
|
+
"additionalProperties": false
|
|
324
|
+
},
|
|
325
|
+
"SemanticModel": {
|
|
326
|
+
"type": "object",
|
|
327
|
+
"description": "Top-level container representing a complete semantic model",
|
|
328
|
+
"properties": {
|
|
329
|
+
"name": {
|
|
330
|
+
"type": "string",
|
|
331
|
+
"minLength": 1,
|
|
332
|
+
"description": "Unique identifier for the semantic model"
|
|
333
|
+
},
|
|
334
|
+
"description": {
|
|
335
|
+
"type": "string",
|
|
336
|
+
"description": "Human-readable description"
|
|
337
|
+
},
|
|
338
|
+
"ai_context": {
|
|
339
|
+
"$ref": "#/$defs/AIContext"
|
|
340
|
+
},
|
|
341
|
+
"datasets": {
|
|
342
|
+
"type": "array",
|
|
343
|
+
"items": {
|
|
344
|
+
"$ref": "#/$defs/Dataset"
|
|
345
|
+
},
|
|
346
|
+
"minItems": 1,
|
|
347
|
+
"description": "Collection of logical datasets"
|
|
348
|
+
},
|
|
349
|
+
"relationships": {
|
|
350
|
+
"type": "array",
|
|
351
|
+
"items": {
|
|
352
|
+
"$ref": "#/$defs/Relationship"
|
|
353
|
+
},
|
|
354
|
+
"description": "Defines how datasets are connected"
|
|
355
|
+
},
|
|
356
|
+
"metrics": {
|
|
357
|
+
"type": "array",
|
|
358
|
+
"items": {
|
|
359
|
+
"$ref": "#/$defs/Metric"
|
|
360
|
+
},
|
|
361
|
+
"description": "Quantifiable measures spanning datasets"
|
|
362
|
+
},
|
|
363
|
+
"custom_extensions": {
|
|
364
|
+
"type": "array",
|
|
365
|
+
"items": {
|
|
366
|
+
"$ref": "#/$defs/CustomExtension"
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
"required": ["name", "datasets"],
|
|
371
|
+
"additionalProperties": false
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Read-only execution with caps, and the receipt emitted on the execution path.
|
|
2
|
+
|
|
3
|
+
The connection is opened read-only with external access disabled and the configuration locked, so
|
|
4
|
+
a statement cannot read or write files outside the database (no read_csv, no COPY TO) or loosen
|
|
5
|
+
those settings. Every result is capped by rows and by time. The same instance is capped in memory, threads
|
|
6
|
+
and temp-file disk use (set before the lock), and at most MAX_CONCURRENT_RUNS statements run at once.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import decimal
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
import duckdb
|
|
18
|
+
|
|
19
|
+
RECEIPT_VERSION = "2" # 2: `compiler` required and tied to `kind`; no non_additive_guard check
|
|
20
|
+
ROW_CAP = 1000
|
|
21
|
+
TIMEOUT_S = 20.0
|
|
22
|
+
MEMORY_LIMIT = "1GB" # per DuckDB instance, shared by every cursor on it
|
|
23
|
+
THREADS = 2
|
|
24
|
+
MAX_TEMP_DIRECTORY_SIZE = "0B" # no spilling to disk: a query over the memory limit fails instead
|
|
25
|
+
MAX_CONCURRENT_RUNS = 4 # per process; further calls wait (up to their timeout) for a slot
|
|
26
|
+
_RUN_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_RUNS)
|
|
27
|
+
_LIMITS = {"memory_limit": f"'{MEMORY_LIMIT}'", "threads": str(THREADS),
|
|
28
|
+
"max_temp_directory_size": f"'{MAX_TEMP_DIRECTORY_SIZE}'"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def connect(db_path):
|
|
32
|
+
con = duckdb.connect(db_path, read_only=True, config={"enable_external_access": False})
|
|
33
|
+
# a second connection to the same file in one process shares the (already locked) instance
|
|
34
|
+
if not con.execute("SELECT current_setting('lock_configuration')").fetchone()[0]:
|
|
35
|
+
for name, value in _LIMITS.items():
|
|
36
|
+
con.execute(f"SET {name} = {value}")
|
|
37
|
+
con.execute("SET lock_configuration = true")
|
|
38
|
+
if _settings(con) != _expected_settings():
|
|
39
|
+
raise RuntimeError("this database instance was locked with other resource limits; refusing to serve")
|
|
40
|
+
if con.execute("SELECT current_setting('enable_external_access')").fetchone()[0]:
|
|
41
|
+
raise RuntimeError("external access is enabled on this database instance; refusing to serve")
|
|
42
|
+
return con
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _settings(con):
|
|
46
|
+
return {n: con.execute("SELECT current_setting(?)", [n]).fetchone()[0] for n in _LIMITS}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_EXPECTED = []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _expected_settings():
|
|
53
|
+
# how DuckDB reports the limits (e.g. '1GB' reads back as '953.6 MiB'), from a scratch in-memory instance
|
|
54
|
+
if not _EXPECTED:
|
|
55
|
+
scratch = duckdb.connect()
|
|
56
|
+
for name, value in _LIMITS.items():
|
|
57
|
+
scratch.execute(f"SET {name} = {value}")
|
|
58
|
+
_EXPECTED.append(_settings(scratch))
|
|
59
|
+
scratch.close()
|
|
60
|
+
return _EXPECTED[0]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cell(v):
|
|
64
|
+
if isinstance(v, decimal.Decimal):
|
|
65
|
+
return float(v)
|
|
66
|
+
if isinstance(v, (dt.date, dt.datetime)):
|
|
67
|
+
return v.isoformat()
|
|
68
|
+
return v
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def file_sha256(path):
|
|
72
|
+
h = hashlib.sha256()
|
|
73
|
+
with open(path, "rb") as f:
|
|
74
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
75
|
+
h.update(chunk)
|
|
76
|
+
return h.hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Executor:
|
|
80
|
+
def __init__(self, db_path):
|
|
81
|
+
self.db_path = db_path
|
|
82
|
+
self.con = connect(db_path)
|
|
83
|
+
# a warehouse built by analyticsreceipts.load carries snapshot_meta; any other warehouse is
|
|
84
|
+
# fingerprinted by its file hash alone, and its data range comes from the semantic model
|
|
85
|
+
has_meta = self.con.execute("SELECT count(*) FROM information_schema.tables "
|
|
86
|
+
"WHERE table_name = 'snapshot_meta'").fetchone()[0]
|
|
87
|
+
if has_meta:
|
|
88
|
+
meta = self.con.execute("SELECT xlsx_sha256, max_invoice_ts FROM snapshot_meta").fetchone()
|
|
89
|
+
self.snapshot = {"source_xlsx_sha256": meta[0], "max_invoice_ts": meta[1].isoformat(),
|
|
90
|
+
"db_file_sha256": file_sha256(db_path)}
|
|
91
|
+
else:
|
|
92
|
+
self.snapshot = {"db_file_sha256": file_sha256(db_path)}
|
|
93
|
+
self.data_range = None # (start_date, end_date, end_label), set by the server from the model
|
|
94
|
+
|
|
95
|
+
def set_data_range(self, start, end):
|
|
96
|
+
label = self.snapshot.get("max_invoice_ts") or end
|
|
97
|
+
self.data_range = (start, end, label)
|
|
98
|
+
|
|
99
|
+
def run(self, sql, params=(), cap=ROW_CAP, timeout_s=TIMEOUT_S):
|
|
100
|
+
"""Returns (columns, rows, truncated, error). Never raises for a bad statement.
|
|
101
|
+
|
|
102
|
+
Each call runs on its own cursor (a separate connection to the same read-only database), and
|
|
103
|
+
the timeout interrupts only that cursor. Concurrent calls (the MCP SDK runs sync tools on worker
|
|
104
|
+
threads) therefore can neither read each other's results nor cancel each other's queries. At most
|
|
105
|
+
MAX_CONCURRENT_RUNS calls run at once; a call that gets no slot within timeout_s returns an error.
|
|
106
|
+
"""
|
|
107
|
+
if not _RUN_SLOTS.acquire(timeout=timeout_s):
|
|
108
|
+
return [], [], False, f"Busy: {MAX_CONCURRENT_RUNS} queries already running; no slot within {timeout_s} s"
|
|
109
|
+
try:
|
|
110
|
+
return self._run(sql, params, cap, timeout_s)
|
|
111
|
+
finally:
|
|
112
|
+
_RUN_SLOTS.release()
|
|
113
|
+
|
|
114
|
+
def _run(self, sql, params, cap, timeout_s):
|
|
115
|
+
cur = self.con.cursor()
|
|
116
|
+
timer = threading.Timer(timeout_s, cur.interrupt)
|
|
117
|
+
timer.start()
|
|
118
|
+
try:
|
|
119
|
+
cur.execute(sql, list(params))
|
|
120
|
+
cols = [d[0] for d in (cur.description or [])]
|
|
121
|
+
rows = cur.fetchmany(cap + 1)
|
|
122
|
+
except Exception as e: # the statement failed; the caller records it
|
|
123
|
+
return [], [], False, f"{type(e).__name__}: {str(e).splitlines()[0][:300]}"
|
|
124
|
+
finally:
|
|
125
|
+
timer.cancel()
|
|
126
|
+
cur.close()
|
|
127
|
+
truncated = len(rows) > cap
|
|
128
|
+
rows = [[_cell(v) for v in r] for r in rows[:cap]]
|
|
129
|
+
return cols, rows, truncated, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def result_sha256(columns, rows):
|
|
133
|
+
return hashlib.sha256(json.dumps({"columns": columns, "rows": rows}, sort_keys=True, default=str).encode()).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def make_receipt(*, executor, kind, request, metric_id, metric_def_version, semantic_model_sha256, sql, params,
|
|
137
|
+
sql_sha256, columns, rows, truncated, error, checks, caveats, server_version, started, compiler=None):
|
|
138
|
+
return {
|
|
139
|
+
"receipt_version": RECEIPT_VERSION,
|
|
140
|
+
"kind": kind, # "governed_metric" (MCP query_metric), "control_metric" (planted compiler) or "raw_sql"
|
|
141
|
+
"compiler": compiler, # "governed", "planted:<bug>" for a control run, None for raw SQL
|
|
142
|
+
"request": request,
|
|
143
|
+
"metric_id": metric_id,
|
|
144
|
+
"metric_def_version": metric_def_version,
|
|
145
|
+
"semantic_model_sha256": semantic_model_sha256,
|
|
146
|
+
"compiled_sql": sql,
|
|
147
|
+
"params": list(params),
|
|
148
|
+
"compiled_sql_sha256": sql_sha256 or hashlib.sha256((sql + "\n" + json.dumps(list(params))).encode()).hexdigest(),
|
|
149
|
+
"engine": {"name": "duckdb", "version": duckdb.__version__},
|
|
150
|
+
"snapshot": executor.snapshot,
|
|
151
|
+
"result": {"columns": columns, "rows": rows},
|
|
152
|
+
"result_sha256": None if error else result_sha256(columns, rows),
|
|
153
|
+
"row_count": len(rows),
|
|
154
|
+
"truncated": truncated,
|
|
155
|
+
"error": error,
|
|
156
|
+
"checks": checks,
|
|
157
|
+
"uncertainty": {"caveats_applied": caveats},
|
|
158
|
+
"run": {"request_id": uuid.uuid4().hex, "server_version": server_version, "started_at": started,
|
|
159
|
+
"elapsed_s": round(time.time() - _epoch(started), 3)},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _epoch(iso):
|
|
164
|
+
return dt.datetime.fromisoformat(iso).timestamp()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def now_iso():
|
|
168
|
+
return dt.datetime.now().isoformat(timespec="milliseconds")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def period_checks(data_range, time_range, comparison_range=None):
|
|
172
|
+
"""Checks that make an incomplete or out-of-data period visible in the receipt.
|
|
173
|
+
data_range = (first data date, last data date, label for the last data point)."""
|
|
174
|
+
min_date, max_date, end_label = data_range
|
|
175
|
+
out = []
|
|
176
|
+
for name, r in (("time_range", time_range), ("comparison_range", comparison_range)):
|
|
177
|
+
if not r:
|
|
178
|
+
continue
|
|
179
|
+
complete = r["end"] <= max_date
|
|
180
|
+
out.append({"name": f"{name}_complete", "success": complete,
|
|
181
|
+
"expected": f"end <= last data date {max_date}", "actual": r["end"],
|
|
182
|
+
"note": None if complete else f"data ends {end_label}; the period is incomplete"})
|
|
183
|
+
out.append({"name": f"{name}_starts_in_data", "success": r["start"] >= min_date,
|
|
184
|
+
"expected": f"start >= {min_date}", "actual": r["start"]})
|
|
185
|
+
return out
|