composable-data-stack 0.5.1__tar.gz → 0.5.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 (75) hide show
  1. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/PKG-INFO +1 -1
  2. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/getter.py +42 -7
  3. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/main.py +20 -7
  4. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/PKG-INFO +1 -1
  5. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/pyproject.toml +1 -1
  6. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_getter.py +134 -0
  7. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_main.py +54 -0
  8. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/LICENSE +0 -0
  9. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/README.md +0 -0
  10. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/__init__.py +0 -0
  11. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/constants.py +0 -0
  12. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/diagnostics.py +0 -0
  13. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/graph.py +0 -0
  14. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/image_updates.py +0 -0
  15. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/image_verification.py +0 -0
  16. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/loader.py +0 -0
  17. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/overlay.py +0 -0
  18. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/planner.py +0 -0
  19. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/preflight.py +0 -0
  20. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/renderer.py +0 -0
  21. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resolver.py +0 -0
  22. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resources/__init__.py +0 -0
  23. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resources/contract.schema.json +0 -0
  24. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resources/module.schema.json +0 -0
  25. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resources/profile.schema.json +0 -0
  26. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resources/rule-schema.json +0 -0
  27. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/resources/rule-set.json +0 -0
  28. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/secrets.py +0 -0
  29. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/security.py +0 -0
  30. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/security_common.py +0 -0
  31. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/state.py +0 -0
  32. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/up_runner.py +0 -0
  33. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/utils.py +0 -0
  34. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/cli/validator.py +0 -0
  35. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/SOURCES.txt +0 -0
  36. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  37. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/entry_points.txt +0 -0
  38. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/requires.txt +0 -0
  39. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/top_level.txt +0 -0
  40. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/setup.cfg +0 -0
  41. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_cds_workflow.py +0 -0
  42. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_completion.py +0 -0
  43. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_compose_runtime_smoke.py +0 -0
  44. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_dagster_config_generation.py +0 -0
  45. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_dagster_definitions_uri.py +0 -0
  46. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  47. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_dagster_hardening.py +0 -0
  48. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_environment_classification.py +0 -0
  49. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_environment_cli.py +0 -0
  50. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_graph.py +0 -0
  51. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_image_security_scan_workflow.py +0 -0
  52. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_image_updates.py +0 -0
  53. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_image_verification.py +0 -0
  54. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_module_isolation.py +0 -0
  55. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_modules_no_committed_secrets.py +0 -0
  56. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_mvp_proof_workflow.py +0 -0
  57. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_overlay.py +0 -0
  58. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_planner.py +0 -0
  59. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_preflight.py +0 -0
  60. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_publish_images_workflow.py +0 -0
  61. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_python_version_alignment.py +0 -0
  62. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_release_version_check.py +0 -0
  63. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_render_example_profile.py +0 -0
  64. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_renderer.py +0 -0
  65. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_resolve_profile_path.py +0 -0
  66. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_resolver.py +0 -0
  67. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_secrets.py +0 -0
  68. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_security.py +0 -0
  69. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_smoke_example_profile.py +0 -0
  70. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_state.py +0 -0
  71. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_superset_hardening.py +0 -0
  72. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_trivyignore.py +0 -0
  73. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_up_runner.py +0 -0
  74. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_upstream_image_hardening.py +0 -0
  75. {composable_data_stack-0.5.1 → composable_data_stack-0.5.2}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: CLI for validating and composing composable data stack profiles
5
5
  Author: Ronald Hensbergen
6
6
  License-Expression: Apache-2.0
@@ -5,6 +5,7 @@ import os
5
5
  import re
6
6
  import shlex
7
7
  import shutil
8
+ import sys
8
9
  import tarfile
9
10
  import tempfile
10
11
  from collections.abc import Iterator
