datasecops-cli 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/CHANGELOG.md +8 -0
  2. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/PKG-INFO +1 -2
  3. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/pyproject.toml +1 -2
  4. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/main.py +60 -6
  5. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/models/project_config.py +1 -0
  6. datasecops_cli-0.3.2/src/datasecops_cli/services/upstream_service.py +158 -0
  7. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/utilities/display.py +14 -2
  8. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/.github/workflows/publish-cli.yml +0 -0
  9. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/.gitignore +0 -0
  10. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/DEVELOPMENT.md +0 -0
  11. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/LICENSE +0 -0
  12. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/README.md +0 -0
  13. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/docs/getting-started.md +0 -0
  14. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/docs/legacy.md +0 -0
  15. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/docs/legacy_plan_of_action.md +0 -0
  16. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/docs/mcp-server.md +0 -0
  17. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/mcp-servers.json +0 -0
  18. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/setup.ps1 +0 -0
  19. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/setup.sh +0 -0
  20. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/__init__.py +0 -0
  21. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/config.py +0 -0
  22. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/menus/__init__.py +0 -0
  23. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/menus/development.py +0 -0
  24. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/menus/downloads.py +0 -0
  25. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/menus/git_operations.py +0 -0
  26. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/models/__init__.py +0 -0
  27. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/models/git_helpers.py +0 -0
  28. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/__init__.py +0 -0
  29. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/bootstrap_service.py +0 -0
  30. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/dbt_runner.py +0 -0
  31. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/download_service.py +0 -0
  32. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/git_service.py +0 -0
  33. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/linting_service.py +0 -0
  34. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/skill_service.py +0 -0
  35. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/services/snowflake_service.py +0 -0
  36. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/utilities/__init__.py +0 -0
  37. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/utilities/file_utils.py +0 -0
  38. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  39. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_mcp/__init__.py +0 -0
  40. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_mcp/__main__.py +0 -0
  41. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_mcp/connection.py +0 -0
  42. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/src/datasecops_mcp/server.py +0 -0
  43. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/__init__.py +0 -0
  44. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/test_config.py +0 -0
  45. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/test_file_utils.py +0 -0
  46. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/test_main.py +0 -0
  47. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/test_models.py +0 -0
  48. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/test_version.py +0 -0
  49. {datasecops_cli-0.3.1 → datasecops_cli-0.3.2}/tests/test_yaml_utils.py +0 -0
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to the DataSecOps CLI are documented in this file.
4
4
 
5
+ ## [0.3.2] - 2026-05-15
6
+
7
+ ### Added
8
+
9
+ - **Upstream project version tracking** — if a profile has `upstream_projects` configured in the native app, the main menu header shows each upstream project's current pinned version vs the latest available tag (fetched via `git ls-remote`). Outdated dependencies are highlighted in yellow.
10
+ - **Auto-update upstream packages** — when outdated upstream packages are detected, a new menu option `[5] update pkgs` appears, which updates `packages.yml` revisions to the latest tags and optionally runs `dbtf deps`
11
+ - **`upstream_projects` field on `ProjectProfile`** — new model field to support upstream dependency tracking from the native app
12
+
5
13
  ## [0.3.0] - 2026-05-14
6
14
 
7
15
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datasecops-cli
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -10,7 +10,6 @@ Requires-Dist: gitpython>=3.1
10
10
  Requires-Dist: pydantic>=2.0
11
11
  Requires-Dist: pyyaml>=6.0
12
12
  Requires-Dist: snowflake-connector-python>=3.0
13
- Requires-Dist: sqlfluff>=3.0
14
13
  Provides-Extra: mcp
15
14
  Requires-Dist: mcp>=1.0; extra == 'mcp'
16
15
  Provides-Extra: test
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "datasecops-cli"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "DataSecOps Framework CLI for Snowflake Native App"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,7 +14,6 @@ dependencies = [
14
14
  "snowflake-connector-python>=3.0",
15
15
  "colorama>=0.4",
16
16
  "pyyaml>=6.0",
17
- "sqlfluff>=3.0",
18
17
  "pydantic>=2.0",
19
18
  ]
20
19
 
@@ -247,6 +247,15 @@ def _run_interactive(config: Config):
247
247
  warning_line("dbt Fusion (dbtf) not found on PATH — dbt commands will not work.")
248
248
  warning_line("Install dbt Fusion: https://docs.getdbt.com/docs/core/installation")
