composable-data-stack 0.5.0__tar.gz → 0.5.1__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.1}/PKG-INFO +16 -12
  2. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/README.md +15 -11
  3. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/getter.py +131 -38
  4. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/main.py +18 -1
  5. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1/composable_data_stack.egg-info}/PKG-INFO +16 -12
  6. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/pyproject.toml +1 -1
  7. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_getter.py +151 -16
  8. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_main.py +2 -2
  9. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/LICENSE +0 -0
  10. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/__init__.py +0 -0
  11. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/constants.py +0 -0
  12. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/diagnostics.py +0 -0
  13. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/graph.py +0 -0
  14. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/image_updates.py +0 -0
  15. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/image_verification.py +0 -0
  16. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/loader.py +0 -0
  17. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/overlay.py +0 -0
  18. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/planner.py +0 -0
  19. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/preflight.py +0 -0
  20. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/renderer.py +0 -0
  21. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resolver.py +0 -0
  22. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resources/__init__.py +0 -0
  23. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resources/contract.schema.json +0 -0
  24. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resources/module.schema.json +0 -0
  25. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resources/profile.schema.json +0 -0
  26. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resources/rule-schema.json +0 -0
  27. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/resources/rule-set.json +0 -0
  28. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/secrets.py +0 -0
  29. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/security.py +0 -0
  30. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/security_common.py +0 -0
  31. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/state.py +0 -0
  32. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/up_runner.py +0 -0
  33. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/utils.py +0 -0
  34. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/cli/validator.py +0 -0
  35. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/composable_data_stack.egg-info/SOURCES.txt +0 -0
  36. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  37. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/composable_data_stack.egg-info/entry_points.txt +0 -0
  38. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/composable_data_stack.egg-info/requires.txt +0 -0
  39. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/composable_data_stack.egg-info/top_level.txt +0 -0
  40. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/setup.cfg +0 -0
  41. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_cds_workflow.py +0 -0
  42. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_completion.py +0 -0
  43. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_compose_runtime_smoke.py +0 -0
  44. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_dagster_config_generation.py +0 -0
  45. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_dagster_definitions_uri.py +0 -0
  46. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  47. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_dagster_hardening.py +0 -0
  48. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_environment_classification.py +0 -0
  49. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_environment_cli.py +0 -0
  50. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_graph.py +0 -0
  51. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_image_security_scan_workflow.py +0 -0
  52. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_image_updates.py +0 -0
  53. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_image_verification.py +0 -0
  54. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_module_isolation.py +0 -0
  55. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_modules_no_committed_secrets.py +0 -0
  56. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_mvp_proof_workflow.py +0 -0
  57. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_overlay.py +0 -0
  58. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_planner.py +0 -0
  59. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_preflight.py +0 -0
  60. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_publish_images_workflow.py +0 -0
  61. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_python_version_alignment.py +0 -0
  62. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_release_version_check.py +0 -0
  63. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_render_example_profile.py +0 -0
  64. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_renderer.py +0 -0
  65. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_resolve_profile_path.py +0 -0
  66. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_resolver.py +0 -0
  67. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_secrets.py +0 -0
  68. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_security.py +0 -0
  69. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_smoke_example_profile.py +0 -0
  70. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_state.py +0 -0
  71. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_superset_hardening.py +0 -0
  72. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_trivyignore.py +0 -0
  73. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_up_runner.py +0 -0
  74. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/tests/test_upstream_image_hardening.py +0 -0
  75. {composable_data_stack-0.5.0 → composable_data_stack-0.5.1}/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.1
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,34 @@ 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 tarfile
9
+ import tempfile
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
7
12
  from dataclasses import dataclass
8
13
  from datetime import UTC, datetime
9
14
  from pathlib import Path
10
15
  from typing import Any
16
+ from urllib.error import HTTPError, URLError
17
+ from urllib.request import Request, urlopen
11
18
 
12
19
  from .loader import load_yaml_file, resolve_module_dir
13
20
  from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string
14
21
 
15
22
 
