datasecops-cli 0.2.6__tar.gz → 0.2.8__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 (48) hide show
  1. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/CHANGELOG.md +25 -0
  2. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/PKG-INFO +54 -8
  3. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/README.md +53 -7
  4. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/docs/getting-started.md +1 -1
  5. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/pyproject.toml +1 -1
  6. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/setup.ps1 +6 -5
  7. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/setup.sh +4 -4
  8. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/main.py +113 -48
  9. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/menus/development.py +64 -23
  10. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/menus/downloads.py +6 -3
  11. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/bootstrap_service.py +12 -8
  12. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/dbt_runner.py +5 -2
  13. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/download_service.py +36 -13
  14. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/linting_service.py +13 -7
  15. datasecops_cli-0.2.8/tests/test_main.py +207 -0
  16. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/.github/workflows/publish-cli.yml +0 -0
  17. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/.gitignore +0 -0
  18. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/DEVELOPMENT.md +0 -0
  19. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/LICENSE +0 -0
  20. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/docs/legacy.md +0 -0
  21. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/docs/legacy_plan_of_action.md +0 -0
  22. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/docs/mcp-server.md +0 -0
  23. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/mcp-servers.json +0 -0
  24. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/__init__.py +0 -0
  25. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/config.py +0 -0
  26. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/menus/__init__.py +0 -0
  27. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/menus/git_operations.py +0 -0
  28. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/models/__init__.py +0 -0
  29. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/models/git_helpers.py +0 -0
  30. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/models/project_config.py +0 -0
  31. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/__init__.py +0 -0
  32. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/git_service.py +0 -0
  33. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/skill_service.py +0 -0
  34. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/services/snowflake_service.py +0 -0
  35. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/utilities/__init__.py +0 -0
  36. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/utilities/display.py +0 -0
  37. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/utilities/file_utils.py +0 -0
  38. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  39. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_mcp/__init__.py +0 -0
  40. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_mcp/__main__.py +0 -0
  41. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_mcp/connection.py +0 -0
  42. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/src/datasecops_mcp/server.py +0 -0
  43. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/tests/__init__.py +0 -0
  44. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/tests/test_config.py +0 -0
  45. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/tests/test_file_utils.py +0 -0
  46. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/tests/test_models.py +0 -0
  47. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/tests/test_version.py +0 -0
  48. {datasecops_cli-0.2.6 → datasecops_cli-0.2.8}/tests/test_yaml_utils.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to the DataSecOps CLI are documented in this file.
4
4
 
5
+ ## [0.2.8] - 2026-05-14
6
+
7
+ ### Added
8
+
9
+ - **Non-interactive `download` subcommand** — `datasecops download <items>` downloads framework config without prompts, designed for CI/CD pipelines. Supports `sqlfluff`, `pipelines`, `packages`, `macros`, or `all`
10
+ - **Runtime `dbtf` check** — the CLI now warns at startup if dbt Fusion (`dbtf`) is not found on PATH
11
+
12
+ ### Changed
13
+
14
+ - **Platform auto-detected from framework config** — pipeline downloads, bootstrap, and the downloads menu now read `source_control_platform` from the native app instead of prompting the user to select GitHub or Azure DevOps
15
+ - **Case-insensitive platform matching** — `download_pipelines()` now compares platform values case-insensitively, fixing a bug where pipelines stored as `"GitHub"` would not match the lowercase `"github"` filter
16
+ - **All dbt commands use `dbtf`** — `DbtRunner` and setup scripts now consistently use the `dbtf` binary (dbt Fusion) instead of `dbt`
17
+ - **Refactored CLI entry point** — `main()` now uses `argparse` with subcommands (`bootstrap`, `download`) instead of manual `sys.argv` parsing. Shared connection/config logic extracted to `_connect_and_load()`
18
+ - **Development menu option 14** relabelled to clarify it installs dbt-core/dbt-snowflake for linting, not dbt Fusion
19
+
20
+ ## [0.2.7] - 2026-05-12
21
+
22
+ ### Changed
23
+
24
+ - **Separate SQLFluff and dbt installs** — `get_sqlfluff_requirements()` now returns only `sqlfluff` and `sqlfluff-templater-dbt`; new `get_dbt_requirements()` returns only `dbt-core` and `dbt-snowflake`. The lint menu install option and the new development menu install option each handle their own packages independently
25
+ - **Reorganised development menu** — added `[7] parse` for `dbt parse`, added `[14] install dbt` for explicit dbt-core/dbt-snowflake installation, removed standalone retry (available in run and test submenus)
26
+ - **Expanded test submenu** — added unit tests (`test_type:unit`), data tests (`test_type:data`), and failed test retry options alongside all tests and specific selector
27
+ - **Clearer input prompts** — run, test, and lint specific-file prompts now include examples of valid input (e.g. `my_model+`, `tag:nightly`, `models/staging/stg_orders.sql`)
28
+ - **Bootstrap shows separate install hints** — step 7 now lists SQLFluff and dbt requirements separately with individual `uv pip install` commands
29
+
5
30
  ## [0.2.6] - 2026-05-11
6
31
 
7
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datasecops-cli
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -55,7 +55,7 @@ Requires Python 3.10 or later.
55
55
 
56
56
  Optional:
57
57
 
58
- - **dbt Fusion** (or dbt-core with dbt-snowflake) for dbt commands
58
+ - **dbt Fusion** required for dbt commands (`dbtf`). Install from https://docs.getdbt.com/docs/core/installation
59
59
  - **Cortex Code** for skill downloads
60
60
  - **Node.js 18+** for GitHub/Azure DevOps MCP servers
61
61
 
@@ -96,6 +96,58 @@ datasecops
96
96
  | **Git** | Branch create/checkout/delete, commit & push, rebase, squash, deploy to environment branches, cherry-pick |
97
97
  | **Downloads** | SQLFluff config, CI/CD pipelines (GitHub Actions / Azure DevOps), dbt packages, Cortex Code skills |
98
98
 
