datasecops-cli 0.7.0b2__tar.gz → 0.7.0b5__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 (90) hide show
  1. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/CHANGELOG.md +15 -0
  2. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/PKG-INFO +28 -1
  3. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/README.md +27 -0
  4. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/docs/getting-started.md +23 -17
  5. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/pyproject.toml +1 -1
  6. datasecops_cli-0.7.0b5/src/datasecops_cli/__init__.py +1 -0
  7. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/config.py +6 -2
  8. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/main.py +81 -49
  9. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/models/project_config.py +1 -3
  10. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/dbt_project_generator.py +89 -56
  11. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/download_service.py +12 -8
  12. datasecops_cli-0.7.0b5/src/datasecops_cli/services/pipeline_env.py +93 -0
  13. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/snowflake_service.py +30 -1
  14. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_mcp/connection.py +4 -1
  15. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_config.py +7 -0
  16. datasecops_cli-0.7.0b5/tests/test_dbt_project_generator_profiles.py +150 -0
  17. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_main.py +40 -1
  18. datasecops_cli-0.7.0b5/tests/test_pipeline_env.py +108 -0
  19. datasecops_cli-0.7.0b2/src/datasecops_cli/__init__.py +0 -1
  20. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/.github/workflows/auto-tag.yml +0 -0
  21. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/.github/workflows/publish-cli.yml +0 -0
  22. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/.github/workflows/test.yml +0 -0
  23. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/.gitignore +0 -0
  24. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/AGENTS.md +0 -0
  25. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/DEVELOPMENT.md +0 -0
  26. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/LICENSE +0 -0
  27. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/docs/dcm.md +0 -0
  28. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/docs/git-operations.md +0 -0
  29. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/docs/legacy.md +0 -0
  30. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/docs/legacy_plan_of_action.md +0 -0
  31. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/docs/mcp-server.md +0 -0
  32. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/mcp-servers.json +0 -0
  33. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/scripts/vendor_model.py +0 -0
  34. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/setup.ps1 +0 -0
  35. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/setup.sh +0 -0
  36. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/menus/__init__.py +0 -0
  37. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/menus/configuration.py +0 -0
  38. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/menus/dcm_operations.py +0 -0
  39. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/menus/development.py +0 -0
  40. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/menus/downloads.py +0 -0
  41. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/menus/git_operations.py +0 -0
  42. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/models/__init__.py +0 -0
  43. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/models/git_helpers.py +0 -0
  44. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/__init__.py +0 -0
  45. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/bootstrap_service.py +0 -0
  46. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/dbt_runner.py +0 -0
  47. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/dcm_orchestrator.py +0 -0
  48. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/dcm_pipelines.py +0 -0
  49. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/dcm_registry.py +0 -0
  50. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/dcm_service.py +0 -0
  51. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/directory_scaffolder.py +0 -0
  52. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/git_service.py +0 -0
  53. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/linting_service.py +0 -0
  54. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/model_commands.py +0 -0
  55. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/skill_service.py +0 -0
  56. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/services/upstream_service.py +0 -0
  57. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/utilities/__init__.py +0 -0
  58. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/utilities/display.py +0 -0
  59. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/utilities/file_utils.py +0 -0
  60. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/utilities/mcp.py +0 -0
  61. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  62. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_mcp/__init__.py +0 -0
  63. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_mcp/__main__.py +0 -0
  64. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_mcp/server.py +0 -0
  65. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/__init__.py +0 -0
  66. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/convert.py +0 -0
  67. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/diagram.py +0 -0
  68. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/expand.py +0 -0
  69. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/extract.py +0 -0
  70. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/render.py +0 -0
  71. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/schema.py +0 -0
  72. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/scope.py +0 -0
  73. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/scripts.py +0 -0
  74. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/starter.py +0 -0
  75. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/templates/macros/role.sql +0 -0
  76. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/templates/macros/role_guard.sql +0 -0
  77. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/templates/macros/warehouse.sql +0 -0
  78. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/src/datasecops_model/validate.py +0 -0
  79. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/__init__.py +0 -0
  80. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_dbt_runner.py +0 -0
  81. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_dcm_model.py +0 -0
  82. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_dcm_orchestrator.py +0 -0
  83. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_dcm_service.py +0 -0
  84. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_download_service.py +0 -0
  85. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_file_utils.py +0 -0
  86. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_linting_service.py +0 -0
  87. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_model_commands.py +0 -0
  88. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_models.py +0 -0
  89. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_version.py +0 -0
  90. {datasecops_cli-0.7.0b2 → datasecops_cli-0.7.0b5}/tests/test_yaml_utils.py +0 -0
