datasecops-cli 0.2.8__tar.gz → 0.3.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.
Files changed (48) hide show
  1. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/CHANGELOG.md +15 -0
  2. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/PKG-INFO +15 -19
  3. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/README.md +14 -18
  4. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/pyproject.toml +1 -1
  5. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/main.py +114 -5
  6. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/test_main.py +103 -8
  7. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/.github/workflows/publish-cli.yml +0 -0
  8. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/.gitignore +0 -0
  9. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/DEVELOPMENT.md +0 -0
  10. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/LICENSE +0 -0
  11. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/docs/getting-started.md +0 -0
  12. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/docs/legacy.md +0 -0
  13. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/docs/legacy_plan_of_action.md +0 -0
  14. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/docs/mcp-server.md +0 -0
  15. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/mcp-servers.json +0 -0
  16. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/setup.ps1 +0 -0
  17. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/setup.sh +0 -0
  18. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/__init__.py +0 -0
  19. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/config.py +0 -0
  20. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/menus/__init__.py +0 -0
  21. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/menus/development.py +0 -0
  22. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/menus/downloads.py +0 -0
  23. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/menus/git_operations.py +0 -0
  24. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/models/__init__.py +0 -0
  25. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/models/git_helpers.py +0 -0
  26. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/models/project_config.py +0 -0
  27. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/__init__.py +0 -0
  28. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/bootstrap_service.py +0 -0
  29. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/dbt_runner.py +0 -0
  30. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/download_service.py +0 -0
  31. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/git_service.py +0 -0
  32. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/linting_service.py +0 -0
  33. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/skill_service.py +0 -0
  34. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/services/snowflake_service.py +0 -0
  35. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/utilities/__init__.py +0 -0
  36. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/utilities/display.py +0 -0
  37. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/utilities/file_utils.py +0 -0
  38. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  39. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_mcp/__init__.py +0 -0
  40. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_mcp/__main__.py +0 -0
  41. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_mcp/connection.py +0 -0
  42. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/src/datasecops_mcp/server.py +0 -0
  43. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/__init__.py +0 -0
  44. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/test_config.py +0 -0
  45. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/test_file_utils.py +0 -0
  46. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/test_models.py +0 -0
  47. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/test_version.py +0 -0
  48. {datasecops_cli-0.2.8 → datasecops_cli-0.3.0}/tests/test_yaml_utils.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to the DataSecOps CLI are documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-05-14
6
+
7
+ ### Added
8
+
9
+ - **`datasecops setup` subcommand** — pure-Python replacement for `setup.ps1` / `setup.sh`. Discovers Snowflake connections from `~/.snowflake/connections.toml`, prompts for connection and app database, and writes `.datasecops.yml`. No platform-specific scripts needed.
10
+ - **Interactive profile selection** — when `profile_name` is not set (no `dbt_project.yml` and no profile in `.datasecops.yml`), the CLI prompts the user to choose from available profiles in the native app instead of silently picking the first one
11
+ - **Auto-setup on first run** — running `datasecops` without a `.datasecops.yml` now offers to run setup interactively instead of just exiting with an error
12
+ - **dbt project directory resolution from framework** — `dbt_project_dir` is now re-resolved using the framework's `project_dir` setting after native app config is loaded
13
+
14
+ ## [0.2.9] - 2026-05-14
15
+
16
+ ### Added
17
+
18
+ - **`install-sqlfluff` and `install-dbt` download items** — `datasecops download install-sqlfluff` and `datasecops download install-dbt` fetch framework-pinned package versions from the native app and install them via `uv pip install`. `datasecops download all` now includes both installs.
19
+
5
20
  ## [0.2.8] - 2026-05-14
6
21
 
7
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datasecops-cli
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -61,30 +61,19 @@ Optional:
61
61
 
62
62
  ## Quick Start
63
63
 
64
- ### 1. Run the setup script
65
-
66
- The setup script creates a virtual environment, installs the CLI, and writes your local configuration.
67
-
68
- **Linux / macOS:**
64
+ ### 1. Configure your project
69
65
 
70
66
  ```bash
71
- chmod +x setup.sh && ./setup.sh
67
+ datasecops setup
72
68
  ```
73
69
 
