dataform-sqlx-lint 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Acuantia
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,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: dataform-sqlx-lint
3
+ Version: 0.1.0
4
+ Summary: Convention linter for Dataform .sqlx files — checks the config-block and project conventions that SQL linters cannot see
5
+ Author-email: Ivan Histand <ihistand@rotoplas.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/acuantia/dataform-sqlx-lint
8
+ Keywords: dataform,sqlx,lint,bigquery,pre-commit
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # dataform-sqlx-lint
19
+
20
+ A convention linter for [Dataform](https://cloud.google.com/dataform) `.sqlx`
21
+ files. SQL linters (sqlfluff) check the SQL body; `dataform compile` checks
22
+ syntax. Neither sees the **config-block and project conventions** that keep a
23
+ Dataform repo healthy — this tool does.
24
+
25
+ Zero dependencies (Python ≥ 3.11 standard library only). Designed for
26
+ [pre-commit](https://pre-commit.com).
27
+
28
+ ```bash
29
+ pip install dataform-sqlx-lint
30
+ ```
31
+
32
+ ## Rules
33
+
34
+ | Code | Default | Checks |
35
+ |------|---------|--------|
36
+ | E001 | on | `config {}` block present and balanced |
37
+ | E002 | on | non-empty `columns: {}` documentation on tables/views/incrementals/declarations |
38
+ | E003 | on | `schema:` must not hardcode an environment suffix (`_prod`/`_dev` by default) — `--schema-suffix` appends it, so a literal doubles up (`looker_prod_prod`) |
39
+ | E004 | on | `name:` matching the filename is redundant (declarations exempt) |
40
+ | E005 | opt-in | operations/assertions must not set `schema:` (`hasOutput: true` operations exempt — schema+name define `${self()}`) |
41
+ | E006 | on | hardcoded `` `project.dataset.table` `` paths instead of `${ref()}` — these silently break Dataform's dependency graph |
42
+ | E007 | on* | configurable per-directory naming/type policies (*no-op until policies are configured) |
43
+ | W008 | opt-in | `post_operations {}` placed before the main SELECT (style preference; Dataform accepts either) |
44
+ | E010 | on | every determinable output column appears in `columns: {}` — parses the main SELECT conservatively (unparseable expressions are skipped, never false-flagged) and follows `select *` through a single plain `${ref()}` into the upstream file |
45
+
46
+ Why E010 matters: `columns: {}` is what Dataform writes to BigQuery column
47
+ descriptions — the metadata data catalogs, BI tools, and AI/conversational
48
+ analytics agents read. Partial blocks leave silent gaps.
49
+
50
+ ## Usage
51
+
52
+ ```bash
53
+ dataform-sqlx-lint definitions/output/my_table.sqlx [...]
54
+ # exit 0 = clean or warnings only; 1 = errors
55
+ ```
56
+
57
+ Run from the repo root so `--definitions-root` (default `./definitions`) can
58
+ index `${ref()}` targets for E010's star-resolution.
59
+
60
+ ### pre-commit
61
+
62
+ ```yaml
63
+ repos:
64
+ - repo: https://github.com/acuantia/dataform-sqlx-lint
65
+ rev: v0.1.0
66
+ hooks:
67
+ - id: dataform-sqlx-lint
68
+ ```
69
+
70
+ ### Configuration
71
+
72
+ `.sqlx-lint.toml` in the repo root, or a `[tool.sqlx-lint]` table in
73
+ `pyproject.toml` (the standalone file wins). All keys optional:
74
+
75
+ ```toml
76
+ schema_suffixes = ["_prod", "_dev"] # E003 suffix list ([] disables)
77
+ documented_types = ["table", "view", "incremental", "declaration"] # E002
78
+ coverage_paths = ["definitions/output/"] # E010 scope; empty = everywhere
79
+ enable = ["E005", "W008"] # switch on opt-in rules
80
+ disable = ["E004"] # switch off default rules
81
+
82
+ [[dir_policies]] # E007 (repeatable)
83
+ path_contains = "definitions/output/looker/"
84
+ require_prefix = "looker_"
85
+ require_types = ["table", "incremental"]
86
+ severity = "error" # or "warning"
87
+ ```
88
+
89
+ See `examples/acuantia.sqlx-lint.toml` for a complete real-world config.
90
+
91
+ ### Suppressing findings
92
+
93
+ ```sql
94
+ from `proj.raw_api.events` -- sqlx-lint: disable=E006 (declaration repoints at cutover)
95
+ ```
96
+
97
+ or file-wide, anywhere in the file:
98
+
99
+ ```sql
100
+ -- sqlx-lint: disable-file=E006
101
+ ```
102
+
103
+ Suppress with a reason, sparingly — the convention is usually the fix.
104
+
105
+ ## Design notes
106
+
107
+ - **Conservative by construction**: the SQL projection parser only claims
108
+ column names it can determine (aliases, simple identifiers, resolvable
109
+ `select *`); anything ambiguous is skipped, so E010 never false-flags.
110
+ - **Declarations are exempt** from E003/E004 deliberately: raw source datasets
111
+ legitimately carry environment-suffixed names, and Dataform requires `name:`
112
+ on declarations.
113
+ - Rule codes are stable; gaps in the numbering are historical.
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ python3 -m venv .venv && .venv/bin/pip install -e . pytest
119
+ .venv/bin/pytest # 53 tests
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,107 @@
1
+ # dataform-sqlx-lint
2
+
3
+ A convention linter for [Dataform](https://cloud.google.com/dataform) `.sqlx`
4
+ files. SQL linters (sqlfluff) check the SQL body; `dataform compile` checks
5
+ syntax. Neither sees the **config-block and project conventions** that keep a
6
+ Dataform repo healthy — this tool does.
7
+
8
+ Zero dependencies (Python ≥ 3.11 standard library only). Designed for
9
+ [pre-commit](https://pre-commit.com).
10
+
11
+ ```bash
12
+ pip install dataform-sqlx-lint
13
+ ```
14
+
15
+ ## Rules
16
+
17
+ | Code | Default | Checks |
18
+ |------|---------|--------|
19
+ | E001 | on | `config {}` block present and balanced |
20
+ | E002 | on | non-empty `columns: {}` documentation on tables/views/incrementals/declarations |
21
+ | E003 | on | `schema:` must not hardcode an environment suffix (`_prod`/`_dev` by default) — `--schema-suffix` appends it, so a literal doubles up (`looker_prod_prod`) |
22
+ | E004 | on | `name:` matching the filename is redundant (declarations exempt) |
23
+ | E005 | opt-in | operations/assertions must not set `schema:` (`hasOutput: true` operations exempt — schema+name define `${self()}`) |
24
+ | E006 | on | hardcoded `` `project.dataset.table` `` paths instead of `${ref()}` — these silently break Dataform's dependency graph |
25
+ | E007 | on* | configurable per-directory naming/type policies (*no-op until policies are configured) |
26
+ | W008 | opt-in | `post_operations {}` placed before the main SELECT (style preference; Dataform accepts either) |
27
+ | E010 | on | every determinable output column appears in `columns: {}` — parses the main SELECT conservatively (unparseable expressions are skipped, never false-flagged) and follows `select *` through a single plain `${ref()}` into the upstream file |
28
+
29
+ Why E010 matters: `columns: {}` is what Dataform writes to BigQuery column
30
+ descriptions — the metadata data catalogs, BI tools, and AI/conversational
31
+ analytics agents read. Partial blocks leave silent gaps.
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ dataform-sqlx-lint definitions/output/my_table.sqlx [...]
37
+ # exit 0 = clean or warnings only; 1 = errors
38
+ ```
39
+
40
+ Run from the repo root so `--definitions-root` (default `./definitions`) can
41
+ index `${ref()}` targets for E010's star-resolution.
42
+
43
+ ### pre-commit
44
+
45
+ ```yaml
46
+ repos:
47
+ - repo: https://github.com/acuantia/dataform-sqlx-lint
48
+ rev: v0.1.0
49
+ hooks:
50
+ - id: dataform-sqlx-lint
51
+ ```
52
+
53
+ ### Configuration
54
+
55
+ `.sqlx-lint.toml` in the repo root, or a `[tool.sqlx-lint]` table in
56
+ `pyproject.toml` (the standalone file wins). All keys optional:
57
+
58
+ ```toml
59
+ schema_suffixes = ["_prod", "_dev"] # E003 suffix list ([] disables)
60
+ documented_types = ["table", "view", "incremental", "declaration"] # E002
61
+ coverage_paths = ["definitions/output/"] # E010 scope; empty = everywhere
62
+ enable = ["E005", "W008"] # switch on opt-in rules
63
+ disable = ["E004"] # switch off default rules
64
+
65
+ [[dir_policies]] # E007 (repeatable)
66
+ path_contains = "definitions/output/looker/"
67
+ require_prefix = "looker_"
68
+ require_types = ["table", "incremental"]
69
+ severity = "error" # or "warning"
70
+ ```
71
+
72
+ See `examples/acuantia.sqlx-lint.toml` for a complete real-world config.
73
+
74
+ ### Suppressing findings
75
+
76
+ ```sql
77
+ from `proj.raw_api.events` -- sqlx-lint: disable=E006 (declaration repoints at cutover)
78
+ ```
79
+
80
+ or file-wide, anywhere in the file:
81
+
82
+ ```sql
83
+ -- sqlx-lint: disable-file=E006
84
+ ```
85
+
86
+ Suppress with a reason, sparingly — the convention is usually the fix.
87
+
88
+ ## Design notes
89
+
90
+ - **Conservative by construction**: the SQL projection parser only claims
91
+ column names it can determine (aliases, simple identifiers, resolvable
92
+ `select *`); anything ambiguous is skipped, so E010 never false-flags.
93
+ - **Declarations are exempt** from E003/E004 deliberately: raw source datasets
94
+ legitimately carry environment-suffixed names, and Dataform requires `name:`
95
+ on declarations.
96
+ - Rule codes are stable; gaps in the numbering are historical.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ python3 -m venv .venv && .venv/bin/pip install -e . pytest
102
+ .venv/bin/pytest # 53 tests
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dataform-sqlx-lint"
7
+ version = "0.1.0"
8
+ description = "Convention linter for Dataform .sqlx files — checks the config-block and project conventions that SQL linters cannot see"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Ivan Histand", email = "ihistand@rotoplas.com" }]
14
+ keywords = ["dataform", "sqlx", "lint", "bigquery", "pre-commit"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ ]
21
+
22
+ [project.scripts]
23
+ dataform-sqlx-lint = "dataform_sqlx_lint.cli:entrypoint"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/acuantia/dataform-sqlx-lint"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """dataform-sqlx-lint: convention linter for Dataform .sqlx files."""
2
+
3
+ from .config import Config, DirPolicy, load_config
4
+ from .linter import Finding, lint_file, lint_text
5
+
6
+ __all__ = ["Config", "DirPolicy", "Finding", "lint_file", "lint_text", "load_config"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,81 @@
1
+ """Command-line interface.
2
+
3
+ Usage: dataform-sqlx-lint [--config PATH] [--definitions-root DIR] FILE [FILE ...]
4
+ Exit codes: 0 = clean or warnings only, 1 = errors found, 2 = usage error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import tomllib
14
+
15
+ from .config import Config, _from_dict, load_config
16
+ from .linter import lint_file
17
+
18
+
19
+ def _repo_resolver(root: Path):
20
+ """Resolve an action name to the text of <root>/**/<name>.sqlx.
21
+ Used to follow `select *` through ${ref()} for E010."""
22
+ if not root.is_dir():
23
+ return lambda name: None
24
+ index = {p.stem: p for p in root.rglob("*.sqlx")}
25
+
26
+ def resolve(name):
27
+ p = index.get(name)
28
+ try:
29
+ return p.read_text(encoding="utf-8") if p else None
30
+ except OSError:
31
+ return None
32
+
33
+ return resolve
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ parser = argparse.ArgumentParser(
38
+ prog="dataform-sqlx-lint",
39
+ description="Convention linter for Dataform .sqlx files",
40
+ )
41
+ parser.add_argument("files", nargs="+", help=".sqlx files to lint")
42
+ parser.add_argument(
43
+ "--config",
44
+ help="path to a TOML config file (default: .sqlx-lint.toml or "
45
+ "[tool.sqlx-lint] in ./pyproject.toml)",
46
+ )
47
+ parser.add_argument(
48
+ "--definitions-root",
49
+ default="definitions",
50
+ help="directory indexed to resolve ${ref()} targets for the E010 "
51
+ "coverage rule (default: ./definitions)",
52
+ )
53
+ args = parser.parse_args(argv)
54
+
55
+ if args.config:
56
+ cfg: Config = _from_dict(tomllib.loads(Path(args.config).read_text()))
57
+ else:
58
+ cfg = load_config(".")
59
+ resolver = _repo_resolver(Path(args.definitions_root))
60
+
61
+ errors = warnings = 0
62
+ for path in args.files:
63
+ try:
64
+ findings = lint_file(path, config=cfg, resolver=resolver)
65
+ except OSError as exc:
66
+ print(f"{path}: cannot read: {exc}")
67
+ errors += 1
68
+ continue
69
+ for f in sorted(findings, key=lambda f: f.line):
70
+ print(f"{path}:{f.line}: {f.code} [{f.severity}] {f.message}")
71
+ if f.severity == "error":
72
+ errors += 1
73
+ else:
74
+ warnings += 1
75
+ if errors or warnings:
76
+ print(f"sqlx-lint: {errors} error(s), {warnings} warning(s)")
77
+ return 1 if errors else 0
78
+
79
+
80
+ def entrypoint() -> None:
81
+ sys.exit(main())
@@ -0,0 +1,110 @@
1
+ """Configuration model and TOML loading.
2
+
3
+ Precedence: .sqlx-lint.toml in the working directory, else the
4
+ [tool.sqlx-lint] table of pyproject.toml, else built-in defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ #: Rules that run unless disabled.
14
+ DEFAULT_ENABLED = {"E001", "E002", "E003", "E004", "E006", "E007", "E010"}
15
+ #: Opt-in rules (house-style checks): enable via `enable = [...]`.
16
+ OPT_IN = {"E005", "W008"}
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class DirPolicy:
21
+ """Naming/type policy applied to files whose path contains a substring."""
22
+
23
+ path_contains: str
24
+ require_prefix: str | None = None
25
+ require_types: tuple[str, ...] | None = None
26
+ severity: str = "error"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Config:
31
+ schema_suffixes: list[str] = field(default_factory=lambda: ["_prod", "_dev"])
32
+ documented_types: set[str] = field(
33
+ default_factory=lambda: {"table", "view", "incremental", "declaration"}
34
+ )
35
+ #: E010 applies only to files whose path contains one of these; empty = all.
36
+ coverage_paths: list[str] = field(default_factory=list)
37
+ dir_policies: list[DirPolicy] = field(default_factory=list)
38
+ enabled_extra: set[str] = field(default_factory=set)
39
+ disabled: set[str] = field(default_factory=set)
40
+
41
+ def rule_on(self, code: str) -> bool:
42
+ if code in self.disabled:
43
+ return False
44
+ return code in DEFAULT_ENABLED or code in self.enabled_extra
45
+
46
+ def __eq__(self, other):
47
+ if not isinstance(other, Config):
48
+ return NotImplemented
49
+ return (
50
+ self.schema_suffixes == other.schema_suffixes
51
+ and self.documented_types == other.documented_types
52
+ and self.coverage_paths == other.coverage_paths
53
+ and self.dir_policies == other.dir_policies
54
+ and self.enabled_extra == other.enabled_extra
55
+ and self.disabled == other.disabled
56
+ )
57
+
58
+
59
+ _KNOWN_KEYS = {
60
+ "schema_suffixes",
61
+ "documented_types",
62
+ "coverage_paths",
63
+ "dir_policies",
64
+ "enable",
65
+ "disable",
66
+ }
67
+
68
+
69
+ def _from_dict(raw: dict) -> Config:
70
+ unknown = set(raw) - _KNOWN_KEYS
71
+ if unknown:
72
+ raise ValueError(
73
+ f"unknown sqlx-lint config key(s): {', '.join(sorted(unknown))}"
74
+ )
75
+ policies = [
76
+ DirPolicy(
77
+ path_contains=p["path_contains"],
78
+ require_prefix=p.get("require_prefix"),
79
+ require_types=tuple(p["require_types"]) if p.get("require_types") else None,
80
+ severity=p.get("severity", "error"),
81
+ )
82
+ for p in raw.get("dir_policies", [])
83
+ ]
84
+ kwargs = {}
85
+ if "schema_suffixes" in raw:
86
+ kwargs["schema_suffixes"] = list(raw["schema_suffixes"])
87
+ if "documented_types" in raw:
88
+ kwargs["documented_types"] = set(raw["documented_types"])
89
+ if "coverage_paths" in raw:
90
+ kwargs["coverage_paths"] = list(raw["coverage_paths"])
91
+ return Config(
92
+ dir_policies=policies,
93
+ enabled_extra=set(raw.get("enable", [])),
94
+ disabled=set(raw.get("disable", [])),
95
+ **kwargs,
96
+ )
97
+
98
+
99
+ def load_config(root: str | Path = ".") -> Config:
100
+ root = Path(root)
101
+ standalone = root / ".sqlx-lint.toml"
102
+ if standalone.is_file():
103
+ return _from_dict(tomllib.loads(standalone.read_text(encoding="utf-8")))
104
+ pyproject = root / "pyproject.toml"
105
+ if pyproject.is_file():
106
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
107
+ tool = data.get("tool", {}).get("sqlx-lint")
108
+ if tool is not None:
109
+ return _from_dict(tool)
110
+ return Config()