249
249
 
250
+ # Check upstream project versions
251
+ from datasecops_cli.services.upstream_service import check_upstream_versions
252
+ upstream_statuses = []
253
+ if config.profile and config.profile.upstream_projects:
254
+ info_line("Checking upstream project versions...")
255
+ upstream_statuses = check_upstream_versions(
256
+ config.profile, config.all_profiles, config.dbt_project_dir
257
+ )
258
+
250
259
  # Initialize services
251
260
  dbt_runner = DbtRunner(
252
261
  project_dir=config.dbt_project_dir,
@@ -265,7 +274,8 @@ def _run_interactive(config: Config):
265
274
 
266
275
  # Main menu loop
267
276
  _main_menu(config, dbt_runner, git_service, linting_service,
268
- download_service, skill_service, sf_service)
277
+ download_service, skill_service, sf_service,
278
+ upstream_statuses=upstream_statuses)
269
279
  finally:
270
280
  sf_service.close()
271
281
 
@@ -341,13 +351,44 @@ def _run_download(config: Config, items: list[str],
341
351
  sf_service.close()
342
352
 
343
353
 
354
+ def _update_upstream_packages(config: Config, dbt_runner: DbtRunner,
355
+ upstream_statuses: list):
356
+ """Update packages.yml with latest upstream versions and optionally run deps."""
357
+ from datasecops_cli.services.upstream_service import update_packages_yml
358
+ from datasecops_cli.utilities.display import display_action_header, get_input_true_false, complete_action
359
+
360
+ display_action_header("Update Upstream Packages")
361
+
362
+ outdated = [s for s in upstream_statuses if s.needs_update and s.latest_tag != "unknown"]
363
+ if not outdated:
364
+ info_line("No packages to update")
365
+ complete_action()
366
+ return
367
+
368
+ for s in outdated:
369
+ info_line(f" {s.profile_name}: {s.current_version} → {s.latest_tag}")
370
+
371
+ info_line("")
372
+ count = update_packages_yml(config.dbt_project_dir, upstream_statuses)
373
+ if count:
374
+ success_line(f"Updated {count} package(s) in packages.yml")
375
+ if get_input_true_false("Run dbtf deps now?"):
376
+ dbt_runner.deps()
377
+ else:
378
+ info_line("No changes made to packages.yml")
379
+
380
+ complete_action()
381
+
382
+
344
383
  def _main_menu(config: Config, dbt_runner: DbtRunner, git_service: GitService,
345
384
  linting_service: LintingService, download_service: DownloadService,
346
- skill_service: SkillService, sf_service: SnowflakeService):
385
+ skill_service: SkillService, sf_service: SnowflakeService,
386
+ upstream_statuses: list = None):
347
387
  """Main menu loop."""
348
388
  profile_name = config.profile_name
389
+ has_outdated = upstream_statuses and any(s.needs_update for s in upstream_statuses)
349
390
 
350
- _show_main_menu(profile_name, git_service)
391
+ _show_main_menu(profile_name, git_service, upstream_statuses)
351
392
  option = get_input_number("Choose an option: ")
352
393
 
353
394
  while option != 0:
@@ -391,21 +432,34 @@ def _main_menu(config: Config, dbt_runner: DbtRunner, git_service: GitService,
391
432
  profile=config.profile,
392
433
  )
393
434
  bootstrap.run(platform=platform, install_skills=install_skills, run_deps=run_deps)
435
+
436
+ elif option == 5 and has_outdated:
437
+ _update_upstream_packages(config, dbt_runner, upstream_statuses)
438
+ # Refresh upstream statuses after update
439
+ from datasecops_cli.services.upstream_service import check_upstream_versions
440
+ upstream_statuses = check_upstream_versions(
441
+ config.profile, config.all_profiles, config.dbt_project_dir
442
+ )
443
+ has_outdated = any(s.needs_update for s in upstream_statuses)
394
444
 
395
- _show_main_menu(profile_name, git_service)
445
+ _show_main_menu(profile_name, git_service, upstream_statuses)
396
446
  option = get_input_number("Choose an option: ")
397
447
 
398
448
  info_line("Exiting DataSecOps Framework CLI")
399
449
 
400
450
 
401
- def _show_main_menu(profile_name: str, git_service: GitService = None):
451
+ def _show_main_menu(profile_name: str, git_service: GitService = None,
452
+ upstream_statuses: list = None):
402
453
  clear()
