composable-data-stack 0.5.0__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.0/composable_data_stack.egg-info → composable_data_stack-0.5.2}/PKG-INFO +16 -12
  2. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/README.md +15 -11
  3. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/getter.py +173 -45
  4. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/main.py +38 -8
  5. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2/composable_data_stack.egg-info}/PKG-INFO +16 -12
  6. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/pyproject.toml +1 -1
  7. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_getter.py +285 -16
  8. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_main.py +56 -2
  9. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/LICENSE +0 -0
  10. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/__init__.py +0 -0
  11. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/constants.py +0 -0
  12. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/diagnostics.py +0 -0
  13. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/graph.py +0 -0
  14. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/image_updates.py +0 -0
  15. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/image_verification.py +0 -0
  16. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/loader.py +0 -0
  17. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/overlay.py +0 -0
  18. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/planner.py +0 -0
  19. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/preflight.py +0 -0
  20. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/renderer.py +0 -0
  21. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resolver.py +0 -0
  22. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resources/__init__.py +0 -0
  23. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resources/contract.schema.json +0 -0
  24. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resources/module.schema.json +0 -0
  25. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resources/profile.schema.json +0 -0
  26. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resources/rule-schema.json +0 -0
  27. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/resources/rule-set.json +0 -0
  28. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/secrets.py +0 -0
  29. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/security.py +0 -0
  30. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/security_common.py +0 -0
  31. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/state.py +0 -0
  32. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/up_runner.py +0 -0
  33. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/utils.py +0 -0
  34. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/cli/validator.py +0 -0
  35. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/SOURCES.txt +0 -0
  36. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  37. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/entry_points.txt +0 -0
  38. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/requires.txt +0 -0
  39. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/composable_data_stack.egg-info/top_level.txt +0 -0
  40. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/setup.cfg +0 -0
  41. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_cds_workflow.py +0 -0
  42. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_completion.py +0 -0
  43. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_compose_runtime_smoke.py +0 -0
  44. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_dagster_config_generation.py +0 -0
  45. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_dagster_definitions_uri.py +0 -0
  46. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  47. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_dagster_hardening.py +0 -0
  48. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_environment_classification.py +0 -0
  49. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_environment_cli.py +0 -0
  50. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_graph.py +0 -0
  51. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_image_security_scan_workflow.py +0 -0
  52. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_image_updates.py +0 -0
  53. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_image_verification.py +0 -0
  54. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_module_isolation.py +0 -0
  55. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_modules_no_committed_secrets.py +0 -0
  56. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_mvp_proof_workflow.py +0 -0
  57. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_overlay.py +0 -0
  58. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_planner.py +0 -0
  59. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_preflight.py +0 -0
  60. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_publish_images_workflow.py +0 -0
  61. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_python_version_alignment.py +0 -0
  62. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_release_version_check.py +0 -0
  63. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_render_example_profile.py +0 -0
  64. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_renderer.py +0 -0
  65. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_resolve_profile_path.py +0 -0
  66. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_resolver.py +0 -0
  67. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_secrets.py +0 -0
  68. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_security.py +0 -0
  69. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_smoke_example_profile.py +0 -0
  70. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_state.py +0 -0
  71. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_superset_hardening.py +0 -0
  72. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_trivyignore.py +0 -0
  73. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_up_runner.py +0 -0
  74. {composable_data_stack-0.5.0 → composable_data_stack-0.5.2}/tests/test_upstream_image_hardening.py +0 -0
  75. {composable_data_stack-0.5.0 → 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.0
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
@@ -55,6 +55,10 @@ Composable Data Stack (CDS) is a framework for defining and assembling data plat
55
55
 
56
56
  > **Note:** Development helper tools are located in the `tools/` directory (git-ignored). See `tools/pr-cli/README.md` for PR creation scripts.
57
57
 
58
+ ---
59
+
60
+ ## ⚡ Why CDS
61
+
58
62
  Instead of hardcoding integrations or relying on fragile pipelines, CDS introduces:
59
63
 
60
64
  - 🔧 **Modules**: reusable components (Dagster, Postgres, Superset)
@@ -63,10 +67,6 @@ Instead of hardcoding integrations or relying on fragile pipelines, CDS introduc
63
67
 
64
68
  Think of it as Infrastructure as Code, but for data platforms.
65
69
 
66
- ---
67
-
68
- ## ⚡ Why CDS
69
-
70
70
  Modern data platforms force a trade-off:
71
71
 
72
72
  |Approach|Problem|
@@ -642,7 +642,7 @@ before and never look for an `environments/` directory.
642
642
 
643
643
  |Command|Description|
644
644
  |---|---|
645
- |cds get \<profile\> [--remote \<repo\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets into a local CDS layout|
645
+ |cds get \<profile\> [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets from GitHub into a local CDS layout|
646
646
  |cds init [profile]|Generate a project `.env` template from profile secret definitions|
647
647
  |cds validate [profile]|Validate modules and contracts|
648
648
  |cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
@@ -663,12 +663,16 @@ all accept `--environment <name>` (or `-e <name>`) to merge
663
663
 
664
664
  `cds get` copies the selected `profiles/<name>/` tree, every referenced module
665
665
  directory, and any local build-context assets referenced by those modules'
666
- Dockerfiles. By default it reads from the current repository and writes into the
667
- current working directory; use `--remote` to point at another checked-out
668
- repository, `--into` to choose a destination root, `--dry-run` to inspect the
669
- copy plan first, and `--force` to replace conflicting local files. Successful
670
- fetches record tracking metadata in `.cds/get-manifest.json` for future update
671
- workflows.
666
+ Dockerfiles. By design it downloads from GitHub rather than a local checkout:
667
+ by default it fetches this project's upstream repository at the `main` branch,
668
+ downloading a tarball via the GitHub API (no `git` binary required). Use
669
+ `--remote <owner/repo>` (or a `github.com/...` URL) to fetch a fork, and
670
+ `--ref <branch|tag|sha>` to select a specific revision. Pass `--local <dir>`
671
+ to use an existing local directory instead of downloading (mutually exclusive
672
+ with `--remote`/`--ref`) for offline/dev workflows. Use `--into` to choose a
673
+ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
674
+ replace conflicting local files. Successful fetches record tracking metadata
675
+ in `.cds/get-manifest.json` for future update workflows.
672
676
 
673
677
  `[profile]` accepts:
674
678
 
@@ -19,6 +19,10 @@ Composable Data Stack (CDS) is a framework for defining and assembling data plat
19
19
 
20
20
  > **Note:** Development helper tools are located in the `tools/` directory (git-ignored). See `tools/pr-cli/README.md` for PR creation scripts.
21
21
 
22
+ ---
23
+
24
+ ## ⚡ Why CDS
25
+
22
26
  Instead of hardcoding integrations or relying on fragile pipelines, CDS introduces:
23
27
 
24
28
  - 🔧 **Modules**: reusable components (Dagster, Postgres, Superset)
@@ -27,10 +31,6 @@ Instead of hardcoding integrations or relying on fragile pipelines, CDS introduc
27
31
 
28
32
  Think of it as Infrastructure as Code, but for data platforms.
29
33
 
30
- ---
31
-
32
- ## ⚡ Why CDS
33
-
34
34
  Modern data platforms force a trade-off:
35
35
 
36
36
  |Approach|Problem|
@@ -606,7 +606,7 @@ before and never look for an `environments/` directory.
606
606
 
607
607
  |Command|Description|
608
608
  |---|---|
609
- |cds get \<profile\> [--remote \<repo\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets into a local CDS layout|
609
+ |cds get \<profile\> [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets from GitHub into a local CDS layout|
610
610
  |cds init [profile]|Generate a project `.env` template from profile secret definitions|
611
611
  |cds validate [profile]|Validate modules and contracts|
612
612
  |cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
@@ -627,12 +627,16 @@ all accept `--environment <name>` (or `-e <name>`) to merge
627
627
 
628
628
  `cds get` copies the selected `profiles/<name>/` tree, every referenced module
629
629
  directory, and any local build-context assets referenced by those modules'
630
- Dockerfiles. By default it reads from the current repository and writes into the
631
- current working directory; use `--remote` to point at another checked-out
632
- repository, `--into` to choose a destination root, `--dry-run` to inspect the
633
- copy plan first, and `--force` to replace conflicting local files. Successful
634
- fetches record tracking metadata in `.cds/get-manifest.json` for future update
635
- workflows.
630
+ Dockerfiles. By design it downloads from GitHub rather than a local checkout:
631
+ by default it fetches this project's upstream repository at the `main` branch,
632
+ downloading a tarball via the GitHub API (no `git` binary required). Use
633
+ `--remote <owner/repo>` (or a `github.com/...` URL) to fetch a fork, and
634
+ `--ref <branch|tag|sha>` to select a specific revision. Pass `--local <dir>`
635
+ to use an existing local directory instead of downloading (mutually exclusive
636
+ with `--remote`/`--ref`) for offline/dev workflows. Use `--into` to choose a
637
+ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
638
+ replace conflicting local files. Successful fetches record tracking metadata
639
+ in `.cds/get-manifest.json` for future update workflows.
636
640
 
637
641
  `[profile]` accepts:
638
642
 
@@ -2,17 +2,35 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import shlex
6
7
  import shutil
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ from collections.abc import Iterator
12
+ from contextlib import contextmanager
7
13
  from dataclasses import dataclass
8
14
  from datetime import UTC, datetime
9
15
  from pathlib import Path
10
16
  from typing import Any
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.request import Request, urlopen
11
19
 
12
20
  from .loader import load_yaml_file, resolve_module_dir
13
21
  from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string
14
22
 
15
23
 
24
+ # The upstream repository `cds get` downloads from when no `--remote` is
25
+ # given. Keep in sync with the `Repository` URL in pyproject.toml.
26
+ DEFAULT_REMOTE = "RonaldHensbergen/composable-data-stack"
27
+ DEFAULT_REF = "main"
28
+
29
+ _GITHUB_URL_PATTERN = re.compile(
30
+ r"^(?:https?://|git@)?(?:www\.)?github\.com[/:](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
31
+ )
32
+ _GITHUB_SHORTHAND_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
33
+
16
34
  _TRACKING_FILE = Path(".cds") / "get-manifest.json"
17
35
  _SKIP_DIRS = {
18
36
  ".cds",
@@ -49,41 +67,46 @@ def fetch_profile(
49
67
  profile: str,
50
68
  *,
51
69
  remote: str | None = None,
70
+ ref: str = DEFAULT_REF,
71
+ local: str | None = None,
52
72
  destination_root: Path | None = None,
53
73
  force: bool = False,
54
74
  dry_run: bool = False,
55
75
  ) -> tuple[list[CopyAction], Path]:
56
- source_repo = _resolve_source_repository(remote)
57
76
  target_root = (destination_root or Path.cwd()).expanduser().resolve()
58
- profile_path = _resolve_source_profile_path(source_repo, profile)
59
- asset_roots = _collect_asset_roots(source_repo, profile_path)
60
77
 
61
- actions = _build_copy_plan(source_repo, asset_roots, target_root)
62
- if dry_run:
63
- return actions, target_root / _TRACKING_FILE
78
+ with _prepare_source_repository(remote, ref, local) as source_repo:
79
+ profile_path = _resolve_source_profile_path(source_repo, profile)
80
+ asset_roots = _collect_asset_roots(source_repo, profile_path)
64
81
 
65
- conflicts = _find_conflicts(actions)
66
- if conflicts and not force:
67
- rendered = ", ".join(conflicts[:5])
68
- extra = "" if len(conflicts) <= 5 else f" (+{len(conflicts) - 5} more)"
69
- raise GetError(
70
- "Refusing to overwrite existing files without --force: "
71
- f"{rendered}{extra}"
72
- )
82
+ actions = _build_copy_plan(source_repo, asset_roots, target_root)
83
+ if dry_run:
84
+ return actions, target_root / _TRACKING_FILE
73
85
 
74
- for action in actions:
75
- action.destination.parent.mkdir(parents=True, exist_ok=True)
76
- shutil.copy2(action.source, action.destination)
77
-
78
- _write_tracking_manifest(
79
- target_root=target_root,
80
- requested_profile=profile,
81
- source_repo=source_repo,
82
- profile_path=profile_path,
83
- remote=remote,
84
- actions=actions,
85
- asset_roots=asset_roots,
86
- )
86
+ conflicts = _find_conflicts(actions)
87
+ if conflicts and not force:
88
+ rendered = ", ".join(conflicts[:5])
89
+ extra = "" if len(conflicts) <= 5 else f" (+{len(conflicts) - 5} more)"
90
+ raise GetError(
91
+ "Refusing to overwrite existing files without --force: "
92
+ f"{rendered}{extra}"
93
+ )
94
+
95
+ for action in actions:
96
+ action.destination.parent.mkdir(parents=True, exist_ok=True)
97
+ shutil.copy2(action.source, action.destination)
98
+
99
+ _write_tracking_manifest(
100
+ target_root=target_root,
101
+ requested_profile=profile,
102
+ source_repo=source_repo,
103
+ profile_path=profile_path,
104
+ remote=remote,
105
+ ref=ref,
106
+ local=local,
107
+ actions=actions,
108
+ asset_roots=asset_roots,
109
+ )
87
110
  return actions, target_root / _TRACKING_FILE
88
111
 
89
112
 
@@ -99,8 +122,84 @@ def format_get_plan(actions: list[CopyAction], *, destination_root: Path) -> str
99
122
  return "\n".join(lines)
100
123
 
101
124
 
102
- def _resolve_source_repository(remote: str | None) -> Path:
103
- candidate = Path(remote).expanduser() if remote else _find_project_root()
125
+ @contextmanager
126
+ def _prepare_source_repository(
127
+ remote: str | None, ref: str, local: str | None
128
+ ) -> Iterator[Path]:
129
+ """Resolve the source repository containing a `profiles/` tree.
130
+
131
+ By design, `cds get` downloads its source from GitHub: a bare `remote`
132
+ defaults to this project's upstream repository, and any `owner/repo` or
133
+ `github.com/...` value is fetched as a tarball for `ref`. Pass `local` to
134
+ explicitly use an existing local directory instead (e.g. an offline/dev
135
+ checkout) -- `remote`/`ref` are ignored in that case.
136
+ """
137
+ if local is not None:
138
+ if remote is not None:
139
+ raise GetError("Specify only one of --remote and --local")
140
+ yield _validate_source_repository(Path(local).expanduser())
141
+ return
142
+
143
+ candidate = remote or DEFAULT_REMOTE
144
+ parsed = _parse_github_remote(candidate)
145
+ if parsed is None:
146
+ raise GetError(
147
+ f'Could not resolve remote "{candidate}": expected an "owner/repo" '
148
+ 'GitHub reference or a github.com URL. Use --local for an existing '
149
+ "local directory instead."
150
+ )
151
+ owner, repo = parsed
152
+ with tempfile.TemporaryDirectory(prefix="cds-get-") as tmp_dir:
153
+ extracted = _download_github_repository(owner, repo, ref, Path(tmp_dir))
154
+ yield _validate_source_repository(extracted)
155
+
156
+
157
+ def _parse_github_remote(remote: str) -> tuple[str, str] | None:
158
+ candidate = remote.strip()
159
+ match = _GITHUB_URL_PATTERN.match(candidate)
160
+ if match:
161
+ return match.group("owner"), match.group("repo")
162
+ if _GITHUB_SHORTHAND_PATTERN.match(candidate):
163
+ owner, repo = candidate.split("/", 1)
164
+ return owner, repo
165
+ return None
166
+
167
+
168
+ def _download_github_repository(owner: str, repo: str, ref: str, work_dir: Path) -> Path:
169
+ url = f"https://api.github.com/repos/{owner}/{repo}/tarball/{ref}"
170
+ request = Request(url, headers={"User-Agent": "composable-data-stack-cds-get"})
171
+ try:
172
+ with urlopen(request, timeout=30) as response: # nosec B310 - fixed https GitHub API host
173
+ archive_bytes = response.read()
174
+ except HTTPError as exc:
175
+ raise GetError(
176
+ f"Could not download {owner}/{repo}@{ref} from GitHub: HTTP {exc.code}"
177
+ ) from exc
178
+ except URLError as exc:
179
+ raise GetError(
180
+ f"Could not download {owner}/{repo}@{ref} from GitHub: {exc.reason}"
181
+ ) from exc
182
+
183
+ archive_path = work_dir / "repository.tar.gz"
184
+ archive_path.write_bytes(archive_bytes)
185
+
186
+ extract_root = work_dir / "extracted"
187
+ extract_root.mkdir(parents=True, exist_ok=True)
188
+ try:
189
+ with tarfile.open(archive_path) as archive:
190
+ archive.extractall(extract_root, filter="data")
191
+ except tarfile.TarError as exc:
192
+ raise GetError(
193
+ f"Could not extract archive for {owner}/{repo}@{ref}: {exc}"
194
+ ) from exc
195
+
196
+ extracted_entries = [entry for entry in extract_root.iterdir() if entry.is_dir()]
197
+ if len(extracted_entries) != 1:
198
+ raise GetError(f"Unexpected archive layout for {owner}/{repo}@{ref}")
199
+ return extracted_entries[0]
200
+
201
+
202
+ def _validate_source_repository(candidate: Path) -> Path:
104
203
  resolved = candidate.resolve()
105
204
  if not resolved.exists():
106
205
  raise GetError(f"Source repository does not exist: {resolved}")
@@ -113,14 +212,6 @@ def _resolve_source_repository(remote: str | None) -> Path:
113
212
  return resolved
114
213
 
115
214
 
116
- def _find_project_root(start: Path | None = None) -> Path:
117
- current = (start or Path.cwd()).resolve()
118
- for directory in [current, *current.parents]:
119
- if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
120
- return directory
121
- return current
122
-
123
-
124
215
  def _resolve_source_profile_path(source_repo: Path, profile: str) -> Path:
125
216
  profile_selector = Path(profile)
126
217
  candidates = [
@@ -544,6 +635,8 @@ def _write_tracking_manifest(
544
635
  source_repo: Path,
545
636
  profile_path: Path,
546
637
  remote: str | None,
638
+ ref: str,
639
+ local: str | None,
547
640
  actions: list[CopyAction],
548
641
  asset_roots: list[Path],
549
642
  ) -> None:
@@ -553,7 +646,8 @@ def _write_tracking_manifest(
553
646
  entry = {
554
647
  "requestedProfile": requested_profile,
555
648
  "sourceProfile": profile_path.relative_to(source_repo).as_posix(),
556
- "remote": remote or str(source_repo),
649
+ "remote": local or remote or DEFAULT_REMOTE,
650
+ "ref": None if local else ref,
557
651
  "fetchedAt": datetime.now(UTC).isoformat(),
558
652
  "assetRoots": [
559
653
  _asset_root_relative_path(asset_root, source_repo) for asset_root in asset_roots
@@ -574,15 +668,49 @@ def _write_tracking_manifest(
574
668
  def _read_tracking_manifest(path: Path) -> dict[str, Any]:
575
669
  if not path.exists():
576
670
  return {"version": 1, "profiles": {}}
671
+
577
672
  try:
578
- data = json.loads(path.read_text(encoding="utf-8"))
579
- except (OSError, json.JSONDecodeError):
580
- return {"version": 1, "profiles": {}}
581
- 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
+ )
582
681
  return {"version": 1, "profiles": {}}
583
- data.setdefault("version", 1)
584
- data.setdefault("profiles", {})
585
- 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
+ )
586
714
 
587
715
 
588
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
 
@@ -853,7 +866,22 @@ def main() -> int:
853
866
  )
854
867
  get_parser.add_argument(
855
868
  "--remote",
856
- help="Path to the source repository root (default: current repository)",
869
+ help=(
870
+ "GitHub repository to fetch from, as 'owner/repo' or a github.com URL "
871
+ "(default: the upstream composable-data-stack repository)"
872
+ ),
873
+ )
874
+ get_parser.add_argument(
875
+ "--ref",
876
+ default="main",
877
+ help="Branch, tag, or commit to fetch from --remote (default: main)",
878
+ )
879
+ get_parser.add_argument(
880
+ "--local",
881
+ help=(
882
+ "Use an existing local directory as the source repository instead of "
883
+ "downloading from GitHub (mutually exclusive with --remote/--ref)"
884
+ ),
857
885
  )
858
886
  get_parser.add_argument(
859
887
  "--into",
@@ -1279,6 +1307,8 @@ def main() -> int:
1279
1307
  actions, manifest_path = fetch_profile(
1280
1308
  args.profile,
1281
1309
  remote=args.remote,
1310
+ ref=args.ref,
1311
+ local=args.local,
1282
1312
  destination_root=Path(args.into) if args.into else None,
1283
1313
  force=args.force,
1284
1314
  dry_run=args.dry_run,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.5.0
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
@@ -55,6 +55,10 @@ Composable Data Stack (CDS) is a framework for defining and assembling data plat
55
55
 
56
56
  > **Note:** Development helper tools are located in the `tools/` directory (git-ignored). See `tools/pr-cli/README.md` for PR creation scripts.
57
57
 
58
+ ---
59
+
60
+ ## ⚡ Why CDS
61
+
58
62
  Instead of hardcoding integrations or relying on fragile pipelines, CDS introduces:
59
63
 
60
64
  - 🔧 **Modules**: reusable components (Dagster, Postgres, Superset)
@@ -63,10 +67,6 @@ Instead of hardcoding integrations or relying on fragile pipelines, CDS introduc
63
67
 
64
68
  Think of it as Infrastructure as Code, but for data platforms.
65
69
 
66
- ---
67
-
68
- ## ⚡ Why CDS
69
-
70
70
  Modern data platforms force a trade-off:
71
71
 
72
72
  |Approach|Problem|
@@ -642,7 +642,7 @@ before and never look for an `environments/` directory.
642
642
 
643
643
  |Command|Description|
644
644
  |---|---|
645
- |cds get \<profile\> [--remote \<repo\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets into a local CDS layout|
645
+ |cds get \<profile\> [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets from GitHub into a local CDS layout|
646
646
  |cds init [profile]|Generate a project `.env` template from profile secret definitions|
647
647
  |cds validate [profile]|Validate modules and contracts|
648
648
  |cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
@@ -663,12 +663,16 @@ all accept `--environment <name>` (or `-e <name>`) to merge
663
663
 
664
664
  `cds get` copies the selected `profiles/<name>/` tree, every referenced module
665
665
  directory, and any local build-context assets referenced by those modules'
666
- Dockerfiles. By default it reads from the current repository and writes into the
667
- current working directory; use `--remote` to point at another checked-out
668
- repository, `--into` to choose a destination root, `--dry-run` to inspect the
669
- copy plan first, and `--force` to replace conflicting local files. Successful
670
- fetches record tracking metadata in `.cds/get-manifest.json` for future update
671
- workflows.
666
+ Dockerfiles. By design it downloads from GitHub rather than a local checkout:
667
+ by default it fetches this project's upstream repository at the `main` branch,
668
+ downloading a tarball via the GitHub API (no `git` binary required). Use
669
+ `--remote <owner/repo>` (or a `github.com/...` URL) to fetch a fork, and
670
+ `--ref <branch|tag|sha>` to select a specific revision. Pass `--local <dir>`
671
+ to use an existing local directory instead of downloading (mutually exclusive
672
+ with `--remote`/`--ref`) for offline/dev workflows. Use `--into` to choose a
673
+ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
674
+ replace conflicting local files. Successful fetches record tracking metadata
675
+ in `.cds/get-manifest.json` for future update workflows.
672
676
 
673
677
  `[profile]` accepts:
674
678
 
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "composable-data-stack"
8
- version = "0.5.0"
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"