74
- **Windows (PowerShell):**
75
-
76
- ```powershell
77
- .\setup.ps1
78
- ```
70
+ This prompts for your Snowflake connection name and native app database, then writes `.datasecops.yml`.
79
71
 
80
- You'll be prompted for your Snowflake connection name and the native app database name.
72
+ If you skip this step, running `datasecops` will offer to run setup automatically.
81
73
 
82
- ### 2. Activate the virtual environment and run
74
+ ### 2. Run the CLI
83
75
 
84
76
  ```bash
85
- source .venv/bin/activate # Linux/macOS
86
- .\.venv\Scripts\Activate.ps1 # Windows
87
-
88
77
  datasecops
89
78
  ```
90
79
 
@@ -106,11 +95,18 @@ datasecops download sqlfluff
106
95
  datasecops download sqlfluff packages
107
96
  datasecops download pipelines macros
108
97
 
109
- # Download everything
98
+ # Install framework-pinned package versions
99
+ datasecops download install-sqlfluff
100
+ datasecops download install-dbt
101
+
102
+ # Download config and install packages together
103
+ datasecops download sqlfluff install-sqlfluff
104
+
105
+ # Download and install everything
110
106
  datasecops download all
111
107
  ```
112
108
 
113
- Available items: `sqlfluff`, `pipelines`, `packages`, `macros`, `all`
109
+ Available items: `sqlfluff`, `pipelines`, `packages`, `macros`, `install-sqlfluff`, `install-dbt`, `all`
114
110
 
115
111
  The pipeline platform (GitHub / Azure DevOps) is auto-detected from the native app's source control configuration.
116
112
 
@@ -41,30 +41,19 @@ Optional:
41
41
 
42
42
  ## Quick Start
43
43
 
44
- ### 1. Run the setup script
45
-
46
- The setup script creates a virtual environment, installs the CLI, and writes your local configuration.
47
-
48
- **Linux / macOS:**
44
+ ### 1. Configure your project
49
45
 
50
46
  ```bash
51
- chmod +x setup.sh && ./setup.sh
47
+ datasecops setup
52
48
  ```
53
49
 
54
- **Windows (PowerShell):**
55
-
56
- ```powershell
57
- .\setup.ps1
58
- ```
50
+ This prompts for your Snowflake connection name and native app database, then writes `.datasecops.yml`.
59
51
 
60
- You'll be prompted for your Snowflake connection name and the native app database name.
52
+ If you skip this step, running `datasecops` will offer to run setup automatically.
61
53
 
62
- ### 2. Activate the virtual environment and run
54
+ ### 2. Run the CLI
63
55
 
64
56
  ```bash
65
- source .venv/bin/activate # Linux/macOS
66
- .\.venv\Scripts\Activate.ps1 # Windows
67
-
68
57
  datasecops
69
58
  ```
70
59
 
@@ -86,11 +75,18 @@ datasecops download sqlfluff
86
75
  datasecops download sqlfluff packages
87
76
  datasecops download pipelines macros
88
77
 
89
- # Download everything
78
+ # Install framework-pinned package versions
79
+ datasecops download install-sqlfluff
80
+ datasecops download install-dbt
81
+
82
+ # Download config and install packages together
83
+ datasecops download sqlfluff install-sqlfluff
84
+
85
+ # Download and install everything
90
86
  datasecops download all
91
87
  ```
92
88
 
93
- Available items: `sqlfluff`, `pipelines`, `packages`, `macros`, `all`
89
+ Available items: `sqlfluff`, `pipelines`, `packages`, `macros`, `install-sqlfluff`, `install-dbt`, `all`
94
90
 
95
91
  The pipeline platform (GitHub / Azure DevOps) is auto-detected from the native app's source control configuration.
96
92
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "datasecops-cli"
7
- version = "0.2.8"
7
+ version = "0.3.0"
8
8
  description = "DataSecOps Framework CLI for Snowflake Native App"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,7 @@
1
1
  import argparse
2
+ import os
2
3
  import shutil
4
+ import subprocess
3
5
  import sys
4
6
  from pathlib import Path
5
7
 
@@ -18,9 +20,13 @@ from datasecops_cli.utilities.display import (
18
20
  clear, section_header, menu_option, get_input_number,
19
21
  info_line, error_line, success_line
20
22
  )
23
+ from datasecops_cli.utilities.yaml_utils import write_datasecops_config
21
24
 