403
454
  branch = git_service.get_current_branch() if git_service else None
404
- section_header("DataSecOps Framework CLI", profile_name, branch)
455
+ section_header("DataSecOps Framework CLI", profile_name, branch,
456
+ upstream_info=upstream_statuses)
405
457
  menu_option(1, "development - dbt Development Commands")
406
458
  menu_option(2, "git - Source Control Operations")
407
459
  menu_option(3, "downloads - Download Configs & Skills")
408
460
  menu_option(4, "bootstrap - Set up a new dbt project with all framework config")
461
+ if upstream_statuses and any(s.needs_update for s in upstream_statuses):
462
+ menu_option(5, "update pkgs - Update upstream packages to latest versions")
409
463
  menu_option(0, "exit - Exit")
410
464
 
411
465
 
@@ -61,6 +61,7 @@ class ProjectProfile(BaseModel):
61
61
  project_description: str = ""
62
62
  model_types: list[str] = Field(default_factory=list)
63
63
  downstream_projects: list[str] = Field(default_factory=list)
64
+ upstream_projects: list[str] = Field(default_factory=list)
64
65
  git_url: str = ""
65
66
  target_database: str = ""
66
67
  dbt_version: str = "1.9"
@@ -0,0 +1,158 @@
1
+ """Service for checking upstream project versions against packages.yml."""
2
+
3
+ import re
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from datasecops_cli.models.project_config import ProjectProfile
10
+ from datasecops_cli.utilities.display import info_line, warning_line
11
+ from datasecops_cli.utilities.yaml_utils import read_yaml, write_yaml
12
+
13
+
14
+ @dataclass
15
+ class UpstreamStatus:
16
+ """Version status of an upstream project dependency."""
17
+ profile_name: str
18
+ git_url: str
19
+ latest_tag: str = ""
20
+ current_version: str = ""
21
+ needs_update: bool = False
22
+ error: str = ""
23
+
24
+
25
+ def get_latest_tag(git_url: str, timeout: int = 10) -> Optional[str]:
26
+ """Fetch the latest tag from a remote git repo without cloning."""
27
+ try:
28
+ result = subprocess.run(
29
+ ["git", "ls-remote", "--tags", "--sort=-v:refname", git_url],
30
+ capture_output=True, text=True, timeout=timeout,
31
+ )
32
+ if result.returncode != 0:
33
+ return None
34
+
35
+ for line in result.stdout.strip().splitlines():
36
+ ref = line.split("\t")[-1]
37
+ # Skip ^{} dereferenced tags
38
+ if ref.endswith("^{}"):
39
+ continue
40
+ tag = ref.replace("refs/tags/", "")
41
+ # Skip pre-release tags
42
+ if any(pre in tag.lower() for pre in ("alpha", "beta", "rc", "dev")):
43
+ continue
44
+ return tag
45
+ return None
46
+ except (subprocess.TimeoutExpired, FileNotFoundError):
47
+ return None
48
+
49
+
50
+ def read_packages_yml(dbt_project_dir: Path) -> Optional[dict]:
51
+ """Read packages.yml from the dbt project directory."""
52
+ return read_yaml(dbt_project_dir / "packages.yml")
53
+
54
+
55
+ def find_pinned_version(packages_data: dict, git_url: str) -> str:
56
+ """Find the pinned revision/version for a git URL in packages.yml."""
57
+ if not packages_data:
58
+ return ""
59
+ for pkg in packages_data.get("packages", []):
60
+ pkg_url = pkg.get("git", "")
61
+ if pkg_url and _urls_match(pkg_url, git_url):
62
+ return pkg.get("revision", "")
63
+ return ""
64
+
65
+
66
+ def _urls_match(url1: str, url2: str) -> bool:
67
+ """Compare two git URLs, ignoring trailing .git and protocol differences."""
68
+ def normalize(url: str) -> str:
69
+ url = url.rstrip("/").removesuffix(".git").lower()
70
+ url = re.sub(r"^https?://", "", url)
71
+ url = re.sub(r"^git@([^:]+):", r"\1/", url)
72
+ return url
73
+ return normalize(url1) == normalize(url2)
74
+
75
+
76
+ def check_upstream_versions(
77
+ profile: ProjectProfile,
78
+ all_profiles: list[ProjectProfile],
79
+ dbt_project_dir: Path,
80
+ ) -> list[UpstreamStatus]:
81
+ """Check version status of all upstream project dependencies."""
82
+ if not profile.upstream_projects:
83
+ return []
84
+
85
+ # Build lookup of profiles by name
86
+ profile_map = {p.profile_name: p for p in all_profiles}
87
+
88
+ # Read current packages.yml
89
+ packages_data = read_packages_yml(dbt_project_dir)
90
+
91
+ results = []
92
+ for upstream_name in profile.upstream_projects:
93
+ upstream_profile = profile_map.get(upstream_name)
94
+ if not upstream_profile:
95
+ results.append(UpstreamStatus(
96
+ profile_name=upstream_name,
97
+ git_url="",
98
+ error=f"Profile '{upstream_name}' not found",
99
+ ))
100
+ continue
101
+
102
+ git_url = upstream_profile.git_url
103
+ if not git_url:
104
+ results.append(UpstreamStatus(
105
+ profile_name=upstream_name,
106
+ git_url="",
107
+ error="No git_url configured",
108
+ ))
109
+ continue
110
+
111
+ latest_tag = get_latest_tag(git_url)
112
+ current_version = find_pinned_version(packages_data, git_url)
113
+
114
+ needs_update = False
115
+ if latest_tag and current_version:
116
+ needs_update = latest_tag.lstrip("v") != current_version.lstrip("v")
117
+ elif latest_tag and not current_version:
118
+ needs_update = True
119
+
120
+ results.append(UpstreamStatus(
121
+ profile_name=upstream_name,
122
+ git_url=git_url,
123
+ latest_tag=latest_tag or "unknown",
124
+ current_version=current_version or "not pinned",
125
+ needs_update=needs_update,
126
+ ))
127
+
128
+ return results
129
+
130
+
131
+ def update_packages_yml(
132
+ dbt_project_dir: Path,
133
+ upstream_statuses: list[UpstreamStatus],
134
+ ) -> int:
135
+ """Update packages.yml with latest versions for outdated upstream packages.
136
+
137
+ Returns the number of packages updated.
138
+ """
139
+ packages_data = read_packages_yml(dbt_project_dir)
140
+ if not packages_data:
141
+ return 0
142
+
143
+ updated = 0
144
+ for status in upstream_statuses:
145
+ if not status.needs_update or status.latest_tag == "unknown":
146
+ continue
147
+ for pkg in packages_data.get("packages", []):
148
+ pkg_url = pkg.get("git", "")
149
+ if pkg_url and _urls_match(pkg_url, status.git_url):
150
+ pkg["revision"] = status.latest_tag
151
+ updated += 1
152
+ break
153
+
154
+ if updated:
155
+ dest = dbt_project_dir / "packages.yml"
156
+ write_yaml(dest, packages_data)
157
+
158
+ return updated
@@ -16,7 +16,8 @@ def print_long_line(color: str = Fore.GREEN) -> None:
16
16
  print_detail("=" * 76, color)