@@ -4,6 +4,21 @@ All notable changes to the DataSecOps CLI are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.0b5] - 2026-10-26
8
+
9
+ ### Added
10
+
11
+ - **`datasecops pipeline env`** — resolves the dbt target for a pipeline run from the native app's dbt Targets (`--branch` matches a target's Branch, then a target named like the branch; `--target` picks one by name) and sets `TARGET`, `SF_ROLE`, `SF_WAREHOUSE` and `TARGET_SCHEMA` (`$GITHUB_ENV` on GitHub Actions, `##vso` on Azure DevOps, `KEY=value` otherwise). Generated pipelines use it instead of hard-coding the CI role and warehouse.
12
+
13
+ ### Fixed
14
+
15
+ - **`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`).
16
+ - **`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.
17
+ - **`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.
18
+ - **`app_database` is validated before it is used in SQL** — it is interpolated into the native app `CALL` statements (CLI and MCP server), so a value that isn't a single plain or double-quoted identifier is now rejected instead of being able to change the statement.
19
+
20
+ ## [0.7.0b2] - 2026-10-26
21
+
7
22
  ### Fixed
8
23
 
9
24
  - **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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datasecops-cli
3
- Version: 0.7.0b2
3
+ Version: 0.7.0b5
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -146,6 +146,33 @@ Your pipeline needs two things:
146
146
 
147
147
  The exit code is `0` on success, `1` if any download fails.
148
148
 
149
+ ### Environment settings for the branch
150
+
151
+ `datasecops pipeline env` reads the dbt Targets from the native app (**Settings › Data › dbt
152
+ Targets**) and sets `TARGET`, `SF_ROLE`, `SF_WAREHOUSE` and `TARGET_SCHEMA` for the target mapped to
153
+ the branch, so pipelines don't hard-code them:
154
+
155
+ ```bash
156
+ datasecops pipeline env --branch "$GITHUB_REF_NAME" # GitHub Actions: target whose Branch is this branch
157
+ datasecops pipeline env --branch "$BUILD_SOURCEBRANCH" # Azure DevOps (the full ref, not BUILD_SOURCEBRANCHNAME)
158
+ datasecops pipeline env --target unit-test # a named target
159
+ ```
160
+
161
+ Pass the branch through the runner's environment variable, not a `${{ ... }}` / `$(...)` expression:
162
+ those are substituted into the script before the shell runs, so a branch name containing `$(...)`
163
+ would execute.
164
+
165
+ - `--branch` matches a target's **Branch**, then a target named like the branch; `refs/heads/` is
166
+ ignored and a pull request's merge ref resolves to its source branch (`$GITHUB_HEAD_REF` on GitHub,
167
+ `$SYSTEM_PULLREQUEST_SOURCEBRANCH` on Azure DevOps). On Azure DevOps pass `$BUILD_SOURCEBRANCH`:
168
+ `$BUILD_SOURCEBRANCHNAME` is only the last part of the ref (`release/prod` -> `prod`).
169
+ Exact names win; a case-insensitive match is used only when it is unambiguous.
170
+ - GitHub Actions: the values are appended to `$GITHUB_ENV`. Azure DevOps: they are set with
171
+ `##vso[task.setvariable]`. Elsewhere (or `--format shell`) they are printed as shell-quoted `KEY=value` lines.
172
+ - A target with no role, warehouse or schema leaves that variable as it is.
173
+ - The command connects with `.datasecops.yml`'s connection, so the CI user needs the `orbit_developer`
174
+ application role. It exits `1` when no target matches.
175
+
149
176
  ## DCM projects
150
177
 