99
+ ## Non-Interactive Mode (CI/CD)
100
+
101
+ The `download` subcommand lets you pull framework config in CI/CD pipelines without interactive prompts:
102
+
103
+ ```bash
104
+ # Download specific items
105
+ datasecops download sqlfluff
106
+ datasecops download sqlfluff packages
107
+ datasecops download pipelines macros
108
+
109
+ # Download everything
110
+ datasecops download all
111
+ ```
112
+
113
+ Available items: `sqlfluff`, `pipelines`, `packages`, `macros`, `all`
114
+
115
+ The pipeline platform (GitHub / Azure DevOps) is auto-detected from the native app's source control configuration.
116
+
117
+ ### Pipeline Setup
118
+
119
+ Your pipeline needs two things:
120
+
121
+ 1. **A `.datasecops.yml`** in the repo (already committed — contains no secrets):
122
+
123
+ ```yaml
124
+ connection_name: "ci"
125
+ app_database: "DATA_ENGINEERS_DATASECOPS_FRAMEWORK"
126
+ ```
127
+
128
+ 2. **A Snowflake connection** in `~/.snowflake/connections.toml` for the CI service account:
129
+
130
+ ```yaml
131
+ # GitHub Actions example
132
+ - name: Configure Snowflake connection
133
+ run: |
134
+ mkdir -p ~/.snowflake
135
+ cat > ~/.snowflake/connections.toml << EOF
136
+ [ci]
137
+ account = "${{ vars.SNOWFLAKE_ACCOUNT }}"
138
+ user = "${{ vars.SNOWFLAKE_USER }}"
139
+ authenticator = "snowflake_jwt"
140
+ private_key_file = "/tmp/rsa_key.p8"
141
+ warehouse = "CI_WH"
142
+ role = "CI_ROLE"
143
+ EOF
144
+
145
+ - name: Download SQLFluff config
146
+ run: datasecops download sqlfluff
147
+ ```
148
+
149
+ The exit code is `0` on success, `1` if any download fails.
150
+
99
151
  ## MCP Server
100
152
 
101
153
  The package includes an MCP (Model Context Protocol) server that exposes your framework's governance configuration to AI coding assistants. Instead of static skill files, the MCP server gives AI tools live access to your native app's current rules.
@@ -154,12 +206,6 @@ app_database: "DATA_ENGINEERS_DATASECOPS_FRAMEWORK"
154
206
 
155
207
  Project profiles, linting rules, pipeline templates, and deployment targets are all managed centrally in the native app and pulled down by the CLI.
156
208
 
157
- ## Documentation
158
-
159
- - [Getting Started Guide](docs/getting-started.md) — install CLI, configure MCP servers for VS Code/Cursor/Cortex Code with Snowflake, dbt, and GitHub/Azure DevOps
160
- - [MCP Server Reference](docs/mcp-server.md) — full tool documentation, architecture, and usage examples
161
- - [Development Guide](DEVELOPMENT.md) — project structure, setup scripts, native app API reference, and publishing details
162
-
163
209
  ## License
164
210
 
165
211
  MIT
@@ -35,7 +35,7 @@ Requires Python 3.10 or later.
35
35
 
36
36
  Optional:
37
37
 
38
- - **dbt Fusion** (or dbt-core with dbt-snowflake) for dbt commands
38
+ - **dbt Fusion** required for dbt commands (`dbtf`). Install from https://docs.getdbt.com/docs/core/installation
39
39
  - **Cortex Code** for skill downloads
40
40
  - **Node.js 18+** for GitHub/Azure DevOps MCP servers
41
41
 
@@ -76,6 +76,58 @@ datasecops
76
76
  | **Git** | Branch create/checkout/delete, commit & push, rebase, squash, deploy to environment branches, cherry-pick |
77
77
  | **Downloads** | SQLFluff config, CI/CD pipelines (GitHub Actions / Azure DevOps), dbt packages, Cortex Code skills |
78
78
 
79
+ ## Non-Interactive Mode (CI/CD)
80
+
81
+ The `download` subcommand lets you pull framework config in CI/CD pipelines without interactive prompts:
82
+
83
+ ```bash
84
+ # Download specific items
85
+ datasecops download sqlfluff
86
+ datasecops download sqlfluff packages
87
+ datasecops download pipelines macros
88
+
89
+ # Download everything
90
+ datasecops download all
91
+ ```
92
+
93
+ Available items: `sqlfluff`, `pipelines`, `packages`, `macros`, `all`
94
+
95
+ The pipeline platform (GitHub / Azure DevOps) is auto-detected from the native app's source control configuration.
96
+
97
+ ### Pipeline Setup
98
+
99
+ Your pipeline needs two things:
100
+
101
+ 1. **A `.datasecops.yml`** in the repo (already committed — contains no secrets):
102
+
103
+ ```yaml
104
+ connection_name: "ci"
105
+ app_database: "DATA_ENGINEERS_DATASECOPS_FRAMEWORK"
106
+ ```
107
+
108
+ 2. **A Snowflake connection** in `~/.snowflake/connections.toml` for the CI service account:
109
+
110
+ ```yaml
111
+ # GitHub Actions example
112
+ - name: Configure Snowflake connection
113
+ run: |
114
+ mkdir -p ~/.snowflake
115
+ cat > ~/.snowflake/connections.toml << EOF
116
+ [ci]
117
+ account = "${{ vars.SNOWFLAKE_ACCOUNT }}"
118
+ user = "${{ vars.SNOWFLAKE_USER }}"
119
+ authenticator = "snowflake_jwt"
120
+ private_key_file = "/tmp/rsa_key.p8"
121
+ warehouse = "CI_WH"
122
+ role = "CI_ROLE"
123
+ EOF
124
+
125
+ - name: Download SQLFluff config
126
+ run: datasecops download sqlfluff
127
+ ```
128
+
129
+ The exit code is `0` on success, `1` if any download fails.
130
+
79
131
  ## MCP Server
80
132
 
81
133
  The package includes an MCP (Model Context Protocol) server that exposes your framework's governance configuration to AI coding assistants. Instead of static skill files, the MCP server gives AI tools live access to your native app's current rules.
@@ -134,12 +186,6 @@ app_database: "DATA_ENGINEERS_DATASECOPS_FRAMEWORK"
134
186
 
135
187
  Project profiles, linting rules, pipeline templates, and deployment targets are all managed centrally in the native app and pulled down by the CLI.
136
188
 
137
- ## Documentation
138
-
139
- - [Getting Started Guide](docs/getting-started.md) — install CLI, configure MCP servers for VS Code/Cursor/Cortex Code with Snowflake, dbt, and GitHub/Azure DevOps
140
- - [MCP Server Reference](docs/mcp-server.md) — full tool documentation, architecture, and usage examples
141
- - [Development Guide](DEVELOPMENT.md) — project structure, setup scripts, native app API reference, and publishing details
142
-
143
189
  ## License
144
190
 
145
191
  MIT
@@ -14,7 +14,7 @@ Before you begin, ensure you have:
14
14
  - **Git** configured with access to your repository
15
15
 
16
16
  Optional:
17
- - **dbt Fusion** (or dbt-core with dbt-snowflake) for dbt commands
17
+ - **dbt Fusion** required for dbt commands (`dbtf`). Install from https://docs.getdbt.com/docs/core/installation. The CLI invokes `dbtf` for all dbt operations.
18
18
  - **A GitHub Personal Access Token** (for GitHub MCP server)
