coop-sql-review 0.1.0__py3-none-any.whl

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 (52) hide show
  1. coop_sql_review/__init__.py +3 -0
  2. coop_sql_review/__main__.py +4 -0
  3. coop_sql_review/cli.py +321 -0
  4. coop_sql_review/data/standards.md +564 -0
  5. coop_sql_review/diagnostics.py +47 -0
  6. coop_sql_review/engine.py +87 -0
  7. coop_sql_review/finding.py +54 -0
  8. coop_sql_review/identifiers.py +45 -0
  9. coop_sql_review/parser.py +214 -0
  10. coop_sql_review/report.py +125 -0
  11. coop_sql_review/rules/__init__.py +32 -0
  12. coop_sql_review/rules/base.py +68 -0
  13. coop_sql_review/rules/helpers.py +89 -0
  14. coop_sql_review/rules/sql_alias_descriptive.py +44 -0
  15. coop_sql_review/rules/sql_bronze_raw_names.py +85 -0
  16. coop_sql_review/rules/sql_ctas_explicit_cast.py +115 -0
  17. coop_sql_review/rules/sql_cte_prefix.py +44 -0
  18. coop_sql_review/rules/sql_date_filter_param.py +61 -0
  19. coop_sql_review/rules/sql_distinct_smell.py +48 -0
  20. coop_sql_review/rules/sql_exists_comment.py +49 -0
  21. coop_sql_review/rules/sql_exists_why_quality.py +52 -0
  22. coop_sql_review/rules/sql_filter_upstream.py +49 -0
  23. coop_sql_review/rules/sql_header_comment.py +55 -0
  24. coop_sql_review/rules/sql_implicit_convert.py +123 -0
  25. coop_sql_review/rules/sql_insert_alias_match.py +59 -0
  26. coop_sql_review/rules/sql_join_filter.py +110 -0
  27. coop_sql_review/rules/sql_no_alter_column.py +51 -0
  28. coop_sql_review/rules/sql_no_select_star.py +43 -0
  29. coop_sql_review/rules/sql_order_by_in_view.py +95 -0
  30. coop_sql_review/rules/sql_prefer_cte.py +50 -0
  31. coop_sql_review/rules/sql_query_label.py +85 -0
  32. coop_sql_review/rules/sql_sargability.py +89 -0
  33. coop_sql_review/rules/sql_scd2_correct.py +92 -0
  34. coop_sql_review/rules/sql_silver_pascalcase.py +96 -0
  35. coop_sql_review/rules/sql_singleton_insert.py +51 -0
  36. coop_sql_review/rules/sql_table_layer_name.py +45 -0
  37. coop_sql_review/rules/sql_try_cast_bronze.py +79 -0
  38. coop_sql_review/rules/sql_txn_short.py +50 -0
  39. coop_sql_review/rules/sql_type_datetime.py +39 -0
  40. coop_sql_review/rules/sql_type_deprecated.py +45 -0
  41. coop_sql_review/rules/sql_type_money.py +39 -0
  42. coop_sql_review/rules/sql_type_nvarchar.py +39 -0
  43. coop_sql_review/rules/sql_upsert_choice.py +45 -0
  44. coop_sql_review/sql_common.py +160 -0
  45. coop_sql_review/sql_model.py +139 -0
  46. coop_sql_review/standards.py +84 -0
  47. coop_sql_review/upgrade.py +322 -0
  48. coop_sql_review-0.1.0.dist-info/METADATA +244 -0
  49. coop_sql_review-0.1.0.dist-info/RECORD +52 -0
  50. coop_sql_review-0.1.0.dist-info/WHEEL +4 -0
  51. coop_sql_review-0.1.0.dist-info/entry_points.txt +2 -0
  52. coop_sql_review-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """coop-sql-review: offline, advisory SQL standards linter for Fabric DW."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from coop_sql_review.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
coop_sql_review/cli.py ADDED
@@ -0,0 +1,321 @@
1
+ """Command-line interface.
2
+
3
+ Thin wrapper over the pipeline (discover -> parse -> run rules -> render).
4
+ Advisory by default: exit code 0 no matter what is found. ``--strict`` is the
5
+ opt-in CI gate — exit 2 when any reported finding remains after the
6
+ ``--min-severity`` filter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import click
16
+
17
+ from coop_sql_review import __version__
18
+ from coop_sql_review.diagnostics import FILE_UNREADABLE, Diagnostic
19
+ from coop_sql_review.engine import run_rules
20
+ from coop_sql_review.finding import SEVERITIES
21
+ from coop_sql_review.parser import parse_sql
22
+ from coop_sql_review.report import console_lines, json_text, log_text
23
+ from coop_sql_review.rules import all_rules
24
+ from coop_sql_review.sql_model import ParsedFile
25
+ from coop_sql_review.standards import (
26
+ RuleConfig,
27
+ StandardsError,
28
+ apply_config,
29
+ default_config_path,
30
+ resolve_standards_path,
31
+ standards_info,
32
+ )
33
+
34
+ _SEVERITY_CHOICE = click.Choice(SEVERITIES)
35
+
36
+
37
+ def _display_path(path: Path) -> str:
38
+ """POSIX-style path, relative to cwd when possible (deterministic, OS-stable)."""
39
+ try:
40
+ return path.resolve().relative_to(Path.cwd()).as_posix()
41
+ except ValueError:
42
+ return path.resolve().as_posix()
43
+
44
+
45
+ def discover_sql_files(paths: tuple[str, ...]) -> list[Path]:
46
+ """Expand the given paths into a sorted list of ``.sql`` files.
47
+
48
+ Files are taken as-is; directories are searched recursively, skipping
49
+ hidden directories. Defaults to the current directory when none given.
50
+ """
51
+ roots = [Path(p) for p in paths] or [Path(".")]
52
+ found: set[Path] = set()
53
+ for root in roots:
54
+ if root.is_file():
55
+ found.add(root)
56
+ elif root.is_dir():
57
+ for candidate in root.rglob("*.sql"):
58
+ rel = candidate.relative_to(root)
59
+ if any(part.startswith(".") for part in rel.parts):
60
+ continue
61
+ if candidate.is_file():
62
+ found.add(candidate)
63
+ return sorted(found, key=lambda p: _display_path(p))
64
+
65
+
66
+ def _parse_files(files: list[Path], dialect: str) -> tuple[list[ParsedFile], list[Diagnostic]]:
67
+ """Parse each file; an unreadable file becomes a diagnostic, not a crash."""
68
+ parsed: list[ParsedFile] = []
69
+ read_diagnostics: list[Diagnostic] = []
70
+ for path in files:
71
+ try:
72
+ text = path.read_text(encoding="utf-8-sig", errors="replace")
73
+ except OSError as exc:
74
+ read_diagnostics.append(
75
+ Diagnostic(
76
+ severity="error",
77
+ category=FILE_UNREADABLE,
78
+ file=_display_path(path),
79
+ line=0,
80
+ message=f"could not read file: {exc}",
81
+ )
82
+ )
83
+ continue
84
+ parsed.append(parse_sql(_display_path(path), text, dialect=dialect))
85
+ return parsed, read_diagnostics
86
+
87
+
88
+ @click.group(invoke_without_command=True)
89
+ @click.version_option(version=__version__, prog_name="coop-sql-review")
90
+ @click.pass_context
91
+ def cli(ctx: click.Context) -> None:
92
+ """Offline, advisory SQL standards linter for Microsoft Fabric DW.
93
+
94
+ Reports deviations from the SQL standards; never edits or blocks.
95
+ Processing problems (parse failures, rule errors) are reported as
96
+ diagnostics in every run; use ``check --log-file`` to capture them.
97
+ """
98
+ ctx.ensure_object(dict)
99
+ # sqlglot logs a warning for every statement it falls back to a Command on;
100
+ # we already surface that as a controlled parse_degraded diagnostic, so keep
101
+ # its noise out of the report (stderr).
102
+ logging.getLogger("sqlglot").setLevel(logging.ERROR)
103
+ if ctx.invoked_subcommand is None:
104
+ click.echo(ctx.get_help())
105
+
106
+
107
+ @cli.command()
108
+ @click.argument("paths", nargs=-1, type=click.Path())
109
+ @click.option(
110
+ "--standards", "standards_path", default=None, help="Path to the standards file (default: bundled)."
111
+ )
112
+ @click.option(
113
+ "--config", "config_path", default=None, help="Path to a rules.yml (default: alongside standards)."
114
+ )
115
+ @click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", show_default=True)
116
+ @click.option(
117
+ "--min-severity",
118
+ type=_SEVERITY_CHOICE,
119
+ default="info",
120
+ show_default=True,
121
+ help="Hide findings below this severity.",
122
+ )
123
+ @click.option("--dialect", default="tsql", show_default=True, help="sqlglot dialect to parse with.")
124
+ @click.option(
125
+ "--log-file",
126
+ "log_file",
127
+ type=click.Path(),
128
+ default=None,
129
+ help="Write a diagnostics log (parse problems, rule errors) to this file.",
130
+ )
131
+ @click.option("--strict", is_flag=True, help="Exit 2 if any reported finding remains (opt-in CI gate).")
132
+ @click.pass_context
133
+ def check(
134
+ ctx: click.Context,
135
+ paths: tuple[str, ...],
136
+ standards_path: str | None,
137
+ config_path: str | None,
138
+ fmt: str,
139
+ min_severity: str,
140
+ dialect: str,
141
+ log_file: str | None,
142
+ strict: bool,
143
+ ) -> None:
144
+ """Check SQL files (or directories) against the standards."""
145
+ try:
146
+ std_path = resolve_standards_path(standards_path)
147
+ except StandardsError as exc:
148
+ raise click.ClickException(str(exc)) from exc
149
+
150
+ config = RuleConfig.load(Path(config_path) if config_path else default_config_path(std_path))
151
+ rules = apply_config(all_rules(), config)
152
+
153
+ files = discover_sql_files(paths)
154
+ if not files:
155
+ click.echo("No .sql files found.", err=True)
156
+ return
157
+
158
+ parsed, read_diagnostics = _parse_files(files, dialect)
159
+ result = run_rules(parsed, rules)
160
+ result.diagnostics.extend(read_diagnostics)
161
+ result.diagnostics.sort(key=lambda d: d.sort_key())
162
+ result = result.filtered(min_severity)
163
+
164
+ if fmt == "json":
165
+ click.echo(json_text(result, version=__version__, standards=standards_info(std_path)), nl=False)
166
+ else:
167
+ for line in console_lines(result):
168
+ click.echo(line)
169
+
170
+ if log_file:
171
+ try:
172
+ Path(log_file).write_text(log_text(result), encoding="utf-8", newline="\n")
173
+ click.echo(f"Diagnostics log written to {log_file}", err=True)
174
+ except OSError as exc:
175
+ raise click.ClickException(f"could not write log file {log_file}: {exc}") from exc
176
+
177
+ if strict and result.findings:
178
+ sys.exit(2)
179
+
180
+
181
+ @cli.command(name="rules")
182
+ @click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", show_default=True)
183
+ def rules_cmd(fmt: str) -> None:
184
+ """List every rule: id, severity, tier, and whether it needs the agent."""
185
+ rules = all_rules()
186
+ if fmt == "json":
187
+ import json
188
+
189
+ payload = [
190
+ {
191
+ "id": r.id,
192
+ "title": r.title,
193
+ "severity": r.severity,
194
+ "category": r.category,
195
+ "standard_ref": r.standard_ref,
196
+ "tier": r.tier,
197
+ "kind": r.kind,
198
+ }
199
+ for r in rules
200
+ ]
201
+ click.echo(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True))
202
+ return
203
+ click.echo(f"{len(rules)} rule(s):\n")
204
+ for r in rules:
205
+ tag = "agent" if r.kind == "agent" else r.severity
206
+ click.echo(f" {r.id:26} [{tag:7}] T{r.tier} {r.standard_ref:5} {r.title}")
207
+
208
+
209
+ def _run_upgrade(check_only: bool, yes: bool) -> None:
210
+ """Shared self-update behind both `upgrade` and `update` (the only networked path)."""
211
+ from coop_sql_review.upgrade import UpgradeError, apply_plan, build_plan
212
+
213
+ plan = build_plan()
214
+ click.echo(f"coop-sql-review {plan.tool_installed} ({plan.install_method}) — {plan.tool_note}")
215
+ if plan.dependencies:
216
+ click.echo("\nDependencies:")
217
+ for dep in plan.dependencies:
218
+ latest = dep.latest or "?"
219
+ label = {
220
+ "current": "up to date",
221
+ "safe": f"update available -> {latest}",
222
+ "major": f"MAJOR update available -> {latest} (review before applying)",
223
+ "unknown": "could not check (offline?)",
224
+ }[dep.kind]
225
+ click.echo(f" {dep.name:20} {dep.installed:12} {label}")
226
+ if check_only:
227
+ return
228
+ if not yes:
229
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
230
+ click.echo("\nRe-run with --yes to apply in non-interactive environments.", err=True)
231
+ return
232
+ if not click.confirm("\nApply the update and any non-breaking dependency updates?", default=True):
233
+ click.echo("Nothing changed.")
234
+ return
235
+ try:
236
+ executed = apply_plan(plan)
237
+ except UpgradeError as exc:
238
+ raise click.ClickException(str(exc)) from exc
239
+ for command in executed:
240
+ click.echo(f"ran: {' '.join(command)}", err=True)
241
+ click.echo("Done. Run `coop-sql-review --version` to confirm.")
242
+
243
+
244
+ _UPGRADE_OPTIONS = [
245
+ click.option("--check", "check_only", is_flag=True, help="Report available updates; change nothing."),
246
+ click.option("--yes", is_flag=True, help="Apply without asking for confirmation."),
247
+ ]
248
+
249
+
250
+ def _with_upgrade_options(func):
251
+ for option in reversed(_UPGRADE_OPTIONS):
252
+ func = option(func)
253
+ return func
254
+
255
+
256
+ @cli.command()
257
+ @_with_upgrade_options
258
+ def upgrade(check_only: bool, yes: bool) -> None:
259
+ """Update coop-sql-review to the latest version (and safe dependency bumps).
260
+
261
+ The ONLY command that uses the network. Major dependency jumps are
262
+ reported but never auto-applied.
263
+ """
264
+ _run_upgrade(check_only, yes)
265
+
266
+
267
+ @cli.command()
268
+ @_with_upgrade_options
269
+ def update(check_only: bool, yes: bool) -> None:
270
+ """Alias for `upgrade` — update coop-sql-review to the latest version."""
271
+ _run_upgrade(check_only, yes)
272
+
273
+
274
+ @cli.command(name="help")
275
+ @click.argument("command_name", required=False)
276
+ @click.pass_context
277
+ def help_cmd(ctx: click.Context, command_name: str | None) -> None:
278
+ """Show help. `help` for everything, or `help <command>` (e.g. `help check`)."""
279
+ parent = ctx.parent
280
+ if command_name is None:
281
+ click.echo(parent.get_help())
282
+ return
283
+ command = cli.get_command(ctx, command_name)
284
+ if command is None:
285
+ raise click.UsageError(f"unknown command '{command_name}' — try `coop-sql-review help`", ctx=parent)
286
+ sub_ctx = click.Context(command, info_name=command_name, parent=parent)
287
+ click.echo(command.get_help(sub_ctx))
288
+
289
+
290
+ def _force_utf8_console() -> None:
291
+ """Emit UTF-8 on every platform so non-ASCII in messages (the § section
292
+ marks, em-dashes) never raise UnicodeEncodeError on a legacy Windows
293
+ console (cp1252/cp437). errors='replace' guarantees we never crash on
294
+ output; worst case an old console shows a replacement glyph."""
295
+ for stream in (sys.stdout, sys.stderr):
296
+ try:
297
+ stream.reconfigure(encoding="utf-8", errors="replace")
298
+ except (AttributeError, ValueError, OSError):
299
+ pass # not a reconfigurable text stream (e.g. under test capture)
300
+
301
+
302
+ def main() -> None:
303
+ """Console-script entrypoint: friendly one-line errors, 130 on Ctrl-C."""
304
+ _force_utf8_console()
305
+ try:
306
+ cli(obj={}, standalone_mode=False)
307
+ except click.exceptions.Abort:
308
+ click.echo("\nInterrupted.", err=True)
309
+ sys.exit(130)
310
+ except click.exceptions.Exit as exc: # --help / --version
311
+ sys.exit(exc.exit_code)
312
+ except click.ClickException as exc:
313
+ exc.show()
314
+ sys.exit(exc.exit_code)
315
+ except KeyboardInterrupt:
316
+ click.echo("\nInterrupted.", err=True)
317
+ sys.exit(130)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()