151
178
  ```bash
@@ -126,6 +126,33 @@ Your pipeline needs two things:
126
126
 
127
127
  The exit code is `0` on success, `1` if any download fails.
128
128
 
129
+ ### Environment settings for the branch
130
+
131
+ `datasecops pipeline env` reads the dbt Targets from the native app (**Settings › Data › dbt
132
+ Targets**) and sets `TARGET`, `SF_ROLE`, `SF_WAREHOUSE` and `TARGET_SCHEMA` for the target mapped to
133
+ the branch, so pipelines don't hard-code them:
134
+
135
+ ```bash
136
+ datasecops pipeline env --branch "$GITHUB_REF_NAME" # GitHub Actions: target whose Branch is this branch
137
+ datasecops pipeline env --branch "$BUILD_SOURCEBRANCH" # Azure DevOps (the full ref, not BUILD_SOURCEBRANCHNAME)
138
+ datasecops pipeline env --target unit-test # a named target
139
+ ```
140
+
141
+ Pass the branch through the runner's environment variable, not a `${{ ... }}` / `$(...)` expression:
142
+ those are substituted into the script before the shell runs, so a branch name containing `$(...)`
143
+ would execute.
144
+
145
+ - `--branch` matches a target's **Branch**, then a target named like the branch; `refs/heads/` is
146
+ ignored and a pull request's merge ref resolves to its source branch (`$GITHUB_HEAD_REF` on GitHub,
147
+ `$SYSTEM_PULLREQUEST_SOURCEBRANCH` on Azure DevOps). On Azure DevOps pass `$BUILD_SOURCEBRANCH`:
148
+ `$BUILD_SOURCEBRANCHNAME` is only the last part of the ref (`release/prod` -> `prod`).
149
+ Exact names win; a case-insensitive match is used only when it is unambiguous.
150
+ - GitHub Actions: the values are appended to `$GITHUB_ENV`. Azure DevOps: they are set with
151
+ `##vso[task.setvariable]`. Elsewhere (or `--format shell`) they are printed as shell-quoted `KEY=value` lines.
152
+ - A target with no role, warehouse or schema leaves that variable as it is.
153
+ - The command connects with `.datasecops.yml`'s connection, so the CI user needs the `orbit_developer`
154
+ application role. It exits `1` when no target matches.
155
+
129
156
  ## DCM projects
130
157
 