19
19
  - **An Azure DevOps PAT** (for Azure DevOps MCP server)
20
20
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "datasecops-cli"
7
- version = "0.2.6"
7
+ version = "0.2.8"
8
8
  description = "DataSecOps Framework CLI for Snowflake Native App"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,13 +16,14 @@ try {
16
16
  $env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
17
17
  }
18
18
 
19
- # Check for dbt
19
+ # Check for dbt Fusion (dbtf)
20
20
  try {
21
- $null = Get-Command dbt -ErrorAction Stop
22
- Write-Host "[OK] dbt found" -ForegroundColor Green
21
+ $null = Get-Command dbtf -ErrorAction Stop
22
+ Write-Host "[OK] dbt Fusion (dbtf) found" -ForegroundColor Green
23
23
  } catch {
24
- Write-Host "WARNING: dbt not found on PATH." -ForegroundColor Yellow
25
- Write-Host " Install dbt Fusion or: pip install dbt-snowflake" -ForegroundColor Yellow
24
+ Write-Host "WARNING: dbt Fusion (dbtf) not found on PATH." -ForegroundColor Yellow
25
+ Write-Host " Install dbt Fusion: https://docs.getdbt.com/docs/core/installation" -ForegroundColor Yellow
26
+ Write-Host " The CLI uses 'dbtf' for all dbt commands." -ForegroundColor Yellow
26
27
  }
27
28
 
28
29
  # Check for cortex (optional)
@@ -16,12 +16,12 @@ if ! command -v uv &> /dev/null; then
16
16
  fi
17
17
  echo "[OK] uv $(uv --version 2>/dev/null || echo 'installed')"
18
18
 
19
- # Check for dbt
20
- if ! command -v dbt &> /dev/null; then
19
+ # Check for dbt Fusion (dbtf)
20
+ if ! command -v dbtf &> /dev/null; then
21
21
  echo ""
22
- echo "WARNING: dbt not found on PATH."
22
+ echo "WARNING: dbt Fusion (dbtf) not found on PATH."
23
23
  echo " Install dbt Fusion: https://docs.getdbt.com/docs/core/installation"
24
- echo " Or install via pip: pip install dbt-snowflake"
24
+ echo " The CLI uses 'dbtf' for all dbt commands."
25
25
  echo ""
26
26
  fi
27
27
 
@@ -1,3 +1,5 @@
1
+ import argparse
2
+ import shutil
1
3
  import sys
2
4
  from pathlib import Path
3
5
 
@@ -18,22 +20,55 @@ from datasecops_cli.utilities.display import (
18
20
  )
19
21
 
20
22
 
23
+ DOWNLOAD_ITEMS = ["sqlfluff", "pipelines", "packages", "macros", "all"]
24
+
25
+
26
+ def _build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="datasecops",
29
+ description="DataSecOps Framework CLI for Snowflake Native App",
30
+ )
31
+ sub = parser.add_subparsers(dest="command")
32
+
33
+ sub.add_parser("bootstrap", help="Set up a new dbt project with all framework config")
34
+
35
+ dl = sub.add_parser("download", help="Download framework config non-interactively (for CI/CD)")
36
+ dl.add_argument(
37
+ "items",
38
+ nargs="+",
39
+ choices=DOWNLOAD_ITEMS,
40
+ help="Item(s) to download: sqlfluff, pipelines, packages, macros, or all",
41
+ )
42
+
43
+ return parser
44
+
45
+
21
46
  def main():
22
47
  """Main entry point for the datasecops CLI."""
48
+ parser = _build_parser()
49
+ args = parser.parse_args()
50
+
23
51
  config = Config()
24
52
 
25
- # Handle 'bootstrap' subcommand
26
- if len(sys.argv) > 1 and sys.argv[1] == "bootstrap":
53
+ if args.command == "bootstrap":
27
54
  _run_bootstrap(config)
28
- return
55
+ elif args.command == "download":
56
+ _run_download(config, args.items)
57
+ else:
58
+ _run_interactive(config)
29
59
 
60
+
61
+ def _connect_and_load(config: Config) -> SnowflakeService:
62
+ """Load config, connect to Snowflake, and load native app settings.
63
+
64
+ Returns the connected SnowflakeService, or calls sys.exit on failure.
65
+ """
30
66
  if not config.load():
31
67
  sys.exit(1)
32
-
33
- # Connect to Snowflake
68
+
34
69
  sf_config = config.datasecops
35
70
  sf_service = SnowflakeService(sf_config)
36
-
71
+
37
72
  try:
38
73
  info_line(f"Connecting to Snowflake ({sf_config.connection_name})...")
39
74
  sf_service.connect()
@@ -41,38 +76,85 @@ def main():
41
76
  except Exception as e:
42
77
  error_line(f"Failed to connect to Snowflake: {e}")
43
78
  sys.exit(1)
44
-
79
+
80
+ info_line("Loading framework configuration...")
81
+ config.load_from_native_app(sf_service)
82
+
83
+ if not config.profile:
84
+ error_line(f"Profile '{config.profile_name}' not found in native app")
85
+ sf_service.close()
86
+ sys.exit(1)
87
+
88
+ success_line(f"Profile: {config.profile.project_name} ({config.profile_name})")
89
+ return sf_service
90
+
91
+
92
+ def _run_interactive(config: Config):
93
+ """Run the interactive menu-driven CLI."""
94
+ sf_service = _connect_and_load(config)
95
+
45
96
  try:
46
- # Load config from native app
47
- info_line("Loading framework configuration...")
48
- config.load_from_native_app(sf_service)
49
-
50
- if not config.profile:
51
- error_line(f"Profile '{config.profile_name}' not found in native app")
52
- sys.exit(1)
53
-
54
- success_line(f"Profile: {config.profile.project_name} ({config.profile_name})")
55
-
97
+ # Check for dbt Fusion (dbtf) on PATH
98
+ if not shutil.which("dbtf"):
99
+ from datasecops_cli.utilities.display import warning_line
100
+ warning_line("dbt Fusion (dbtf) not found on PATH — dbt commands will not work.")
101
+ warning_line("Install dbt Fusion: https://docs.getdbt.com/docs/core/installation")
102
+
56
103
  # Initialize services
57
104
  dbt_runner = DbtRunner(
58
105
  project_dir=config.dbt_project_dir,
59
106
  profiles_dir=config.get_dbt_profiles_dir(),
60
107
  target=config.project_settings.get_default_target().target_name if config.get_default_target() else "dev"
61
108
  )
