datasecops-cli 0.2.0__tar.gz → 0.2.2__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 (47) hide show
  1. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/CHANGELOG.md +27 -0
  2. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/PKG-INFO +1 -1
  3. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/pyproject.toml +1 -1
  4. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/setup.ps1 +48 -6
  5. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/setup.sh +45 -6
  6. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/main.py +3 -2
  7. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/menus/development.py +19 -1
  8. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/menus/downloads.py +2 -1
  9. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/bootstrap_service.py +2 -1
  10. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/download_service.py +130 -18
  11. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/.github/workflows/publish-cli.yml +0 -0
  12. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/.gitignore +0 -0
  13. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/DEVELOPMENT.md +0 -0
  14. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/LICENSE +0 -0
  15. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/README.md +0 -0
  16. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/docs/getting-started.md +0 -0
  17. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/docs/legacy.md +0 -0
  18. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/docs/legacy_plan_of_action.md +0 -0
  19. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/docs/mcp-server.md +0 -0
  20. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/mcp-servers.json +0 -0
  21. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/__init__.py +0 -0
  22. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/config.py +0 -0
  23. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/menus/__init__.py +0 -0
  24. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/menus/git_operations.py +0 -0
  25. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/models/__init__.py +0 -0
  26. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/models/git_helpers.py +0 -0
  27. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/models/project_config.py +0 -0
  28. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/__init__.py +0 -0
  29. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/dbt_runner.py +0 -0
  30. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/git_service.py +0 -0
  31. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/linting_service.py +0 -0
  32. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/skill_service.py +0 -0
  33. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/services/snowflake_service.py +0 -0
  34. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/utilities/__init__.py +0 -0
  35. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/utilities/display.py +0 -0
  36. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/utilities/file_utils.py +0 -0
  37. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  38. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_mcp/__init__.py +0 -0
  39. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_mcp/__main__.py +0 -0
  40. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_mcp/connection.py +0 -0
  41. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/src/datasecops_mcp/server.py +0 -0
  42. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/tests/__init__.py +0 -0
  43. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/tests/test_config.py +0 -0
  44. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/tests/test_file_utils.py +0 -0
  45. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/tests/test_models.py +0 -0
  46. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/tests/test_version.py +0 -0
  47. {datasecops_cli-0.2.0 → datasecops_cli-0.2.2}/tests/test_yaml_utils.py +0 -0
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to the DataSecOps CLI are documented in this file.
4
4
 
5
+ ## [0.2.2] - 2026-05-10
6
+
7
+ ### Fixed
8
+
9
+ - **Full SQLFluff config generation** — `download_sqlfluff_config()` now generates all sections from the native app config: core, templater, indentation, layout types, global rules, and all rule bundles (aliasing, ambiguous, capitalisation, convention, jinja, layout, references, structure)
10
+ - Previously only `core` and `indentation` sections were emitted, missing ~90% of the config
11
+
12
+ ### Added
13
+
14
+ - **Setup script connection selector** — `setup.ps1` and `setup.sh` now parse `~/.snowflake/connections.toml`, display a numbered list of connections with the default highlighted, and let the user select by number instead of typing the name manually
15
+ - Rule code to section name mapping (`RULE_SECTION_MAP`) for all sqlfluff rule bundles (AL01-AL09, AM01-AM08, CP01-CP05, CV01-CV12, JJ01, LT01-LT15, RF01-RF06, ST01-ST11)
16
+ - Value formatting helper (`_format_value`) for correct INI output of bools, lists, nulls, and empty strings
17
+
18
+ ## [0.2.1] - 2026-05-10
19
+
20
+ ### Fixed
21
+
22
+ - **SQLFluff config now includes `[sqlfluff:templater:dbt]` section** with `project_dir` and `profiles_dir` when downloaded from the framework, eliminating the need for `${DBT_PROFILES_DIR}` environment variable substitution
23
+ - **Auto-download `.sqlfluff` before linting** — the lint menu now automatically downloads the config from the framework if it doesn't exist locally, instead of failing silently or erroring
24
+ - **CI lint action handles missing `.sqlfluffconfig`** — the `tasks/lint-sql` action now only runs `envsubst` if `.sqlfluffconfig` exists, and accepts a pre-generated `.sqlfluff` file directly
25
+
26
+ ### Changed
27
+
28
+ - `download_sqlfluff_config()` accepts an optional `profiles_dir` parameter to embed in the generated config
29
+ - `DevelopmentMenu` and `DownloadsMenu` pass the resolved `profiles_dir` through to the download service
30
+ - `.sqlfluff` no longer needs to be committed to client repos — it is generated on-the-fly from the framework
31
+
5
32
  ## [0.2.0] - 2026-05-09