131
158
  ```bash
@@ -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.0b5"
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.0b5"
@@ -23,8 +23,9 @@ class Config:
23
23
  self.dbt_project_dir: Path = Path.cwd()
24
24
  self.profile_name: str = ""
25
25
 
26
- def load(self, project_dir: Path = None) -> bool:
27
- """Load configuration from .datasecops.yml and dbt_project.yml."""
26
+ def load(self, project_dir: Path = None, connection_name: Optional[str] = None) -> bool:
27
+ """Load configuration from .datasecops.yml and dbt_project.yml. `connection_name`
28
+ (a --connection option) overrides the file's and may stand in for a missing one."""
28
29
  self.project_dir = project_dir or Path.cwd()
29
30
 
30
31
  # Load .datasecops.yml
@@ -38,6 +39,7 @@ class Config:
38
39
  app_database=raw.get("app_database", ""),
39
40
  profile_name=raw.get("profile_name", ""),
40
41
  cortex_connection=raw.get("cortex_connection", ""),
42
+ sandpit_name=raw.get("sandpit_name", ""),
41
43
  dbt_engine=raw.get("dbt_engine", "dbt_core"),
42
44
  dcm_enabled=raw.get("dcm_enabled", False),
43
45
  dcm_security_dir=raw.get("dcm_security_dir", "security"),
@@ -53,6 +55,8 @@ class Config:
53
55
  warning_line(f"Invalid dbt_engine '{self.datasecops.dbt_engine}' in .datasecops.yml — falling back to dbt_core")
54
56
  self.datasecops.dbt_engine = "dbt_core"
55
57
 
58
+ if connection_name:
59
+ self.datasecops.connection_name = connection_name
56
60
  if not self.datasecops.connection_name or not self.datasecops.app_database:
57
61
  error_line(".datasecops.yml is missing connection_name or app_database.")
58
62
  return False
@@ -17,7 +17,8 @@ from datasecops_cli.services.download_service import DownloadService
17
17
  from datasecops_cli.services.skill_service import SkillService
18
18
  from datasecops_cli.services.bootstrap_service import BootstrapService
19
19
  from datasecops_cli.services.dcm_service import DcmService
20
- from datasecops_cli.services import dcm_registry, model_commands
20
+ from datasecops_cli.services import dcm_registry, model_commands, pipeline_env
21
+ from datasecops_cli.models.project_config import DbtTarget
21
22
  from datasecops_cli.menus.development import DevelopmentMenu
22
23
  from datasecops_cli.menus.git_operations import GitOperationsMenu
23
24
  from datasecops_cli.menus.downloads import DownloadsMenu
@@ -239,6 +240,55 @@ def dcm_pipelines_cmd(platform, names, force, projects, connection):
239
240
  sf_service.close()
240
241
 
241
242
 
243
+ # ── pipeline commands ──────────────────────────────────────────────────────
244
+
245
+
246
+ @cli.group()
247
+ def pipeline():
248
+ """Helpers for CI/CD pipelines."""
249
+
250
+
251
+ @pipeline.command("env")
252
+ @click.option("--branch", "-b", default=None,
253
+ help="Branch being built: picks the dbt target mapped to it (a GitHub pull request's "
254
+ "<n>/merge ref resolves to its source branch)")
255
+ @click.option("--target", "-t", default=None, help="dbt target name (instead of --branch)")
256
+ @click.option("--connection", "-c", default=None, help="Snowflake connection name (overrides .datasecops.yml)")
257
+ @click.option("--format", "fmt", type=click.Choice(["auto", *pipeline_env.PLATFORMS]), default="auto",
258
+ show_default=True, help="auto: GitHub when $GITHUB_ENV is set, Azure DevOps when $TF_BUILD is set")
259
+ def pipeline_env_cmd(branch: Optional[str], target: Optional[str], connection: Optional[str], fmt: str):
260
+ """Set TARGET, SF_ROLE, SF_WAREHOUSE and TARGET_SCHEMA from the app's dbt Targets.
261
+
262
+ GitHub Actions: appended to $GITHUB_ENV. Azure DevOps: set with ##vso logging commands.
263
+ Otherwise printed as KEY=value. Empty role / warehouse / schema values are left unset.
264
+ """
265
+ if bool(branch) == bool(target):
266
+ raise click.UsageError("give exactly one of --branch or --target")
267
+ config = Config()
268
+ if not config.load(connection_name=connection):
269
+ sys.exit(1)
270
+ sf_service = SnowflakeService(config.datasecops)
271
+ try:
272
+ raw = sf_service.get_framework_config("PROJECT") or {}
273
+ except Exception as e: # noqa: BLE001 - connector errors
274
+ error_line(f"Failed to read the dbt targets from {config.datasecops.app_database}: {e}")
275
+ sys.exit(1)
276
+ finally:
277
+ sf_service.close()
278
+ targets = [DbtTarget(**{k: v for k, v in t.items() if k in DbtTarget.model_fields})
279
+ for t in raw.get("targets") or []]
280
+ try:
281
+ resolved = pipeline_env.resolve_target(targets, branch=branch, target=target)
282
+ except pipeline_env.TargetNotFound as e:
283
+ error_line(str(e))
284
+ sys.exit(1)
285
+ values = pipeline_env.env_values(resolved)
286
+ platform = pipeline_env.detect_platform() if fmt == "auto" else fmt
287
+ for line in pipeline_env.export(values, platform):
288
+ click.echo(line)
289
+ click.echo("dbt environment: " + ", ".join(f"{k}={v}" for k, v in values.items()), err=True)
290
+
291
+
242
292
  # ── model commands ─────────────────────────────────────────────────────────
243
293
 
244
294
 
@@ -478,6 +528,9 @@ def _run_setup(project_dir: Path):
478
528
  "MONITORIAL_ORBIT"
479
529
  )
480
530
 
531
+ # --- Sandpit name (for your personal local-dev database) ---
532
+ sandpit_name = get_input_string("Enter your name (used to build your personal sandpit database, e.g. 'jon'): ")
533
+
481
534
  # --- CoCo agent connection ---
482
535
  cortex_connection = ""
483
536
  if shutil.which("cortex") and connection_names:
@@ -588,46 +641,15 @@ def _run_setup(project_dir: Path):
588
641
  # --- Ensure profiles.yml has the profile entry ---
589
642
  if profile_name and matched_profile and not project_settings_loaded:
590
643
  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")
644
+ "was not loaded, so the local-dev role/warehouse aren't known")
592
645
  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
646
+ from datasecops_cli.services.dbt_project_generator import write_profiles_yml
647
+ from datasecops_cli.services.snowflake_service import get_connection_params
596
648
 
597
649
  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}")
650
+ connection_params = get_connection_params(connection_name)
651
+ status = write_profiles_yml(profiles_dir, profile_name, matched_profile, project_settings, connection_params, sandpit_name)
652
+ success_line(f"Profile '{profile_name}' {status} in {profiles_dir / 'profiles.yml'}")
631
653
  except Exception as e:
632
654
  warning_line(f"Could not resolve the project profile / write profiles.yml: {e}")
633
655
  warning_line("You can set profile_name manually in .datasecops.yml")
@@ -643,6 +665,7 @@ def _run_setup(project_dir: Path):
643
665
  "app_database": app_database,
644
666
  "profile_name": profile_name,
645
667
  "cortex_connection": cortex_connection,
668
+ "sandpit_name": sandpit_name,
646
669
  }
647
670
  write_datasecops_config(project_dir, config_data)
648
671
  success_line(f".datasecops.yml written to {project_dir / '.datasecops.yml'}")
@@ -838,11 +861,11 @@ def _connect_and_load(config: Config, interactive: bool = True,
838
861
  break
839
862
  # Save the selected profile back to .datasecops.yml
840
863
  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
- }
864
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config
865
+ config_data = read_datasecops_config(config.project_dir) or {}
866
+ config_data["connection_name"] = config.datasecops.connection_name
867
+ config_data["app_database"] = config.datasecops.app_database
868
+ config_data["profile_name"] = config.profile_name
846
869
  write_datasecops_config(config.project_dir, config_data)
847
870
  success_line(f"Saved profile_name '{config.profile_name}' to .datasecops.yml")
848
871
  else:
@@ -850,11 +873,11 @@ def _connect_and_load(config: Config, interactive: bool = True,
850
873
  info_line(f"Using default profile: {config.profile_name}")
851
874
  elif not profile_was_set and len(config.all_profiles) == 1:
852
875
  # 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
- }
876
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config
877
+ config_data = read_datasecops_config(config.project_dir) or {}
878
+ config_data["connection_name"] = config.datasecops.connection_name
879
+ config_data["app_database"] = config.datasecops.app_database
880
+ config_data["profile_name"] = config.profile_name
858
881
  write_datasecops_config(config.project_dir, config_data)
859
882
  success_line(f"Saved profile_name '{config.profile_name}' to .datasecops.yml")
860
883
 
@@ -1274,7 +1297,7 @@ def _show_main_menu(profile_name: str, git_service: GitService = None,
1274
1297
 
1275
1298
  def _run_bootstrap(config: Config):
1276
1299
  """Run the bootstrap command to initialise a new project."""
1277
- from datasecops_cli.utilities.display import get_input_true_false, select_multiple_from_list
1300
+ from datasecops_cli.utilities.display import get_input_string, get_input_true_false, select_multiple_from_list
1278
1301
 
1279
1302
  sf_service = _connect_and_load(config)
1280
1303
 
@@ -1285,6 +1308,15 @@ def _run_bootstrap(config: Config):
1285
1308
  platform = config.source_control.source_control_platform.lower()
1286
1309
  info_line(f"Platform: {platform}")
1287
1310
 
1311
+ # Sandpit name (for your personal local-dev database) — ask once, then persist
1312
+ if not config.datasecops.sandpit_name:
1313
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config, write_datasecops_config
1314
+ config.datasecops.sandpit_name = get_input_string(
1315
+ "Enter your name (used to build your personal sandpit database, e.g. 'jon'): ")
1316
+ config_data = read_datasecops_config(config.project_dir) or {}
1317
+ config_data["sandpit_name"] = config.datasecops.sandpit_name
1318
+ write_datasecops_config(config.project_dir, config_data)
1319
+
1288
1320
  # Ask about optional steps
1289
1321
  install_skills = get_input_true_false("Install AI coding skills?")
1290
1322
  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())