22
25
 
23
- DOWNLOAD_ITEMS = ["sqlfluff", "pipelines", "packages", "macros", "all"]
26
+ DOWNLOAD_ITEMS = [
27
+ "sqlfluff", "pipelines", "packages", "macros",
28
+ "install-sqlfluff", "install-dbt", "all",
29
+ ]
24
30
 
25
31
 
26
32
  def _build_parser() -> argparse.ArgumentParser:
@@ -30,6 +36,7 @@ def _build_parser() -> argparse.ArgumentParser:
30
36
  )
31
37
  sub = parser.add_subparsers(dest="command")
32
38
 
39
+ sub.add_parser("setup", help="Configure .datasecops.yml for this project")
33
40
  sub.add_parser("bootstrap", help="Set up a new dbt project with all framework config")
34
41
 
35
42
  dl = sub.add_parser("download", help="Download framework config non-interactively (for CI/CD)")
@@ -37,7 +44,7 @@ def _build_parser() -> argparse.ArgumentParser:
37
44
  "items",
38
45
  nargs="+",
39
46
  choices=DOWNLOAD_ITEMS,
40
- help="Item(s) to download: sqlfluff, pipelines, packages, macros, or all",
47
+ help="Item(s) to download/install: sqlfluff, pipelines, packages, macros, install-sqlfluff, install-dbt, or all",
41
48
  )
42
49
 
43
50
  return parser
@@ -50,7 +57,9 @@ def main():
50
57
 
51
58
  config = Config()
52
59
 
53
- if args.command == "bootstrap":
60
+ if args.command == "setup":
61
+ _run_setup(config.project_dir)
62
+ elif args.command == "bootstrap":
54
63
  _run_bootstrap(config)
55
64
  elif args.command == "download":
56
65
  _run_download(config, args.items)
@@ -58,16 +67,82 @@ def main():
58
67
  _run_interactive(config)
59
68
 
60
69
 
70
+ def _run_setup(project_dir: Path):
71
+ """Interactive setup: create .datasecops.yml by prompting for connection and app database."""
72
+ from datasecops_cli.utilities.display import (
73
+ get_input_string, select_from_list
74
+ )
75
+
76
+ section_header("DataSecOps Setup")
77
+
78
+ # --- Discover Snowflake connections ---
79
+ connections_file = Path.home() / ".snowflake" / "connections.toml"
80
+ connection_names = []
81
+ if connections_file.exists():
82
+ import re
83
+ for line in connections_file.read_text(encoding="utf-8").splitlines():
84
+ m = re.match(r"^\[([^\]]+)\]", line)
85
+ if m:
86
+ connection_names.append(m.group(1))
87
+
88
+ if connection_names:
89
+ connection_name = select_from_list(connection_names, "Snowflake connection", add_back=False)
90
+ else:
91
+ info_line("No connections found in ~/.snowflake/connections.toml")
92
+ connection_name = get_input_string("Enter Snowflake connection name: ")
93
+
94
+ if not connection_name:
95
+ error_line("Connection name is required")
96
+ sys.exit(1)
97
+
98
+ # --- App database ---
99
+ app_database = get_input_string(
100
+ "Enter native app database name (e.g. DATA_ENGINEERS_DATASECOPS_FRAMEWORK): "
101
+ )
102
+ if not app_database:
103
+ error_line("App database name is required")
104
+ sys.exit(1)
105
+
106
+ # --- Write config ---
107
+ config_data = {
108
+ "connection_name": connection_name,
109
+ "app_database": app_database,
110
+ "profile_name": "",
111
+ }
112
+ write_datasecops_config(project_dir, config_data)
113
+ success_line(f".datasecops.yml written to {project_dir / '.datasecops.yml'}")
114
+
115
+
116
+ def _offer_setup(project_dir: Path):
117
+ """Offer to run setup when .datasecops.yml is missing."""
118
+ from datasecops_cli.utilities.display import get_input_true_false
119
+
120
+ info_line("No .datasecops.yml found in this project.")
121
+ if not get_input_true_false("Run setup now?"):
122
+ sys.exit(1)
123
+
124
+ _run_setup(project_dir)
125
+
126
+
61
127
  def _connect_and_load(config: Config) -> SnowflakeService:
62
128
  """Load config, connect to Snowflake, and load native app settings.
63
129
 
64
130
  Returns the connected SnowflakeService, or calls sys.exit on failure.
131
+ If .datasecops.yml is missing and a setup script exists, offers to run it.
65
132
  """
66
133
  if not config.load():
67
- sys.exit(1)
134
+ # Check if the failure is due to missing .datasecops.yml
135
+ if not (config.project_dir / ".datasecops.yml").exists():
136
+ _offer_setup(config.project_dir)
137
+ # Retry after setup
138
+ if not config.load():
139
+ sys.exit(1)
140
+ else:
141
+ sys.exit(1)
68
142
 
69
143
  sf_config = config.datasecops
70
144
  sf_service = SnowflakeService(sf_config)
145
+ profile_was_set = bool(config.profile_name)
71
146
 
72
147
  try:
73
148
  info_line(f"Connecting to Snowflake ({sf_config.connection_name})...")
@@ -80,6 +155,24 @@ def _connect_and_load(config: Config) -> SnowflakeService:
80
155
  info_line("Loading framework configuration...")
81
156
  config.load_from_native_app(sf_service)
82
157
 
158
+ # If profile_name was not explicitly set and there are multiple profiles, ask the user
159
+ if not profile_was_set and len(config.all_profiles) > 1:
160
+ from datasecops_cli.utilities.display import select_from_list
161
+ names = [p.profile_name for p in config.all_profiles]
162
+ info_line(f"Found {len(names)} project profiles:")
163
+ selected = select_from_list(names, "profile", add_back=False)
164
+ config.profile = None
165
+ for p in config.all_profiles:
166
+ if p.profile_name == selected:
167
+ config.profile = p
168
+ config.profile_name = selected
169
+ break
170
+
171
+ # Re-resolve dbt_project_dir using framework project_dir setting
172
+ framework_dbt_dir = config.project_dir / config.project_settings.project_dir
173
+ if (framework_dbt_dir / "dbt_project.yml").exists():
174
+ config.dbt_project_dir = framework_dbt_dir
175
+
83
176
  if not config.profile:
84
177
  error_line(f"Profile '{config.profile_name}' not found in native app")
85
178
  sf_service.close()
@@ -129,10 +222,12 @@ def _run_download(config: Config, items: list[str]):
129
222
 
130
223
  try:
131
224
  download_service = DownloadService(sf_service, config.project_dir)
225
+ linting_service = LintingService(config.dbt_project_dir)
132
226
  profiles_dir = str(config.get_dbt_profiles_dir())
133
227
 
134
228
  if "all" in items:
135
- items = ["sqlfluff", "pipelines", "packages", "macros"]
229
+ items = ["sqlfluff", "pipelines", "packages", "macros",
230
+ "install-sqlfluff", "install-dbt"]
136
231
 
137
232
  failed = False
138
233
  for item in items:
@@ -153,6 +248,20 @@ def _run_download(config: Config, items: list[str]):
153
248
  elif item == "macros":
154
249
  if not download_service.download_macros(config.profile_name, config.dbt_project_dir):
155
250
  failed = True
251
+ elif item == "install-sqlfluff":
252
+ packages = download_service.get_sqlfluff_requirements()
253
+ if packages:
254
+ if not linting_service.install_requirements(packages):
255
+ failed = True
256
+ else:
257
+ failed = True
258
+ elif item == "install-dbt":
259
+ packages = download_service.get_dbt_requirements()
260
+ if packages:
261
+ if not linting_service.install_requirements(packages):
262
+ failed = True
263
+ else:
264
+ failed = True
156
265
 
157
266
  sys.exit(1 if failed else 0)
158
267
  finally:
@@ -73,7 +73,8 @@ class TestRunDownload:
73
73
  mock_sf = MagicMock()
74
74
  mock_connect.return_value = mock_sf
75
75
 
76
- with patch("datasecops_cli.main.DownloadService") as MockDS:
76
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
77
+ patch("datasecops_cli.main.LintingService"):
77
78
  mock_ds = MockDS.return_value
78
79
  mock_ds.download_sqlfluff_config.return_value = True
79
80
 
@@ -92,7 +93,8 @@ class TestRunDownload:
92
93
  mock_sf = MagicMock()
93
94
  mock_connect.return_value = mock_sf
94
95
 