6
33
 
7
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datasecops-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "datasecops-cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "DataSecOps Framework CLI for Snowflake Native App"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -48,18 +48,60 @@ Write-Host "--- Snowflake Connection ---" -ForegroundColor Green
48
48
  Write-Host ""
49
49
 
50
50
  $connectionsFile = Join-Path $env:USERPROFILE ".snowflake\connections.toml"
51
+ $connectionNames = @()
52
+ $defaultConnection = $null
53
+
51
54
  if (Test-Path $connectionsFile) {
55
+ # Parse default connection name
56
+ $defaultLine = Get-Content $connectionsFile | Select-String '^default_connection_name\s*=' | Select-Object -First 1
57
+ if ($defaultLine) {
58
+ $defaultConnection = ($defaultLine -replace '.*=\s*', '').Trim().Trim('"').Trim("'")
59
+ }
60
+
61
+ # Parse connection section names
62
+ $connectionNames = @(Get-Content $connectionsFile | Select-String '^\[([^\]]+)\]' | ForEach-Object { $_.Matches[0].Groups[1].Value })
63
+ }
64
+
65
+ if ($connectionNames.Count -gt 0) {
52
66
  Write-Host "Available connections:" -ForegroundColor Cyan
53
- Get-Content $connectionsFile | Select-String '^\[' | ForEach-Object { $_.Line -replace '[\[\]]', '' }
67
+ for ($i = 0; $i -lt $connectionNames.Count; $i++) {
68
+ $marker = ""
69
+ if ($connectionNames[$i] -eq $defaultConnection) { $marker = " (default)" }
70
+ Write-Host " [$($i + 1)] $($connectionNames[$i])$marker"
71
+ }
54
72
  Write-Host ""
55
- }
56
73
 
57
- $connectionName = Read-Host "Enter connection name"
58
- if ([string]::IsNullOrWhiteSpace($connectionName)) {
59
- Write-Host "ERROR: Connection name is required" -ForegroundColor Red
60
- exit 1
74
+ $defaultIndex = -1
75
+ if ($defaultConnection) {
76
+ $defaultIndex = [array]::IndexOf($connectionNames, $defaultConnection)
77
+ }
78
+
79
+ if ($defaultIndex -ge 0) {
80
+ $prompt = "Select connection [1-$($connectionNames.Count)] (default: $($defaultIndex + 1))"
81
+ } else {
82
+ $prompt = "Select connection [1-$($connectionNames.Count)]"
83
+ }
84
+
85
+ $selection = Read-Host $prompt
86
+ if ([string]::IsNullOrWhiteSpace($selection) -and $defaultIndex -ge 0) {
87
+ $connectionName = $defaultConnection
88
+ } elseif ($selection -match '^\d+$' -and [int]$selection -ge 1 -and [int]$selection -le $connectionNames.Count) {
89
+ $connectionName = $connectionNames[[int]$selection - 1]
90
+ } else {
91
+ Write-Host "ERROR: Invalid selection" -ForegroundColor Red
92
+ exit 1
93
+ }
94
+ } else {
95
+ Write-Host "No connections found in $connectionsFile" -ForegroundColor Yellow
96
+ $connectionName = Read-Host "Enter connection name"
97
+ if ([string]::IsNullOrWhiteSpace($connectionName)) {
98
+ Write-Host "ERROR: Connection name is required" -ForegroundColor Red
99
+ exit 1
100
+ }
61
101
  }
62
102
 
103
+ Write-Host "[OK] Using connection: $connectionName" -ForegroundColor Green
104
+
63
105
  # --- Step 3: Enter native app database name ---