23
+ # The upstream repository `cds get` downloads from when no `--remote` is
24
+ # given. Keep in sync with the `Repository` URL in pyproject.toml.
25
+ DEFAULT_REMOTE = "RonaldHensbergen/composable-data-stack"
26
+ DEFAULT_REF = "main"
27
+
28
+ _GITHUB_URL_PATTERN = re.compile(
29
+ r"^(?:https?://|git@)?(?:www\.)?github\.com[/:](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
30
+ )
31
+ _GITHUB_SHORTHAND_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
32
+
16
33
  _TRACKING_FILE = Path(".cds") / "get-manifest.json"
17
34
  _SKIP_DIRS = {
18
35
  ".cds",
@@ -49,41 +66,46 @@ def fetch_profile(
49
66
  profile: str,
50
67
  *,
51
68
  remote: str | None = None,
69
+ ref: str = DEFAULT_REF,
70
+ local: str | None = None,
52
71
  destination_root: Path | None = None,
53
72
  force: bool = False,
54
73
  dry_run: bool = False,
55
74
  ) -> tuple[list[CopyAction], Path]:
56
- source_repo = _resolve_source_repository(remote)
57
75
  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
76
 
61
- actions = _build_copy_plan(source_repo, asset_roots, target_root)
62
- if dry_run:
63
- return actions, target_root / _TRACKING_FILE
77
+ with _prepare_source_repository(remote, ref, local) as source_repo:
78
+ profile_path = _resolve_source_profile_path(source_repo, profile)
79
+ asset_roots = _collect_asset_roots(source_repo, profile_path)
64
80
 
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
- )
81
+ actions = _build_copy_plan(source_repo, asset_roots, target_root)
82
+ if dry_run:
83
+ return actions, target_root / _TRACKING_FILE
73
84
 
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
- )
85
+ conflicts = _find_conflicts(actions)
86
+ if conflicts and not force:
87
+ rendered = ", ".join(conflicts[:5])
88
+ extra = "" if len(conflicts) <= 5 else f" (+{len(conflicts) - 5} more)"
89
+ raise GetError(
90
+ "Refusing to overwrite existing files without --force: "
91
+ f"{rendered}{extra}"
92
+ )
93
+
94
+ for action in actions:
95
+ action.destination.parent.mkdir(parents=True, exist_ok=True)
96
+ shutil.copy2(action.source, action.destination)
97
+
98
+ _write_tracking_manifest(
99
+ target_root=target_root,
100
+ requested_profile=profile,
101
+ source_repo=source_repo,
102
+ profile_path=profile_path,
103
+ remote=remote,
104
+ ref=ref,
105
+ local=local,
106
+ actions=actions,
107
+ asset_roots=asset_roots,
108
+ )
87
109
  return actions, target_root / _TRACKING_FILE
88
110
 
89
111
 
@@ -99,8 +121,84 @@ def format_get_plan(actions: list[CopyAction], *, destination_root: Path) -> str
99
121
  return "\n".join(lines)
100
122
 
101
123
 