62
-
109
+
63
110
  try:
64
111
  git_service = GitService(config.project_dir)
65
112
  except Exception:
66
113
  git_service = None
67
-
114
+
68
115
  linting_service = LintingService(config.dbt_project_dir)
69
116
  download_service = DownloadService(sf_service, config.project_dir)
70
117
  skill_service = SkillService(sf_service)
71
-
118
+
72
119
  # Main menu loop
73
- _main_menu(config, dbt_runner, git_service, linting_service,
120
+ _main_menu(config, dbt_runner, git_service, linting_service,
74
121
  download_service, skill_service, sf_service)
75
-
122
+ finally:
123
+ sf_service.close()
124
+
125
+
126
+ def _run_download(config: Config, items: list[str]):
127
+ """Run non-interactive downloads for CI/CD pipelines."""
128
+ sf_service = _connect_and_load(config)
129
+
130
+ try:
131
+ download_service = DownloadService(sf_service, config.project_dir)
132
+ profiles_dir = str(config.get_dbt_profiles_dir())
133
+
134
+ if "all" in items:
135
+ items = ["sqlfluff", "pipelines", "packages", "macros"]
136
+
137
+ failed = False
138
+ for item in items:
139
+ info_line("")
140
+ if item == "sqlfluff":
141
+ if not download_service.download_sqlfluff_config(
142
+ profiles_dir=profiles_dir, dbt_project_dir=config.dbt_project_dir
143
+ ):
144
+ failed = True
145
+ elif item == "pipelines":
146
+ platform = config.source_control.source_control_platform.lower()
147
+ info_line(f"Platform: {platform}")
148
+ if not download_service.download_pipelines(platform=platform):
149
+ failed = True
150
+ elif item == "packages":
151
+ if not download_service.download_dbt_packages(config.dbt_project_dir):
152
+ failed = True
153
+ elif item == "macros":
154
+ if not download_service.download_macros(config.profile_name, config.dbt_project_dir):
155
+ failed = True
156
+
157
+ sys.exit(1 if failed else 0)
76
158
  finally:
77
159
  sf_service.close()
78
160
 
@@ -110,12 +192,14 @@ def _main_menu(config: Config, dbt_runner: DbtRunner, git_service: GitService,
110
192
  profile_name, config.dbt_project_dir,
111
193
  project_settings=config.project_settings,
112
194
  profile=config.profile,
195
+ source_control=config.source_control,
113
196
  )
114
197
  dl_menu.show()
115
198
 
116
199
  elif option == 4:
117
- from datasecops_cli.utilities.display import select_from_list, get_input_true_false
118
- platform = select_from_list(["github", "azuredevops"], "source control platform", add_back=False)
200
+ from datasecops_cli.utilities.display import get_input_true_false
201
+ platform = config.source_control.source_control_platform.lower()
202
+ info_line(f"Platform: {platform}")
119
203
  install_skills = get_input_true_false("Install Cortex Code skills?")
120
204
  run_deps = get_input_true_false("Run dbt deps after downloading packages?")
121
205
  bootstrap = BootstrapService(
@@ -145,35 +229,16 @@ def _show_main_menu(profile_name: str, git_service: GitService = None):
145
229
 
146
230
  def _run_bootstrap(config: Config):
147
231
  """Run the bootstrap command to initialise a new project."""
148
- from datasecops_cli.utilities.display import select_from_list, get_input_true_false
232
+ from datasecops_cli.utilities.display import get_input_true_false
149
233
 
150
- if not config.load():
151
- sys.exit(1)
152
-
153
- sf_config = config.datasecops
154
- sf_service = SnowflakeService(sf_config)
155
-
156
- try:
157
- info_line(f"Connecting to Snowflake ({sf_config.connection_name})...")
158
- sf_service.connect()
159
- success_line("Connected")
160
- except Exception as e:
161
- error_line(f"Failed to connect to Snowflake: {e}")
162
- sys.exit(1)
234
+ sf_service = _connect_and_load(config)
163
235
 
164
236
  try:
165
- info_line("Loading framework configuration...")
166
- config.load_from_native_app(sf_service)
167
-
168
- if not config.profile:
169
- error_line(f"Profile '{config.profile_name}' not found in native app")
170
- sys.exit(1)
171
-
172
- success_line(f"Profile: {config.profile.project_name} ({config.profile_name})")
173
237
  info_line("")
174
238
 
175
- # Ask for platform
176
- platform = select_from_list(["github", "azuredevops"], "source control platform", add_back=False)
239
+ # Use platform from framework config
240
+ platform = config.source_control.source_control_platform.lower()
241
+ info_line(f"Platform: {platform}")
177
242
 
178
243
  # Ask about optional steps
179
244
  install_skills = get_input_true_false("Install Cortex Code skills?")
@@ -48,33 +48,35 @@ class DevelopmentMenu:
48
48
  self.dbt.deps()
49
49
  complete_action()
50
50
  elif option == 7:
51
+ display_action_header("dbt parse")
52
+ self.dbt.parse()
53
+ complete_action()
54
+ elif option == 8:
51
55
  display_action_header("dbt compile")
52
56
  self.dbt.compile()
53
57
  complete_action()
54
- elif option == 8:
58
+ elif option == 9:
55
59
  display_action_header("dbt snapshot")
56
60
  self.dbt.snapshot()
57
61
  complete_action()
58
- elif option == 9:
62
+ elif option == 10:
59
63
  display_action_header("dbt source freshness")
60
64
  self.dbt.source_freshness()
61
65
  complete_action()
62
- elif option == 10:
66
+ elif option == 11:
63
67
  display_action_header("dbt clean")
64
68
  self.dbt.clean()
65
69
  complete_action()
66
- elif option == 11:
70
+ elif option == 12:
67
71
  display_action_header("dbt debug")
68
72
  self.dbt.debug()
69
73
  complete_action()
70
- elif option == 12:
74
+ elif option == 13:
71
75
  display_action_header("dbt list")
72
76
  self.dbt.list_models()
73
77
  complete_action()
74
- elif option == 13:
75
- display_action_header("dbt retry")
76
- self.dbt.retry()
77
- complete_action()
78
+ elif option == 14:
79
+ self._install_dbt_requirements()
78
80
  self._menu()
79
81
  option = get_input_number("Choose an option: ")
80
82
 
@@ -87,13 +89,14 @@ class DevelopmentMenu:
87
89
  menu_option(4, "docs - Generate & serve dbt docs")
88
90
  menu_option(5, "seed - Load seed data")
89
91
  menu_option(6, "deps - Install dbt packages")
90
- menu_option(7, "compile - Compile dbt models")
91
- menu_option(8, "snapshot - Run dbt snapshots")
92
- menu_option(9, "freshness - Check source freshness")
93
- menu_option(10, "clean - Clean dbt target")
94
- menu_option(11, "debug - Debug dbt connection")
95
- menu_option(12, "list - List dbt resources")
96
- menu_option(13, "retry - Retry failed dbt run")
92
+ menu_option(7, "parse - Parse dbt project")
93
+ menu_option(8, "compile - Compile dbt models")
94
+ menu_option(9, "snapshot - Run dbt snapshots")
95
+ menu_option(10, "freshness - Check source freshness")
96
+ menu_option(11, "clean - Clean dbt target")
97
+ menu_option(12, "debug - Debug dbt connection")
98
+ menu_option(13, "list - List dbt resources")
99
+ menu_option(14, "install lint - Install dbt-core & dbt-snowflake for linting (not dbt Fusion)")
97
100
  menu_option(0, "back - Return to main menu")
98
101
 
99
102
  def _run_menu(self) -> None:
@@ -113,7 +116,7 @@ class DevelopmentMenu:
113
116
  full_refresh = get_input_true_false("Full refresh?", "n")
114
117
  self.dbt.run(modified_only=True, full_refresh=full_refresh)
115
118
  elif option == 3:
116
- select = get_input_string("Enter model selector: ")
119
+ select = get_input_string("Enter model selector (e.g. my_model, my_model+, tag:nightly, path:models/...): ")
117
120
  if select != "0":
118
121
  self.dbt.run(select=select)
119
122
  elif option == 4:
@@ -129,13 +132,22 @@ class DevelopmentMenu:
129
132
  clear()
130
133
  display_action_header("dbt Test Options")
131
134
  menu_option(1, "all tests - Run all tests")
132
- menu_option(2, "specific - Run specific test(s)")
135
+ menu_option(2, "unit tests - Run unit tests only")
136
+ menu_option(3, "data tests - Run data tests only")
137
+ menu_option(4, "failed - Retry failed tests")
138
+ menu_option(5, "specific - Run specific test(s)")
133
139
  menu_option(0, "back - Return to development menu")
134
140
  option = get_input_number("Choose an option: ")
135
141
  if option == 1:
136
142
  self.dbt.test()
137
143
  elif option == 2:
138
- select = get_input_string("Enter test selector: ")
144
+ self.dbt.test(select="test_type:unit")
145
+ elif option == 3:
146
+ self.dbt.test(select="test_type:data")
147
+ elif option == 4:
148
+ self.dbt.retry()
149
+ elif option == 5:
150
+ select = get_input_string("Enter test selector (e.g. test_name, model_name, tag:nightly): ")
139
151
  if select != "0":
140
152
  self.dbt.test(select=select)
141
153
  complete_action()
@@ -179,7 +191,7 @@ class DevelopmentMenu:
179
191
  elif option == 4:
180
192
  self.linting.lint_file(fix=True)
181
193
  elif option == 5:
182
- path = get_input_string("Enter file path: ")
194
+ path = get_input_string("Enter SQL file path (e.g. models/staging/stg_orders.sql): ")
183
195
  if path != "0":
184
196
  self.linting.lint_file(file_path=path, fix=False)
185
197
  elif option == 6:
@@ -192,11 +204,11 @@ class DevelopmentMenu:
192
204
  error_line("Download service not available")
193
205
  return
194
206
 
195
- # Show current versions
207
+ # Show current sqlfluff versions
196
208
  installed = self.linting.get_installed_versions()
197
209
  info_line("Currently installed:")
198
- for pkg, ver in installed.items():
199
- info_line(f" {pkg}: {ver or 'not installed'}")
210
+ for pkg in ("sqlfluff", "sqlfluff-templater-dbt"):
211
+ info_line(f" {pkg}: {installed.get(pkg) or 'not installed'}")
200
212
 
201
213
  # Fetch required versions from framework
202
214
  packages = self.downloads.get_sqlfluff_requirements()
@@ -210,3 +222,32 @@ class DevelopmentMenu:
210
222
 
211
223
  info_line("")
212
224
  self.linting.install_requirements(packages)
225
+
226
+ def _install_dbt_requirements(self) -> None:
227
+ """Install dbt-core and dbt-snowflake at versions defined by the framework."""
228
+ display_action_header("Install dbt Requirements")
229
+ if not self.downloads:
230
+ error_line("Download service not available")
231
+ complete_action()
232
+ return
233
+
234
+ # Show current dbt versions
235
+ installed = self.linting.get_installed_versions()
236
+ info_line("Currently installed:")
237
+ for pkg in ("dbt-core", "dbt-snowflake"):
238
+ info_line(f" {pkg}: {installed.get(pkg) or 'not installed'}")
239
+
240
+ # Fetch required dbt versions from framework
241
+ packages = self.downloads.get_dbt_requirements()
242
+ if not packages:
243
+ complete_action()
244
+ return
245
+
246
+ info_line("")
247
+ info_line("Framework requires:")
248
+ for pkg in packages:
249
+ info_line(f" {pkg}")
250
+
251
+ info_line("")
252
+ self.linting.install_requirements(packages)
253
+ complete_action()
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
- from datasecops_cli.models.project_config import ProjectProfile, ProjectSettings
3
+ from datasecops_cli.models.project_config import ProjectProfile, ProjectSettings, SourceControl
4
4
  from datasecops_cli.services.download_service import DownloadService
5
5
  from datasecops_cli.services.skill_service import SkillService
6
6
  from datasecops_cli.services.dbt_runner import DbtRunner
@@ -14,7 +14,8 @@ from datasecops_cli.utilities.display import (
14
14
  class DownloadsMenu:
15
15
  def __init__(self, download_service: DownloadService, skill_service: SkillService,
16
16
  dbt_runner: DbtRunner, profile_name: str, dbt_project_dir: Path,
17
- project_settings: ProjectSettings = None, profile: ProjectProfile = None):
17
+ project_settings: ProjectSettings = None, profile: ProjectProfile = None,
18
+ source_control: SourceControl = None):
18
19
  self.downloads = download_service
19
20
  self.skills = skill_service
20
21
  self.dbt = dbt_runner
@@ -22,6 +23,7 @@ class DownloadsMenu:
22
23
  self.dbt_project_dir = dbt_project_dir
23
24
  self.project_settings = project_settings
24
25
  self.profile = profile
26
+ self.source_control = source_control
25
27
 
26
28
  def show(self) -> None:
27
29
  self._menu()
@@ -34,7 +36,8 @@ class DownloadsMenu:
34
36
  complete_action()
35
37
  elif option == 2:
36
38
  display_action_header("Download Pipeline Files")
37
- platform = select_from_list(["github", "azuredevops"], "platform", add_back=False)
39
+ platform = self.source_control.source_control_platform.lower() if self.source_control else "github"
40
+ info_line(f"Platform: {platform}")
38
41
  self.downloads.download_pipelines(platform=platform)
39
42
  complete_action()
40
43
  elif option == 3:
@@ -109,14 +109,18 @@ class BootstrapService:
109
109
  info_line(" (dbt deps failed - run manually after fixing packages.yml)")
110
110
  steps_passed += 1
111
111
 
112
- # Step 7: Download SQLFluff requirements
112
+ # Step 7: Check SQLFluff & dbt version requirements
113
113
  info_line("")
114
114
  step_num = 7 if run_deps else 6
115
- info_line(f"[{step_num}] Checking SQLFluff version requirements...")
116
- requirements = self.download_service.get_sqlfluff_requirements()
117
- if requirements:
118
- info_line(f" Required: {', '.join(requirements)}")
119
- info_line(" (install with: uv pip install " + " ".join(requirements) + ")")
115
+ info_line(f"[{step_num}] Checking SQLFluff & dbt version requirements...")
116
+ sqlfluff_requirements = self.download_service.get_sqlfluff_requirements()
117
+ if sqlfluff_requirements:
118
+ info_line(f" SQLFluff: {', '.join(sqlfluff_requirements)}")
119
+ info_line(" (install with: uv pip install " + " ".join(sqlfluff_requirements) + ")")
120
+ dbt_requirements = self.download_service.get_dbt_requirements()
121
+ if dbt_requirements:
122
+ info_line(f" dbt: {', '.join(dbt_requirements)}")
123
+ info_line(" (install with: uv pip install " + " ".join(dbt_requirements) + ")")
120
124
  steps_passed += 1
121
125
 
122
126
  # Step 8: Install Cortex Code skills
@@ -134,7 +138,7 @@ class BootstrapService:
134
138
  info_line("Your project is ready. Next steps:")
135
139
  info_line(f" cd {dbt_project_dir}")
136
140
  info_line(" datasecops — run the framework CLI")
137
- info_line(" dbt debug — verify Snowflake connection")
138
- info_line(" dbt run — run your first models")
141
+ info_line(" dbtf debug — verify Snowflake connection")
142
+ info_line(" dbtf run — run your first models")
139
143
  info_line("")
140
144
  return True
@@ -21,7 +21,7 @@ class DbtRunner:
21
21
  ]
22
22
 
23
23
  def _run_command(self, command: str, extra_args: list[str] = None) -> subprocess.CompletedProcess:
24
- cmd = ["dbt", command] + (extra_args or []) + self._default_args()
24
+ cmd = ["dbtf", command] + (extra_args or []) + self._default_args()
25
25
  info_line(f"Running: {' '.join(cmd)}")
26
26
  result = subprocess.run(cmd, capture_output=False)
27
27
  if result.returncode != 0:
@@ -94,7 +94,7 @@ class DbtRunner:
94
94
  return self._run_command("docs", ["generate", f"--target={self.target}"])
95
95
 
96
96
  def docs_serve(self) -> subprocess.Popen:
97
- cmd = ["dbt", "docs", "serve"] + self._default_args()
97
+ cmd = ["dbtf", "docs", "serve"] + self._default_args()
98
98
  info_line(f"Running: {' '.join(cmd)}")
99
99
  return subprocess.Popen(cmd)
100
100
 
@@ -117,6 +117,9 @@ class DbtRunner:
117
117
  self._copy_manifest()
118
118
  return result
119
119
 
120
+ def parse(self) -> subprocess.CompletedProcess:
121
+ return self._run_command("parse")
122
+
120
123
  def run_operation(self, macro: str, args_str: str = None) -> subprocess.CompletedProcess:
121
124
  cmd_args = [f"--target={self.target}"]
122
125
  if args_str:
@@ -159,7 +159,12 @@ class DownloadService:
159
159
  section_name = self.RULE_SECTION_MAP.get(code)
160
160
  if not section_name:
161
161
  continue
162
- opts = entry.get("options", {})
162
+ opts = dict(entry.get("options", {}))
163
+ # For aliasing.length, 0 means "no limit" which sqlfluff expects as None
164
+ if section_name == "aliasing.length":
165
+ for len_key in ("min_alias_length", "max_alias_length"):
166
+ if len_key in opts and opts[len_key] == 0:
167
+ opts[len_key] = None
163
168
  self._emit_section(lines, f"sqlfluff:rules:{section_name}", opts)
164
169
 
165
170
  content = "\n".join(lines)
@@ -168,17 +173,16 @@ class DownloadService:
168
173
  success_line(f"SQLFluff config written to {dest}")
169
174
  return True
170
175
 
171
- def get_sqlfluff_requirements(self) -> list[str]:
172
- """Fetch active sqlfluff and dbt package versions from the framework."""
173
- info_line("Fetching SQLFluff requirements from framework...")
176
+ def _fetch_framework_versions(self) -> dict[str, str]:
177
+ """Fetch all active package versions from the framework's DBT_VERSIONS config.
178
+
179
+ Returns a dict mapping pip package name to pinned spec, e.g. {"dbt-core": "dbt-core==1.9.0", ...}.
180
+ """
174
181
  raw = self.sf.get_framework_config("DBT_VERSIONS")
175
182
  if not raw:
176
183
  error_line("No DBT_VERSIONS configuration found in native app")
177
- return []
178
-
179
- packages = []
184
+ return {}
180
185
 
181
- # Map of config key -> pip package name
182
186
  version_keys = {
183
187
  "sqlfluff_versions": "sqlfluff",
184
188
  "sqlfluff_templater_versions": "sqlfluff-templater-dbt",
@@ -186,16 +190,35 @@ class DownloadService:
186
190
  "dbt_snowflake_versions": "dbt-snowflake",
187
191
  }
188
192
 
193
+ result: dict[str, str] = {}
189
194
  for config_key, pkg_name in version_keys.items():
190
195
  for entry in raw.get(config_key, []):
191
196
  if entry.get("active"):
192
- packages.append(f"{pkg_name}=={entry['version']}")
197
+ result[pkg_name] = f"{pkg_name}=={entry['version']}"
193
198
  break
194
199
 
195
- if not packages:
200
+ if not result:
196
201
  error_line("No active versions found in framework configuration")
197
202
 
198
- return packages
203
+ return result
204
+
205
+ def get_sqlfluff_requirements(self) -> list[str]:
206
+ """Fetch active sqlfluff and sqlfluff-templater-dbt versions from the framework."""
207
+ info_line("Fetching SQLFluff requirements from framework...")
208
+ versions = self._fetch_framework_versions()
209
+ sqlfluff_packages = [versions[k] for k in ("sqlfluff", "sqlfluff-templater-dbt") if k in versions]
210
+ if not sqlfluff_packages:
211
+ error_line("No active SQLFluff versions found in framework configuration")
212
+ return sqlfluff_packages
213
+
214
+ def get_dbt_requirements(self) -> list[str]:
215
+ """Fetch active dbt-core and dbt-snowflake versions from the framework."""
216
+ info_line("Fetching dbt requirements from framework...")
217
+ versions = self._fetch_framework_versions()
218
+ dbt_packages = [versions[k] for k in ("dbt-core", "dbt-snowflake") if k in versions]
219
+ if not dbt_packages:
220
+ error_line("No active dbt versions found in framework configuration")
221
+ return dbt_packages
199
222
 
200
223
  def download_pipelines(self, platform: str = "github") -> bool:
201
224
  info_line(f"Downloading {platform} pipeline configurations...")
@@ -207,12 +230,12 @@ class DownloadService:
207
230
  pipelines = raw.get("pipelines", [])
208
231
  count = 0
209
232
  for pipe in pipelines:
210
- if pipe.get("platform", "github") != platform or not pipe.get("enabled", True):
233
+ if pipe.get("platform", "github").lower() != platform.lower() or not pipe.get("enabled", True):
211
234
  continue
212
235
  filename = pipe.get("filename", "")
213
236
  yaml_content = pipe.get("yaml_content", "")
214
237
  if filename and yaml_content:
215
- if platform == "github":
238
+ if platform.lower() == "github":
216
239
  dest = self.project_dir / ".github" / "workflows" / filename
217
240
  else:
218
241
  dest = self.project_dir / filename
@@ -10,10 +10,16 @@ class LintingService:
10
10
  def __init__(self, project_dir: Path):
11
11
  self.project_dir = project_dir
12
12
 
13
- def get_installed_versions(self) -> dict[str, str]:
14
- """Get currently installed versions of sqlfluff packages."""
13
+ def get_installed_versions(self, packages: list[str] = None) -> dict[str, str]:
14
+ """Get currently installed versions of the specified packages.
15
+
16
+ Args:
17
+ packages: Package names to check. Defaults to sqlfluff, sqlfluff-templater-dbt, dbt-core, dbt-snowflake.
18
+ """
19
+ if packages is None:
20
+ packages = ["sqlfluff", "sqlfluff-templater-dbt", "dbt-core", "dbt-snowflake"]
15
21
  versions = {}
16
- for package in ["sqlfluff", "sqlfluff-templater-dbt", "dbt-core", "dbt-snowflake"]:
22
+ for package in packages:
17
23
  result = subprocess.run(
18
24
  ["uv", "pip", "show", package],
19
25
  capture_output=True, text=True
@@ -28,7 +34,7 @@ class LintingService:
28
34
  return versions
29
35
 
30
36
  def install_requirements(self, packages: list[str]) -> bool:
31
- """Install sqlfluff packages at specified versions."""
37
+ """Install packages at specified versions via uv pip."""
32
38
  if not packages:
33
39
  warning_line("No packages to install")
34
40
  return False
@@ -37,7 +43,7 @@ class LintingService:
37
43
  info_line(f"Installing: {', '.join(packages)}")
38
44
  result = subprocess.run(cmd, capture_output=False)
39
45
  if result.returncode == 0:
40
- success_line("SQLFluff requirements installed successfully")
46
+ success_line("Packages installed successfully")
41
47
  return True
42
48
  else:
43
49
  error_line(f"uv pip install failed with exit code {result.returncode}")
@@ -75,8 +81,8 @@ class LintingService:
75
81
  return True
76
82
  return self.install_requirements(to_install)
77
83
 
78
- # No pinned versions — just ensure packages are present
79
- missing = [pkg for pkg in ["sqlfluff", "sqlfluff-templater-dbt", "dbt-core", "dbt-snowflake"] if not installed.get(pkg)]
84
+ # No pinned versions — just ensure sqlfluff packages are present
85
+ missing = [pkg for pkg in ["sqlfluff", "sqlfluff-templater-dbt"] if not installed.get(pkg)]
80
86
  if not missing:
81
87
  return True
82
88
  warning_line(f"Missing packages: {', '.join(missing)}")
@@ -0,0 +1,207 @@
1
+ """Tests for datasecops_cli.main argument parsing and download dispatch."""
2
+ import pytest
3
+ from unittest.mock import MagicMock, patch, call
4
+ from pathlib import Path
5
+
6
+ from datasecops_cli.main import _build_parser, _run_download, DOWNLOAD_ITEMS
7
+ from datasecops_cli.config import Config
8
+
9
+
10
+ class TestBuildParser:
11
+ """Tests for the argparse configuration."""
12
+
13
+ def test_no_args_gives_no_command(self):
14
+ parser = _build_parser()
15
+ args = parser.parse_args([])
16
+ assert args.command is None
17
+
18
+ def test_bootstrap_command(self):
19
+ parser = _build_parser()
20
+ args = parser.parse_args(["bootstrap"])
21
+ assert args.command == "bootstrap"
22
+
23
+ def test_download_single_item(self):
24
+ parser = _build_parser()
25
+ args = parser.parse_args(["download", "sqlfluff"])
26
+ assert args.command == "download"
27
+ assert args.items == ["sqlfluff"]
28
+
29
+ def test_download_multiple_items(self):
30
+ parser = _build_parser()
31
+ args = parser.parse_args(["download", "sqlfluff", "packages", "macros"])
32
+ assert args.command == "download"
33
+ assert args.items == ["sqlfluff", "packages", "macros"]
34
+
35
+ def test_download_all(self):
36
+ parser = _build_parser()
37
+ args = parser.parse_args(["download", "all"])
38
+ assert args.command == "download"
39
+ assert args.items == ["all"]
40
+
41
+ def test_download_invalid_item_exits(self):
42
+ parser = _build_parser()
43
+ with pytest.raises(SystemExit):
44
+ parser.parse_args(["download", "invalid"])
45
+
46
+ def test_download_no_items_exits(self):
47
+ parser = _build_parser()
48
+ with pytest.raises(SystemExit):
49
+ parser.parse_args(["download"])
50
+
51
+ def test_all_download_items_accepted(self):
52
+ parser = _build_parser()
53
+ for item in DOWNLOAD_ITEMS:
54
+ args = parser.parse_args(["download", item])
55
+ assert args.items == [item]
56
+
57
+
58
+ class TestRunDownload:
59
+ """Tests for _run_download dispatch logic using mocked services."""
60
+
61
+ def _make_config(self, tmp_path):
62
+ """Create a Config with minimal valid state."""
63
+ config = Config()
64
+ config.project_dir = tmp_path
65
+ config.dbt_project_dir = tmp_path
66
+ config.profile_name = "test_profile"
67
+ # source_control defaults to GitHub
68
+ return config
69
+
70
+ @patch("datasecops_cli.main._connect_and_load")
71
+ def test_download_sqlfluff(self, mock_connect, tmp_path):
72
+ config = self._make_config(tmp_path)
73
+ mock_sf = MagicMock()
74
+ mock_connect.return_value = mock_sf
75
+
76
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
77
+ mock_ds = MockDS.return_value
78
+ mock_ds.download_sqlfluff_config.return_value = True
79
+
80
+ with pytest.raises(SystemExit) as exc:
81
+ _run_download(config, ["sqlfluff"])
82
+
83
+ assert exc.value.code == 0
84
+ mock_ds.download_sqlfluff_config.assert_called_once()
85
+ mock_ds.download_pipelines.assert_not_called()
86
+ mock_ds.download_dbt_packages.assert_not_called()
87
+ mock_ds.download_macros.assert_not_called()
88
+
89
+ @patch("datasecops_cli.main._connect_and_load")
90
+ def test_download_pipelines(self, mock_connect, tmp_path):
91
+ config = self._make_config(tmp_path)
92
+ mock_sf = MagicMock()
93
+ mock_connect.return_value = mock_sf
94
+
95
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
96
+ mock_ds = MockDS.return_value
97
+ mock_ds.download_pipelines.return_value = True
98
+
99
+ with pytest.raises(SystemExit) as exc:
100
+ _run_download(config, ["pipelines"])
101
+
102
+ assert exc.value.code == 0
103
+ mock_ds.download_pipelines.assert_called_once_with(platform="github")
104
+
105
+ @patch("datasecops_cli.main._connect_and_load")
106
+ def test_download_packages(self, mock_connect, tmp_path):
107
+ config = self._make_config(tmp_path)
108
+ mock_sf = MagicMock()
109
+ mock_connect.return_value = mock_sf
110
+
111
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
112
+ mock_ds = MockDS.return_value
113
+ mock_ds.download_dbt_packages.return_value = True
114
+
115
+ with pytest.raises(SystemExit) as exc:
116
+ _run_download(config, ["packages"])
117
+
118
+ assert exc.value.code == 0
119
+ mock_ds.download_dbt_packages.assert_called_once_with(tmp_path)
120
+
121
+ @patch("datasecops_cli.main._connect_and_load")
122
+ def test_download_macros(self, mock_connect, tmp_path):
123
+ config = self._make_config(tmp_path)
124
+ mock_sf = MagicMock()
125
+ mock_connect.return_value = mock_sf
126
+
127
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
128
+ mock_ds = MockDS.return_value
129
+ mock_ds.download_macros.return_value = True
130
+
131
+ with pytest.raises(SystemExit) as exc:
132
+ _run_download(config, ["macros"])
133
+
134
+ assert exc.value.code == 0
135
+ mock_ds.download_macros.assert_called_once_with("test_profile", tmp_path)
136
+
137
+ @patch("datasecops_cli.main._connect_and_load")
138
+ def test_download_all_expands_to_all_items(self, mock_connect, tmp_path):
139
+ config = self._make_config(tmp_path)
140
+ mock_sf = MagicMock()
141
+ mock_connect.return_value = mock_sf
142
+
143
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
144
+ mock_ds = MockDS.return_value
145
+ mock_ds.download_sqlfluff_config.return_value = True
146
+ mock_ds.download_pipelines.return_value = True
147
+ mock_ds.download_dbt_packages.return_value = True
148
+ mock_ds.download_macros.return_value = True
149
+
150
+ with pytest.raises(SystemExit) as exc:
151
+ _run_download(config, ["all"])
152
+
153
+ assert exc.value.code == 0
154
+ mock_ds.download_sqlfluff_config.assert_called_once()
155
+ mock_ds.download_pipelines.assert_called_once()
156
+ mock_ds.download_dbt_packages.assert_called_once()
157
+ mock_ds.download_macros.assert_called_once()
158
+
159
+ @patch("datasecops_cli.main._connect_and_load")
160
+ def test_download_failure_exits_1(self, mock_connect, tmp_path):
161
+ config = self._make_config(tmp_path)
162
+ mock_sf = MagicMock()
163
+ mock_connect.return_value = mock_sf
164
+
165
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
166
+ mock_ds = MockDS.return_value
167
+ mock_ds.download_sqlfluff_config.return_value = False # failure
168
+
169
+ with pytest.raises(SystemExit) as exc:
170
+ _run_download(config, ["sqlfluff"])
171
+
172
+ assert exc.value.code == 1
173
+
174
+ @patch("datasecops_cli.main._connect_and_load")
175
+ def test_download_partial_failure_exits_1(self, mock_connect, tmp_path):
176
+ """If one item fails but another succeeds, exit code is still 1."""
177
+ config = self._make_config(tmp_path)
178
+ mock_sf = MagicMock()
179
+ mock_connect.return_value = mock_sf
180
+
181
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
182
+ mock_ds = MockDS.return_value
183
+ mock_ds.download_sqlfluff_config.return_value = True
184
+ mock_ds.download_dbt_packages.return_value = False
185
+
186
+ with pytest.raises(SystemExit) as exc:
187
+ _run_download(config, ["sqlfluff", "packages"])
188
+
189
+ assert exc.value.code == 1
190
+
191
+ @patch("datasecops_cli.main._connect_and_load")
192
+ def test_download_pipelines_uses_configured_platform(self, mock_connect, tmp_path):
193
+ """Pipeline download reads platform from source_control config."""
194
+ config = self._make_config(tmp_path)
195
+ config.source_control.source_control_platform = "AzureDevOps"
196
+ mock_sf = MagicMock()
197
+ mock_connect.return_value = mock_sf
198
+
199
+ with patch("datasecops_cli.main.DownloadService") as MockDS:
200
+ mock_ds = MockDS.return_value
201
+ mock_ds.download_pipelines.return_value = True
202
+
203
+ with pytest.raises(SystemExit) as exc:
204
+ _run_download(config, ["pipelines"])
205
+
206
+ assert exc.value.code == 0
207
+ mock_ds.download_pipelines.assert_called_once_with(platform="azuredevops")
File without changes