tff-sqlmesh 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.
- tff_sqlmesh-0.2.0/.gitignore +10 -0
- tff_sqlmesh-0.2.0/LICENSE +21 -0
- tff_sqlmesh-0.2.0/PKG-INFO +222 -0
- tff_sqlmesh-0.2.0/README.md +210 -0
- tff_sqlmesh-0.2.0/pyproject.toml +27 -0
- tff_sqlmesh-0.2.0/src/tff/sqlmesh/__init__.py +9 -0
- tff_sqlmesh-0.2.0/src/tff/sqlmesh/cli.py +90 -0
- tff_sqlmesh-0.2.0/src/tff/sqlmesh/loader.py +107 -0
- tff_sqlmesh-0.2.0/src/tff/sqlmesh/runner.py +136 -0
- tff_sqlmesh-0.2.0/tests/fixtures/minimal_project/config.py +12 -0
- tff_sqlmesh-0.2.0/tests/fixtures/minimal_project/fitness_functions.yaml +5 -0
- tff_sqlmesh-0.2.0/tests/fixtures/minimal_project/linter/my_custom_rule.py +5 -0
- tff_sqlmesh-0.2.0/tests/fixtures/minimal_project/models/derived/violating_model.sql +9 -0
- tff_sqlmesh-0.2.0/tests/fixtures/minimal_project/models/sources/src_model.sql +9 -0
- tff_sqlmesh-0.2.0/tests/fixtures/minimal_project/settings.yaml +16 -0
- tff_sqlmesh-0.2.0/tests/test_loader.py +94 -0
- tff_sqlmesh-0.2.0/tests/test_runner.py +57 -0
|
@@ -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,222 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tff-sqlmesh
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: SQLMesh adapter for Transformation Fitness Functions (tff)
|
|
5
|
+
Author-email: Bart Schuijt <schuijt.bart@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: sqlmesh>=0.234.0
|
|
10
|
+
Requires-Dist: tff-core
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# sqlmesh-ff
|
|
14
|
+
|
|
15
|
+
[](https://pypi.org/project/sqlmesh-ff/)
|
|
16
|
+
[](https://pypi.org/project/sqlmesh-ff/)
|
|
17
|
+
|
|
18
|
+
Configurable fitness functions plugin for [SQLMesh](https://sqlmesh.com) projects.
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Install from PyPI:
|
|
26
|
+
uv add sqlmesh-ff
|
|
27
|
+
|
|
28
|
+
# Or using pip:
|
|
29
|
+
pip install sqlmesh-ff
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
1. Add `fitness_functions.yaml` to your SQLMesh project root (see [Configuration](#configuration)).
|
|
35
|
+
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.
|
|
36
|
+
3. Run lint:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
sqlmesh-ff lint
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Where configuration lives
|
|
43
|
+
|
|
44
|
+
There are three layers. Only the YAML/JSON files in your project are user-editable settings.
|
|
45
|
+
|
|
46
|
+
| Layer | File | Role | You edit this? |
|
|
47
|
+
|-------|------|------|----------------|
|
|
48
|
+
| 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 |
|
|
49
|
+
| SQLMesh project | `settings.yaml` | Gateways, `linter.rules`, variables, CI/CD bot | Yes — normal SQLMesh config |
|
|
50
|
+
| Fitness functions | `fitness_functions.yaml` | Thresholds, rule toggles, column naming/type rules, paths to JSON data | Yes — main FF config |
|
|
51
|
+
| Loader bootstrap | `config.py` (project root) | Loads `settings.yaml` and registers `FitnessLoader` | Rarely — ~15 lines of wiring |
|
|
52
|
+
| Contract data | `linter_contract_groups.json`, `linter_exclusions.json` | Repo-specific schema parity and dependency exclusions | Yes — project data |
|
|
53
|
+
|
|
54
|
+
**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`.
|
|
55
|
+
|
|
56
|
+
**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).
|
|
57
|
+
|
|
58
|
+
Example bootstrap:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
|
|
63
|
+
from sqlmesh.core.config import Config
|
|
64
|
+
from sqlmesh.utils.yaml import load as yaml_load
|
|
65
|
+
from sqlmesh_ff.loader import FitnessLoader
|
|
66
|
+
|
|
67
|
+
_settings = yaml_load(Path(__file__).parent / "settings.yaml")
|
|
68
|
+
config = Config.parse_obj(_settings).update_with({
|
|
69
|
+
"loader": FitnessLoader,
|
|
70
|
+
"loader_kwargs": {"fitness_functions_config": "fitness_functions.yaml"},
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Enable individual SQLMesh rules in `settings.yaml` under `linter.rules` / `linter.warn_rules`.
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
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`).
|
|
79
|
+
|
|
80
|
+
### Example `fitness_functions.yaml`
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
contract_groups_path: linter_contract_groups.json
|
|
84
|
+
exclusions_path: linter_exclusions.json
|
|
85
|
+
|
|
86
|
+
layers:
|
|
87
|
+
order: [sources, derived, core, marts, export]
|
|
88
|
+
|
|
89
|
+
checks:
|
|
90
|
+
layer_integrity: { enabled: true }
|
|
91
|
+
custom_exclusions: { enabled: true }
|
|
92
|
+
schema_contracts: { enabled: true }
|
|
93
|
+
dependency_graph:
|
|
94
|
+
enabled: true
|
|
95
|
+
fan_out_warn: 15
|
|
96
|
+
fan_out_fail: 25
|
|
97
|
+
fan_in_warn: 10
|
|
98
|
+
|
|
99
|
+
rules:
|
|
100
|
+
classification_macros:
|
|
101
|
+
enabled: true
|
|
102
|
+
skip_layers: [sources]
|
|
103
|
+
columns:
|
|
104
|
+
product_type: "@product_type\\b|@PRODUCT_TYPE\\b"
|
|
105
|
+
sql_complexity:
|
|
106
|
+
enabled: true
|
|
107
|
+
thresholds:
|
|
108
|
+
decision_points: [15, 25]
|
|
109
|
+
cte_count: [8, 12]
|
|
110
|
+
join_count: [8, 12]
|
|
111
|
+
line_count: [250, 400]
|
|
112
|
+
mart_naming:
|
|
113
|
+
enabled: true
|
|
114
|
+
layer_name: marts
|
|
115
|
+
rule: prefix_with_subdirectory
|
|
116
|
+
column_names:
|
|
117
|
+
enabled: true
|
|
118
|
+
replacements: {}
|
|
119
|
+
column_types:
|
|
120
|
+
enabled: true
|
|
121
|
+
rules: []
|
|
122
|
+
equivalent_types:
|
|
123
|
+
text: [text, varchar]
|
|
124
|
+
metadata:
|
|
125
|
+
owner: true
|
|
126
|
+
description: true
|
|
127
|
+
grain: true
|
|
128
|
+
filename_equals_modelname:
|
|
129
|
+
enabled: true
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Project-specific JSON
|
|
133
|
+
|
|
134
|
+
Keep repo-specific contract and exclusion data in your project:
|
|
135
|
+
|
|
136
|
+
- `linter_contract_groups.json` — cross-model schema parity groups
|
|
137
|
+
- `linter_exclusions.json` — blocked dependency patterns and allowed exceptions
|
|
138
|
+
|
|
139
|
+
Reference their paths from `fitness_functions.yaml`. The plugin ships generic engines only; examples live in this README.
|
|
140
|
+
|
|
141
|
+
### Rule name mapping
|
|
142
|
+
|
|
143
|
+
SQLMesh uses lowercase class names in `linter.rules`:
|
|
144
|
+
|
|
145
|
+
| Config key | SQLMesh rule name |
|
|
146
|
+
|------------|-------------------|
|
|
147
|
+
| `classification_macros` | `classificationmacros` |
|
|
148
|
+
| `sql_complexity` | `sqlcomplexity` |
|
|
149
|
+
| `mart_naming` | `martmodelnamingconvention` |
|
|
150
|
+
| `column_names` | `columnnames` |
|
|
151
|
+
| `column_types` | `columntypes` |
|
|
152
|
+
| `metadata.owner` | `nomissingowner` |
|
|
153
|
+
| `metadata.description` | `nomissingdescription` |
|
|
154
|
+
| `metadata.grain` | `nomissinggrain` |
|
|
155
|
+
| `filename_equals_modelname` | `filenameequalsmodelname` |
|
|
156
|
+
|
|
157
|
+
## CLI
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
sqlmesh-ff lint [--project PATH] [--config PATH] [--checks CHECK,...] [--fail-level error|warning] [--group-by connascence|model]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **Default:** all enabled checks plus SQLMesh linter rules
|
|
164
|
+
- **`--checks layer_integrity,custom_exclusions`:** run subset (for pre-push hooks)
|
|
165
|
+
- **`--fail-level warning`:** treat warnings as failures
|
|
166
|
+
- **`--group-by connascence|model`:** change how violations are grouped in the report (default: `connascence`)
|
|
167
|
+
|
|
168
|
+
## Integration example
|
|
169
|
+
|
|
170
|
+
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`.
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
column_names:
|
|
174
|
+
replacements:
|
|
175
|
+
api_request: api_call
|
|
176
|
+
column_types:
|
|
177
|
+
rules:
|
|
178
|
+
- name: id_is_text
|
|
179
|
+
pattern: "_id$"
|
|
180
|
+
data_type: text
|
|
181
|
+
- name: boolean
|
|
182
|
+
pattern: "^is_"
|
|
183
|
+
data_type: boolean
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Examples
|
|
187
|
+
|
|
188
|
+
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.
|
|
189
|
+
|
|
190
|
+
To run the linter against the example project locally, run:
|
|
191
|
+
```bash
|
|
192
|
+
sqlmesh-ff lint --project examples/minimal-sqlmesh-project
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
Initialize your local environment and configure the Git pre-push hook:
|
|
200
|
+
```bash
|
|
201
|
+
make init
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Run linter, tests, or check diff coverage:
|
|
205
|
+
```bash
|
|
206
|
+
make lint
|
|
207
|
+
make test
|
|
208
|
+
make coverage
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Releases and PR titles
|
|
212
|
+
|
|
213
|
+
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.
|
|
214
|
+
|
|
215
|
+
PR titles must start with a type prefix, for example:
|
|
216
|
+
|
|
217
|
+
- `feat: add dependency graph fan-in check`
|
|
218
|
+
- `fix: remove unused import in loader tests`
|
|
219
|
+
- `docs: document fitness_functions.yaml merge order`
|
|
220
|
+
- `ci: add release-please workflow`
|
|
221
|
+
|
|
222
|
+
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
|
+
[](https://pypi.org/project/sqlmesh-ff/)
|
|
4
|
+
[](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,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tff-sqlmesh"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "SQLMesh adapter for Transformation Fitness Functions (tff)"
|
|
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
|
+
"tff-core",
|
|
13
|
+
"sqlmesh>=0.234.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
tff-sqlmesh = "tff.sqlmesh.cli:main"
|
|
18
|
+
|
|
19
|
+
[tool.uv.sources]
|
|
20
|
+
tff-core = { workspace = true }
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/tff"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Command-line interface for tff-sqlmesh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tff.core.config import load_fitness_config
|
|
11
|
+
from tff.core.context import set_ff_config
|
|
12
|
+
from tff.core.report import render_lint_report
|
|
13
|
+
from tff.sqlmesh.runner import run_all_checks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_checks(value: str | None) -> list[str] | None:
|
|
17
|
+
if not value:
|
|
18
|
+
return None
|
|
19
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="tff-sqlmesh",
|
|
25
|
+
description="Run SQLMesh Transformation Fitness Function (tff) checks",
|
|
26
|
+
)
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
|
|
29
|
+
lint_parser = subparsers.add_parser("lint", help="Run all enabled fitness checks")
|
|
30
|
+
lint_parser.add_argument(
|
|
31
|
+
"--project",
|
|
32
|
+
type=Path,
|
|
33
|
+
default=Path.cwd(),
|
|
34
|
+
help="SQLMesh project root (default: current directory)",
|
|
35
|
+
)
|
|
36
|
+
lint_parser.add_argument(
|
|
37
|
+
"--config",
|
|
38
|
+
default="fitness_functions.yaml",
|
|
39
|
+
help="Path to fitness_functions.yaml (relative to project root)",
|
|
40
|
+
)
|
|
41
|
+
lint_parser.add_argument(
|
|
42
|
+
"--checks",
|
|
43
|
+
default=None,
|
|
44
|
+
help="Comma-separated checks to run (default: all enabled). "
|
|
45
|
+
"Use 'sqlmesh' for SQLMesh linter rules only.",
|
|
46
|
+
)
|
|
47
|
+
lint_parser.add_argument(
|
|
48
|
+
"--fail-level",
|
|
49
|
+
choices=["error", "warning"],
|
|
50
|
+
default="error",
|
|
51
|
+
help="Exit non-zero when findings at or above this severity exist",
|
|
52
|
+
)
|
|
53
|
+
lint_parser.add_argument(
|
|
54
|
+
"--group-by",
|
|
55
|
+
choices=["connascence", "model"],
|
|
56
|
+
default="connascence",
|
|
57
|
+
help="How to group violations in the report (default: connascence)",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
if args.command == "lint":
|
|
63
|
+
logging.basicConfig(level=logging.ERROR)
|
|
64
|
+
project_root = args.project.resolve()
|
|
65
|
+
config = load_fitness_config(
|
|
66
|
+
project_root,
|
|
67
|
+
config_path=args.config,
|
|
68
|
+
)
|
|
69
|
+
set_ff_config(config)
|
|
70
|
+
checks = _parse_checks(args.checks)
|
|
71
|
+
|
|
72
|
+
findings, models_checked, executed_checks = run_all_checks(
|
|
73
|
+
project_root=project_root,
|
|
74
|
+
config=config,
|
|
75
|
+
checks=checks,
|
|
76
|
+
)
|
|
77
|
+
passed = render_lint_report(
|
|
78
|
+
findings,
|
|
79
|
+
models_checked=models_checked,
|
|
80
|
+
executed_checks=executed_checks,
|
|
81
|
+
fail_level=args.fail_level, # type: ignore[arg-type]
|
|
82
|
+
group_by=args.group_by, # type: ignore[arg-type]
|
|
83
|
+
)
|
|
84
|
+
return 0 if passed else 1
|
|
85
|
+
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""SQLMesh loader that wraps tff-core package rules and registers them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import typing as t
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlglot.helper import subclasses
|
|
10
|
+
from sqlmesh.core import constants as c
|
|
11
|
+
from sqlmesh.core.linter.definition import RuleSet
|
|
12
|
+
from sqlmesh.core.linter.rule import Rule as SqlMeshRule, RuleViolation as SqlMeshRuleViolation
|
|
13
|
+
from sqlmesh.core.model import Model as SqlMeshModel
|
|
14
|
+
from sqlmesh.core.loader import SqlMeshLoader
|
|
15
|
+
from sqlmesh.utils import UniqueKeyDict
|
|
16
|
+
from sqlmesh.utils.metaprogramming import import_python_file
|
|
17
|
+
|
|
18
|
+
from tff.core.config import load_fitness_config
|
|
19
|
+
from tff.core.context import set_ff_config
|
|
20
|
+
from tff.core.model import ModelRepresentation
|
|
21
|
+
from tff.core.rules import ALL_RULES as CORE_RULES
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def map_sqlmesh_model(model: SqlMeshModel) -> ModelRepresentation:
|
|
25
|
+
columns_to_types = {
|
|
26
|
+
name: str(dtype)
|
|
27
|
+
for name, dtype in (model.columns_to_types or {}).items()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
audits = []
|
|
31
|
+
if model.audits:
|
|
32
|
+
for audit_name, audit_args in model.audits:
|
|
33
|
+
audits.append((audit_name, audit_args or {}))
|
|
34
|
+
|
|
35
|
+
return ModelRepresentation(
|
|
36
|
+
name=str(model.name),
|
|
37
|
+
path=str(model._path),
|
|
38
|
+
dialect=model.dialect or "bigquery",
|
|
39
|
+
is_symbolic=bool(model.kind.is_symbolic),
|
|
40
|
+
is_external=bool(model.kind.name == "EXTERNAL"),
|
|
41
|
+
columns_to_types=columns_to_types,
|
|
42
|
+
depends_on={str(dep) for dep in model.depends_on},
|
|
43
|
+
description=model.description,
|
|
44
|
+
owner=model.owner,
|
|
45
|
+
grains=[str(g) for g in (model.grains or [])],
|
|
46
|
+
audits=audits,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wrap_core_rule(core_rule_cls) -> type[SqlMeshRule]:
|
|
51
|
+
def check_model(self, model: SqlMeshModel) -> t.Optional[SqlMeshRuleViolation]:
|
|
52
|
+
rep = map_sqlmesh_model(model)
|
|
53
|
+
rule_instance = core_rule_cls()
|
|
54
|
+
violation = rule_instance.check_model(rep)
|
|
55
|
+
if violation:
|
|
56
|
+
return self.violation(violation.violation_msg)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
cls_name = core_rule_cls.__name__
|
|
60
|
+
attrs = {
|
|
61
|
+
"check_model": check_model,
|
|
62
|
+
"__doc__": core_rule_cls.__doc__,
|
|
63
|
+
}
|
|
64
|
+
return type(cls_name, (SqlMeshRule,), attrs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FitnessLoader(SqlMeshLoader):
|
|
68
|
+
"""Load core fitness rules adapted for SQLMesh, plus optional project-local rules."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, context, path: Path, **loader_kwargs: t.Any) -> None:
|
|
71
|
+
super().__init__(context, path)
|
|
72
|
+
config_path = loader_kwargs.get("fitness_functions_config", "fitness_functions.yaml")
|
|
73
|
+
overrides = {
|
|
74
|
+
key: value
|
|
75
|
+
for key, value in loader_kwargs.items()
|
|
76
|
+
if key != "fitness_functions_config"
|
|
77
|
+
}
|
|
78
|
+
ff_config = load_fitness_config(
|
|
79
|
+
self.config_path,
|
|
80
|
+
config_path=config_path,
|
|
81
|
+
overrides=overrides or None,
|
|
82
|
+
)
|
|
83
|
+
set_ff_config(ff_config)
|
|
84
|
+
self._ff_config = ff_config
|
|
85
|
+
|
|
86
|
+
def _load_linting_rules(self) -> RuleSet:
|
|
87
|
+
user_rules: UniqueKeyDict[str, type[SqlMeshRule]] = UniqueKeyDict("rules")
|
|
88
|
+
|
|
89
|
+
# Dynamically wrap all tff-core rules to be SQLMesh-compatible
|
|
90
|
+
for core_rule_cls in CORE_RULES:
|
|
91
|
+
wrapped = wrap_core_rule(core_rule_cls)
|
|
92
|
+
user_rules[wrapped.name] = wrapped
|
|
93
|
+
|
|
94
|
+
for path in self._glob_paths(
|
|
95
|
+
self.config_path / c.LINTER,
|
|
96
|
+
ignore_patterns=self.config.ignore_patterns,
|
|
97
|
+
extension=".py",
|
|
98
|
+
):
|
|
99
|
+
if os.path.getsize(path):
|
|
100
|
+
self._track_file(path)
|
|
101
|
+
module = import_python_file(path, self.config_path)
|
|
102
|
+
_rule_exclude: t.Set[t.Type[SqlMeshRule]] = {SqlMeshRule} # type: ignore[type-abstract]
|
|
103
|
+
module_rules = subclasses(module.__name__, SqlMeshRule, exclude=_rule_exclude)
|
|
104
|
+
for user_rule in module_rules:
|
|
105
|
+
user_rules[user_rule.name] = user_rule
|
|
106
|
+
|
|
107
|
+
return RuleSet(user_rules.values())
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Unified lint runner orchestrating SQLMesh rules and architectural checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sqlmesh.core.context import Context
|
|
9
|
+
from sqlmesh.core.linter.definition import AnnotatedRuleViolation
|
|
10
|
+
|
|
11
|
+
from tff.core.checks.custom_exclusions import collect_custom_exclusion_findings
|
|
12
|
+
from tff.core.checks.dependency_graph import collect_dependency_graph_findings
|
|
13
|
+
from tff.core.checks.layer_integrity import collect_layer_integrity_findings
|
|
14
|
+
from tff.core.checks.schema_contracts import collect_schema_contract_findings
|
|
15
|
+
from tff.core.config import FitnessFunctionsConfig, load_fitness_config
|
|
16
|
+
from tff.core.context import set_ff_config
|
|
17
|
+
from tff.core.report import LintFinding, format_message, normalize_model_name
|
|
18
|
+
from tff.core.utils.paths import model_path_relative
|
|
19
|
+
from tff.core.model import ModelRepresentation
|
|
20
|
+
from tff.sqlmesh.loader import FitnessLoader, map_sqlmesh_model
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CHECK_COLLECTORS = {
|
|
25
|
+
"layer_integrity": lambda models, cfg: collect_layer_integrity_findings(models, cfg),
|
|
26
|
+
"custom_exclusions": lambda models, cfg: collect_custom_exclusion_findings(models, cfg),
|
|
27
|
+
"schema_contracts": lambda _models, cfg: collect_schema_contract_findings(cfg),
|
|
28
|
+
"dependency_graph": lambda models, cfg: collect_dependency_graph_findings(models, cfg),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _SilentLinterConsole:
|
|
33
|
+
def show_linter_violations(self, *args, **kwargs) -> None:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def collect_sqlmesh_findings(context: Context) -> list[LintFinding]:
|
|
38
|
+
findings: list[LintFinding] = []
|
|
39
|
+
silent_console = _SilentLinterConsole()
|
|
40
|
+
|
|
41
|
+
for model in context.models.values():
|
|
42
|
+
if model.kind.is_symbolic:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
linter = context._linters.get(model.project)
|
|
46
|
+
if not linter or not linter.enabled:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
_, violations = linter.lint_model(model, context, console=silent_console)
|
|
50
|
+
model_label = normalize_model_name(str(model.name))
|
|
51
|
+
for violation in violations:
|
|
52
|
+
if not isinstance(violation, AnnotatedRuleViolation):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
message = format_message(violation.violation_msg)
|
|
56
|
+
if message.startswith(f"{model_label}: "):
|
|
57
|
+
message = message[len(model_label) + 2 :]
|
|
58
|
+
|
|
59
|
+
messages = (
|
|
60
|
+
[part.strip() for part in message.split(";") if part.strip()]
|
|
61
|
+
if violation.rule.name == "sqlcomplexity"
|
|
62
|
+
else [message]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
for part in messages:
|
|
66
|
+
findings.append(
|
|
67
|
+
LintFinding(
|
|
68
|
+
check=violation.rule.name,
|
|
69
|
+
severity=violation.violation_type,
|
|
70
|
+
model=str(model.name),
|
|
71
|
+
path=model_path_relative(model),
|
|
72
|
+
message=part,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return findings
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def count_models_checked(context: Context) -> int:
|
|
80
|
+
return sum(
|
|
81
|
+
1 for model in context.models.values() if not model.kind.is_symbolic
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_enabled(config: FitnessFunctionsConfig, check_name: str) -> bool:
|
|
86
|
+
check = getattr(config.checks, check_name, None)
|
|
87
|
+
return bool(getattr(check, "enabled", False))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def map_sqlmesh_context_models(context: Context) -> dict[str, ModelRepresentation]:
|
|
91
|
+
mapped = {}
|
|
92
|
+
for model_name, model in context.models.items():
|
|
93
|
+
mapped[str(model_name)] = map_sqlmesh_model(model)
|
|
94
|
+
return mapped
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_all_checks(
|
|
98
|
+
project_root: Path | None = None,
|
|
99
|
+
context: Context | None = None,
|
|
100
|
+
config: FitnessFunctionsConfig | None = None,
|
|
101
|
+
checks: list[str] | None = None,
|
|
102
|
+
) -> tuple[list[LintFinding], int, list[str]]:
|
|
103
|
+
project_root = project_root or Path.cwd()
|
|
104
|
+
if config is None:
|
|
105
|
+
config = load_fitness_config(project_root)
|
|
106
|
+
set_ff_config(config)
|
|
107
|
+
|
|
108
|
+
context = context or Context(
|
|
109
|
+
paths=[str(project_root)],
|
|
110
|
+
loader=FitnessLoader,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if checks is None:
|
|
114
|
+
selected = ["sqlmesh"] + [
|
|
115
|
+
name
|
|
116
|
+
for name in CHECK_COLLECTORS
|
|
117
|
+
if _check_enabled(config, name)
|
|
118
|
+
]
|
|
119
|
+
else:
|
|
120
|
+
selected = checks
|
|
121
|
+
|
|
122
|
+
findings: list[LintFinding] = []
|
|
123
|
+
|
|
124
|
+
if "sqlmesh" in selected:
|
|
125
|
+
findings.extend(collect_sqlmesh_findings(context))
|
|
126
|
+
|
|
127
|
+
mapped_models = map_sqlmesh_context_models(context)
|
|
128
|
+
|
|
129
|
+
for check_name, collector in CHECK_COLLECTORS.items():
|
|
130
|
+
if check_name not in selected:
|
|
131
|
+
continue
|
|
132
|
+
if checks is None and not _check_enabled(config, check_name):
|
|
133
|
+
continue
|
|
134
|
+
findings.extend(collector(mapped_models, config))
|
|
135
|
+
|
|
136
|
+
return findings, count_models_checked(context), selected
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from sqlmesh.core.config import Config
|
|
4
|
+
from sqlmesh.utils.yaml import load as yaml_load
|
|
5
|
+
|
|
6
|
+
from tff.sqlmesh.loader import FitnessLoader
|
|
7
|
+
|
|
8
|
+
_settings = yaml_load(Path(__file__).parent / "settings.yaml")
|
|
9
|
+
config = Config.parse_obj(_settings).update_with({
|
|
10
|
+
"loader": FitnessLoader,
|
|
11
|
+
"loader_kwargs": {"fitness_functions_config": "fitness_functions.yaml"},
|
|
12
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
from sqlmesh.core.model import Model as SqlMeshModel
|
|
4
|
+
|
|
5
|
+
from tff.core.config import load_fitness_config, FitnessFunctionsConfig
|
|
6
|
+
from tff.core.context import set_ff_config
|
|
7
|
+
from tff.core.rules import ALL_RULES
|
|
8
|
+
from tff.sqlmesh.loader import map_sqlmesh_model, wrap_core_rule, FitnessLoader
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_all_rules_have_unique_names() -> None:
|
|
12
|
+
names = [rule.name for rule in ALL_RULES]
|
|
13
|
+
assert len(names) == len(set(names))
|
|
14
|
+
assert "classificationmacros" in names
|
|
15
|
+
assert "martmodelnamingconvention" in names
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_load_fitness_config_from_fixture(tmp_path: Path) -> None:
|
|
19
|
+
yaml_path = tmp_path / "fitness_functions.yaml"
|
|
20
|
+
yaml_path.write_text("checks:\n layer_integrity:\n enabled: false\n", encoding="utf-8")
|
|
21
|
+
config = load_fitness_config(tmp_path, config_path=yaml_path)
|
|
22
|
+
set_ff_config(config)
|
|
23
|
+
assert config.checks.layer_integrity.enabled is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_map_sqlmesh_model_with_audits() -> None:
|
|
27
|
+
mock_model = MagicMock(spec=SqlMeshModel)
|
|
28
|
+
mock_model.name = "my_model"
|
|
29
|
+
mock_model._path = Path("models/my_model.sql")
|
|
30
|
+
mock_model.dialect = "duckdb"
|
|
31
|
+
mock_model.kind = MagicMock()
|
|
32
|
+
mock_model.kind.is_symbolic = False
|
|
33
|
+
mock_model.kind.name = "FULL"
|
|
34
|
+
mock_model.columns_to_types = {"id": "int"}
|
|
35
|
+
mock_model.depends_on = {"dep_model"}
|
|
36
|
+
mock_model.description = "My desc"
|
|
37
|
+
mock_model.owner = "data-team"
|
|
38
|
+
mock_model.grains = ["id"]
|
|
39
|
+
mock_model.audits = [("not_null", {"column": "id"})]
|
|
40
|
+
|
|
41
|
+
model_rep = map_sqlmesh_model(mock_model)
|
|
42
|
+
assert model_rep.name == "my_model"
|
|
43
|
+
assert model_rep.audits == [("not_null", {"column": "id"})]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_wrapped_rule_execution() -> None:
|
|
47
|
+
from tff.core.rules.no_select_star import NoSelectStar
|
|
48
|
+
|
|
49
|
+
config = FitnessFunctionsConfig()
|
|
50
|
+
config.rules.no_select_star.enabled = True
|
|
51
|
+
set_ff_config(config)
|
|
52
|
+
|
|
53
|
+
WrappedRuleClass = wrap_core_rule(NoSelectStar)
|
|
54
|
+
rule_instance = WrappedRuleClass(context=MagicMock())
|
|
55
|
+
|
|
56
|
+
mock_model = MagicMock(spec=SqlMeshModel)
|
|
57
|
+
mock_model.name = "my_model"
|
|
58
|
+
mock_model._path = Path("models/my_model.sql")
|
|
59
|
+
mock_model.dialect = "duckdb"
|
|
60
|
+
mock_model.kind = MagicMock()
|
|
61
|
+
mock_model.kind.is_symbolic = False
|
|
62
|
+
mock_model.kind.name = "FULL"
|
|
63
|
+
mock_model.columns_to_types = {}
|
|
64
|
+
mock_model.depends_on = set()
|
|
65
|
+
mock_model.description = None
|
|
66
|
+
mock_model.owner = None
|
|
67
|
+
mock_model.grains = []
|
|
68
|
+
mock_model.audits = []
|
|
69
|
+
|
|
70
|
+
from unittest.mock import patch
|
|
71
|
+
with patch("tff.core.rules.no_select_star.Path.exists", return_value=True), \
|
|
72
|
+
patch("tff.core.rules.no_select_star.Path.read_text", return_value="SELECT * FROM table"):
|
|
73
|
+
violation = rule_instance.check_model(mock_model)
|
|
74
|
+
assert violation is not None
|
|
75
|
+
assert "SELECT * is prohibited" in str(violation)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_fitness_loader_integration() -> None:
|
|
79
|
+
from sqlmesh.core.context import Context
|
|
80
|
+
|
|
81
|
+
fixture_path = Path(__file__).parent / "fixtures" / "minimal_project"
|
|
82
|
+
context = Context(paths=[str(fixture_path)], loader=FitnessLoader)
|
|
83
|
+
|
|
84
|
+
# Verify that the FitnessLoader configured fitness settings
|
|
85
|
+
assert context._loaders[0]._ff_config.checks.layer_integrity.enabled is True
|
|
86
|
+
|
|
87
|
+
# Verify that it registered the rules into SQLMesh linter
|
|
88
|
+
linter = context._linters.get("")
|
|
89
|
+
assert linter is not None
|
|
90
|
+
rule_names = set(linter.rules)
|
|
91
|
+
assert "classificationmacros" in rule_names
|
|
92
|
+
assert "sqlcomplexity" in rule_names
|
|
93
|
+
# Verify local custom dummy rule was loaded
|
|
94
|
+
assert "customdummyrule" in rule_names
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from tff.sqlmesh.cli import main
|
|
4
|
+
from tff.sqlmesh.runner import run_all_checks
|
|
5
|
+
|
|
6
|
+
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "minimal_project"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_run_all_checks_integration():
|
|
10
|
+
findings, models_checked, selected = run_all_checks(
|
|
11
|
+
project_root=FIXTURE_PATH
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# 2 models in the project
|
|
15
|
+
assert models_checked == 2
|
|
16
|
+
assert "layer_integrity" in selected
|
|
17
|
+
|
|
18
|
+
# Should find one layer_integrity violation: src_model depending on violating_model
|
|
19
|
+
layer_integrity_findings = [
|
|
20
|
+
f for f in findings if f.check == "layer_integrity"
|
|
21
|
+
]
|
|
22
|
+
assert len(layer_integrity_findings) == 1
|
|
23
|
+
finding = layer_integrity_findings[0]
|
|
24
|
+
assert finding.model == "sqlmesh_example.src_model"
|
|
25
|
+
assert "downstream layer" in finding.message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cli_lint_fails():
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
with patch("tff.sqlmesh.cli.run_all_checks") as mock_run, \
|
|
32
|
+
patch("tff.sqlmesh.cli.render_lint_report") as mock_render:
|
|
33
|
+
mock_run.return_value = ([], 0, [])
|
|
34
|
+
mock_render.return_value = False
|
|
35
|
+
|
|
36
|
+
exit_code = main(["lint", "--project", "."])
|
|
37
|
+
assert exit_code == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_cli_lint_with_group_by():
|
|
41
|
+
from unittest.mock import patch
|
|
42
|
+
|
|
43
|
+
with patch("tff.sqlmesh.cli.run_all_checks") as mock_run, \
|
|
44
|
+
patch("tff.sqlmesh.cli.render_lint_report") as mock_render:
|
|
45
|
+
mock_run.return_value = ([], 0, [])
|
|
46
|
+
mock_render.return_value = True
|
|
47
|
+
|
|
48
|
+
exit_code = main(["lint", "--project", ".", "--group-by", "model"])
|
|
49
|
+
assert exit_code == 0
|
|
50
|
+
|
|
51
|
+
mock_render.assert_called_with(
|
|
52
|
+
[],
|
|
53
|
+
models_checked=0,
|
|
54
|
+
executed_checks=[],
|
|
55
|
+
fail_level="error",
|
|
56
|
+
group_by="model",
|
|
57
|
+
)
|