17
17
 
18
18
 
19
- def section_header(message: str, active_profile: str = None, current_branch: str = None) -> None:
19
+ def section_header(message: str, active_profile: str = None, current_branch: str = None,
20
+ upstream_info: list = None) -> None:
20
21
  print_long_line(Fore.GREEN)
21
22
  print_detail(message, Fore.GREEN)
22
23
  print_long_line(Fore.GREEN)
@@ -24,7 +25,18 @@ def section_header(message: str, active_profile: str = None, current_branch: str
24
25
  print_detail(f" Profile: {active_profile}", Fore.GREEN)
25
26
  if current_branch:
26
27
  print_detail(f" Branch: {current_branch}", Fore.GREEN)
27
- if active_profile or current_branch:
28
+ if upstream_info:
29
+ for status in upstream_info:
30
+ if status.error:
31
+ print_detail(f" Upstream: {status.profile_name} — {status.error}", Fore.YELLOW)
32
+ elif status.needs_update:
33
+ print_detail(
34
+ f" Upstream: {status.profile_name} ({status.current_version} → {status.latest_tag} available)",
35
+ Fore.YELLOW,
36
+ )
37
+ else:
38
+ print_detail(f" Upstream: {status.profile_name} ({status.current_version} ✓)", Fore.GREEN)
39
+ if active_profile or current_branch or upstream_info:
28
40
  print_long_line(Fore.GREEN)
29
41
 
30
42
 
File without changes
File without changes
File without changes
File without changes