@@ -667,15 +668,49 @@ def _write_tracking_manifest(
667
668
  def _read_tracking_manifest(path: Path) -> dict[str, Any]:
668
669
  if not path.exists():
669
670
  return {"version": 1, "profiles": {}}
671
+
670
672
  try:
671
- data = json.loads(path.read_text(encoding="utf-8"))
672
- except (OSError, json.JSONDecodeError):
673
- return {"version": 1, "profiles": {}}
674
- if not isinstance(data, dict):
673
+ raw_text = path.read_text(encoding="utf-8")
674
+ except OSError as exc:
675
+ # The file could not be read at all, so it cannot be backed up either;
676
+ # skip the doomed copy attempt and report the read failure directly.
677
+ print(
678
+ f"WARNING {path} could not be read ({exc}); resetting tracking manifest.",
679
+ file=sys.stderr,
680
+ )
675
681
  return {"version": 1, "profiles": {}}
676
- data.setdefault("version", 1)
677
- data.setdefault("profiles", {})
678
- return data
682
+
683
+ malformed_reason: str = ""
684
+ try:
685
+ data = json.loads(raw_text)
686
+ except json.JSONDecodeError as exc:
687
+ malformed_reason = f"invalid JSON: {exc}"
688
+ else:
689
+ if not isinstance(data, dict):
690
+ malformed_reason = "manifest root must be a JSON object"
691
+ else:
692
+ data.setdefault("version", 1)
693
+ data.setdefault("profiles", {})
694
+ return data
695
+
696
+ _backup_malformed_manifest(path, malformed_reason)
697
+ return {"version": 1, "profiles": {}}
698
+
699
+
700
+ def _backup_malformed_manifest(path: Path, reason: str) -> None:
701
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
702
+ backup_path = path.with_name(f"{path.name}.corrupt-{timestamp}")
703
+ try:
704
+ shutil.copy2(path, backup_path)
705
+ backup_message = f"backed up to {backup_path}"
706
+ except OSError as exc:
707
+ backup_message = f"backup failed: {exc}"
708
+
709
+ print(
710
+ f"WARNING {path} is malformed ({reason}); resetting tracking manifest "
711
+ f"({backup_message}).",
712
+ file=sys.stderr,
713
+ )
679
714
 
680
715
 
681
716
  def _asset_root_relative_path(asset_root: Path, source_repo: Path) -> str:
@@ -114,12 +114,19 @@ def get_modules_root() -> Path:
114
114
  def find_project_root(start: Path | None = None) -> Path:
115
115
  """
116
116
  Walk up from `start` (default: current working directory) looking for a
117
- project root marker (pyproject.toml or .git). Falls back to `start` itself
118
- if no marker is found.
117
+ project root marker: `.cds` (CDS's own state directory, created by
118
+ `cds get`/`cds use`), `pyproject.toml`, or `.git`. `.cds` is checked first
119
+ at each level so a CDS working directory takes priority over an unrelated
120
+ ancestor repository (e.g. a dotfiles repo at $HOME) that happens to sit
121
+ further up the tree. Falls back to `start` itself if no marker is found.
119
122
  """
120
123
  current = (start or Path.cwd()).resolve()
121
124
  for directory in [current, *current.parents]:
122
- if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
125
+ if (
126
+ (directory / ".cds").exists()
127
+ or (directory / "pyproject.toml").exists()
128
+ or (directory / ".git").exists()
129
+ ):
123
130
  return directory
124
131
  return current
125
132
 
@@ -335,13 +342,19 @@ def resolve_project_root(profile_path: str) -> Path:
335
342
  """
336
343
  Resolve a project root for output artifacts.
337
344
 
338
- The resolver walks up from the selected profile location and picks the first
339
- directory containing either pyproject.toml or .git. If no marker is found,
340
- it falls back to the current working directory.
345
+ The resolver walks up from the selected profile location and picks the
346
+ first directory containing `.cds` (CDS's own state directory, checked
347
+ first so it takes priority over an unrelated ancestor repository),
348
+ `pyproject.toml`, or `.git`. If no marker is found, it falls back to the
349
+ current working directory.
341
350
  """
342
351
  start = Path(profile_path).resolve().parent
343
352
  for directory in [start, *start.parents]:
344
- if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
353
+ if (
354
+ (directory / ".cds").exists()
355
+ or (directory / "pyproject.toml").exists()
356
+ or (directory / ".git").exists()
357
+ ):
345
358
  return directory
346
359
  return Path.cwd().resolve()
347
360
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: CLI for validating and composing composable data stack profiles
5
5
  Author: Ronald Hensbergen
6
6
  License-Expression: Apache-2.0
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "composable-data-stack"
8
- version = "0.5.1"
8
+ version = "0.5.2"
9
9
  description = "CLI for validating and composing composable data stack profiles"
10
10
  readme = "README.md"
11
11
  license = "Apache-2.0"
@@ -1,3 +1,4 @@
1
+ import contextlib
1
2
  import io
2
3
  import json
3
4
  import os
@@ -119,6 +120,139 @@ class GetterTest(unittest.TestCase):
119
120
  )