64
106
 
65
107
  Write-Host ""
@@ -46,18 +46,57 @@ echo "--- Snowflake Connection ---"
46
46
  echo ""
47
47
 
48
48
  CONNECTIONS_FILE="$HOME/.snowflake/connections.toml"
49
+ CONNECTION_NAMES=()
50
+ DEFAULT_CONNECTION=""
51
+
49
52
  if [ -f "$CONNECTIONS_FILE" ]; then
53
+ # Parse default connection name
54
+ DEFAULT_CONNECTION=$(grep '^default_connection_name' "$CONNECTIONS_FILE" | sed 's/.*=\s*//;s/[" ]//g' | head -1)
55
+
56
+ # Parse connection section names
57
+ while IFS= read -r name; do
58
+ CONNECTION_NAMES+=("$name")
59
+ done < <(grep '^\[' "$CONNECTIONS_FILE" | sed 's/\[//;s/\]//')
60
+ fi
61
+
62
+ if [ ${#CONNECTION_NAMES[@]} -gt 0 ]; then
50
63
  echo "Available connections:"
51
- grep '^\[' "$CONNECTIONS_FILE" | sed 's/\[//;s/\]//' | nl -ba
64
+ DEFAULT_INDEX=-1
65
+ for i in "${!CONNECTION_NAMES[@]}"; do
66
+ marker=""
67
+ if [ "${CONNECTION_NAMES[$i]}" = "$DEFAULT_CONNECTION" ]; then
68
+ marker=" (default)"
69
+ DEFAULT_INDEX=$i
70
+ fi
71
+ echo " [$((i + 1))] ${CONNECTION_NAMES[$i]}${marker}"
72
+ done
52
73
  echo ""
53
- fi
54
74
 
55
- read -rp "Enter connection name: " CONNECTION_NAME
56
- if [ -z "$CONNECTION_NAME" ]; then
57
- echo "ERROR: Connection name is required"
58
- exit 1
75
+ if [ $DEFAULT_INDEX -ge 0 ]; then
76
+ read -rp "Select connection [1-${#CONNECTION_NAMES[@]}] (default: $((DEFAULT_INDEX + 1))): " SELECTION
77
+ else
78
+ read -rp "Select connection [1-${#CONNECTION_NAMES[@]}]: " SELECTION
79
+ fi
80
+
81
+ if [ -z "$SELECTION" ] && [ $DEFAULT_INDEX -ge 0 ]; then
82
+ CONNECTION_NAME="$DEFAULT_CONNECTION"
83
+ elif [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ "$SELECTION" -ge 1 ] && [ "$SELECTION" -le ${#CONNECTION_NAMES[@]} ]; then
84
+ CONNECTION_NAME="${CONNECTION_NAMES[$((SELECTION - 1))]}"
85
+ else
86
+ echo "ERROR: Invalid selection"
87
+ exit 1
88
+ fi
89
+ else
90
+ echo "No connections found in $CONNECTIONS_FILE"
91
+ read -rp "Enter connection name: " CONNECTION_NAME
92
+ if [ -z "$CONNECTION_NAME" ]; then
93
+ echo "ERROR: Connection name is required"
94
+ exit 1
95
+ fi
59
96
  fi
60
97
 
98
+ echo "[OK] Using connection: $CONNECTION_NAME"
99
+
61
100
  # --- Step 3: Enter native app database name ---
62
101
 
63
102
  echo ""
@@ -88,12 +88,13 @@ def _main_menu(config: Config, dbt_runner: DbtRunner, git_service: GitService,
88
88
 
89
89
  while option != 0:
90
90
  if option == 1:
91
+ profiles_dir = str(config.get_dbt_profiles_dir())
91
92
  if git_service:
92
93
  dev_menu = DevelopmentMenu(dbt_runner, linting_service, git_service, profile_name,
93
- download_service=download_service)
94
+ download_service=download_service, profiles_dir=profiles_dir)
94
95
  else:
95
96
  dev_menu = DevelopmentMenu(dbt_runner, linting_service, None, profile_name,
96
- download_service=download_service)
97
+ download_service=download_service, profiles_dir=profiles_dir)
97
98
  dev_menu.show()
98
99
 
99
100
  elif option == 2 and git_service:
@@ -1,3 +1,5 @@
1
+ from pathlib import Path
2
+
1
3
  from datasecops_cli.services.dbt_runner import DbtRunner
2
4
  from datasecops_cli.services.linting_service import LintingService
3
5
  from datasecops_cli.services.download_service import DownloadService
@@ -12,12 +14,14 @@ from datasecops_cli.utilities.display import (
12
14
  class DevelopmentMenu:
13
15
  def __init__(self, dbt_runner: DbtRunner, linting_service: LintingService,
14
16
  git_service: GitService, profile_name: str,
15
- download_service: DownloadService = None):
17
+ download_service: DownloadService = None,
18
+ profiles_dir: str = None):
16
19
  self.dbt = dbt_runner
17
20
  self.linting = linting_service
18
21
  self.git = git_service
19
22
  self.profile_name = profile_name
20
23
  self.downloads = download_service
24
+ self.profiles_dir = profiles_dir
21
25
 
22
26
  def show(self) -> None:
23
27
  self._menu()
@@ -136,6 +140,17 @@ class DevelopmentMenu:
136
140
  self.dbt.test(select=select)
137
141
  complete_action()
138
142
 
143
+ def _ensure_sqlfluff_config(self) -> bool:
144
+ """Download .sqlfluff from the framework if it doesn't exist locally."""
145
+ config_path = self.linting.project_dir / ".sqlfluff"
146
+ if config_path.exists():
147
+ return True
148
+ if not self.downloads:
149
+ error_line("No .sqlfluff config found and download service not available")
150
+ return False
151
+ info_line("No .sqlfluff config found — downloading from framework...")
152
+ return self.downloads.download_sqlfluff_config(profiles_dir=self.profiles_dir)
153
+
139
154
  def _lint_menu(self) -> None:
140
155
  clear()
141
156
  display_action_header("SQLFluff Linting Options")
@@ -147,6 +162,9 @@ class DevelopmentMenu:
147
162
  menu_option(6, "install - Install SQLFluff requirements from framework")
148
163
  menu_option(0, "back - Return to development menu")
149
164
  option = get_input_number("Choose an option: ")
165
+ if option in (1, 2, 3, 4, 5) and not self._ensure_sqlfluff_config():
166
+ complete_action()
167
+ return
150
168
  if option == 1:
151
169
  changed = [f.file for f in self.git.get_changed_files()] if self.git else []
152
170
  self.linting.lint_modified(fix=False, changed_files=changed)
@@ -29,7 +29,8 @@ class DownloadsMenu:
29
29
  while option != 0:
30
30
  if option == 1:
31
31
  display_action_header("Download SQLFluff Config")
32
- self.downloads.download_sqlfluff_config()
32
+ profiles_dir = str(Path(self.project_settings.profile_dir).expanduser()) if self.project_settings else None
33
+ self.downloads.download_sqlfluff_config(profiles_dir=profiles_dir)
33
34
  complete_action()
34
35
  elif option == 2:
35
36
  display_action_header("Download Pipeline Files")
@@ -68,7 +68,8 @@ class BootstrapService:
68
68
  # Step 3: Download SQLFluff config
69
69
  info_line("")
70
70
  info_line("[3] Downloading SQLFluff configuration...")
71
- if self.download_service.download_sqlfluff_config():
71
+ profiles_dir = str(Path(self.project_settings.profile_dir).expanduser())
72
+ if self.download_service.download_sqlfluff_config(profiles_dir=profiles_dir):
72
73
  steps_passed += 1
73
74
  else:
74
75
  info_line(" (skipped - no SQLFluff config in native app)")
@@ -12,37 +12,149 @@ from datasecops_cli.utilities.file_utils import write_file, ensure_dir
12
12
 
13
13
  class DownloadService:
14
14
  """Downloads configurations from the native app."""
15
+
16
+ # Maps rule codes to their sqlfluff INI section names.
17
+ RULE_SECTION_MAP: dict[str, str] = {
18
+ # Aliasing
19
+ "AL01": "aliasing.table", "AL02": "aliasing.column", "AL03": "aliasing.expression",
20
+ "AL04": "aliasing.unique.table", "AL05": "aliasing.unused", "AL06": "aliasing.length",
21
+ "AL07": "aliasing.forbid", "AL08": "aliasing.unique.column", "AL09": "aliasing.self_alias",
22
+ # Ambiguous
23
+ "AM01": "ambiguous.distinct", "AM02": "ambiguous.union", "AM03": "ambiguous.order_by",
24
+ "AM04": "ambiguous.column_count", "AM05": "ambiguous.join",
25
+ "AM06": "ambiguous.column_references", "AM07": "ambiguous.set_columns",
26
+ "AM08": "ambiguous.union_type",
27
+ # Capitalisation
28
+ "CP01": "capitalisation.keywords", "CP02": "capitalisation.identifiers",
29
+ "CP03": "capitalisation.functions", "CP04": "capitalisation.literals",
30
+ "CP05": "capitalisation.types",
31
+ # Convention
32
+ "CV01": "convention.not_equal", "CV02": "convention.coalesce",
33
+ "CV03": "convention.select_trailing_comma", "CV04": "convention.count_rows",
34
+ "CV05": "convention.is_null", "CV06": "convention.terminator",
35
+ "CV07": "convention.statement_brackets", "CV08": "convention.left_join",
36
+ "CV09": "convention.blocked_words", "CV10": "convention.quoted_literals",
37
+ "CV11": "convention.casting_style", "CV12": "convention.semicolon",
38
+ # Jinja
39
+ "JJ01": "jinja.padding",
40
+ # Layout
41
+ "LT01": "layout.spacing", "LT02": "layout.indent", "LT03": "layout.operators",
42
+ "LT04": "layout.commas", "LT05": "layout.long_lines", "LT06": "layout.functions",
43
+ "LT07": "layout.cte_bracket", "LT08": "layout.cte_newline",
44
+ "LT09": "layout.select_targets", "LT10": "layout.select_modifiers",
45
+ "LT11": "layout.set_operators", "LT12": "layout.end-of-file",
46
+ "LT13": "layout.start_of_file", "LT14": "layout.one_line", "LT15": "layout.newlines",
47
+ # References
48
+ "RF01": "references.from", "RF02": "references.qualification",
49
+ "RF03": "references.consistent", "RF04": "references.keywords",
50
+ "RF05": "references.special_chars", "RF06": "references.quoting",
51
+ # Structure
52
+ "ST01": "structure.else_null", "ST02": "structure.simple_case",
53
+ "ST03": "structure.unused_cte", "ST04": "structure.nested_case",
54
+ "ST05": "structure.subquery", "ST06": "structure.column_order",
55
+ "ST07": "structure.using", "ST08": "structure.distinct",
56
+ "ST09": "structure.join_condition_order", "ST10": "structure.cte_definition_order",
57
+ "ST11": "structure.test_query",
58
+ }
59
+
60
+ # Core sub-keys that map to specific INI sections.
61
+ CORE_SECTION_MAP: dict[str, str] = {
62
+ "sqlfluff": "sqlfluff",
63
+ "dbt": "sqlfluff:templater:dbt",
64
+ "jinja": "sqlfluff:templater:jinja",
65
+ "templater": "sqlfluff:templater",
66
+ }
15
67
 
16
68
  def __init__(self, snowflake_service: SnowflakeService, project_dir: Path):
17
69
  self.sf = snowflake_service
18
70
  self.project_dir = project_dir
71
+
72
+ @staticmethod
73
+ def _format_value(val) -> str:
74
+ """Format a JSON value for sqlfluff INI output."""
75
+ if isinstance(val, bool):
76
+ return str(val)
77
+ if isinstance(val, list):
78
+ if not val:
79
+ return "None"
80
+ return ", ".join(str(v) for v in val)
81
+ if val is None or val == "":
82
+ return "None"
83
+ return str(val)
84
+
85
+ @staticmethod
86
+ def _emit_section(lines: list[str], header: str, options: dict) -> None:
87
+ """Append an INI section with its options to lines."""
88
+ lines.append(f"[{header}]")
89
+ for key, val in options.items():
90
+ lines.append(f"{key} = {DownloadService._format_value(val)}")
91
+ lines.append("")
19
92
 
20
- def download_sqlfluff_config(self) -> bool:
93
+ def download_sqlfluff_config(self, profiles_dir: str = None) -> bool:
21
94
  info_line("Downloading SQLFluff configuration...")
22
95
  raw = self.sf.get_framework_config("SQLFLUFF_RULES")
23
96
  if not raw:
24
97
  error_line("No SQLFluff configuration found in native app")
25
98
  return False
26
99
 
27
- lines = ["[sqlfluff]", "dialect = snowflake", "templater = dbt", ""]
28
-
100
+ lines: list[str] = []
101
+
102
+ # --- Core sections ([sqlfluff], [sqlfluff:templater:dbt], etc.) ---
29
103
  core = raw.get("core", {})
30
- for code, state in core.items():
31
- if isinstance(state, dict) and state.get("enabled", True):
32
- opts = state.get("options", {})
33
- for opt_key, opt_val in opts.items():
34
- lines.append(f"{opt_key} = {opt_val}")
35
-
36
- lines.append("")
37
- lines.append("[sqlfluff:indentation]")
104
+ for key, section_header in self.CORE_SECTION_MAP.items():
105
+ entry = core.get(key)
106
+ if not isinstance(entry, dict) or not entry.get("enabled", True):
107
+ continue
108
+ opts = dict(entry.get("options", {}))
109
+ if key == "dbt":
110
+ # Override profiles_dir / project_dir with local values
111
+ opts["project_dir"] = "."
112
+ if profiles_dir:
113
+ opts["profiles_dir"] = profiles_dir
114
+ elif "profiles_dir" in opts:
115
+ del opts["profiles_dir"]
116
+ if opts:
117
+ self._emit_section(lines, section_header, opts)
118
+
119
+ # --- Indentation ---
38
120
  indentation = raw.get("indentation", {})
39
- for code, state in indentation.items():
40
- if isinstance(state, dict):
41
- opts = state.get("options", {})
42
- for opt_key, opt_val in opts.items():
43
- lines.append(f"{opt_key} = {opt_val}")
44
-
45
- content = "\n".join(lines) + "\n"
121
+ for _code, entry in indentation.items():
122
+ if isinstance(entry, dict) and entry.get("enabled", True):
123
+ opts = entry.get("options", {})
124
+ if opts:
125
+ self._emit_section(lines, "sqlfluff:indentation", opts)
126
+
127
+ # --- Layout sections ([sqlfluff:layout:type:<code>]) ---
128
+ layout = raw.get("layout", {})
129
+ for code, entry in layout.items():
130
+ if not isinstance(entry, dict) or not entry.get("enabled", True):
131
+ continue
132
+ opts = entry.get("options", {})
133
+ if opts:
134
+ self._emit_section(lines, f"sqlfluff:layout:type:{code}", opts)
135
+
136
+ # --- Global rules ([sqlfluff:rules]) ---
137
+ rules = raw.get("rules", {})
138
+ for _code, entry in rules.items():
139
+ if isinstance(entry, dict) and entry.get("enabled", True):
140
+ opts = entry.get("options", {})
141
+ if opts:
142
+ self._emit_section(lines, "sqlfluff:rules", opts)
143
+
144
+ # --- Rule bundle sections (rules_aliasing, rules_capitalisation, etc.) ---
145
+ for section_key, entries in raw.items():
146
+ if not section_key.startswith("rules_") or not isinstance(entries, dict):
147
+ continue
148
+ for code, entry in entries.items():
149
+ if not isinstance(entry, dict) or not entry.get("enabled", True):
150
+ continue
151
+ section_name = self.RULE_SECTION_MAP.get(code)
152
+ if not section_name:
153
+ continue
154
+ opts = entry.get("options", {})
155
+ self._emit_section(lines, f"sqlfluff:rules:{section_name}", opts)
156
+
157
+ content = "\n".join(lines)
46
158
  dest = self.project_dir / ".sqlfluff"
47
159
  write_file(dest, content)
48
160
  success_line(f"SQLFluff config written to {dest}")
File without changes
File without changes