95
- with patch("datasecops_cli.main.DownloadService") as MockDS:
96
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
97
+ patch("datasecops_cli.main.LintingService"):
96
98
  mock_ds = MockDS.return_value
97
99
  mock_ds.download_pipelines.return_value = True
98
100
 
@@ -108,7 +110,8 @@ class TestRunDownload:
108
110
  mock_sf = MagicMock()
109
111
  mock_connect.return_value = mock_sf
110
112
 
111
- with patch("datasecops_cli.main.DownloadService") as MockDS:
113
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
114
+ patch("datasecops_cli.main.LintingService"):
112
115
  mock_ds = MockDS.return_value
113
116
  mock_ds.download_dbt_packages.return_value = True
114
117
 
@@ -124,7 +127,8 @@ class TestRunDownload:
124
127
  mock_sf = MagicMock()
125
128
  mock_connect.return_value = mock_sf
126
129
 
127
- with patch("datasecops_cli.main.DownloadService") as MockDS:
130
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
131
+ patch("datasecops_cli.main.LintingService"):
128
132
  mock_ds = MockDS.return_value
129
133
  mock_ds.download_macros.return_value = True
130
134
 
@@ -140,12 +144,17 @@ class TestRunDownload:
140
144
  mock_sf = MagicMock()
141
145
  mock_connect.return_value = mock_sf
142
146
 
143
- with patch("datasecops_cli.main.DownloadService") as MockDS:
147
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
148
+ patch("datasecops_cli.main.LintingService") as MockLS:
144
149
  mock_ds = MockDS.return_value
150
+ mock_ls = MockLS.return_value
145
151
  mock_ds.download_sqlfluff_config.return_value = True
146
152
  mock_ds.download_pipelines.return_value = True
147
153
  mock_ds.download_dbt_packages.return_value = True
148
154
  mock_ds.download_macros.return_value = True
155
+ mock_ds.get_sqlfluff_requirements.return_value = ["sqlfluff==3.4.0"]
156
+ mock_ds.get_dbt_requirements.return_value = ["dbt-core==1.9.0"]
157
+ mock_ls.install_requirements.return_value = True
149
158
 
150
159
  with pytest.raises(SystemExit) as exc:
151
160
  _run_download(config, ["all"])
@@ -155,6 +164,9 @@ class TestRunDownload:
155
164
  mock_ds.download_pipelines.assert_called_once()
156
165
  mock_ds.download_dbt_packages.assert_called_once()
157
166
  mock_ds.download_macros.assert_called_once()
167
+ mock_ds.get_sqlfluff_requirements.assert_called_once()
168
+ mock_ds.get_dbt_requirements.assert_called_once()
169
+ assert mock_ls.install_requirements.call_count == 2
158
170
 
159
171
  @patch("datasecops_cli.main._connect_and_load")
160
172
  def test_download_failure_exits_1(self, mock_connect, tmp_path):
@@ -162,7 +174,8 @@ class TestRunDownload:
162
174
  mock_sf = MagicMock()
163
175
  mock_connect.return_value = mock_sf
164
176
 
165
- with patch("datasecops_cli.main.DownloadService") as MockDS:
177
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
178
+ patch("datasecops_cli.main.LintingService"):
166
179
  mock_ds = MockDS.return_value
167
180
  mock_ds.download_sqlfluff_config.return_value = False # failure
168
181
 
@@ -178,7 +191,8 @@ class TestRunDownload:
178
191
  mock_sf = MagicMock()
179
192
  mock_connect.return_value = mock_sf
180
193
 
181
- with patch("datasecops_cli.main.DownloadService") as MockDS:
194
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
195
+ patch("datasecops_cli.main.LintingService"):
182
196
  mock_ds = MockDS.return_value
183
197
  mock_ds.download_sqlfluff_config.return_value = True
184
198
  mock_ds.download_dbt_packages.return_value = False
@@ -196,7 +210,8 @@ class TestRunDownload:
196
210
  mock_sf = MagicMock()
197
211
  mock_connect.return_value = mock_sf
198
212
 
199
- with patch("datasecops_cli.main.DownloadService") as MockDS:
213
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
214
+ patch("datasecops_cli.main.LintingService"):
200
215
  mock_ds = MockDS.return_value
201
216
  mock_ds.download_pipelines.return_value = True
202
217
 
