tff-core 0.2.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 (46) hide show
  1. tff_core-0.2.0/.gitignore +10 -0
  2. tff_core-0.2.0/LICENSE +21 -0
  3. tff_core-0.2.0/PKG-INFO +224 -0
  4. tff_core-0.2.0/README.md +210 -0
  5. tff_core-0.2.0/pyproject.toml +23 -0
  6. tff_core-0.2.0/src/tff/core/__init__.py +0 -0
  7. tff_core-0.2.0/src/tff/core/checks/__init__.py +0 -0
  8. tff_core-0.2.0/src/tff/core/checks/custom_exclusions.py +174 -0
  9. tff_core-0.2.0/src/tff/core/checks/dependency_graph.py +78 -0
  10. tff_core-0.2.0/src/tff/core/checks/layer_integrity.py +98 -0
  11. tff_core-0.2.0/src/tff/core/checks/schema_contracts.py +106 -0
  12. tff_core-0.2.0/src/tff/core/config.py +200 -0
  13. tff_core-0.2.0/src/tff/core/context.py +30 -0
  14. tff_core-0.2.0/src/tff/core/model.py +19 -0
  15. tff_core-0.2.0/src/tff/core/report.py +322 -0
  16. tff_core-0.2.0/src/tff/core/rules/__init__.py +52 -0
  17. tff_core-0.2.0/src/tff/core/rules/base.py +24 -0
  18. tff_core-0.2.0/src/tff/core/rules/classification_macros.py +77 -0
  19. tff_core-0.2.0/src/tff/core/rules/column_names.py +41 -0
  20. tff_core-0.2.0/src/tff/core/rules/column_types.py +56 -0
  21. tff_core-0.2.0/src/tff/core/rules/filename_equals_modelname.py +31 -0
  22. tff_core-0.2.0/src/tff/core/rules/mart_naming.py +43 -0
  23. tff_core-0.2.0/src/tff/core/rules/metadata.py +104 -0
  24. tff_core-0.2.0/src/tff/core/rules/no_positional_group_by_or_order_by.py +70 -0
  25. tff_core-0.2.0/src/tff/core/rules/no_select_star.py +52 -0
  26. tff_core-0.2.0/src/tff/core/rules/sql_complexity.py +144 -0
  27. tff_core-0.2.0/src/tff/core/utils/paths.py +57 -0
  28. tff_core-0.2.0/src/tff/core/utils/schema_contract_utils.py +113 -0
  29. tff_core-0.2.0/tests/fixtures/minimal_project/config.py +12 -0
  30. tff_core-0.2.0/tests/fixtures/minimal_project/fitness_functions.yaml +5 -0
  31. tff_core-0.2.0/tests/fixtures/minimal_project/models/derived/violating_model.sql +9 -0
  32. tff_core-0.2.0/tests/fixtures/minimal_project/models/sources/src_model.sql +9 -0
  33. tff_core-0.2.0/tests/fixtures/minimal_project/settings.yaml +15 -0
  34. tff_core-0.2.0/tests/test_classification_macros.py +22 -0
  35. tff_core-0.2.0/tests/test_column_rules.py +67 -0
  36. tff_core-0.2.0/tests/test_config.py +132 -0
  37. tff_core-0.2.0/tests/test_custom_exclusions.py +54 -0
  38. tff_core-0.2.0/tests/test_layer_filtering.py +139 -0
  39. tff_core-0.2.0/tests/test_mart_naming.py +40 -0
  40. tff_core-0.2.0/tests/test_metadata.py +102 -0
  41. tff_core-0.2.0/tests/test_no_positional_group_by_or_order_by.py +114 -0
  42. tff_core-0.2.0/tests/test_no_select_star.py +103 -0
  43. tff_core-0.2.0/tests/test_report.py +172 -0
  44. tff_core-0.2.0/tests/test_schema_contract_utils.py +33 -0
  45. tff_core-0.2.0/tests/test_schema_contracts.py +28 -0
  46. tff_core-0.2.0/tests/test_sql_complexity.py +21 -0
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ .cache/
9
+ .coverage
10
+ coverage.xml
tff_core-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bart Schuijt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: tff-core
3
+ Version: 0.2.0
4
+ Summary: Core rules and fitness functions engine for transformation projects
5
+ Author-email: Bart Schuijt <schuijt.bart@gmail.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: sqlglot>=23.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # sqlmesh-ff
16
+
17
+ [![PyPI version](https://img.shields.io/pypi/v/sqlmesh-ff.svg)](https://pypi.org/project/sqlmesh-ff/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/sqlmesh-ff.svg)](https://pypi.org/project/sqlmesh-ff/)
19
+
20
+ Configurable fitness functions plugin for [SQLMesh](https://sqlmesh.com) projects.
21
+
22
+ Ships SQLMesh linter rules (classification macros, SQL complexity, metadata, naming) and architectural checks (layer integrity, custom exclusions, schema contracts, dependency graph) with a unified Rich lint report.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ # Install from PyPI:
28
+ uv add sqlmesh-ff
29
+
30
+ # Or using pip:
31
+ pip install sqlmesh-ff
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ 1. Add `fitness_functions.yaml` to your SQLMesh project root (see [Configuration](#configuration)).
37
+ 2. Add a small `config.py` bootstrap (see [Where configuration lives](#where-configuration-lives)) — SQLMesh requires the loader as a Python class and cannot load `config.py` and `config.yaml` in the same folder.
38
+ 3. Run lint:
39
+
40
+ ```bash
41
+ sqlmesh-ff lint
42
+ ```
43
+
44
+ ## Where configuration lives
45
+
46
+ There are three layers. Only the YAML/JSON files in your project are user-editable settings.
47
+
48
+ | Layer | File | Role | You edit this? |
49
+ |-------|------|------|----------------|
50
+ | Plugin defaults | `sqlmesh_ff/config.py` (installed package) | Pydantic schema and built-in defaults (e.g. `fan_out_warn: 15`) | No — library code, never overwritten |
51
+ | SQLMesh project | `settings.yaml` | Gateways, `linter.rules`, variables, CI/CD bot | Yes — normal SQLMesh config |
52
+ | Fitness functions | `fitness_functions.yaml` | Thresholds, rule toggles, column naming/type rules, paths to JSON data | Yes — main FF config |
53
+ | Loader bootstrap | `config.py` (project root) | Loads `settings.yaml` and registers `FitnessLoader` | Rarely — ~15 lines of wiring |
54
+ | Contract data | `linter_contract_groups.json`, `linter_exclusions.json` | Repo-specific schema parity and dependency exclusions | Yes — project data |
55
+
56
+ **Merge order for fitness settings:** plugin defaults → `fitness_functions.yaml` → optional `loader_kwargs` overrides in `config.py`. Your YAML always wins over plugin defaults. The project `config.py` does not hold fitness thresholds — it only points at `fitness_functions.yaml`.
57
+
58
+ **Why `config.py` exists:** SQLMesh accepts `loader: FitnessLoader` only as a Python class, not as a YAML string. Because SQLMesh rejects having both `config.py` and `config.yaml` in one folder, projects use `settings.yaml` (SQLMesh settings) plus `config.py` (loader registration).
59
+
60
+ Example bootstrap:
61
+
62
+ ```python
63
+ from pathlib import Path
64
+
65
+ from sqlmesh.core.config import Config
66
+ from sqlmesh.utils.yaml import load as yaml_load
67
+ from sqlmesh_ff.loader import FitnessLoader
68
+
69
+ _settings = yaml_load(Path(__file__).parent / "settings.yaml")
70
+ config = Config.parse_obj(_settings).update_with({
71
+ "loader": FitnessLoader,
72
+ "loader_kwargs": {"fitness_functions_config": "fitness_functions.yaml"},
73
+ })
74
+ ```
75
+
76
+ Enable individual SQLMesh rules in `settings.yaml` under `linter.rules` / `linter.warn_rules`.
77
+
78
+ ## Configuration
79
+
80
+ Fitness function settings live in `fitness_functions.yaml` at the project root. Override the file path or individual keys via `loader_kwargs` in `config.py` (advanced — most projects only set `fitness_functions_config`).
81
+
82
+ ### Example `fitness_functions.yaml`
83
+
84
+ ```yaml
85
+ contract_groups_path: linter_contract_groups.json
86
+ exclusions_path: linter_exclusions.json
87
+
88
+ layers:
89
+ order: [sources, derived, core, marts, export]
90
+
91
+ checks:
92
+ layer_integrity: { enabled: true }
93
+ custom_exclusions: { enabled: true }
94
+ schema_contracts: { enabled: true }
95
+ dependency_graph:
96
+ enabled: true
97
+ fan_out_warn: 15
98
+ fan_out_fail: 25
99
+ fan_in_warn: 10
100
+
101
+ rules:
102
+ classification_macros:
103
+ enabled: true
104
+ skip_layers: [sources]
105
+ columns:
106
+ product_type: "@product_type\\b|@PRODUCT_TYPE\\b"
107
+ sql_complexity:
108
+ enabled: true
109
+ thresholds:
110
+ decision_points: [15, 25]
111
+ cte_count: [8, 12]
112
+ join_count: [8, 12]
113
+ line_count: [250, 400]
114
+ mart_naming:
115
+ enabled: true
116
+ layer_name: marts
117
+ rule: prefix_with_subdirectory
118
+ column_names:
119
+ enabled: true
120
+ replacements: {}
121
+ column_types:
122
+ enabled: true
123
+ rules: []
124
+ equivalent_types:
125
+ text: [text, varchar]
126
+ metadata:
127
+ owner: true
128
+ description: true
129
+ grain: true
130
+ filename_equals_modelname:
131
+ enabled: true
132
+ ```
133
+
134
+ ### Project-specific JSON
135
+
136
+ Keep repo-specific contract and exclusion data in your project:
137
+
138
+ - `linter_contract_groups.json` — cross-model schema parity groups
139
+ - `linter_exclusions.json` — blocked dependency patterns and allowed exceptions
140
+
141
+ Reference their paths from `fitness_functions.yaml`. The plugin ships generic engines only; examples live in this README.
142
+
143
+ ### Rule name mapping
144
+
145
+ SQLMesh uses lowercase class names in `linter.rules`:
146
+
147
+ | Config key | SQLMesh rule name |
148
+ |------------|-------------------|
149
+ | `classification_macros` | `classificationmacros` |
150
+ | `sql_complexity` | `sqlcomplexity` |
151
+ | `mart_naming` | `martmodelnamingconvention` |
152
+ | `column_names` | `columnnames` |
153
+ | `column_types` | `columntypes` |
154
+ | `metadata.owner` | `nomissingowner` |
155
+ | `metadata.description` | `nomissingdescription` |
156
+ | `metadata.grain` | `nomissinggrain` |
157
+ | `filename_equals_modelname` | `filenameequalsmodelname` |
158
+
159
+ ## CLI
160
+
161
+ ```
162
+ sqlmesh-ff lint [--project PATH] [--config PATH] [--checks CHECK,...] [--fail-level error|warning] [--group-by connascence|model]
163
+ ```
164
+
165
+ - **Default:** all enabled checks plus SQLMesh linter rules
166
+ - **`--checks layer_integrity,custom_exclusions`:** run subset (for pre-push hooks)
167
+ - **`--fail-level warning`:** treat warnings as failures
168
+ - **`--group-by connascence|model`:** change how violations are grouped in the report (default: `connascence`)
169
+
170
+ ## Integration example
171
+
172
+ Example overrides. `api_request` should always be named `api_call`. `_id` columns should always be of type `text` and `is_` columns should always be of type `boolean`.
173
+
174
+ ```yaml
175
+ column_names:
176
+ replacements:
177
+ api_request: api_call
178
+ column_types:
179
+ rules:
180
+ - name: id_is_text
181
+ pattern: "_id$"
182
+ data_type: text
183
+ - name: boolean
184
+ pattern: "^is_"
185
+ data_type: boolean
186
+ ```
187
+
188
+ ## Examples
189
+
190
+ A complete, runnable example project showcasing the configuration of `sqlmesh-ff` rules, exclusions, contracts, and a continuous integration workflow is located in the [examples/](file:///Users/bartschuijt/git/sqlmesh-ff/examples/) directory.
191
+
192
+ To run the linter against the example project locally, run:
193
+ ```bash
194
+ sqlmesh-ff lint --project examples/minimal-sqlmesh-project
195
+ ```
196
+
197
+ See [examples/minimal-sqlmesh-project/fitness_functions.yaml](file:///Users/bartschuijt/git/sqlmesh-ff/examples/minimal-sqlmesh-project/fitness_functions.yaml) to inspect the configured rules.
198
+
199
+ ## Development
200
+
201
+ Initialize your local environment and configure the Git pre-push hook:
202
+ ```bash
203
+ make init
204
+ ```
205
+
206
+ Run linter, tests, or check diff coverage:
207
+ ```bash
208
+ make lint
209
+ make test
210
+ make coverage
211
+ ```
212
+
213
+ ### Releases and PR titles
214
+
215
+ Releases are automated with [release-please](https://github.com/googleapis/release-please) on merges to `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) in PR titles so changelog entries and semver bumps are correct.
216
+
217
+ PR titles must start with a type prefix, for example:
218
+
219
+ - `feat: add dependency graph fan-in check`
220
+ - `fix: remove unused import in loader tests`
221
+ - `docs: document fitness_functions.yaml merge order`
222
+ - `ci: add release-please workflow`
223
+
224
+ Supported types include `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, and `chore`. The PR title check in CI enforces this format.
@@ -0,0 +1,210 @@
1
+ # sqlmesh-ff
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/sqlmesh-ff.svg)](https://pypi.org/project/sqlmesh-ff/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/sqlmesh-ff.svg)](https://pypi.org/project/sqlmesh-ff/)
5
+
6
+ Configurable fitness functions plugin for [SQLMesh](https://sqlmesh.com) projects.
7
+
8
+ Ships SQLMesh linter rules (classification macros, SQL complexity, metadata, naming) and architectural checks (layer integrity, custom exclusions, schema contracts, dependency graph) with a unified Rich lint report.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ # Install from PyPI:
14
+ uv add sqlmesh-ff
15
+
16
+ # Or using pip:
17
+ pip install sqlmesh-ff
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ 1. Add `fitness_functions.yaml` to your SQLMesh project root (see [Configuration](#configuration)).
23
+ 2. Add a small `config.py` bootstrap (see [Where configuration lives](#where-configuration-lives)) — SQLMesh requires the loader as a Python class and cannot load `config.py` and `config.yaml` in the same folder.
24
+ 3. Run lint:
25
+
26
+ ```bash
27
+ sqlmesh-ff lint
28
+ ```
29
+
30
+ ## Where configuration lives
31
+
32
+ There are three layers. Only the YAML/JSON files in your project are user-editable settings.
33
+
34
+ | Layer | File | Role | You edit this? |
35
+ |-------|------|------|----------------|
36
+ | Plugin defaults | `sqlmesh_ff/config.py` (installed package) | Pydantic schema and built-in defaults (e.g. `fan_out_warn: 15`) | No — library code, never overwritten |
37
+ | SQLMesh project | `settings.yaml` | Gateways, `linter.rules`, variables, CI/CD bot | Yes — normal SQLMesh config |
38
+ | Fitness functions | `fitness_functions.yaml` | Thresholds, rule toggles, column naming/type rules, paths to JSON data | Yes — main FF config |
39
+ | Loader bootstrap | `config.py` (project root) | Loads `settings.yaml` and registers `FitnessLoader` | Rarely — ~15 lines of wiring |
40
+ | Contract data | `linter_contract_groups.json`, `linter_exclusions.json` | Repo-specific schema parity and dependency exclusions | Yes — project data |
41
+
42
+ **Merge order for fitness settings:** plugin defaults → `fitness_functions.yaml` → optional `loader_kwargs` overrides in `config.py`. Your YAML always wins over plugin defaults. The project `config.py` does not hold fitness thresholds — it only points at `fitness_functions.yaml`.
43
+
44
+ **Why `config.py` exists:** SQLMesh accepts `loader: FitnessLoader` only as a Python class, not as a YAML string. Because SQLMesh rejects having both `config.py` and `config.yaml` in one folder, projects use `settings.yaml` (SQLMesh settings) plus `config.py` (loader registration).
45
+
46
+ Example bootstrap:
47
+
48
+ ```python
49
+ from pathlib import Path
50
+
51
+ from sqlmesh.core.config import Config
52
+ from sqlmesh.utils.yaml import load as yaml_load
53
+ from sqlmesh_ff.loader import FitnessLoader
54
+
55
+ _settings = yaml_load(Path(__file__).parent / "settings.yaml")
56
+ config = Config.parse_obj(_settings).update_with({
57
+ "loader": FitnessLoader,
58
+ "loader_kwargs": {"fitness_functions_config": "fitness_functions.yaml"},
59
+ })
60
+ ```
61
+
62
+ Enable individual SQLMesh rules in `settings.yaml` under `linter.rules` / `linter.warn_rules`.
63
+
64
+ ## Configuration
65
+
66
+ Fitness function settings live in `fitness_functions.yaml` at the project root. Override the file path or individual keys via `loader_kwargs` in `config.py` (advanced — most projects only set `fitness_functions_config`).
67
+
68
+ ### Example `fitness_functions.yaml`
69
+
70
+ ```yaml
71
+ contract_groups_path: linter_contract_groups.json
72
+ exclusions_path: linter_exclusions.json
73
+
74
+ layers:
75
+ order: [sources, derived, core, marts, export]
76
+
77
+ checks:
78
+ layer_integrity: { enabled: true }
79
+ custom_exclusions: { enabled: true }
80
+ schema_contracts: { enabled: true }
81
+ dependency_graph:
82
+ enabled: true
83
+ fan_out_warn: 15
84
+ fan_out_fail: 25
85
+ fan_in_warn: 10
86
+
87
+ rules:
88
+ classification_macros:
89
+ enabled: true
90
+ skip_layers: [sources]
91
+ columns:
92
+ product_type: "@product_type\\b|@PRODUCT_TYPE\\b"
93
+ sql_complexity:
94
+ enabled: true
95
+ thresholds:
96
+ decision_points: [15, 25]
97
+ cte_count: [8, 12]
98
+ join_count: [8, 12]
99
+ line_count: [250, 400]
100
+ mart_naming:
101
+ enabled: true
102
+ layer_name: marts
103
+ rule: prefix_with_subdirectory
104
+ column_names:
105
+ enabled: true
106
+ replacements: {}
107
+ column_types:
108
+ enabled: true
109
+ rules: []
110
+ equivalent_types:
111
+ text: [text, varchar]
112
+ metadata:
113
+ owner: true
114
+ description: true
115
+ grain: true
116
+ filename_equals_modelname:
117
+ enabled: true
118
+ ```
119
+
120
+ ### Project-specific JSON
121
+
122
+ Keep repo-specific contract and exclusion data in your project:
123
+
124
+ - `linter_contract_groups.json` — cross-model schema parity groups
125
+ - `linter_exclusions.json` — blocked dependency patterns and allowed exceptions
126
+
127
+ Reference their paths from `fitness_functions.yaml`. The plugin ships generic engines only; examples live in this README.
128
+
129
+ ### Rule name mapping
130
+
131
+ SQLMesh uses lowercase class names in `linter.rules`:
132
+
133
+ | Config key | SQLMesh rule name |
134
+ |------------|-------------------|
135
+ | `classification_macros` | `classificationmacros` |
136
+ | `sql_complexity` | `sqlcomplexity` |
137
+ | `mart_naming` | `martmodelnamingconvention` |
138
+ | `column_names` | `columnnames` |
139
+ | `column_types` | `columntypes` |
140
+ | `metadata.owner` | `nomissingowner` |
141
+ | `metadata.description` | `nomissingdescription` |
142
+ | `metadata.grain` | `nomissinggrain` |
143
+ | `filename_equals_modelname` | `filenameequalsmodelname` |
144
+
145
+ ## CLI
146
+
147
+ ```
148
+ sqlmesh-ff lint [--project PATH] [--config PATH] [--checks CHECK,...] [--fail-level error|warning] [--group-by connascence|model]
149
+ ```
150
+
151
+ - **Default:** all enabled checks plus SQLMesh linter rules
152
+ - **`--checks layer_integrity,custom_exclusions`:** run subset (for pre-push hooks)
153
+ - **`--fail-level warning`:** treat warnings as failures
154
+ - **`--group-by connascence|model`:** change how violations are grouped in the report (default: `connascence`)
155
+
156
+ ## Integration example
157
+
158
+ Example overrides. `api_request` should always be named `api_call`. `_id` columns should always be of type `text` and `is_` columns should always be of type `boolean`.
159
+
160
+ ```yaml
161
+ column_names:
162
+ replacements:
163
+ api_request: api_call
164
+ column_types:
165
+ rules:
166
+ - name: id_is_text
167
+ pattern: "_id$"
168
+ data_type: text
169
+ - name: boolean
170
+ pattern: "^is_"
171
+ data_type: boolean
172
+ ```
173
+
174
+ ## Examples
175
+
176
+ A complete, runnable example project showcasing the configuration of `sqlmesh-ff` rules, exclusions, contracts, and a continuous integration workflow is located in the [examples/](file:///Users/bartschuijt/git/sqlmesh-ff/examples/) directory.
177
+
178
+ To run the linter against the example project locally, run:
179
+ ```bash
180
+ sqlmesh-ff lint --project examples/minimal-sqlmesh-project
181
+ ```
182
+
183
+ See [examples/minimal-sqlmesh-project/fitness_functions.yaml](file:///Users/bartschuijt/git/sqlmesh-ff/examples/minimal-sqlmesh-project/fitness_functions.yaml) to inspect the configured rules.
184
+
185
+ ## Development
186
+
187
+ Initialize your local environment and configure the Git pre-push hook:
188
+ ```bash
189
+ make init
190
+ ```
191
+
192
+ Run linter, tests, or check diff coverage:
193
+ ```bash
194
+ make lint
195
+ make test
196
+ make coverage
197
+ ```
198
+
199
+ ### Releases and PR titles
200
+
201
+ Releases are automated with [release-please](https://github.com/googleapis/release-please) on merges to `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) in PR titles so changelog entries and semver bumps are correct.
202
+
203
+ PR titles must start with a type prefix, for example:
204
+
205
+ - `feat: add dependency graph fan-in check`
206
+ - `fix: remove unused import in loader tests`
207
+ - `docs: document fitness_functions.yaml merge order`
208
+ - `ci: add release-please workflow`
209
+
210
+ Supported types include `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, and `chore`. The PR title check in CI enforces this format.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "tff-core"
3
+ version = "0.2.0"
4
+ description = "Core rules and fitness functions engine for transformation projects"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Bart Schuijt", email = "schuijt.bart@gmail.com" }
10
+ ]
11
+ dependencies = [
12
+ "pydantic>=2.0",
13
+ "pyyaml>=6.0",
14
+ "rich>=13.0",
15
+ "sqlglot>=23.0",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/tff"]
File without changes
File without changes
@@ -0,0 +1,174 @@
1
+ """Custom dependency exclusion rules for layer/domain boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from tff.core.model import ModelRepresentation
10
+ from tff.core.config import FitnessFunctionsConfig, resolve_project_path
11
+ from tff.core.report import LintFinding
12
+ from tff.core.utils.paths import get_layer_and_domain, model_path_relative
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CustomExclusionsChecker:
18
+ """Enforce custom exclusions for model dependencies between layers."""
19
+
20
+ def __init__(self, models: dict[str, ModelRepresentation], exclusions_path: Path):
21
+ self.models = models
22
+ self.exclusions_path = exclusions_path
23
+ self.exclusions = self._load_exclusions()
24
+
25
+ def _load_exclusions(self) -> dict:
26
+ if not self.exclusions_path.exists():
27
+ logger.warning(
28
+ "Config file %s not found. No exclusions will be enforced.",
29
+ self.exclusions_path,
30
+ )
31
+ return {}
32
+
33
+ try:
34
+ with open(self.exclusions_path, encoding="utf-8") as f:
35
+ return json.load(f)
36
+ except (json.JSONDecodeError, OSError) as e:
37
+ logger.warning(
38
+ "Could not load exclusions config from %s: %s",
39
+ self.exclusions_path,
40
+ e,
41
+ )
42
+ return {}
43
+
44
+ def _normalize_model_name(self, name: str) -> str:
45
+ parts = name.replace('"', "").split(".")
46
+ if len(parts) >= 2:
47
+ return f"{parts[-2]}.{parts[-1]}"
48
+ return name
49
+
50
+ def _is_allowed_exception(self, model_name: str, dependency_name: str) -> bool:
51
+ normalized_model = self._normalize_model_name(model_name)
52
+ normalized_dependency = self._normalize_model_name(dependency_name)
53
+
54
+ for exception in self.exclusions.get("allowed_exceptions", []):
55
+ if (
56
+ exception.get("model") == normalized_model
57
+ and exception.get("dependency") == normalized_dependency
58
+ ):
59
+ return True
60
+ return False
61
+
62
+ def _is_excluded_dependency(
63
+ self,
64
+ source_layer: str,
65
+ source_domain: str,
66
+ target_layer: str,
67
+ target_domain: str,
68
+ model_name: str | None = None,
69
+ dependency_name: str | None = None,
70
+ ) -> bool:
71
+ if model_name and dependency_name:
72
+ if self._is_allowed_exception(model_name, dependency_name):
73
+ return False
74
+
75
+ for exclusion in self.exclusions.get("exclusions", []):
76
+ source_match = True
77
+ if (
78
+ "source_layer" in exclusion
79
+ and exclusion["source_layer"] != source_layer
80
+ ):
81
+ source_match = False
82
+ if (
83
+ "source_domain" in exclusion
84
+ and exclusion["source_domain"] != source_domain
85
+ ):
86
+ source_match = False
87
+
88
+ target_match = True
89
+ if (
90
+ "target_layer" in exclusion
91
+ and exclusion["target_layer"] != target_layer
92
+ ):
93
+ target_match = False
94
+ if (
95
+ "target_domain" in exclusion
96
+ and exclusion["target_domain"] != target_domain
97
+ ):
98
+ target_match = False
99
+
100
+ if source_match and target_match:
101
+ return True
102
+
103
+ return False
104
+
105
+ def check_model(self, model: ModelRepresentation) -> list[str]:
106
+ if model.is_symbolic:
107
+ return []
108
+
109
+ violations = []
110
+ model_layer, model_domain = get_layer_and_domain(model.path)
111
+ if not model_layer:
112
+ return []
113
+
114
+ for dependency_name in model.depends_on:
115
+ try:
116
+ dependency_model = self.models.get(dependency_name)
117
+ if not dependency_model:
118
+ continue
119
+
120
+ dep_layer, dep_domain = get_layer_and_domain(dependency_model.path)
121
+ if not dep_layer:
122
+ continue
123
+
124
+ if self._is_excluded_dependency(
125
+ source_layer=dep_layer,
126
+ source_domain=dep_domain or "",
127
+ target_layer=model_layer,
128
+ target_domain=model_domain or "",
129
+ model_name=str(model.name),
130
+ dependency_name=str(dependency_name),
131
+ ):
132
+ violations.append(
133
+ f"Model '{model.name}' in layer '{model_layer}"
134
+ f"{f'/{model_domain}' if model_domain else ''}' "
135
+ f"depends on '{dependency_name}' in layer '{dep_layer}"
136
+ f"{f'/{dep_domain}' if dep_domain else ''}', "
137
+ f"which is not allowed by custom exclusions"
138
+ )
139
+ except Exception as e:
140
+ logger.error(
141
+ "Unexpected error checking dependency %s for model %s: %s",
142
+ dependency_name,
143
+ model.name,
144
+ e,
145
+ exc_info=True,
146
+ )
147
+ continue
148
+
149
+ return violations
150
+
151
+
152
+ def collect_custom_exclusion_findings(
153
+ models: dict[str, ModelRepresentation], config: FitnessFunctionsConfig
154
+ ) -> list[LintFinding]:
155
+ exclusions_path = resolve_project_path(config, config.exclusions_path)
156
+ checker = CustomExclusionsChecker(models, exclusions_path)
157
+ findings: list[LintFinding] = []
158
+
159
+ for model_name, model in models.items():
160
+ if model.is_symbolic:
161
+ continue
162
+
163
+ for message in checker.check_model(model):
164
+ findings.append(
165
+ LintFinding(
166
+ check="custom_exclusions",
167
+ severity="error",
168
+ model=str(model.name),
169
+ path=model_path_relative(model),
170
+ message=message.removeprefix(f"Model '{model.name}' ").strip(),
171
+ )
172
+ )
173
+
174
+ return findings