agentic-data-contracts 0.4.0__tar.gz → 0.6.0__tar.gz
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.
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/CHANGELOG.md +22 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/PKG-INFO +17 -4
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/README.md +16 -3
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/docs/architecture.md +17 -5
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/contract.yml +0 -1
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/semantic.yml +4 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/pyproject.toml +1 -1
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/__init__.py +2 -0
- agentic_data_contracts-0.6.0/src/agentic_data_contracts/adapters/__init__.py +15 -0
- agentic_data_contracts-0.6.0/src/agentic_data_contracts/adapters/_normalizer.py +20 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/adapters/base.py +12 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/contract.py +8 -2
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/prompt.py +11 -4
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/base.py +2 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/yaml_source.py +2 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/tools/factory.py +8 -2
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/tools/middleware.py +8 -2
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/validator.py +15 -2
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/semantic_source.yml +3 -0
- agentic_data_contracts-0.6.0/tests/test_core/test_load_semantic_source.py +188 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_relationships.py +54 -0
- agentic_data_contracts-0.6.0/tests/test_validation/test_sql_normalizer.py +204 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/uv.lock +1 -1
- agentic_data_contracts-0.4.0/docs/superpowers/plans/2026-03-31-unified-rule-engine.md +0 -2156
- agentic_data_contracts-0.4.0/docs/superpowers/specs/2026-03-31-unified-rule-engine-design.md +0 -377
- agentic_data_contracts-0.4.0/tests/test_core/test_load_semantic_source.py +0 -101
- agentic_data_contracts-0.4.0/tests/test_validation/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.github/dependabot.yml +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.github/workflows/ci.yml +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.gitignore +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.pre-commit-config.yaml +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.python-version +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/CLAUDE.md +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/LICENSE +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/agent.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/setup_db.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/adapters/duckdb.py +0 -0
- {agentic_data_contracts-0.4.0/src/agentic_data_contracts/adapters → agentic_data_contracts-0.6.0/src/agentic_data_contracts/bridge}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/bridge/compiler.py +0 -0
- {agentic_data_contracts-0.4.0/src/agentic_data_contracts/bridge → agentic_data_contracts-0.6.0/src/agentic_data_contracts/core}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/schema.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/session.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/py.typed +0 -0
- {agentic_data_contracts-0.4.0/src/agentic_data_contracts/core → agentic_data_contracts-0.6.0/src/agentic_data_contracts/semantic}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/cube.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/dbt.py +0 -0
- {agentic_data_contracts-0.4.0/src/agentic_data_contracts/semantic → agentic_data_contracts-0.6.0/src/agentic_data_contracts/tools}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/tools/sdk.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/checkers.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/explain.py +0 -0
- {agentic_data_contracts-0.4.0/src/agentic_data_contracts/tools → agentic_data_contracts-0.6.0/tests}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/conftest.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/minimal_contract.yml +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/sample_cube_schema.yml +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/sample_dbt_manifest.json +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/valid_contract.yml +0 -0
- {agentic_data_contracts-0.4.0/tests → agentic_data_contracts-0.6.0/tests/test_adapters}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_adapters/test_duckdb.py +0 -0
- {agentic_data_contracts-0.4.0/tests/test_adapters → agentic_data_contracts-0.6.0/tests/test_bridge}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_bridge/test_compiler.py +0 -0
- {agentic_data_contracts-0.4.0/tests/test_bridge → agentic_data_contracts-0.6.0/tests/test_core}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_contract.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_prompt_renderers.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_scalability.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_schema.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_sdk_config.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_session.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_system_prompt_metrics.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_wildcard_tables.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_public_api.py +0 -0
- {agentic_data_contracts-0.4.0/tests/test_core → agentic_data_contracts-0.6.0/tests/test_semantic}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_cube.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_dbt.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_search.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_yaml_source.py +0 -0
- {agentic_data_contracts-0.4.0/tests/test_semantic → agentic_data_contracts-0.6.0/tests/test_tools}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_auto_load.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_factory.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_middleware.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_pagination.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_sdk.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_semantic_tools.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_wildcard_tools.py +0 -0
- {agentic_data_contracts-0.4.0/tests/test_tools → agentic_data_contracts-0.6.0/tests/test_validation}/__init__.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_checkers.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_explain.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_result_checks.py +0 -0
- {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_validator.py +0 -0
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-04-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Relationship `description` field**: Optional free-text description on `Relationship` for communicating join conditions, data quality caveats, or usage guidance to the agent. Rendered as an XML attribute in the system prompt when present.
|
|
10
|
+
- **Relationship `required_filter` field**: Optional structured filter condition (e.g., `"attribution_model = 'last_touch'"`) that must be applied when using a relationship. Rendered as a `<required_filter>` element in the system prompt, giving agents a clear, unambiguous signal about mandatory join conditions — especially useful for bridge/junction tables.
|
|
11
|
+
- **Contract-relative path resolution**: `DataContract.from_yaml()` now resolves `source.path` relative to the contract file's directory, not the process CWD. This means `path: "./semantic.yml"` in `contracts/contract.yml` correctly loads `contracts/semantic.yml` regardless of where the process runs. Absolute paths and `from_yaml_string()` are unaffected.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Example contract**: Removed invalid `filter_column` field from `examples/revenue_agent/contract.yml` (the field was removed in v0.4.0 in favor of `query_check.required_filter`).
|
|
16
|
+
|
|
17
|
+
## [0.5.0] - 2026-04-04
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`SqlNormalizer` protocol**: Optional pre-processing hook for adapters serving non-standard SQL dialects (e.g., Denodo VQL, Teradata). Adapters implement `normalize_sql(sql) -> str` to rewrite proprietary syntax into a form sqlglot can parse, while the original SQL is preserved for `execute()` and `explain()`.
|
|
22
|
+
- **Auto-detection in factory and middleware**: When an adapter implements both `DatabaseAdapter` and `SqlNormalizer`, the factory and middleware automatically wire normalization into the `Validator` — no API changes needed.
|
|
23
|
+
- **Normalization in `validate_results()`**: Table-scoped result checks now also benefit from SQL normalization, ensuring scoped checks fire correctly for non-standard dialects.
|
|
24
|
+
- **Adapter package exports**: `adapters/__init__.py` now re-exports `Column`, `DatabaseAdapter`, `QueryResult`, `SqlNormalizer`, and `TableSchema`.
|
|
25
|
+
- **Root export**: `SqlNormalizer` is available via `from agentic_data_contracts import SqlNormalizer`.
|
|
26
|
+
|
|
5
27
|
## [0.4.0] - 2026-03-31
|
|
6
28
|
|
|
7
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-data-contracts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: YAML-first data contract governance for AI agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/flyersworder/agentic-data-contracts
|
|
6
6
|
Project-URL: Repository, https://github.com/flyersworder/agentic-data-contracts
|
|
@@ -289,7 +289,7 @@ rules:
|
|
|
289
289
|
|
|
290
290
|
## Semantic Sources
|
|
291
291
|
|
|
292
|
-
A semantic source provides metric
|
|
292
|
+
A semantic source provides metric, table schema, and relationship metadata to the agent. Paths are resolved relative to the contract file's directory (not the process CWD).
|
|
293
293
|
|
|
294
294
|
**YAML** (built-in):
|
|
295
295
|
```yaml
|
|
@@ -338,11 +338,24 @@ relationships:
|
|
|
338
338
|
- from: analytics.orders.customer_id
|
|
339
339
|
to: analytics.customers.id
|
|
340
340
|
type: many_to_one
|
|
341
|
-
|
|
342
|
-
|
|
341
|
+
description: >
|
|
342
|
+
Join orders to customers for region-level breakdowns.
|
|
343
|
+
Every order has exactly one customer.
|
|
344
|
+
|
|
345
|
+
- from: analytics.bdg_attribution.contact_id
|
|
346
|
+
to: analytics.contacts.contact_id
|
|
343
347
|
type: many_to_one
|
|
348
|
+
description: "Bridge table — filter to avoid fan-out from multiple attribution records."
|
|
349
|
+
required_filter: "attribution_model = 'last_touch_attribution'"
|
|
344
350
|
```
|
|
345
351
|
|
|
352
|
+
| Field | Required | Description |
|
|
353
|
+
|-------|----------|-------------|
|
|
354
|
+
| `from` / `to` | Yes | Fully qualified column references (`schema.table.column`) |
|
|
355
|
+
| `type` | No | Cardinality: `many_to_one` (default), `one_to_one`, `many_to_many` |
|
|
356
|
+
| `description` | No | Free-text context for the agent (join guidance, caveats, data quality notes) |
|
|
357
|
+
| `required_filter` | No | SQL condition that **must** be applied when using this join (e.g., bridge table disambiguation) |
|
|
358
|
+
|
|
346
359
|
The agent sees these in its system prompt and uses them to write correct JOINs instead of guessing from column names.
|
|
347
360
|
|
|
348
361
|
## Custom Prompt Rendering
|
|
@@ -236,7 +236,7 @@ rules:
|
|
|
236
236
|
|
|
237
237
|
## Semantic Sources
|
|
238
238
|
|
|
239
|
-
A semantic source provides metric
|
|
239
|
+
A semantic source provides metric, table schema, and relationship metadata to the agent. Paths are resolved relative to the contract file's directory (not the process CWD).
|
|
240
240
|
|
|
241
241
|
**YAML** (built-in):
|
|
242
242
|
```yaml
|
|
@@ -285,11 +285,24 @@ relationships:
|
|
|
285
285
|
- from: analytics.orders.customer_id
|
|
286
286
|
to: analytics.customers.id
|
|
287
287
|
type: many_to_one
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
description: >
|
|
289
|
+
Join orders to customers for region-level breakdowns.
|
|
290
|
+
Every order has exactly one customer.
|
|
291
|
+
|
|
292
|
+
- from: analytics.bdg_attribution.contact_id
|
|
293
|
+
to: analytics.contacts.contact_id
|
|
290
294
|
type: many_to_one
|
|
295
|
+
description: "Bridge table — filter to avoid fan-out from multiple attribution records."
|
|
296
|
+
required_filter: "attribution_model = 'last_touch_attribution'"
|
|
291
297
|
```
|
|
292
298
|
|
|
299
|
+
| Field | Required | Description |
|
|
300
|
+
|-------|----------|-------------|
|
|
301
|
+
| `from` / `to` | Yes | Fully qualified column references (`schema.table.column`) |
|
|
302
|
+
| `type` | No | Cardinality: `many_to_one` (default), `one_to_one`, `many_to_many` |
|
|
303
|
+
| `description` | No | Free-text context for the agent (join guidance, caveats, data quality notes) |
|
|
304
|
+
| `required_filter` | No | SQL condition that **must** be applied when using this join (e.g., bridge table disambiguation) |
|
|
305
|
+
|
|
293
306
|
The agent sees these in its system prompt and uses them to write correct JOINs instead of guessing from column names.
|
|
294
307
|
|
|
295
308
|
## Custom Prompt Rendering
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Agentic Data Contracts — Architecture
|
|
2
2
|
|
|
3
|
-
**Date:** 2026-
|
|
4
|
-
**Status:** Implemented (v0.
|
|
3
|
+
**Date:** 2026-04-09
|
|
4
|
+
**Status:** Implemented (v0.6.0)
|
|
5
5
|
**Author:** Qing Ye + Claude
|
|
6
6
|
|
|
7
7
|
## Problem Statement
|
|
@@ -88,7 +88,7 @@ name: revenue-analysis
|
|
|
88
88
|
semantic:
|
|
89
89
|
source:
|
|
90
90
|
type: dbt # dbt | cube | yaml | custom
|
|
91
|
-
path: "./dbt/manifest.json" #
|
|
91
|
+
path: "./dbt/manifest.json" # resolved relative to contract file
|
|
92
92
|
|
|
93
93
|
# What the agent is allowed to access
|
|
94
94
|
allowed_tables:
|
|
@@ -369,6 +369,7 @@ class SemanticSource(Protocol):
|
|
|
369
369
|
def get_metric(self, name: str) -> MetricDefinition | None: ...
|
|
370
370
|
def get_table_schema(self, schema: str, table: str) -> TableSchema | None: ...
|
|
371
371
|
def search_metrics(self, query: str) -> list[MetricDefinition]: ...
|
|
372
|
+
def get_relationships(self) -> list[Relationship]: ...
|
|
372
373
|
```
|
|
373
374
|
|
|
374
375
|
**Fuzzy metric search:** When `lookup_metric` receives a query that doesn't exactly match a metric name, it falls back to `search_metrics()` which uses `thefuzz` (`token_set_ratio` scorer, cutoff 50) to find the best matches by name + description. A shared `fuzzy_search_metrics()` helper in `base.py` provides this logic for all source implementations.
|
|
@@ -379,9 +380,10 @@ class SemanticSource(Protocol):
|
|
|
379
380
|
|---|---|---|
|
|
380
381
|
| `DbtSource` | `manifest.json` | Metrics, models, columns |
|
|
381
382
|
| `CubeSource` | Cube meta API or schema files | Metrics, dimensions |
|
|
382
|
-
| `YamlSource` | Inline YAML definitions | Simple metric/table definitions for teams not using dbt/Cube |
|
|
383
|
+
| `YamlSource` | Inline YAML definitions | Simple metric/table/relationship definitions for teams not using dbt/Cube |
|
|
383
384
|
|
|
384
385
|
`MetricDefinition`: `name`, `description`, `sql_expression`, `source_model`, `filters`.
|
|
386
|
+
`Relationship`: `from_`, `to`, `type`, `description`, `required_filter`.
|
|
385
387
|
`TableSchema`: `columns: list[Column]` with name, type, description.
|
|
386
388
|
|
|
387
389
|
## Database Adapters
|
|
@@ -393,8 +395,17 @@ class DatabaseAdapter(Protocol):
|
|
|
393
395
|
def describe_table(self, schema: str, table: str) -> TableSchema: ...
|
|
394
396
|
@property
|
|
395
397
|
def dialect(self) -> str: ... # "bigquery", "snowflake", "postgres", "duckdb"
|
|
398
|
+
|
|
399
|
+
class SqlNormalizer(Protocol):
|
|
400
|
+
def normalize_sql(self, sql: str) -> str: ...
|
|
396
401
|
```
|
|
397
402
|
|
|
403
|
+
### SQL Normalization for Non-Standard Dialects
|
|
404
|
+
|
|
405
|
+
Adapters for databases with proprietary SQL extensions (Denodo VQL, Teradata, ClickHouse) can implement `SqlNormalizer` alongside `DatabaseAdapter`. The `Validator` calls `normalize_sql()` before `sqlglot.parse_one()` to rewrite non-standard syntax into a form sqlglot can parse. The original SQL is preserved for `execute()` and `explain()`.
|
|
406
|
+
|
|
407
|
+
Detection is automatic: `create_tools()` and `contract_middleware()` check `isinstance(adapter, SqlNormalizer)` and wire it into the `Validator` if present. Standard-dialect adapters are unaffected.
|
|
408
|
+
|
|
398
409
|
**`describe_table` maps to native commands:**
|
|
399
410
|
|
|
400
411
|
| Database | Command | What you get |
|
|
@@ -489,7 +500,8 @@ agentic-data-contracts/
|
|
|
489
500
|
│ │ └── yaml_source.py # YamlSource
|
|
490
501
|
│ ├── adapters/
|
|
491
502
|
│ │ ├── __init__.py
|
|
492
|
-
│ │ ├──
|
|
503
|
+
│ │ ├── _normalizer.py # SqlNormalizer protocol (avoids circular import)
|
|
504
|
+
│ │ ├── base.py # DatabaseAdapter protocol + SqlNormalizer re-export
|
|
493
505
|
│ │ ├── bigquery.py # BigQuery adapter
|
|
494
506
|
│ │ ├── snowflake.py # Snowflake adapter
|
|
495
507
|
│ │ ├── postgres.py # Postgres adapter
|
{agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/contract.yml
RENAMED
|
@@ -15,7 +15,6 @@ semantic:
|
|
|
15
15
|
- name: tenant_isolation
|
|
16
16
|
description: "All queries must filter by tenant_id"
|
|
17
17
|
enforcement: block
|
|
18
|
-
filter_column: tenant_id
|
|
19
18
|
- name: use_semantic_revenue
|
|
20
19
|
description: "Revenue calculations must use the metric definitions"
|
|
21
20
|
enforcement: warn
|
{agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/semantic.yml
RENAMED
|
@@ -20,3 +20,7 @@ relationships:
|
|
|
20
20
|
- from: analytics.orders.customer_id
|
|
21
21
|
to: analytics.customers.id
|
|
22
22
|
type: many_to_one
|
|
23
|
+
description: >
|
|
24
|
+
Join orders to customers for region-level breakdowns.
|
|
25
|
+
Every order has exactly one customer.
|
|
26
|
+
required_filter: "status != 'cancelled'"
|
{agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/__init__.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Agentic Data Contracts — YAML-first data contract governance for AI agents."""
|
|
2
2
|
|
|
3
|
+
from agentic_data_contracts.adapters.base import SqlNormalizer
|
|
3
4
|
from agentic_data_contracts.core.contract import DataContract
|
|
4
5
|
from agentic_data_contracts.core.prompt import ClaudePromptRenderer, PromptRenderer
|
|
5
6
|
from agentic_data_contracts.tools.factory import create_tools
|
|
@@ -10,6 +11,7 @@ __all__ = [
|
|
|
10
11
|
"ClaudePromptRenderer",
|
|
11
12
|
"DataContract",
|
|
12
13
|
"PromptRenderer",
|
|
14
|
+
"SqlNormalizer",
|
|
13
15
|
"contract_middleware",
|
|
14
16
|
"create_sdk_mcp_server",
|
|
15
17
|
"create_tools",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""SqlNormalizer protocol — standalone module to avoid circular imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class SqlNormalizer(Protocol):
|
|
10
|
+
"""Rewrite database-specific SQL into a form sqlglot can parse.
|
|
11
|
+
|
|
12
|
+
Called by the Validator before AST parsing. Adapters for non-standard
|
|
13
|
+
dialects implement this alongside DatabaseAdapter. Standard-dialect
|
|
14
|
+
adapters do not need to implement this — the Validator treats its
|
|
15
|
+
absence as a no-op.
|
|
16
|
+
|
|
17
|
+
The original (un-normalized) SQL is still passed to execute() and explain().
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def normalize_sql(self, sql: str) -> str: ...
|
|
@@ -41,3 +41,15 @@ class DatabaseAdapter(Protocol):
|
|
|
41
41
|
|
|
42
42
|
@property
|
|
43
43
|
def dialect(self) -> str: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Re-export SqlNormalizer so consumers can import from adapters.base
|
|
47
|
+
from agentic_data_contracts.adapters._normalizer import SqlNormalizer # noqa: E402
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"Column",
|
|
51
|
+
"DatabaseAdapter",
|
|
52
|
+
"QueryResult",
|
|
53
|
+
"SqlNormalizer",
|
|
54
|
+
"TableSchema",
|
|
55
|
+
]
|
|
@@ -25,6 +25,7 @@ class DataContract:
|
|
|
25
25
|
def __init__(self, schema: DataContractSchema) -> None:
|
|
26
26
|
self.schema = schema
|
|
27
27
|
self._tables_resolved: bool = False
|
|
28
|
+
self._source_dir: Path | None = None
|
|
28
29
|
|
|
29
30
|
@property
|
|
30
31
|
def name(self) -> str:
|
|
@@ -32,8 +33,11 @@ class DataContract:
|
|
|
32
33
|
|
|
33
34
|
@classmethod
|
|
34
35
|
def from_yaml(cls, path: str | Path) -> DataContract:
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
resolved = Path(path).resolve()
|
|
37
|
+
text = resolved.read_text()
|
|
38
|
+
contract = cls.from_yaml_string(text)
|
|
39
|
+
contract._source_dir = resolved.parent
|
|
40
|
+
return contract
|
|
37
41
|
|
|
38
42
|
@classmethod
|
|
39
43
|
def from_yaml_string(cls, text: str) -> DataContract:
|
|
@@ -112,6 +116,8 @@ class DataContract:
|
|
|
112
116
|
|
|
113
117
|
source_type = source_config.type.lower()
|
|
114
118
|
path = source_config.path
|
|
119
|
+
if self._source_dir is not None and not Path(path).is_absolute():
|
|
120
|
+
path = str(self._source_dir / path)
|
|
115
121
|
|
|
116
122
|
loaders: dict[str, type] = {
|
|
117
123
|
"yaml": YamlSource,
|
|
@@ -161,11 +161,18 @@ class ClaudePromptRenderer:
|
|
|
161
161
|
|
|
162
162
|
lines = ["<table_relationships>"]
|
|
163
163
|
for r in rels:
|
|
164
|
+
desc = r.description.strip()
|
|
165
|
+
desc_attr = f' description="{desc}"' if desc else ""
|
|
166
|
+
parts = [
|
|
167
|
+
f"<from>{r.from_}</from>",
|
|
168
|
+
f"<to>{r.to}</to>",
|
|
169
|
+
]
|
|
170
|
+
if r.required_filter:
|
|
171
|
+
filt = r.required_filter.strip()
|
|
172
|
+
parts.append(f"<required_filter>{filt}</required_filter>")
|
|
173
|
+
inner = "".join(parts)
|
|
164
174
|
lines.append(
|
|
165
|
-
f' <relationship type="{r.type}">'
|
|
166
|
-
f"<from>{r.from_}</from>"
|
|
167
|
-
f"<to>{r.to}</to>"
|
|
168
|
-
"</relationship>"
|
|
175
|
+
f' <relationship type="{r.type}"{desc_attr}>{inner}</relationship>'
|
|
169
176
|
)
|
|
170
177
|
lines.append("</table_relationships>")
|
|
171
178
|
return lines
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from agentic_data_contracts.adapters.base import DatabaseAdapter
|
|
9
|
+
from agentic_data_contracts.adapters.base import DatabaseAdapter, SqlNormalizer
|
|
10
10
|
from agentic_data_contracts.core.contract import DataContract
|
|
11
11
|
from agentic_data_contracts.core.session import ContractSession, LimitExceededError
|
|
12
12
|
from agentic_data_contracts.semantic.base import SemanticSource
|
|
@@ -46,7 +46,13 @@ def create_tools(
|
|
|
46
46
|
contract.resolve_tables(adapter)
|
|
47
47
|
|
|
48
48
|
dialect = adapter.dialect if adapter else None
|
|
49
|
-
|
|
49
|
+
sql_normalizer = adapter if isinstance(adapter, SqlNormalizer) else None
|
|
50
|
+
validator = Validator(
|
|
51
|
+
contract,
|
|
52
|
+
dialect=dialect,
|
|
53
|
+
explain_adapter=adapter,
|
|
54
|
+
sql_normalizer=sql_normalizer,
|
|
55
|
+
)
|
|
50
56
|
|
|
51
57
|
# ── Tool 1: list_schemas ──────────────────────────────────────────────────
|
|
52
58
|
async def list_schemas(args: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -6,7 +6,7 @@ import functools
|
|
|
6
6
|
from collections.abc import Callable
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from agentic_data_contracts.adapters.base import DatabaseAdapter
|
|
9
|
+
from agentic_data_contracts.adapters.base import DatabaseAdapter, SqlNormalizer
|
|
10
10
|
from agentic_data_contracts.core.contract import DataContract
|
|
11
11
|
from agentic_data_contracts.core.session import ContractSession, LimitExceededError
|
|
12
12
|
from agentic_data_contracts.validation.validator import Validator
|
|
@@ -22,7 +22,13 @@ def contract_middleware(
|
|
|
22
22
|
session = ContractSession(contract)
|
|
23
23
|
|
|
24
24
|
dialect = adapter.dialect if adapter else None
|
|
25
|
-
|
|
25
|
+
sql_normalizer = adapter if isinstance(adapter, SqlNormalizer) else None
|
|
26
|
+
validator = Validator(
|
|
27
|
+
contract,
|
|
28
|
+
dialect=dialect,
|
|
29
|
+
explain_adapter=adapter,
|
|
30
|
+
sql_normalizer=sql_normalizer,
|
|
31
|
+
)
|
|
26
32
|
|
|
27
33
|
def decorator(fn):
|
|
28
34
|
@functools.wraps(fn)
|
|
@@ -8,6 +8,7 @@ from typing import Any, Protocol, cast
|
|
|
8
8
|
import sqlglot
|
|
9
9
|
from sqlglot import exp
|
|
10
10
|
|
|
11
|
+
from agentic_data_contracts.adapters._normalizer import SqlNormalizer
|
|
11
12
|
from agentic_data_contracts.core.contract import DataContract
|
|
12
13
|
from agentic_data_contracts.validation.checkers import (
|
|
13
14
|
BlockedColumnsChecker,
|
|
@@ -53,10 +54,12 @@ class Validator:
|
|
|
53
54
|
contract: DataContract,
|
|
54
55
|
dialect: str | None = None,
|
|
55
56
|
explain_adapter: ExplainAdapter | None = None,
|
|
57
|
+
sql_normalizer: SqlNormalizer | None = None,
|
|
56
58
|
) -> None:
|
|
57
59
|
self.contract = contract
|
|
58
60
|
self.dialect = dialect
|
|
59
61
|
self.explain_adapter = explain_adapter
|
|
62
|
+
self.sql_normalizer = sql_normalizer
|
|
60
63
|
self._build_checkers()
|
|
61
64
|
|
|
62
65
|
def _build_checkers(self) -> None:
|
|
@@ -154,7 +157,12 @@ class Validator:
|
|
|
154
157
|
estimated_cost_usd: float | None = None
|
|
155
158
|
|
|
156
159
|
try:
|
|
157
|
-
|
|
160
|
+
normalized = (
|
|
161
|
+
self.sql_normalizer.normalize_sql(sql) if self.sql_normalizer else sql
|
|
162
|
+
)
|
|
163
|
+
ast = cast(
|
|
164
|
+
exp.Expression, sqlglot.parse_one(normalized, dialect=self.dialect)
|
|
165
|
+
)
|
|
158
166
|
except sqlglot.errors.ParseError as e:
|
|
159
167
|
return ValidationResult(blocked=True, reasons=[f"SQL parse error: {e}"])
|
|
160
168
|
|
|
@@ -229,7 +237,12 @@ class Validator:
|
|
|
229
237
|
log_messages: list[str] = []
|
|
230
238
|
|
|
231
239
|
try:
|
|
232
|
-
|
|
240
|
+
normalized = (
|
|
241
|
+
self.sql_normalizer.normalize_sql(sql) if self.sql_normalizer else sql
|
|
242
|
+
)
|
|
243
|
+
ast = cast(
|
|
244
|
+
exp.Expression, sqlglot.parse_one(normalized, dialect=self.dialect)
|
|
245
|
+
)
|
|
233
246
|
except sqlglot.errors.ParseError:
|
|
234
247
|
referenced_tables: set[str] = set()
|
|
235
248
|
else:
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Tests for DataContract.load_semantic_source() auto-loading."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from agentic_data_contracts.core.contract import DataContract
|
|
8
|
+
from agentic_data_contracts.core.schema import (
|
|
9
|
+
DataContractSchema,
|
|
10
|
+
SemanticConfig,
|
|
11
|
+
)
|
|
12
|
+
from agentic_data_contracts.core.schema import (
|
|
13
|
+
SemanticSource as SemanticSourceConfig,
|
|
14
|
+
)
|
|
15
|
+
from agentic_data_contracts.semantic.cube import CubeSource
|
|
16
|
+
from agentic_data_contracts.semantic.dbt import DbtSource
|
|
17
|
+
from agentic_data_contracts.semantic.yaml_source import YamlSource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_load_yaml_source(fixtures_dir: Path) -> None:
|
|
21
|
+
schema = DataContractSchema(
|
|
22
|
+
name="test",
|
|
23
|
+
semantic=SemanticConfig(
|
|
24
|
+
source=SemanticSourceConfig(
|
|
25
|
+
type="yaml",
|
|
26
|
+
path=str(fixtures_dir / "semantic_source.yml"),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
dc = DataContract(schema)
|
|
31
|
+
source = dc.load_semantic_source()
|
|
32
|
+
assert isinstance(source, YamlSource)
|
|
33
|
+
assert len(source.get_metrics()) == 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_load_dbt_source(fixtures_dir: Path) -> None:
|
|
37
|
+
schema = DataContractSchema(
|
|
38
|
+
name="test",
|
|
39
|
+
semantic=SemanticConfig(
|
|
40
|
+
source=SemanticSourceConfig(
|
|
41
|
+
type="dbt",
|
|
42
|
+
path=str(fixtures_dir / "sample_dbt_manifest.json"),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
dc = DataContract(schema)
|
|
47
|
+
source = dc.load_semantic_source()
|
|
48
|
+
assert isinstance(source, DbtSource)
|
|
49
|
+
assert len(source.get_metrics()) == 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_load_cube_source(fixtures_dir: Path) -> None:
|
|
53
|
+
schema = DataContractSchema(
|
|
54
|
+
name="test",
|
|
55
|
+
semantic=SemanticConfig(
|
|
56
|
+
source=SemanticSourceConfig(
|
|
57
|
+
type="cube",
|
|
58
|
+
path=str(fixtures_dir / "sample_cube_schema.yml"),
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
dc = DataContract(schema)
|
|
63
|
+
source = dc.load_semantic_source()
|
|
64
|
+
assert isinstance(source, CubeSource)
|
|
65
|
+
assert len(source.get_metrics()) == 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_load_no_source_returns_none() -> None:
|
|
69
|
+
schema = DataContractSchema(
|
|
70
|
+
name="test",
|
|
71
|
+
semantic=SemanticConfig(),
|
|
72
|
+
)
|
|
73
|
+
dc = DataContract(schema)
|
|
74
|
+
assert dc.load_semantic_source() is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_load_unknown_type_raises() -> None:
|
|
78
|
+
schema = DataContractSchema(
|
|
79
|
+
name="test",
|
|
80
|
+
semantic=SemanticConfig(
|
|
81
|
+
source=SemanticSourceConfig(type="unknown", path="foo.json"),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
dc = DataContract(schema)
|
|
85
|
+
with pytest.raises(ValueError, match="Unknown semantic source type"):
|
|
86
|
+
dc.load_semantic_source()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_load_case_insensitive(fixtures_dir: Path) -> None:
|
|
90
|
+
schema = DataContractSchema(
|
|
91
|
+
name="test",
|
|
92
|
+
semantic=SemanticConfig(
|
|
93
|
+
source=SemanticSourceConfig(
|
|
94
|
+
type="YAML",
|
|
95
|
+
path=str(fixtures_dir / "semantic_source.yml"),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
dc = DataContract(schema)
|
|
100
|
+
source = dc.load_semantic_source()
|
|
101
|
+
assert isinstance(source, YamlSource)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_load_relative_path_resolves_from_contract_dir(
|
|
105
|
+
fixtures_dir: Path, tmp_path: Path
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Relative paths in source.path should resolve against the contract file's dir."""
|
|
108
|
+
# Copy the semantic source into tmp_path alongside the contract
|
|
109
|
+
import shutil
|
|
110
|
+
|
|
111
|
+
shutil.copy(fixtures_dir / "semantic_source.yml", tmp_path / "semantic_source.yml")
|
|
112
|
+
contract_yaml = (
|
|
113
|
+
"name: test\n"
|
|
114
|
+
"semantic:\n"
|
|
115
|
+
" allowed_tables:\n"
|
|
116
|
+
" - schema: analytics\n"
|
|
117
|
+
" tables: [orders]\n"
|
|
118
|
+
" source:\n"
|
|
119
|
+
" type: yaml\n"
|
|
120
|
+
" path: ./semantic_source.yml\n"
|
|
121
|
+
)
|
|
122
|
+
(tmp_path / "contract.yml").write_text(contract_yaml)
|
|
123
|
+
dc = DataContract.from_yaml(tmp_path / "contract.yml")
|
|
124
|
+
source = dc.load_semantic_source()
|
|
125
|
+
assert isinstance(source, YamlSource)
|
|
126
|
+
assert len(source.get_metrics()) == 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_load_relative_path_from_subdirectory(
|
|
130
|
+
fixtures_dir: Path, tmp_path: Path
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Relative path with ../ resolves from contract dir."""
|
|
133
|
+
# Put a semantic file in tmp_path
|
|
134
|
+
semantic_content = (
|
|
135
|
+
"metrics:\n - name: m1\n description: test\n sql_expression: '1'\n"
|
|
136
|
+
)
|
|
137
|
+
(tmp_path / "semantic.yml").write_text(semantic_content)
|
|
138
|
+
|
|
139
|
+
# Put a contract in a subdirectory that references ../semantic.yml
|
|
140
|
+
subdir = tmp_path / "contracts"
|
|
141
|
+
subdir.mkdir()
|
|
142
|
+
contract_yaml = (
|
|
143
|
+
"name: test\n"
|
|
144
|
+
"semantic:\n"
|
|
145
|
+
" allowed_tables:\n"
|
|
146
|
+
" - schema: s\n"
|
|
147
|
+
" tables: [t]\n"
|
|
148
|
+
" source:\n"
|
|
149
|
+
" type: yaml\n"
|
|
150
|
+
" path: ../semantic.yml\n"
|
|
151
|
+
)
|
|
152
|
+
(subdir / "contract.yml").write_text(contract_yaml)
|
|
153
|
+
dc = DataContract.from_yaml(subdir / "contract.yml")
|
|
154
|
+
source = dc.load_semantic_source()
|
|
155
|
+
assert isinstance(source, YamlSource)
|
|
156
|
+
assert len(source.get_metrics()) == 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_load_absolute_path_unaffected_by_source_dir(
|
|
160
|
+
fixtures_dir: Path, tmp_path: Path
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Absolute paths should work regardless of _source_dir."""
|
|
163
|
+
abs_path = str(fixtures_dir / "semantic_source.yml")
|
|
164
|
+
contract_yaml = (
|
|
165
|
+
"name: test\n"
|
|
166
|
+
"semantic:\n"
|
|
167
|
+
" allowed_tables:\n"
|
|
168
|
+
" - schema: analytics\n"
|
|
169
|
+
" tables: [orders]\n"
|
|
170
|
+
" source:\n"
|
|
171
|
+
" type: yaml\n"
|
|
172
|
+
" path: " + abs_path + "\n"
|
|
173
|
+
)
|
|
174
|
+
# Contract in tmp_path, but semantic source path is absolute
|
|
175
|
+
(tmp_path / "contract.yml").write_text(contract_yaml)
|
|
176
|
+
dc = DataContract.from_yaml(tmp_path / "contract.yml")
|
|
177
|
+
source = dc.load_semantic_source()
|
|
178
|
+
assert isinstance(source, YamlSource)
|
|
179
|
+
assert len(source.get_metrics()) == 2
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_from_yaml_string_has_no_source_dir() -> None:
|
|
183
|
+
"""Contracts loaded from strings have no _source_dir (backward compat)."""
|
|
184
|
+
contract_yaml = (
|
|
185
|
+
"name: test\nsemantic:\n allowed_tables:\n - schema: s\n tables: [t]\n"
|
|
186
|
+
)
|
|
187
|
+
dc = DataContract.from_yaml_string(contract_yaml)
|
|
188
|
+
assert dc._source_dir is None
|