120
121
  self.assertIn("kind: Profile", profile_file.read_text(encoding="utf-8"))
121
122
 
123
+ def test_fetch_profile_backs_up_and_warns_on_malformed_manifest(self) -> None:
124
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
125
+ source_root = Path(source_dir)
126
+ destination_root = Path(dest_dir)
127
+ _make_source_repo(source_root)
128
+
129
+ manifest_path = destination_root / ".cds" / "get-manifest.json"
130
+ _write(manifest_path, "{not valid json")
131
+
132
+ stderr = io.StringIO()
133
+ with contextlib.redirect_stderr(stderr):
134
+ _, returned_manifest_path = fetch_profile(
135
+ "demo",
136
+ local=str(source_root),
137
+ destination_root=destination_root,
138
+ )
139
+
140
+ self.assertEqual(returned_manifest_path.resolve(), manifest_path.resolve())
141
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
142
+ self.assertEqual(manifest["profiles"]["demo"]["requestedProfile"], "demo")
143
+
144
+ warning_output = stderr.getvalue()
145
+ self.assertIn("WARNING", warning_output)
146
+ self.assertIn("is malformed", warning_output)
147
+ self.assertIn("invalid JSON", warning_output)
148
+
149
+ backups = list(manifest_path.parent.glob("get-manifest.json.corrupt-*"))
150
+ self.assertEqual(len(backups), 1)
151
+ self.assertEqual(backups[0].read_text(encoding="utf-8"), "{not valid json")
152
+ self.assertIn(backups[0].name, warning_output)
153
+
154
+ def test_fetch_profile_backs_up_and_warns_on_non_dict_manifest(self) -> None:
155
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
156
+ source_root = Path(source_dir)
157
+ destination_root = Path(dest_dir)
158
+ _make_source_repo(source_root)
159
+
160
+ manifest_path = destination_root / ".cds" / "get-manifest.json"
161
+ _write(manifest_path, "[1, 2, 3]")
162
+
163
+ stderr = io.StringIO()
164
+ with contextlib.redirect_stderr(stderr):
165
+ fetch_profile(
166
+ "demo",
167
+ local=str(source_root),
168
+ destination_root=destination_root,
169
+ )
170
+
171
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
172
+ self.assertEqual(manifest["profiles"]["demo"]["requestedProfile"], "demo")
173
+
174
+ warning_output = stderr.getvalue()
175
+ self.assertIn("WARNING", warning_output)
176
+ self.assertIn("is malformed", warning_output)
177
+ self.assertIn("manifest root must be a JSON object", warning_output)
178
+
179
+ backups = list(manifest_path.parent.glob("get-manifest.json.corrupt-*"))
180
+ self.assertEqual(len(backups), 1)
181
+ self.assertIn(backups[0].name, warning_output)
182
+
183
+ def test_fetch_profile_warns_without_backup_when_manifest_unreadable(self) -> None:
184
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
185
+ source_root = Path(source_dir)
186
+ destination_root = Path(dest_dir)
187
+ _make_source_repo(source_root)
188
+
189
+ manifest_path = destination_root / ".cds" / "get-manifest.json"
190
+ _write(manifest_path, "{}")
191
+
192
+ original_read_text = Path.read_text
193
+
194
+ def _raising_read_text(self: Path, *args: object, **kwargs: object) -> str:
195
+ if self.resolve() == manifest_path.resolve():
196
+ raise OSError("permission denied")
197
+ return original_read_text(self, *args, **kwargs)
198
+
199
+ stderr = io.StringIO()
200
+ with patch.object(Path, "read_text", _raising_read_text):
201
+ with contextlib.redirect_stderr(stderr):
202
+ fetch_profile(
203
+ "demo",
204
+ local=str(source_root),
205
+ destination_root=destination_root,
206
+ )
207
+
208
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
209
+ self.assertEqual(manifest["profiles"]["demo"]["requestedProfile"], "demo")
210
+
211
+ warning_output = stderr.getvalue()
212
+ self.assertIn("WARNING", warning_output)
213
+ self.assertIn("could not be read", warning_output)
214
+ self.assertIn("permission denied", warning_output)
215
+ self.assertNotIn("is malformed", warning_output)
216
+
217
+ backups = list(manifest_path.parent.glob("get-manifest.json.corrupt-*"))
218
+ self.assertEqual(len(backups), 0)
219
+
220
+ def test_fetch_profile_loads_valid_manifest_unchanged(self) -> None:
221
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
222
+ source_root = Path(source_dir)
223
+ destination_root = Path(dest_dir)
224
+ _make_source_repo(source_root)
225
+
226
+ manifest_path = destination_root / ".cds" / "get-manifest.json"
227
+ _write(
228
+ manifest_path,
229
+ json.dumps(
230
+ {
231
+ "version": 1,
232
+ "profiles": {
233
+ "other": {"requestedProfile": "other", "files": []},
234
+ },
235
+ }
236
+ ),
237
+ )
238
+
239
+ stderr = io.StringIO()
240
+ with contextlib.redirect_stderr(stderr):
241
+ fetch_profile(
242
+ "demo",
243
+ local=str(source_root),
244
+ destination_root=destination_root,
245
+ )
246
+
247
+ self.assertEqual(stderr.getvalue(), "")
248
+
249
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
250
+ self.assertIn("other", manifest["profiles"])
251
+ self.assertEqual(manifest["profiles"]["demo"]["requestedProfile"], "demo")
252
+
253
+ backups = list(manifest_path.parent.glob("get-manifest.json.corrupt-*"))
254
+ self.assertEqual(len(backups), 0)
255
+
122
256
  def test_fetch_profile_dry_run_ignores_conflicting_files(self) -> None:
123
257
  with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
124
258
  source_root = Path(source_dir)
@@ -395,6 +395,60 @@ class MainCLITest(unittest.TestCase):
395
395
 
396
396
  self.assertEqual(resolved, Path("/mock/cwd").resolve())
397
397
 
398
+ def test_find_project_root_prefers_nearby_cds_over_ancestor_git(self):
399
+ """Regression test: a `cds get`/`cds use` working directory that has
400
+ its own `.cds` marker must not be shadowed by an unrelated `.git`
401
+ repository further up the tree (e.g. a dotfiles repo at $HOME)."""
402
+ import tempfile
403
+ from cli.main import find_project_root
404
+
405
+ with tempfile.TemporaryDirectory() as td:
406
+ home_like_root = Path(td)
407
+ (home_like_root / ".git").mkdir()
408
+ work_dir = home_like_root / "tmp"
409
+ work_dir.mkdir()
410
+ (work_dir / ".cds").mkdir()
411
+
412
+ resolved = find_project_root(work_dir)
413
+
414
+ self.assertEqual(resolved, work_dir.resolve())
415
+
416
+ def test_find_project_root_falls_back_to_git_when_no_cds_marker(self):
417
+ """A fresh checkout of the CDS repo itself (no `.cds` yet) should
418
+ still resolve to the repo root via `.git`/`pyproject.toml`."""
419
+ import tempfile
420
+ from cli.main import find_project_root
421
+
422
+ with tempfile.TemporaryDirectory() as td:
423
+ repo_root = Path(td)
424
+ (repo_root / ".git").mkdir()
425
+ sub_dir = repo_root / "profiles" / "some-profile"
426
+ sub_dir.mkdir(parents=True)
427
+
428
+ resolved = find_project_root(sub_dir)
429
+
430
+ self.assertEqual(resolved, repo_root.resolve())
431
+
432
+ def test_resolve_project_root_prefers_nearby_cds_over_ancestor_git(self):
433
+ """Same regression as find_project_root, but for the resolver used
434
+ to place rendered docker-compose.yml output for `cds up`/`render`."""
435
+ import tempfile
436
+ from cli.main import resolve_project_root
437
+
438
+ with tempfile.TemporaryDirectory() as td:
439
+ home_like_root = Path(td)
440
+ (home_like_root / ".git").mkdir()
441
+ work_dir = home_like_root / "tmp"
442
+ work_dir.mkdir()
443
+ (work_dir / ".cds").mkdir()
444
+ profile_path = work_dir / "profiles" / "some-profile" / "profile.yaml"
445
+ profile_path.parent.mkdir(parents=True)
446
+ profile_path.touch()
447
+
448
+ resolved = resolve_project_root(str(profile_path))
449
+
450
+ self.assertEqual(resolved, work_dir.resolve())
451
+
398
452
  def test_init_generates_env_file_from_profile_secrets(self):
399
453
  import tempfile
400
454