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.
Files changed (89) hide show
  1. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/CHANGELOG.md +22 -0
  2. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/PKG-INFO +17 -4
  3. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/README.md +16 -3
  4. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/docs/architecture.md +17 -5
  5. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/contract.yml +0 -1
  6. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/semantic.yml +4 -0
  7. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/pyproject.toml +1 -1
  8. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/__init__.py +2 -0
  9. agentic_data_contracts-0.6.0/src/agentic_data_contracts/adapters/__init__.py +15 -0
  10. agentic_data_contracts-0.6.0/src/agentic_data_contracts/adapters/_normalizer.py +20 -0
  11. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/adapters/base.py +12 -0
  12. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/contract.py +8 -2
  13. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/prompt.py +11 -4
  14. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/base.py +2 -0
  15. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/yaml_source.py +2 -0
  16. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/tools/factory.py +8 -2
  17. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/tools/middleware.py +8 -2
  18. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/validator.py +15 -2
  19. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/semantic_source.yml +3 -0
  20. agentic_data_contracts-0.6.0/tests/test_core/test_load_semantic_source.py +188 -0
  21. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_relationships.py +54 -0
  22. agentic_data_contracts-0.6.0/tests/test_validation/test_sql_normalizer.py +204 -0
  23. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/uv.lock +1 -1
  24. agentic_data_contracts-0.4.0/docs/superpowers/plans/2026-03-31-unified-rule-engine.md +0 -2156
  25. agentic_data_contracts-0.4.0/docs/superpowers/specs/2026-03-31-unified-rule-engine-design.md +0 -377
  26. agentic_data_contracts-0.4.0/tests/test_core/test_load_semantic_source.py +0 -101
  27. agentic_data_contracts-0.4.0/tests/test_validation/__init__.py +0 -0
  28. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.github/dependabot.yml +0 -0
  29. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.github/workflows/ci.yml +0 -0
  30. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.gitignore +0 -0
  31. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.pre-commit-config.yaml +0 -0
  32. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/.python-version +0 -0
  33. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/CLAUDE.md +0 -0
  34. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/LICENSE +0 -0
  35. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/agent.py +0 -0
  36. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/examples/revenue_agent/setup_db.py +0 -0
  37. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/adapters/duckdb.py +0 -0
  38. {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
  39. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/bridge/compiler.py +0 -0
  40. {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
  41. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/schema.py +0 -0
  42. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/core/session.py +0 -0
  43. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/py.typed +0 -0
  44. {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
  45. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/cube.py +0 -0
  46. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/semantic/dbt.py +0 -0
  47. {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
  48. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/tools/sdk.py +0 -0
  49. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/__init__.py +0 -0
  50. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/checkers.py +0 -0
  51. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/src/agentic_data_contracts/validation/explain.py +0 -0
  52. {agentic_data_contracts-0.4.0/src/agentic_data_contracts/tools → agentic_data_contracts-0.6.0/tests}/__init__.py +0 -0
  53. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/conftest.py +0 -0
  54. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/minimal_contract.yml +0 -0
  55. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/sample_cube_schema.yml +0 -0
  56. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/sample_dbt_manifest.json +0 -0
  57. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/fixtures/valid_contract.yml +0 -0
  58. {agentic_data_contracts-0.4.0/tests → agentic_data_contracts-0.6.0/tests/test_adapters}/__init__.py +0 -0
  59. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_adapters/test_duckdb.py +0 -0
  60. {agentic_data_contracts-0.4.0/tests/test_adapters → agentic_data_contracts-0.6.0/tests/test_bridge}/__init__.py +0 -0
  61. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_bridge/test_compiler.py +0 -0
  62. {agentic_data_contracts-0.4.0/tests/test_bridge → agentic_data_contracts-0.6.0/tests/test_core}/__init__.py +0 -0
  63. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_contract.py +0 -0
  64. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_prompt_renderers.py +0 -0
  65. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_scalability.py +0 -0
  66. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_schema.py +0 -0
  67. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_sdk_config.py +0 -0
  68. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_session.py +0 -0
  69. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_system_prompt_metrics.py +0 -0
  70. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_core/test_wildcard_tables.py +0 -0
  71. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_public_api.py +0 -0
  72. {agentic_data_contracts-0.4.0/tests/test_core → agentic_data_contracts-0.6.0/tests/test_semantic}/__init__.py +0 -0
  73. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_cube.py +0 -0
  74. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_dbt.py +0 -0
  75. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_search.py +0 -0
  76. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_semantic/test_yaml_source.py +0 -0
  77. {agentic_data_contracts-0.4.0/tests/test_semantic → agentic_data_contracts-0.6.0/tests/test_tools}/__init__.py +0 -0
  78. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_auto_load.py +0 -0
  79. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_factory.py +0 -0
  80. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_middleware.py +0 -0
  81. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_pagination.py +0 -0
  82. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_sdk.py +0 -0
  83. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_semantic_tools.py +0 -0
  84. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_tools/test_wildcard_tools.py +0 -0
  85. {agentic_data_contracts-0.4.0/tests/test_tools → agentic_data_contracts-0.6.0/tests/test_validation}/__init__.py +0 -0
  86. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_checkers.py +0 -0
  87. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_explain.py +0 -0
  88. {agentic_data_contracts-0.4.0 → agentic_data_contracts-0.6.0}/tests/test_validation/test_result_checks.py +0 -0
  89. {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.4.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 and table schema metadata to the agent.
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
- - from: analytics.orders.product_id
342
- to: analytics.products.id
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 and table schema metadata to the agent.
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
- - from: analytics.orders.product_id
289
- to: analytics.products.id
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-03-31
4
- **Status:** Implemented (v0.4.0)
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" # local path or URL
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
- │ │ ├── base.py # DatabaseAdapter protocol
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
@@ -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
@@ -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'"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentic-data-contracts"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "YAML-first data contract governance for AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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,15 @@
1
+ from agentic_data_contracts.adapters.base import (
2
+ Column,
3
+ DatabaseAdapter,
4
+ QueryResult,
5
+ SqlNormalizer,
6
+ TableSchema,
7
+ )
8
+
9
+ __all__ = [
10
+ "Column",
11
+ "DatabaseAdapter",
12
+ "QueryResult",
13
+ "SqlNormalizer",
14
+ "TableSchema",
15
+ ]
@@ -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
- text = Path(path).read_text()
36
- return cls.from_yaml_string(text)
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
@@ -25,6 +25,8 @@ class Relationship:
25
25
  from_: str # "schema.table.column"
26
26
  to: str # "schema.table.column"
27
27
  type: str = "many_to_one" # many_to_one | one_to_one | many_to_many
28
+ description: str = ""
29
+ required_filter: str | None = None
28
30
 
29
31
 
30
32
  @runtime_checkable
@@ -47,6 +47,8 @@ class YamlSource:
47
47
  from_=r["from"],
48
48
  to=r["to"],
49
49
  type=r.get("type", "many_to_one"),
50
+ description=r.get("description", ""),
51
+ required_filter=r.get("required_filter"),
50
52
  )
51
53
  for r in raw.get("relationships", [])
52
54
  ]
@@ -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
- validator = Validator(contract, dialect=dialect, explain_adapter=adapter)
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
- validator = Validator(contract, dialect=dialect, explain_adapter=adapter)
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
- ast = cast(exp.Expression, sqlglot.parse_one(sql, dialect=self.dialect))
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
- ast = cast(exp.Expression, sqlglot.parse_one(sql, dialect=self.dialect))
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:
@@ -50,3 +50,6 @@ relationships:
50
50
  - from: analytics.orders.customer_id
51
51
  to: analytics.customers.id
52
52
  type: many_to_one
53
+ description: >
54
+ Each order belongs to exactly one customer.
55
+ Use this join for customer-level aggregations.
@@ -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