102
- def _resolve_source_repository(remote: str | None) -> Path:
103
- candidate = Path(remote).expanduser() if remote else _find_project_root()
124
+ @contextmanager
125
+ def _prepare_source_repository(
126
+ remote: str | None, ref: str, local: str | None
127
+ ) -> Iterator[Path]:
128
+ """Resolve the source repository containing a `profiles/` tree.
129
+
130
+ By design, `cds get` downloads its source from GitHub: a bare `remote`
131
+ defaults to this project's upstream repository, and any `owner/repo` or
132
+ `github.com/...` value is fetched as a tarball for `ref`. Pass `local` to
133
+ explicitly use an existing local directory instead (e.g. an offline/dev
134
+ checkout) -- `remote`/`ref` are ignored in that case.
135
+ """
136
+ if local is not None:
137
+ if remote is not None:
138
+ raise GetError("Specify only one of --remote and --local")
139
+ yield _validate_source_repository(Path(local).expanduser())
140
+ return
141
+
142
+ candidate = remote or DEFAULT_REMOTE
143
+ parsed = _parse_github_remote(candidate)
144
+ if parsed is None:
145
+ raise GetError(
146
+ f'Could not resolve remote "{candidate}": expected an "owner/repo" '
147
+ 'GitHub reference or a github.com URL. Use --local for an existing '
148
+ "local directory instead."
149
+ )
150
+ owner, repo = parsed
151
+ with tempfile.TemporaryDirectory(prefix="cds-get-") as tmp_dir:
152
+ extracted = _download_github_repository(owner, repo, ref, Path(tmp_dir))
153
+ yield _validate_source_repository(extracted)
154
+
155
+
156
+ def _parse_github_remote(remote: str) -> tuple[str, str] | None:
157
+ candidate = remote.strip()
158
+ match = _GITHUB_URL_PATTERN.match(candidate)
159
+ if match:
160
+ return match.group("owner"), match.group("repo")
161
+ if _GITHUB_SHORTHAND_PATTERN.match(candidate):
162
+ owner, repo = candidate.split("/", 1)
163
+ return owner, repo
164
+ return None
165
+
166
+
167
+ def _download_github_repository(owner: str, repo: str, ref: str, work_dir: Path) -> Path:
168
+ url = f"https://api.github.com/repos/{owner}/{repo}/tarball/{ref}"
169
+ request = Request(url, headers={"User-Agent": "composable-data-stack-cds-get"})
170
+ try:
171
+ with urlopen(request, timeout=30) as response: # nosec B310 - fixed https GitHub API host
172
+ archive_bytes = response.read()
173
+ except HTTPError as exc:
174
+ raise GetError(
175
+ f"Could not download {owner}/{repo}@{ref} from GitHub: HTTP {exc.code}"
176
+ ) from exc
177
+ except URLError as exc:
178
+ raise GetError(
179
+ f"Could not download {owner}/{repo}@{ref} from GitHub: {exc.reason}"
180
+ ) from exc
181
+
182
+ archive_path = work_dir / "repository.tar.gz"
183
+ archive_path.write_bytes(archive_bytes)
184
+
185
+ extract_root = work_dir / "extracted"
186
+ extract_root.mkdir(parents=True, exist_ok=True)
187
+ try:
188
+ with tarfile.open(archive_path) as archive:
189
+ archive.extractall(extract_root, filter="data")
190
+ except tarfile.TarError as exc:
191
+ raise GetError(
192
+ f"Could not extract archive for {owner}/{repo}@{ref}: {exc}"
193
+ ) from exc
194
+
195
+ extracted_entries = [entry for entry in extract_root.iterdir() if entry.is_dir()]
196
+ if len(extracted_entries) != 1:
197
+ raise GetError(f"Unexpected archive layout for {owner}/{repo}@{ref}")
198
+ return extracted_entries[0]
199
+
200
+
201
+ def _validate_source_repository(candidate: Path) -> Path:
104
202
  resolved = candidate.resolve()
105
203
  if not resolved.exists():
106
204
  raise GetError(f"Source repository does not exist: {resolved}")
@@ -113,14 +211,6 @@ def _resolve_source_repository(remote: str | None) -> Path:
113
211
  return resolved
114
212
 
115
213
 
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
214
  def _resolve_source_profile_path(source_repo: Path, profile: str) -> Path:
125
215
  profile_selector = Path(profile)
