datasecops-cli 0.7.0b2__tar.gz → 0.7.0b4__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 (88) hide show
  1. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/CHANGELOG.md +8 -0
  2. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/PKG-INFO +1 -1
  3. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/docs/getting-started.md +23 -17
  4. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/pyproject.toml +1 -1
  5. datasecops_cli-0.7.0b4/src/datasecops_cli/__init__.py +1 -0
  6. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/config.py +1 -0
  7. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/main.py +30 -48
  8. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/models/project_config.py +1 -3
  9. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/dbt_project_generator.py +89 -56
  10. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/download_service.py +12 -8
  11. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/snowflake_service.py +15 -0
  12. datasecops_cli-0.7.0b4/tests/test_dbt_project_generator_profiles.py +150 -0
  13. datasecops_cli-0.7.0b2/src/datasecops_cli/__init__.py +0 -1
  14. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/.github/workflows/auto-tag.yml +0 -0
  15. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/.github/workflows/publish-cli.yml +0 -0
  16. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/.github/workflows/test.yml +0 -0
  17. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/.gitignore +0 -0
  18. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/AGENTS.md +0 -0
  19. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/DEVELOPMENT.md +0 -0
  20. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/LICENSE +0 -0
  21. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/README.md +0 -0
  22. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/docs/dcm.md +0 -0
  23. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/docs/git-operations.md +0 -0
  24. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/docs/legacy.md +0 -0
  25. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/docs/legacy_plan_of_action.md +0 -0
  26. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/docs/mcp-server.md +0 -0
  27. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/mcp-servers.json +0 -0
  28. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/scripts/vendor_model.py +0 -0
  29. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/setup.ps1 +0 -0
  30. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/setup.sh +0 -0
  31. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/menus/__init__.py +0 -0
  32. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/menus/configuration.py +0 -0
  33. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/menus/dcm_operations.py +0 -0
  34. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/menus/development.py +0 -0
  35. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/menus/downloads.py +0 -0
  36. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/menus/git_operations.py +0 -0
  37. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/models/__init__.py +0 -0
  38. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/models/git_helpers.py +0 -0
  39. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/__init__.py +0 -0
  40. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/bootstrap_service.py +0 -0
  41. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/dbt_runner.py +0 -0
  42. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/dcm_orchestrator.py +0 -0
  43. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/dcm_pipelines.py +0 -0
  44. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/dcm_registry.py +0 -0
  45. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/dcm_service.py +0 -0
  46. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/directory_scaffolder.py +0 -0
  47. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/git_service.py +0 -0
  48. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/linting_service.py +0 -0
  49. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/model_commands.py +0 -0
  50. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/skill_service.py +0 -0
  51. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/services/upstream_service.py +0 -0
  52. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/utilities/__init__.py +0 -0
  53. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/utilities/display.py +0 -0
  54. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/utilities/file_utils.py +0 -0
  55. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/utilities/mcp.py +0 -0
  56. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  57. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_mcp/__init__.py +0 -0
  58. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_mcp/__main__.py +0 -0
  59. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_mcp/connection.py +0 -0
  60. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_mcp/server.py +0 -0
  61. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/__init__.py +0 -0
  62. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/convert.py +0 -0
  63. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/diagram.py +0 -0
  64. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/expand.py +0 -0
  65. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/extract.py +0 -0
  66. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/render.py +0 -0
  67. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/schema.py +0 -0
  68. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/scope.py +0 -0
  69. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/scripts.py +0 -0
  70. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/starter.py +0 -0
  71. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/templates/macros/role.sql +0 -0
  72. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/templates/macros/role_guard.sql +0 -0
  73. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/templates/macros/warehouse.sql +0 -0
  74. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/src/datasecops_model/validate.py +0 -0
  75. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/__init__.py +0 -0
  76. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_config.py +0 -0
  77. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_dbt_runner.py +0 -0
  78. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_dcm_model.py +0 -0
  79. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_dcm_orchestrator.py +0 -0
  80. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_dcm_service.py +0 -0
  81. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_download_service.py +0 -0
  82. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_file_utils.py +0 -0
  83. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_linting_service.py +0 -0
  84. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_main.py +0 -0
  85. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_model_commands.py +0 -0
  86. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_models.py +0 -0
  87. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_version.py +0 -0
  88. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b4}/tests/test_yaml_utils.py +0 -0
@@ -6,6 +6,14 @@ All notable changes to the DataSecOps CLI are documented in this file.
6
6
 
7
7
  ### Fixed
8
8
 