@@ -205,3 +220,83 @@ class TestRunDownload:
205
220
 
206
221
  assert exc.value.code == 0
207
222
  mock_ds.download_pipelines.assert_called_once_with(platform="azuredevops")
223
+
224
+ @patch("datasecops_cli.main._connect_and_load")
225
+ def test_install_sqlfluff(self, mock_connect, tmp_path):
226
+ """install-sqlfluff fetches pinned versions and installs them."""
227
+ config = self._make_config(tmp_path)
228
+ mock_connect.return_value = MagicMock()
229
+
230
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
231
+ patch("datasecops_cli.main.LintingService") as MockLS:
232
+ mock_ds = MockDS.return_value
233
+ mock_ls = MockLS.return_value
234
+ mock_ds.get_sqlfluff_requirements.return_value = ["sqlfluff==3.4.0", "sqlfluff-templater-dbt==3.4.0"]
235
+ mock_ls.install_requirements.return_value = True
236
+
237
+ with pytest.raises(SystemExit) as exc:
238
+ _run_download(config, ["install-sqlfluff"])
239
+
240
+ assert exc.value.code == 0
241
+ mock_ds.get_sqlfluff_requirements.assert_called_once()
242
+ mock_ls.install_requirements.assert_called_once_with(
243
+ ["sqlfluff==3.4.0", "sqlfluff-templater-dbt==3.4.0"]
244
+ )
245
+
246
+ @patch("datasecops_cli.main._connect_and_load")
247
+ def test_install_dbt(self, mock_connect, tmp_path):
248
+ """install-dbt fetches pinned versions and installs them."""
249
+ config = self._make_config(tmp_path)
250
+ mock_connect.return_value = MagicMock()
251
+
252
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
253
+ patch("datasecops_cli.main.LintingService") as MockLS:
254
+ mock_ds = MockDS.return_value
255
+ mock_ls = MockLS.return_value
256
+ mock_ds.get_dbt_requirements.return_value = ["dbt-core==1.9.0", "dbt-snowflake==1.9.0"]
257
+ mock_ls.install_requirements.return_value = True
258
+
259
+ with pytest.raises(SystemExit) as exc:
260
+ _run_download(config, ["install-dbt"])
261
+
262
+ assert exc.value.code == 0
263
+ mock_ds.get_dbt_requirements.assert_called_once()
264
+ mock_ls.install_requirements.assert_called_once_with(
265
+ ["dbt-core==1.9.0", "dbt-snowflake==1.9.0"]
266
+ )
267
+
268
+ @patch("datasecops_cli.main._connect_and_load")
269
+ def test_install_sqlfluff_no_versions_exits_1(self, mock_connect, tmp_path):
270
+ """install-sqlfluff fails if no versions returned from framework."""
271
+ config = self._make_config(tmp_path)
272
+ mock_connect.return_value = MagicMock()
273
+
274
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
275
+ patch("datasecops_cli.main.LintingService") as MockLS:
276
+ mock_ds = MockDS.return_value
277
+ mock_ls = MockLS.return_value
278
+ mock_ds.get_sqlfluff_requirements.return_value = []
279
+
280
+ with pytest.raises(SystemExit) as exc:
281
+ _run_download(config, ["install-sqlfluff"])
282
+
283
+ assert exc.value.code == 1
284
+ mock_ls.install_requirements.assert_not_called()
285
+
286
+ @patch("datasecops_cli.main._connect_and_load")
287
+ def test_install_dbt_pip_failure_exits_1(self, mock_connect, tmp_path):
288
+ """install-dbt exits 1 if uv pip install fails."""
289
+ config = self._make_config(tmp_path)
290
+ mock_connect.return_value = MagicMock()
291
+
292
+ with patch("datasecops_cli.main.DownloadService") as MockDS, \
293
+ patch("datasecops_cli.main.LintingService") as MockLS:
294
+ mock_ds = MockDS.return_value
295
+ mock_ls = MockLS.return_value
296
+ mock_ds.get_dbt_requirements.return_value = ["dbt-core==1.9.0"]
297
+ mock_ls.install_requirements.return_value = False # pip failure
298
+
299
+ with pytest.raises(SystemExit) as exc:
300
+ _run_download(config, ["install-dbt"])
301
+
302
+ assert exc.value.code == 1
File without changes
File without changes
File without changes