126
216
  candidates = [
@@ -544,6 +634,8 @@ def _write_tracking_manifest(
544
634
  source_repo: Path,
545
635
  profile_path: Path,
546
636
  remote: str | None,
637
+ ref: str,
638
+ local: str | None,
547
639
  actions: list[CopyAction],
548
640
  asset_roots: list[Path],
549
641
  ) -> None:
@@ -553,7 +645,8 @@ def _write_tracking_manifest(
553
645
  entry = {
554
646
  "requestedProfile": requested_profile,
555
647
  "sourceProfile": profile_path.relative_to(source_repo).as_posix(),
556
- "remote": remote or str(source_repo),
648
+ "remote": local or remote or DEFAULT_REMOTE,
649
+ "ref": None if local else ref,
557
650
  "fetchedAt": datetime.now(UTC).isoformat(),
558
651
  "assetRoots": [
559
652
  _asset_root_relative_path(asset_root, source_repo) for asset_root in asset_roots
@@ -853,7 +853,22 @@ def main() -> int:
853
853
  )
854
854
  get_parser.add_argument(
855
855
  "--remote",
856
- help="Path to the source repository root (default: current repository)",
856
+ help=(
857
+ "GitHub repository to fetch from, as 'owner/repo' or a github.com URL "
858
+ "(default: the upstream composable-data-stack repository)"
859
+ ),
860
+ )
861
+ get_parser.add_argument(
862
+ "--ref",
863
+ default="main",
864
+ help="Branch, tag, or commit to fetch from --remote (default: main)",
865
+ )
866
+ get_parser.add_argument(
867
+ "--local",
868
+ help=(
869
+ "Use an existing local directory as the source repository instead of "
870
+ "downloading from GitHub (mutually exclusive with --remote/--ref)"
871
+ ),
857
872
  )
858
873
  get_parser.add_argument(
859
874
  "--into",
@@ -1279,6 +1294,8 @@ def main() -> int:
1279
1294
  actions, manifest_path = fetch_profile(
1280
1295
  args.profile,
1281
1296
  remote=args.remote,
1297
+ ref=args.ref,
1298
+ local=args.local,
1282
1299
  destination_root=Path(args.into) if args.into else None,
1283
1300
  force=args.force,
1284
1301
  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.1
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.1"
9
9
  description = "CLI for validating and composing composable data stack profiles"
10
10
  readme = "README.md"
11
11
  license = "Apache-2.0"
@@ -1,12 +1,15 @@
1
+ import io
1
2
  import json
2
3
  import os
3
4
  import stat
4
5
  import sys
6
+ import tarfile
5
7
  import tempfile
6
8
  import unittest
7
9
  from pathlib import Path
10
+ from unittest.mock import patch
8
11
 
9
- from cli.getter import GetError, fetch_profile
12
+ from cli.getter import DEFAULT_REMOTE, GetError, _parse_github_remote, fetch_profile
10
13
 
11
14
 
12
15
  def _write(path: Path, content: str) -> None:
@@ -74,7 +77,7 @@ class GetterTest(unittest.TestCase):
74
77
 
75
78
  actions, manifest_path = fetch_profile(
76
79
  "demo",
77
- remote=str(source_root),
80
+ local=str(source_root),
78
81
  destination_root=destination_root,
79
82
  )
80
83
 
@@ -101,16 +104,16 @@ class GetterTest(unittest.TestCase):
101
104
  destination_root = Path(dest_dir)
102
105
  _make_source_repo(source_root)
103
106
 
104
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
107
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
105
108
  profile_file = destination_root / "profiles" / "demo" / "profile.yaml"
106
109
  profile_file.write_text("changed\n", encoding="utf-8")
107
110
 
108
111
  with self.assertRaises(GetError):
109
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
112
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
110
113
 
111
114
  fetch_profile(
112
115
  "demo",
113
- remote=str(source_root),
116
+ local=str(source_root),
114
117
  destination_root=destination_root,
115
118
  force=True,
116
119
  )
@@ -122,13 +125,13 @@ class GetterTest(unittest.TestCase):
122
125
  destination_root = Path(dest_dir)
123
126
  _make_source_repo(source_root)
124
127
 
125
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
128
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
126
129
  profile_file = destination_root / "profiles" / "demo" / "profile.yaml"
127
130
  profile_file.write_text("changed\n", encoding="utf-8")
128
131
 
129
132
  actions, manifest_path = fetch_profile(
130
133
  "demo",
131
- remote=str(source_root),
134
+ local=str(source_root),
132
135
  destination_root=destination_root,
133
136
  dry_run=True,
134
137
  )
@@ -198,7 +201,7 @@ COPY shared/python /app/shared/python
198
201
  _write(source_root / ".github" / "workflows" / "ci.yml", "name: CI\n")
199
202
  _write(source_root / ".env.example", "EXAMPLE=true\n")
200
203
 
201
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
204
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
202
205
 
203
206
  self.assertTrue(
204
207
  (destination_root / "images" / "demo" / "hardened" / "Dockerfile").exists()
@@ -243,7 +246,7 @@ spec:
243
246
 
244
247
  _, manifest_path = fetch_profile(
245
248
  "demo",
246
- remote=str(source_root),
249
+ local=str(source_root),
247
250
  destination_root=destination_root,
248
251
  )
249
252
 
@@ -294,7 +297,7 @@ COPY ["shared/python", dest]
294
297
  )
295
298
 
296
299
  with self.assertRaises(GetError) as ctx:
297
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
300
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
298
301
 
299
302
  self.assertIn("Could not parse COPY sources", str(ctx.exception))
300
303
 
@@ -341,7 +344,7 @@ COPY "shared/python /app/
341
344
  )
342
345
 
343
346
  with self.assertRaises(GetError) as ctx:
344
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
347
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
345
348
 
346
349
  self.assertIn("Could not parse COPY sources", str(ctx.exception))
347
350
 
@@ -388,7 +391,7 @@ COPY "shared/file#1.txt" /app/
388
391
  )
389
392
  _write(source_root / "shared" / "file#1.txt", "ok\n")
390
393
 
391
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
394
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
392
395
 
393
396
  self.assertTrue((destination_root / "shared" / "file#1.txt").exists())
394
397
 
@@ -438,7 +441,7 @@ COPY shared/python /app/shared/python
438
441
  )
439
442
  _write(source_root / "shared" / "python" / "__init__.py", "")
440
443
 
441
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
444
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
442
445
 
443
446
  self.assertTrue((destination_root / "images" / "demo" / "Dockerfile").exists())
444
447
  self.assertTrue((destination_root / "shared" / "python" / "__init__.py").exists())
@@ -466,7 +469,7 @@ spec:
466
469
  with self.assertRaises(GetError) as ctx:
467
470
  fetch_profile(
468
471
  f"../{outside_root.name}/evil.yaml",
469
- remote=str(source_root),
472
+ local=str(source_root),
470
473
  destination_root=destination_root,
471
474
  )
472
475
 
@@ -509,7 +512,7 @@ spec:
509
512
  )
510
513
 
511
514
  with self.assertRaises(GetError) as ctx:
512
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
515
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
513
516
 
514
517
  self.assertIn('build.dockerfile "/etc/passwd" resolves outside the source repository', str(ctx.exception))
515
518
 
@@ -523,12 +526,144 @@ spec:
523
526
  entrypoint = source_root / "images" / "demo" / "entrypoint.sh"
524
527
  os.chmod(entrypoint, 0o755)
525
528
 
526
- fetch_profile("demo", remote=str(source_root), destination_root=destination_root)
529
+ fetch_profile("demo", local=str(source_root), destination_root=destination_root)
527
530
 
528
531
  destination_entrypoint = destination_root / "images" / "demo" / "entrypoint.sh"
529
532
  mode = stat.S_IMODE(destination_entrypoint.stat().st_mode)
530
533
  self.assertEqual(mode, 0o755)
531
534
 
532
535
 
536
+ class GitHubRemoteTest(unittest.TestCase):
537
+ def _make_tarball(self, root: Path) -> bytes:
538
+ buffer = io.BytesIO()
539
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
540
+ archive.add(root, arcname="owner-demo-repo-abcdef1")
541
+ return buffer.getvalue()
542
+
543
+ def test_parse_github_remote_accepts_shorthand_and_urls(self) -> None:
544
+ self.assertEqual(_parse_github_remote("owner/repo"), ("owner", "repo"))
545
+ self.assertEqual(
546
+ _parse_github_remote("https://github.com/owner/repo"), ("owner", "repo")
547
+ )
548
+ self.assertEqual(
549
+ _parse_github_remote("https://github.com/owner/repo.git"), ("owner", "repo")
550
+ )
551
+ self.assertEqual(
552
+ _parse_github_remote("git@github.com:owner/repo.git"), ("owner", "repo")
553
+ )
554
+ self.assertIsNone(_parse_github_remote("not a remote at all"))
555
+
556
+ def test_fetch_profile_downloads_default_remote_when_no_remote_given(self) -> None:
557
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
558
+ source_root = Path(source_dir)
559
+ destination_root = Path(dest_dir)
560
+ _make_source_repo(source_root)
561
+ archive_bytes = self._make_tarball(source_root)
562
+
563
+ class _FakeResponse:
564
+ def __enter__(self_inner):
565
+ return self_inner
566
+
567
+ def __exit__(self_inner, *exc_info):
568
+ return False
569
+
570
+ def read(self_inner):
571
+ return archive_bytes
572
+
573
+ captured_urls: list[str] = []
574
+
575
+ def _fake_urlopen(request, timeout=30):
576
+ captured_urls.append(request.full_url)
577
+ return _FakeResponse()
578
+
579
+ with patch("cli.getter.urlopen", side_effect=_fake_urlopen):
580
+ actions, manifest_path = fetch_profile(
581
+ "demo",
582
+ destination_root=destination_root,
583
+ )
584
+
585
+ self.assertEqual(
586
+ captured_urls,
587
+ [f"https://api.github.com/repos/{DEFAULT_REMOTE}/tarball/main"],
588
+ )
589
+ self.assertGreater(len(actions), 0)
590
+ self.assertTrue((destination_root / "profiles" / "demo" / "profile.yaml").exists())
591
+
592
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
593
+ entry = manifest["profiles"]["demo"]
594
+ self.assertEqual(entry["remote"], DEFAULT_REMOTE)
595
+ self.assertEqual(entry["ref"], "main")
596
+
597
+ def test_fetch_profile_downloads_explicit_owner_repo_and_ref(self) -> None:
598
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
599
+ source_root = Path(source_dir)
600
+ destination_root = Path(dest_dir)
601
+ _make_source_repo(source_root)
602
+ archive_bytes = self._make_tarball(source_root)
603
+
604
+ class _FakeResponse:
605
+ def __enter__(self_inner):
606
+ return self_inner
607
+
608
+ def __exit__(self_inner, *exc_info):
609
+ return False
610
+
611
+ def read(self_inner):
612
+ return archive_bytes
613
+
614
+ def _fake_urlopen(request, timeout=30):
615
+ return _FakeResponse()
616
+
617
+ with patch("cli.getter.urlopen", side_effect=_fake_urlopen):
618
+ actions, _ = fetch_profile(
619
+ "demo",
620
+ remote="RonaldHensbergen/composable-data-stack",
621
+ ref="v1.2.3",
622
+ destination_root=destination_root,
623
+ )
624
+
625
+ self.assertGreater(len(actions), 0)
626
+ self.assertTrue((destination_root / "modules" / "apps" / "demo" / "module.yaml").exists())
627
+
628
+ def test_fetch_profile_raises_get_error_on_download_failure(self) -> None:
629
+ from urllib.error import URLError
630
+
631
+ def _fake_urlopen(request, timeout=30):
632
+ raise URLError("network unreachable")
633
+
634
+ with tempfile.TemporaryDirectory() as dest_dir:
635
+ with patch("cli.getter.urlopen", side_effect=_fake_urlopen):
636
+ with self.assertRaises(GetError) as ctx:
637
+ fetch_profile("demo", destination_root=Path(dest_dir))
638
+
639
+ self.assertIn("Could not download", str(ctx.exception))
640
+
641
+ def test_fetch_profile_rejects_unresolvable_remote(self) -> None:
642
+ with tempfile.TemporaryDirectory() as dest_dir:
643
+ with self.assertRaises(GetError) as ctx:
644
+ fetch_profile(
645
+ "demo",
646
+ remote="this is not a remote",
647
+ destination_root=Path(dest_dir),
648
+ )
649
+ self.assertIn("Could not resolve remote", str(ctx.exception))
650
+
651
+
652
+ def test_fetch_profile_rejects_both_remote_and_local(self) -> None:
653
+ with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as dest_dir:
654
+ source_root = Path(source_dir)
655
+ destination_root = Path(dest_dir)
656
+ _make_source_repo(source_root)
657
+
658
+ with self.assertRaises(GetError) as ctx:
659
+ fetch_profile(
660
+ "demo",
661
+ remote="owner/repo",
662
+ local=str(source_root),
663
+ destination_root=destination_root,
664
+ )
665
+ self.assertIn("Specify only one of --remote and --local", str(ctx.exception))
666
+
667
+
533
668
  if __name__ == "__main__":
534
669
  unittest.main()
@@ -475,7 +475,7 @@ spec:
475
475
  "cds",
476
476
  "get",
477
477
  "demo",
478
- "--remote",
478
+ "--local",
479
479
  str(source_root),
480
480
  "--into",
481
481
  str(destination_root),
@@ -500,7 +500,7 @@ spec:
500
500
  "cds",
501
501
  "get",
502
502
  "missing-profile",
503
- "--remote",
503
+ "--local",
504
504
  str(Path(tempfile.gettempdir()) / "cds-missing-source-repo"),
505
505
  ],
506
506
  ), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):