9
+ - **`profiles.yml` deployment targets were being written to a developer's own `~/.dbt/profiles.yml`** — CI/deployment targets (`dev`/`test`/`prod`) belong to the native app's CI pipeline, not a developer's machine, so they're no longer written there at all. Only the `local-dev` target is ever added or refreshed; any other targets already present in the file (added manually, or by a framework target sharing the profile's name) are left untouched. Removed the now-unused CI-identity profile generation (`ProjectSettings.ci_username`, the old `generate_profiles_yml`).
10
+ - **`local-dev` used a hardcoded `authenticator: externalbrowser` and a shared database instead of the developer's own identity** — `local-dev` now reuses whatever Snowflake CLI connection is already configured for the project in `~/.snowflake/connections.toml` (account, user, and whatever auth method is already in use — key pair, SSO, password, etc.), read directly via `snowflake.connector.config_manager` without establishing a connection. `role`/`warehouse`/`schema` still always come from the project's dbt targets config. The developer is prompted once for their name (persisted to `.datasecops.yml`), used to build a personal sandpit database (`<target_database>SANDPIT_<name>`) so multiple developers never collide.
11
+ - **`profiles.yml` is no longer written inside the dbt project folder** — dbt never reads a `profiles.yml` there (it reads `--profiles-dir`, i.e. `~/.dbt` per the native app's `profile_dir` setting), so the project-local copy was always a dead reference file. `datasecops setup`/`bootstrap`/`new project` now write/merge the `local-dev` target directly into `~/.dbt/profiles.yml` via a shared `write_profiles_yml()` helper, creating the folder/file if needed.
12
+
13
+ ## [0.7.0b2] - 2026-10-26
14
+
15
+ ### Fixed
16
+
9
17
  - **Default app database renamed to `MONITORIAL_ORBIT`** — the `datasecops setup` suggested app-database name (and matching docs examples) was `DATA_ENGINEERS_DATASECOPS_FRAMEWORK`; it now matches the native app's actual installed name.
10
18
  - **`dbt_project.yml` generator matches the source-based project spec** — added `docs-paths`, fixed `analysis-paths` (was `analyses`), added top-level `seeds:`/`tests:` blocks, `+static_analysis`/`+meta`/`+tags` at the model root, per-object-type `+static_analysis` (stored procedures/UDFs off, tasks baseline), the commented-out post-hook/on-run-start/on-run-end guidance, and the data-governance `vars` block.
11
19
  - **`profiles.yml` deploy targets now run as the framework's CI identity** — every target except `local-dev` previously used the local developer's Snowflake user with each target's own role/warehouse. They now use the CI Username/Role/Warehouse from the native app's Deployment settings (new `ProjectSettings.ci_username`/`ci_role`/`ci_warehouse`, falling back to the previous behavior when unset), matching what the CI pipeline actually deploys with.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datasecops-cli
3
- Version: 0.7.0b2
3
+ Version: 0.7.0b4
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -93,15 +93,24 @@ You should see the main menu, confirming the CLI can reach the native app.
93
93
 
94
94
  ## Step 3b: Set up your local dbt profile (`~/.dbt/profiles.yml`)
95
95
 
96
- Running `datasecops bootstrap` (or **Configuration → new project** from the main menu) scaffolds a
97
- dbt project from the native app's framework config, including a generated `dbt_project.yml` and
98
- `profiles.yml`. The generated `profiles.yml` is written *inside* the dbt project folder (e.g.
99
- `./dbt/profiles.yml`) as a reference — it's gitignored and never committed.
100
-
101
- dbt itself does **not** read that file. It reads whatever `profiles.yml` lives in the directory set
102
- by `--profiles-dir`, which the CLI passes as the native app's `PROJECT.profile_dir` setting
103
- (defaults to `~/.dbt`). So you need to copy the generated `local-dev` target into your own
104
- `~/.dbt/profiles.yml` (create the file/folder if they don't exist yet):
96
+ Running `datasecops setup` or `datasecops bootstrap` (or **Configuration → new project** from the
97
+ main menu) writes a `local-dev` target for your profile directly to `~/.dbt/profiles.yml` (per the
98
+ native app's `PROJECT.profile_dir` setting, used as dbt's `--profiles-dir`) — not inside the dbt
99
+ project folder, since dbt never reads a `profiles.yml` there. The `.dbt` folder and `profiles.yml`
100
+ file are created if they don't exist yet; if a profile entry already exists, only its `local-dev`
101
+ target is refreshed — any other targets already in the file (e.g. CI/deployment targets you or a
102
+ pipeline added) are left untouched. **Only `local-dev` is ever written here** — deployment targets
103
+ (`dev`/`test`/`prod`) belong to the native app's CI pipeline, never to a developer's machine.
104
+
105
+ The `local-dev` target reuses whatever Snowflake CLI connection you already have configured in
106
+ `~/.snowflake/connections.toml` for the `connection_name` in your `.datasecops.yml` — account, user,
107
+ and whatever auth method you already use (external browser, key pair, password, etc.) are copied
108
+ across as-is, so there's nothing extra to configure. You'll be asked for your name once (used to
109
+ build your personal sandpit database, e.g. `jon` → `SDS_SANDPIT_JON`), and it's saved to
110
+ `.datasecops.yml` so you won't be asked again. `role`/`warehouse`/`schema` always come from the
111
+ project's dbt targets config, never from connections.toml.
112
+
113
+ The resulting `~/.dbt/profiles.yml` looks like:
105
114
 
106
115
  ```yaml
107
116
  <profile_name>: # matches the `profile:` field in dbt_project.yml
@@ -109,20 +118,17 @@ by `--profiles-dir`, which the CLI passes as the native app's `PROJECT.profile_d
109
118
  outputs:
110
119
  local-dev:
111
120
  type: snowflake
112
- account: "<your_account>"
113
- authenticator: externalbrowser # SSO login, no password/PAT needed
121
+ account: "<from your connections.toml connection>"
122
+ user: "<from your connections.toml connection>"
123
+ authenticator: "<from your connections.toml connection, if set>"
124
+ private_key_path: "<from your connections.toml connection, if set>"
114
125
  role: DEVELOPERS
115
126
  warehouse: DEV_WH
116
- database: "<profile's target database>"
127
+ database: "<target_database>SANDPIT_<your name>"
117
128
  schema: PUBLIC
118
129
  threads: 4
119
130
  ```
120
131
 
121
- You only need the `local-dev` target locally. The other targets in the generated `profiles.yml`
122
- (e.g. `dev`, `test`, `prod`) run as the framework's CI/deployment identity (the CI Username, Role
123
- and Warehouse configured in the native app's Deployment settings) — they're what the CI pipeline
124
- deploys with, not something a developer runs against directly.
125
-
126
132
  If you work on multiple framework projects, each profile (one per project, named after its
127
133
  `profile:` field) gets its own block in the same `~/.dbt/profiles.yml` file.
128
134
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "datasecops-cli"
7
- version = "0.7.0b2"
7
+ version = "0.7.0b4"
8
8
  description = "DataSecOps Framework CLI for Snowflake Native App"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0b4"
@@ -38,6 +38,7 @@ class Config:
38
38
  app_database=raw.get("app_database", ""),
39
39
  profile_name=raw.get("profile_name", ""),
40
40
  cortex_connection=raw.get("cortex_connection", ""),
41
+ sandpit_name=raw.get("sandpit_name", ""),
41
42
  dbt_engine=raw.get("dbt_engine", "dbt_core"),
42
43
  dcm_enabled=raw.get("dcm_enabled", False),
43
44
  dcm_security_dir=raw.get("dcm_security_dir", "security"),
@@ -478,6 +478,9 @@ def _run_setup(project_dir: Path):
478
478
  "MONITORIAL_ORBIT"
479
479
  )
480
480
 
481
+ # --- Sandpit name (for your personal local-dev database) ---
482
+ sandpit_name = get_input_string("Enter your name (used to build your personal sandpit database, e.g. 'jon'): ")
483
+
481
484
  # --- CoCo agent connection ---
482
485
  cortex_connection = ""
483
486
  if shutil.which("cortex") and connection_names:
@@ -588,46 +591,15 @@ def _run_setup(project_dir: Path):
588
591
  # --- Ensure profiles.yml has the profile entry ---
589
592
  if profile_name and matched_profile and not project_settings_loaded:
590
593
  warning_line(f"Skipping profiles.yml for '{profile_name}': PROJECT framework config "
591
- "was not loaded, so the correct CI identity/targets aren't known")
594
+ "was not loaded, so the local-dev role/warehouse aren't known")
592
595
  elif profile_name and matched_profile:
593
- import yaml
594
- from datasecops_cli.utilities.file_utils import ensure_dir, write_file
595
- from datasecops_cli.services.dbt_project_generator import generate_profiles_yml
596
+ from datasecops_cli.services.dbt_project_generator import write_profiles_yml
597
+ from datasecops_cli.services.snowflake_service import get_connection_params
596
598
 
597
599
  profiles_dir = Path(project_settings.profile_dir).expanduser()
598
- profiles_path = profiles_dir / "profiles.yml"
599
-
600
- # Read existing profiles.yml or start fresh
601
- existing_profiles = {}
602
- if profiles_path.exists():
603
- with open(profiles_path, "r", encoding="utf-8") as f:
604
- existing_profiles = yaml.safe_load(f) or {}
605
-
606
- account_info = temp_sf.get_account_info()
607
- account = account_info.get("ACCOUNT", account_info.get("account", ""))
608
- user = account_info.get("USER_NAME", account_info.get("user_name", ""))
609
- new_profiles_yaml = generate_profiles_yml(matched_profile, project_settings, account, user)
610
- new_profiles = yaml.safe_load(new_profiles_yaml) or {}
611
- new_outputs = new_profiles.get(profile_name, {}).get("outputs", {})
612
-
613
- if profile_name in existing_profiles:
614
- # Refresh the CI/deployment targets every run (so a CI identity change in
615
- # the native app takes effect), but keep an existing local-dev target as
616
- # the developer may have customized it.
617
- existing_outputs = existing_profiles[profile_name].setdefault("outputs", {})
618
- for name, output in new_outputs.items():
619
- if name == "local-dev" and name in existing_outputs:
620
- continue
621
- existing_outputs[name] = output
622
- existing_profiles[profile_name].setdefault("target", new_profiles[profile_name]["target"])
623
- write_file(profiles_path, yaml.dump(existing_profiles, default_flow_style=False, sort_keys=False))
624
- success_line(f"Updated deployment targets for profile '{profile_name}' in {profiles_path}")
625
- else:
626
- info_line(f"Adding profile '{profile_name}' to {profiles_path}...")
627
- existing_profiles.update(new_profiles)
628
- ensure_dir(profiles_dir)
629
- write_file(profiles_path, yaml.dump(existing_profiles, default_flow_style=False, sort_keys=False))
630
- success_line(f"Profile '{profile_name}' written to {profiles_path}")
600
+ connection_params = get_connection_params(connection_name)
601
+ status = write_profiles_yml(profiles_dir, profile_name, matched_profile, project_settings, connection_params, sandpit_name)
602
+ success_line(f"Profile '{profile_name}' {status} in {profiles_dir / 'profiles.yml'}")
631
603
  except Exception as e:
632
604
  warning_line(f"Could not resolve the project profile / write profiles.yml: {e}")
633
605
  warning_line("You can set profile_name manually in .datasecops.yml")
@@ -643,6 +615,7 @@ def _run_setup(project_dir: Path):
643
615
  "app_database": app_database,
644
616
  "profile_name": profile_name,
645
617
  "cortex_connection": cortex_connection,
618
+ "sandpit_name": sandpit_name,
646
619
  }
647
620
  write_datasecops_config(project_dir, config_data)
648
621
  success_line(f".datasecops.yml written to {project_dir / '.datasecops.yml'}")
@@ -838,11 +811,11 @@ def _connect_and_load(config: Config, interactive: bool = True,
838
811
  break
839
812
  # Save the selected profile back to .datasecops.yml
840
813
  if config.profile:
841
- config_data = {
842
- "connection_name": config.datasecops.connection_name,
843
- "app_database": config.datasecops.app_database,
844
- "profile_name": config.profile_name,
845
- }
814
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config
815
+ config_data = read_datasecops_config(config.project_dir) or {}
816
+ config_data["connection_name"] = config.datasecops.connection_name
817
+ config_data["app_database"] = config.datasecops.app_database
818
+ config_data["profile_name"] = config.profile_name
846
819
  write_datasecops_config(config.project_dir, config_data)
847
820
  success_line(f"Saved profile_name '{config.profile_name}' to .datasecops.yml")
848
821
  else:
@@ -850,11 +823,11 @@ def _connect_and_load(config: Config, interactive: bool = True,
850
823
  info_line(f"Using default profile: {config.profile_name}")
851
824
  elif not profile_was_set and len(config.all_profiles) == 1:
852
825
  # Single profile auto-selected — persist it so future runs don't re-prompt
853
- config_data = {
854
- "connection_name": config.datasecops.connection_name,
855
- "app_database": config.datasecops.app_database,
856
- "profile_name": config.profile_name,
857
- }
826
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config
827
+ config_data = read_datasecops_config(config.project_dir) or {}
828
+ config_data["connection_name"] = config.datasecops.connection_name
829
+ config_data["app_database"] = config.datasecops.app_database
830
+ config_data["profile_name"] = config.profile_name
858
831
  write_datasecops_config(config.project_dir, config_data)
859
832
  success_line(f"Saved profile_name '{config.profile_name}' to .datasecops.yml")
860
833
 
@@ -1274,7 +1247,7 @@ def _show_main_menu(profile_name: str, git_service: GitService = None,
1274
1247
 
1275
1248
  def _run_bootstrap(config: Config):
1276
1249
  """Run the bootstrap command to initialise a new project."""
1277
- from datasecops_cli.utilities.display import get_input_true_false, select_multiple_from_list
1250
+ from datasecops_cli.utilities.display import get_input_string, get_input_true_false, select_multiple_from_list
1278
1251
 
1279
1252
  sf_service = _connect_and_load(config)
1280
1253
 
@@ -1285,6 +1258,15 @@ def _run_bootstrap(config: Config):
1285
1258
  platform = config.source_control.source_control_platform.lower()
1286
1259
  info_line(f"Platform: {platform}")
1287
1260
 
1261
+ # Sandpit name (for your personal local-dev database) — ask once, then persist
1262
+ if not config.datasecops.sandpit_name:
1263
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config, write_datasecops_config
1264
+ config.datasecops.sandpit_name = get_input_string(
1265
+ "Enter your name (used to build your personal sandpit database, e.g. 'jon'): ")
1266
+ config_data = read_datasecops_config(config.project_dir) or {}
1267
+ config_data["sandpit_name"] = config.datasecops.sandpit_name
1268
+ write_datasecops_config(config.project_dir, config_data)
1269
+
1288
1270
  # Ask about optional steps
1289
1271
  install_skills = get_input_true_false("Install AI coding skills?")
1290
1272
  skill_targets = None
@@ -10,6 +10,7 @@ class DatasecopsConfig(BaseModel):
10
10
  app_database: str = ""
11
11
  profile_name: str = ""
12
12
  cortex_connection: str = ""
13
+ sandpit_name: str = "" # personal name used to build your local-dev sandpit database (e.g. "jon")
13
14
  dbt_engine: str = "dbt_core" # "dbt2" for dbt 2.0 (pip install dbt), "dbt_core" for dbt Core 1.x (pip install dbt-core + dbt-snowflake)
14
15
  dcm_enabled: bool = False
15
16
  dcm_security_dir: str = "security"
@@ -40,9 +41,6 @@ class ProjectSettings(BaseModel):
40
41
  execution_mode: str = "dbt_cli"
41
42
  dbt_engine: str = "dbt_core"
42
43
  ai_agents_enabled: bool = False
43
- ci_username: str = ""
44
- ci_role: str = ""
45
- ci_warehouse: str = ""
46
44
  targets: list[DbtTarget] = Field(default_factory=lambda: [
47
45
  DbtTarget(target_name="dev", branch_name="dev", target_role="DEVELOPERS", target_warehouse="DEV_WH", is_default=True),
48
46
  DbtTarget(target_name="test", branch_name="test", target_role="DATAOPS_ADMIN", target_warehouse="DATAOPS_WH"),
@@ -46,6 +46,7 @@ _SAFE_PATTERNS = {
46
46
  "profile name": re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$"),
47
47
  "dbt version": re.compile(r"^[0-9]+(\.[0-9]+)*$"),
48
48
  "warehouse name": re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$"),
49
+ "sandpit name": re.compile(r"^[A-Za-z0-9_]+$"),
49
50
  }
50
51
 
51
52
 
@@ -193,74 +194,106 @@ vars:
193
194
  )
194
195
 
195
196
 
196
- def generate_profiles_yml(
197
+ def generate_local_dev_target(
197
198
  profile: ProjectProfile,
198
199
  project_settings: ProjectSettings,
199
- account: str,
200
- user: str = "",
201
- ) -> str:
202
- """Generate profiles.yml from framework targets.
203
-
204
- Every target but `local-dev` runs as the framework's CI/deployment identity
205
- (Deployment settings: CI Username/Role/Warehouse), not the local developer -
206
- these targets are what CI pipelines deploy with. `local-dev` is always the
207
- profile's default target (even if a framework target of that name exists or
208
- the framework marks a different target as its own default), so a plain
209
- `dbt run` with no `--target` always uses the developer's own identity.
200
+ connection_params: dict[str, Any],
201
+ sandpit_name: str,
202
+ ) -> dict[str, Any]:
203
+ """Build the `local-dev` profiles.yml output for a developer's own machine.
204
+
205
+ Connection identity (account/user/authenticator/private key/password) is taken as-is
206
+ from the developer's own Snowflake CLI connection (`~/.snowflake/connections.toml`) -
207
+ whatever auth method they already use to connect works for dbt too, with no CLI code
208
+ needing to know about it. `role`/`warehouse`/`schema` always come from the project's
209
+ dbt targets config, never from connections.toml. `database` is the developer's personal
210
+ sandpit database, built from their name so multiple developers never collide.
210
211
 
211
212
  Args:
212
213
  profile: The project profile.
213
- project_settings: The project settings with targets and CI identity.
214
- account: Snowflake account name.
215
- user: Fallback Snowflake user name, used only when ci_username is not configured.
214
+ project_settings: The project settings with dbt targets.
215
+ connection_params: Raw params for the developer's Snowflake CLI connection, as
216
+ returned by `snowflake_service.get_connection_params()`.
217
+ sandpit_name: The developer's name, used to build their personal sandpit database.
216
218
 
217
219
  Returns:
218
- The YAML content as a string.
220
+ The `local-dev` output dict (to embed under `outputs.local-dev` in profiles.yml).
219
221
  """
220
- outputs: dict[str, Any] = {}
221
-
222
- # Add local-dev target
223
- outputs["local-dev"] = {
224
- "type": "snowflake",
225
- "account": account,
226
- "authenticator": "externalbrowser",
227
- "role": project_settings.targets[0].target_role if project_settings.targets else "DEVELOPERS",
228
- "warehouse": project_settings.targets[0].target_warehouse if project_settings.targets else "DEV_WH",
229
- "database": profile.target_database,
230
- "schema": project_settings.targets[0].target_schema if project_settings.targets else "PUBLIC",
231
- "threads": 4,
232
- }
233
-
234
- # Add all framework (CI/deployment) targets, run as the CI identity. `local-dev` is
235
- # reserved for interactive human use (added above with externalbrowser auth) - a
236
- # framework target of that name must never overwrite it with the CI identity.
237
- ci_user = project_settings.ci_username or user or "{{ env_var('SNOWFLAKE_USER') }}"
238
- for target in project_settings.targets:
239
- if target.target_name == "local-dev":
240
- continue
241
- branch_suffix = target.branch_name.upper()
242
- outputs[target.target_name] = {
243
- "type": "snowflake",
244
- "account": account,
245
- "user": ci_user,
246
- "role": project_settings.ci_role or target.target_role,
247
- "warehouse": project_settings.ci_warehouse or target.target_warehouse,
248
- "database": f"{profile.target_database}_{branch_suffix}",
249
- "schema": target.target_schema,
250
- "threads": 4,
251
- }
222
+ target = project_settings.targets[0] if project_settings.targets else None
223
+
224
+ output: dict[str, Any] = {"type": "snowflake"}
225
+ for key in (
226
+ "account", "user", "authenticator", "password",
227
+ "private_key_path", "private_key", "private_key_passphrase", "token",
228
+ ):
229
+ if connection_params.get(key):
230
+ output[key] = connection_params[key]
231
+
232
+ # Names can contain spaces, apostrophes, hyphens etc. (e.g. "O'Brien") - sanitize down to
233
+ # a safe database-identifier suffix rather than rejecting anything but a bare word.
234
+ sanitized_name = re.sub(r"[^A-Za-z0-9]+", "_", sandpit_name.strip()).strip("_").upper()
235
+ if not sanitized_name:
236
+ raise ValueError(f"sandpit name must contain at least one letter or digit: {sandpit_name!r}")
237
+ sandpit_suffix = _validate_safe(sanitized_name, "sandpit name")
238
+ output["role"] = target.target_role if target else "DEVELOPERS"
239
+ output["warehouse"] = target.target_warehouse if target else "DEV_WH"
240
+ output["database"] = f"{profile.target_database}SANDPIT_{sandpit_suffix}"
241
+ output["schema"] = target.target_schema if target else "PUBLIC"
242
+ output["threads"] = 4
243
+ return output
244
+
245
+
246
+ def write_profiles_yml(
247
+ profiles_dir,
248
+ profile_name: str,
249
+ profile: ProjectProfile,
250
+ project_settings: ProjectSettings,
251
+ connection_params: dict[str, Any],
252
+ sandpit_name: str,
253
+ ) -> str:
254
+ """Add or update only the `local-dev` target for `profile_name` in
255
+ `<profiles_dir>/profiles.yml`, creating the `.dbt` folder and the file itself if
256
+ they don't already exist.
252
257
 
253
- # The profile's default target is always `local-dev`: a bare `dbt run` with no --target
254
- # must use the developer's own identity, never the CI identity on a deployment target -
255
- # even if the framework's own default target (used for CI/deploy commands) differs.
256
- profiles_data = {
257
- profile.profile_name: {
258
+ dbt reads `~/.dbt/profiles.yml` (per `project_settings.profile_dir`), never a copy
259
+ inside the dbt project folder. Deployment/CI targets (dev/test/prod) are never written
260
+ here - those belong to the native app's CI pipeline, not a developer's machine. Any
261
+ other targets already present in the file (added manually, or by a framework target
262
+ sharing this profile's name) are left untouched.
263
+
264
+ Returns "added" or "updated".
265
+ """
266
+ from pathlib import Path
267
+
268
+ profiles_dir = Path(profiles_dir).expanduser()
269
+ profiles_path = profiles_dir / "profiles.yml"
270
+
271
+ existing_profiles: dict[str, Any] = {}
272
+ if profiles_path.exists():
273
+ existing_profiles = yaml.safe_load(profiles_path.read_text(encoding="utf-8")) or {}
274
+
275
+ local_dev_output = generate_local_dev_target(profile, project_settings, connection_params, sandpit_name)
276
+
277
+ if profile_name in existing_profiles:
278
+ entry = existing_profiles[profile_name]
279
+ entry.setdefault("outputs", {})
280
+ entry["outputs"]["local-dev"] = local_dev_output
281
+ # Always force the default target to local-dev: a bare `dbt run` with no --target
282
+ # must use the developer's own identity, never a deployment target that happened
283
+ # to be marked as the file's default (e.g. by a previous run or manual edit).
284
+ entry["target"] = "local-dev"
285
+ status = "updated"
286
+ else:
287
+ existing_profiles[profile_name] = {
258
288
  "target": "local-dev",
259
- "outputs": outputs,
289
+ "outputs": {"local-dev": local_dev_output},
260
290
  }
261
- }
291
+ status = "added"
262
292
 
263
- return yaml.dump(profiles_data, default_flow_style=False, sort_keys=False)
293
+ profiles_dir.mkdir(parents=True, exist_ok=True)
294
+ profiles_path.write_text(
295
+ yaml.dump(existing_profiles, default_flow_style=False, sort_keys=False), encoding="utf-8")
296
+ return status
264
297
 
265
298
 
266
299
  def generate_packages_yml(
@@ -308,10 +308,11 @@ class DownloadService:
308
308
  """
309
309
  from datasecops_cli.services.dbt_project_generator import (
310
310
  generate_dbt_project_yml,
311
- generate_profiles_yml,
311
+ write_profiles_yml,
312
312
  generate_gitignore,
313
313
  )
314
314
  from datasecops_cli.services.directory_scaffolder import scaffold_directories
315
+ from datasecops_cli.services.snowflake_service import get_connection_params
315
316
 
316
317
  info_line("Scaffolding dbt project from framework configuration...")
317
318
 
@@ -324,13 +325,16 @@ class DownloadService:
324
325
  write_file(dbt_project_dir / "dbt_project.yml", dbt_project_content)
325
326
  success_line(f"dbt_project.yml written to {dbt_project_dir}")
326
327
 
327
- # Generate profiles.yml
328
- account_info = self.sf.get_account_info()
329
- account = account_info.get("ACCOUNT", account_info.get("account", ""))
330
- user = account_info.get("USER_NAME", account_info.get("user_name", ""))
331
- profiles_content = generate_profiles_yml(profile, project_settings, account, user)
332
- write_file(dbt_project_dir / "profiles.yml", profiles_content)
333
- success_line(f"profiles.yml written to {dbt_project_dir}")
328
+ # profiles.yml is never written inside the dbt project: dbt reads it from
329
+ # project_settings.profile_dir (default ~/.dbt). Only the local-dev target is ever
330
+ # written there, using the developer's own Snowflake CLI connection and personal
331
+ # sandpit database - deployment targets belong to the native app's CI pipeline.
332
+ connection_params = get_connection_params(self.sf.config.connection_name)
333
+ profiles_dir = Path(project_settings.profile_dir).expanduser()
334
+ status = write_profiles_yml(
335
+ profiles_dir, profile.profile_name, profile, project_settings,
336
+ connection_params, self.sf.config.sandpit_name)
337
+ success_line(f"profiles.yml {status} in {profiles_dir}")
334
338
 
335
339
  # Generate .gitignore
336
340
  write_file(dbt_project_dir / ".gitignore", generate_gitignore())
@@ -16,6 +16,21 @@ ALLOWED_PROCEDURES = frozenset({
16
16
  })
17
17
 
18
18
 
19
+ def get_connection_params(connection_name: str) -> dict[str, Any]:
20
+ """Raw connection parameters for a named Snowflake CLI connection, read directly
21
+ from ~/.snowflake/connections.toml (no connection is established).
22
+
23
+ Returns an empty dict if the connection name isn't found or the config can't be read.
24
+ """
25
+ try:
26
+ from snowflake.connector.config_manager import CONFIG_MANAGER
27
+ CONFIG_MANAGER.read_config()
28
+ connections = CONFIG_MANAGER["connections"]
29
+ return dict(connections.get(connection_name, {}))
30
+ except Exception:
31
+ return {}
32
+
33
+
19
34
  class SnowflakeService:
20
35
  """Connects to Snowflake and calls native app API procedures."""
21
36
 
@@ -0,0 +1,150 @@
1
+ """Tests for the local-dev profiles.yml generation (sandpit database, connections.toml passthrough)."""
2
+
3
+ import yaml
4
+ import pytest
5
+
6
+ from datasecops_cli.models.project_config import DbtTarget, ProjectProfile, ProjectSettings
7
+ from datasecops_cli.services.dbt_project_generator import generate_local_dev_target, write_profiles_yml
8
+
9
+
10
+ def _profile(target_database: str = "SDS_") -> ProjectProfile:
11
+ return ProjectProfile(profile_name="anzco_sds", project_name="Anzco SDS", target_database=target_database)
12
+
13
+
14
+ def _project_settings() -> ProjectSettings:
15
+ return ProjectSettings(targets=[
16
+ DbtTarget(target_name="dev", branch_name="dev", target_role="DEVELOPERS",
17
+ target_warehouse="DEV_WH", target_schema="PUBLIC", is_default=True),
18
+ DbtTarget(target_name="prod", branch_name="prod", target_role="DATAOPS_ADMIN",
19
+ target_warehouse="DATAOPS_WH", target_schema="PUBLIC"),
20
+ ])
21
+
22
+
23
+ def test_generate_local_dev_target_uses_connections_toml_identity():
24
+ connection_params = {
25
+ "account": "myaccount",
26
+ "user": "jon@dataengineers.co.nz",
27
+ "authenticator": "SNOWFLAKE_JWT",
28
+ "private_key_path": "/home/jon/.keys/rsa_key.p8",
29
+ "role": "accountadmin", # must be ignored - role always from dbt targets config
30
+ "warehouse": "COMPUTE_WH", # must be ignored - warehouse always from dbt targets config
31
+ }
32
+
33
+ output = generate_local_dev_target(_profile(), _project_settings(), connection_params, "jon")
34
+
35
+ assert output["type"] == "snowflake"
36
+ assert output["account"] == "myaccount"
37
+ assert output["user"] == "jon@dataengineers.co.nz"
38
+ assert output["authenticator"] == "SNOWFLAKE_JWT"
39
+ assert output["private_key_path"] == "/home/jon/.keys/rsa_key.p8"
40
+ # role/warehouse/schema always come from the project's dbt targets config, never connections.toml
41
+ assert output["role"] == "DEVELOPERS"
42
+ assert output["warehouse"] == "DEV_WH"
43
+ assert output["schema"] == "PUBLIC"
44
+ assert output["threads"] == 4
45
+
46
+
47
+ def test_generate_local_dev_target_builds_sandpit_database():
48
+ output = generate_local_dev_target(_profile("SDS_"), _project_settings(), {}, "jon")
49
+ assert output["database"] == "SDS_SANDPIT_JON"
50
+
51
+
52
+ def test_generate_local_dev_target_sanitizes_name_with_spaces_and_punctuation():
53
+ """Real names (spaces, apostrophes, hyphens) are sanitized into a safe database
54
+ identifier suffix rather than rejected outright."""
55
+ output = generate_local_dev_target(_profile(), _project_settings(), {}, "O'Brien Jr.")
56
+ assert output["database"] == "SDS_SANDPIT_O_BRIEN_JR"
57
+
58
+
59
+ def test_generate_local_dev_target_rejects_name_with_no_alphanumerics():
60
+ with pytest.raises(ValueError):
61
+ generate_local_dev_target(_profile(), _project_settings(), {}, "---")
62
+
63
+
64
+ def test_generate_local_dev_target_omits_missing_connection_fields():
65
+ output = generate_local_dev_target(_profile(), _project_settings(), {"account": "acct"}, "jon")
66
+ assert output["account"] == "acct"
67
+ assert "private_key_path" not in output
68
+ assert "password" not in output
69
+
70
+
71
+ def test_write_profiles_yml_creates_folder_and_file(tmp_path):
72
+ profiles_dir = tmp_path / ".dbt"
73
+ assert not profiles_dir.exists()
74
+
75
+ status = write_profiles_yml(
76
+ profiles_dir, "anzco_sds", _profile(), _project_settings(), {"account": "acct"}, "jon")
77
+
78
+ assert status == "added"
79
+ profiles_path = profiles_dir / "profiles.yml"
80
+ assert profiles_path.exists()
81
+
82
+ data = yaml.safe_load(profiles_path.read_text(encoding="utf-8"))
83
+ assert data["anzco_sds"]["target"] == "local-dev"
84
+ assert data["anzco_sds"]["outputs"]["local-dev"]["database"] == "SDS_SANDPIT_JON"
85
+
86
+
87
+ def test_write_profiles_yml_only_touches_local_dev(tmp_path):
88
+ profiles_dir = tmp_path / ".dbt"
89
+ profiles_dir.mkdir(parents=True)
90
+ profiles_path = profiles_dir / "profiles.yml"
91
+ profiles_path.write_text(yaml.dump({
92
+ "anzco_sds": {
93
+ "target": "local-dev",
94
+ "outputs": {
95
+ "local-dev": {"type": "snowflake", "account": "old", "database": "OLD_DB"},
96
+ "dev": {"type": "snowflake", "account": "ci", "database": "SDS_DEV"},
97
+ },
98
+ }
99
+ }), encoding="utf-8")
100
+
101
+ status = write_profiles_yml(
102
+ profiles_dir, "anzco_sds", _profile(), _project_settings(), {"account": "acct"}, "jon")
103
+
104
+ assert status == "updated"
105
+ data = yaml.safe_load(profiles_path.read_text(encoding="utf-8"))
106
+ # local-dev was refreshed
107
+ assert data["anzco_sds"]["outputs"]["local-dev"]["account"] == "acct"
108
+ assert data["anzco_sds"]["outputs"]["local-dev"]["database"] == "SDS_SANDPIT_JON"
109
+ # the CI/deployment "dev" target is untouched
110
+ assert data["anzco_sds"]["outputs"]["dev"] == {"type": "snowflake", "account": "ci", "database": "SDS_DEV"}
111
+
112
+
113
+ def test_write_profiles_yml_forces_default_target_to_local_dev(tmp_path):
114
+ """A bare `dbt run` must always use local-dev, even if the file's default target was
115
+ previously left pointing at a deployment target (manually, or by an older CLI version)."""
116
+ profiles_dir = tmp_path / ".dbt"
117
+ profiles_dir.mkdir(parents=True)
118
+ profiles_path = profiles_dir / "profiles.yml"
119
+ profiles_path.write_text(yaml.dump({
120
+ "anzco_sds": {
121
+ "target": "dev",
122
+ "outputs": {
123
+ "local-dev": {"type": "snowflake", "account": "old"},
124
+ "dev": {"type": "snowflake", "account": "ci"},
125
+ },
126
+ }
127
+ }), encoding="utf-8")
128
+
129
+ write_profiles_yml(profiles_dir, "anzco_sds", _profile(), _project_settings(), {"account": "acct"}, "jon")
130
+
131
+ data = yaml.safe_load(profiles_path.read_text(encoding="utf-8"))
132
+ assert data["anzco_sds"]["target"] == "local-dev"
133
+
134
+
135
+ def test_write_profiles_yml_adds_new_profile_without_disturbing_others(tmp_path):
136
+ profiles_dir = tmp_path / ".dbt"
137
+ profiles_dir.mkdir(parents=True)
138
+ profiles_path = profiles_dir / "profiles.yml"
139
+ profiles_path.write_text(yaml.dump({
140
+ "other_profile": {"target": "local-dev", "outputs": {"local-dev": {"type": "snowflake", "account": "x"}}},
141
+ }), encoding="utf-8")
142
+
143
+ status = write_profiles_yml(
144
+ profiles_dir, "anzco_sds", _profile(), _project_settings(), {"account": "acct"}, "jon")
145
+
146
+ assert status == "added"
147
+ data = yaml.safe_load(profiles_path.read_text(encoding="utf-8"))
148
+ assert "other_profile" in data
149
+ assert "anzco_sds" in data
150
+ assert data["anzco_sds"]["outputs"]["local-dev"]["database"] == "SDS_SANDPIT_JON"
@@ -1 +0,0 @@
1
- __version__ = "0.7.0b2"