dr-environment 0.1.0__py3-none-any.whl

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 (45) hide show
  1. dr_environment/__init__.py +14 -0
  2. dr_environment/cli.py +91 -0
  3. dr_environment/recipe/__init__.py +19 -0
  4. dr_environment/recipe/build.py +99 -0
  5. dr_environment/recipe/cache/__init__.py +15 -0
  6. dr_environment/recipe/cache/stages.py +151 -0
  7. dr_environment/recipe/discover.py +88 -0
  8. dr_environment/recipe/hooks.py +57 -0
  9. dr_environment/recipe/layout.py +109 -0
  10. dr_environment/recipe/manifests.py +60 -0
  11. dr_environment/recipe/models.py +62 -0
  12. dr_environment/recipe/render.py +139 -0
  13. dr_environment/recipe/templates/docker_context/00-base.fragment.j2 +96 -0
  14. dr_environment/recipe/templates/docker_context/01-user.fragment.j2 +39 -0
  15. dr_environment/recipe/templates/docker_context/02-versions.fragment.j2 +141 -0
  16. dr_environment/recipe/templates/docker_context/03-build-deps.fragment.j2 +44 -0
  17. dr_environment/recipe/templates/docker_context/04-kernel.fragment.j2 +73 -0
  18. dr_environment/recipe/templates/docker_context/99-offline.fragment.j2 +98 -0
  19. dr_environment/recipe/templates/docker_context/build-deps/build-requirements.txt +11 -0
  20. dr_environment/recipe/templates/docker_context/kernel/agent/agent.py +67 -0
  21. dr_environment/recipe/templates/docker_context/kernel/agent/cgroup_watchers.py +181 -0
  22. dr_environment/recipe/templates/docker_context/kernel/common-user-limits.sh +38 -0
  23. dr_environment/recipe/templates/docker_context/kernel/extensions/README.md +2 -0
  24. dr_environment/recipe/templates/docker_context/kernel/extensions/dataframe_formatter.py +344 -0
  25. dr_environment/recipe/templates/docker_context/kernel/ipython_config.py +17 -0
  26. dr_environment/recipe/templates/docker_context/kernel/jupyter_kernel_gateway_config.py +33 -0
  27. dr_environment/recipe/templates/docker_context/kernel/kernel.json +15 -0
  28. dr_environment/recipe/templates/docker_context/kernel/notebooks-path.sh +17 -0
  29. dr_environment/recipe/templates/docker_context/kernel/requirements.txt +8 -0
  30. dr_environment/recipe/templates/docker_context/kernel/setup-caches.sh +49 -0
  31. dr_environment/recipe/templates/docker_context/kernel/setup-prompt.sh +20 -0
  32. dr_environment/recipe/templates/docker_context/kernel/setup-ssh.sh +42 -0
  33. dr_environment/recipe/templates/docker_context/kernel/setup-venv.sh +97 -0
  34. dr_environment/recipe/templates/docker_context/kernel/sshd_config +130 -0
  35. dr_environment/recipe/templates/docker_context/kernel/start_server_codespaces.sh +58 -0
  36. dr_environment/recipe/templates/docker_context/kernel/start_server_custom_model.sh +99 -0
  37. dr_environment/recipe/validate.py +158 -0
  38. dr_environment/recipe/versions.py +82 -0
  39. dr_environment-0.1.0.dist-info/METADATA +216 -0
  40. dr_environment-0.1.0.dist-info/RECORD +45 -0
  41. dr_environment-0.1.0.dist-info/WHEEL +4 -0
  42. dr_environment-0.1.0.dist-info/entry_points.txt +2 -0
  43. dr_environment-0.1.0.dist-info/licenses/AUTHORS +2 -0
  44. dr_environment-0.1.0.dist-info/licenses/LICENSE +201 -0
  45. dr_environment-0.1.0.dist-info/licenses/NOTICE +43 -0
@@ -0,0 +1,14 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
dr_environment/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """CLI entrypoint for dr-environment plugin."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from importlib.metadata import PackageNotFoundError, version
22
+ from pathlib import Path
23
+
24
+ import click
25
+
26
+ from dr_environment.recipe import build
27
+
28
+
29
+ def _package_version() -> str:
30
+ try:
31
+ return version("dr-environment")
32
+ except PackageNotFoundError:
33
+ return "0.0.0"
34
+
35
+
36
+ def print_manifest() -> None:
37
+ manifest = {
38
+ "name": "environment",
39
+ "version": _package_version(),
40
+ "description": "Build execution environment Docker contexts with offline dependency caches",
41
+ "authentication": False,
42
+ }
43
+ print(json.dumps(manifest, indent=2))
44
+
45
+
46
+ @click.group()
47
+ @click.version_option(_package_version(), "--version")
48
+ def cli() -> None:
49
+ """Build DataRobot execution environment Docker contexts."""
50
+
51
+
52
+ @cli.command("recipe")
53
+ @click.option(
54
+ "--recipe-path",
55
+ default=".",
56
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
57
+ help="Recipe root directory, defaults to the current working directory",
58
+ )
59
+ @click.option(
60
+ "--target",
61
+ default="docker_context",
62
+ help="Output directory relative to the current working directory, defaults to docker_context",
63
+ )
64
+ @click.option("--no-tarball", is_flag=True, help="Skip docker_context.tar.gz creation")
65
+ def recipe_cmd(recipe_path: Path, target: str, no_tarball: bool) -> None:
66
+ """Build the recipe Codespace execution environment docker context."""
67
+ docker_context = build(
68
+ recipe_path.resolve(),
69
+ Path(target),
70
+ tarball=not no_tarball,
71
+ )
72
+ click.echo(f"Built docker context: {docker_context}")
73
+ archive = docker_context.parent / "docker_context.tar.gz"
74
+ if archive.is_file():
75
+ click.echo(f"Archive: {archive}")
76
+
77
+
78
+ def main() -> None:
79
+ if "--dr-plugin-manifest" in sys.argv:
80
+ print_manifest()
81
+ return
82
+
83
+ try:
84
+ cli(prog_name="dr-environment")
85
+ except Exception as exc:
86
+ click.echo(f"Error: {exc}", err=True)
87
+ sys.exit(1)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
@@ -0,0 +1,19 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Recipe docker context builder."""
16
+
17
+ from dr_environment.recipe.build import build
18
+
19
+ __all__ = ["build"]
@@ -0,0 +1,99 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Orchestrate docker context build."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ import tarfile
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+ from dr_environment.recipe.cache.stages import write_component_cache_fragments
26
+ from dr_environment.recipe.discover import discover_components
27
+ from dr_environment.recipe.hooks import run_environment_hook
28
+ from dr_environment.recipe.layout import layout_components
29
+ from dr_environment.recipe.models import ComponentStrategy
30
+ from dr_environment.recipe.render import (
31
+ assemble_dockerfile,
32
+ copy_fragment_assets,
33
+ render_base_fragment,
34
+ render_build_deps_fragment,
35
+ render_kernel_setup_fragment,
36
+ render_offline_fragment,
37
+ render_user_fragment,
38
+ render_versions_fragment,
39
+ )
40
+ from dr_environment.recipe.validate import inspect_component, validate_all
41
+
42
+
43
+ def load_versions(versions_file: Path) -> dict:
44
+ if not versions_file.is_file():
45
+ return {}
46
+ return yaml.safe_load(versions_file.read_text(encoding="utf-8")) or {}
47
+
48
+
49
+ def build(
50
+ recipe_path: Path,
51
+ target: Path,
52
+ *,
53
+ tarball: bool = True,
54
+ ) -> Path:
55
+ recipe_path = recipe_path.resolve()
56
+ docker_context = target.resolve() if target.is_absolute() else (Path.cwd() / target).resolve()
57
+ versions_file = recipe_path / ".datarobot/cli/versions.yaml"
58
+
59
+ components = discover_components(recipe_path)
60
+ validate_all(components)
61
+ for component in components:
62
+ inspect_component(component)
63
+
64
+ if docker_context.exists():
65
+ shutil.rmtree(docker_context)
66
+ docker_context.mkdir(parents=True)
67
+
68
+ versions = load_versions(versions_file)
69
+ copy_fragment_assets(docker_context)
70
+ render_base_fragment(docker_context)
71
+ render_user_fragment(docker_context)
72
+ render_versions_fragment(docker_context, versions)
73
+ render_build_deps_fragment(docker_context)
74
+ render_kernel_setup_fragment(docker_context)
75
+
76
+ for component in components:
77
+ if component.strategy == ComponentStrategy.HOOK:
78
+ run_environment_hook(component, docker_context)
79
+ elif component.strategy == ComponentStrategy.DEFAULT:
80
+ inspect_component(component)
81
+ layout_components([component], docker_context)
82
+
83
+ active = [c for c in components if c.strategy == ComponentStrategy.DEFAULT and c.manifests]
84
+ cache_stage = write_component_cache_fragments(active, docker_context)
85
+
86
+ render_offline_fragment(docker_context, cache_stage=cache_stage)
87
+ assemble_dockerfile(docker_context)
88
+
89
+ if tarball:
90
+ create_tarball(docker_context)
91
+
92
+ return docker_context
93
+
94
+
95
+ def create_tarball(docker_context: Path) -> Path:
96
+ archive = docker_context.parent / "docker_context.tar.gz"
97
+ with tarfile.open(archive, "w:gz") as tar:
98
+ tar.add(docker_context, arcname=".")
99
+ return archive
@@ -0,0 +1,15 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Cache stage fragment generators."""
@@ -0,0 +1,151 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Dockerfile cache stage fragment generators."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from dr_environment.recipe.layout import LOCAL_SHARED_PACKAGE
22
+ from dr_environment.recipe.models import Component, Ecosystem
23
+
24
+ PREVIOUS_STAGE = "kernel"
25
+ RUNTIME_USER = "notebooks"
26
+ UV_CACHE = "/opt/cache/uv"
27
+ NPM_CACHE = "/opt/cache/npm"
28
+ GO_MOD_CACHE = "/opt/cache/go/pkg/mod"
29
+ GO_BUILD_CACHE = "/opt/cache/go/build"
30
+ GO_CACHE_ROOT = "/opt/cache/go"
31
+ # Flat find-links directory of the exact published Python wheels/sdists. Kept separate from
32
+ # the uv cache: DataRobot builds the FastAPI custom application FROM this image with
33
+ # `uv pip install --system --no-cache`, and `--no-cache` makes uv ignore UV_CACHE. uv still
34
+ # honours find-links, so the offline stage points UV_FIND_LINKS here (see _python_cache_lines).
35
+ WHEELHOUSE = "/opt/wheelhouse"
36
+
37
+ # Paths copied from the final cache stage into the offline runtime image.
38
+ CACHE_COPY_PATHS = (
39
+ UV_CACHE,
40
+ NPM_CACHE,
41
+ GO_CACHE_ROOT,
42
+ WHEELHOUSE,
43
+ )
44
+
45
+
46
+ def write_component_cache_fragment(
47
+ component: Component,
48
+ docker_context: Path,
49
+ *,
50
+ previous_stage: str,
51
+ ) -> str:
52
+ """Write cache fragment for component; return new stage name."""
53
+ stage_name = f"cache-{component.name}"
54
+ lines: list[str] = [f"FROM {previous_stage} AS {stage_name}"]
55
+
56
+ for info in component.manifests:
57
+ if info.ecosystem == Ecosystem.PYTHON:
58
+ lines.extend(_python_cache_lines(component.name))
59
+ elif info.ecosystem == Ecosystem.NPM:
60
+ lines.extend(_npm_cache_lines(component.name))
61
+ elif info.ecosystem == Ecosystem.GO:
62
+ lines.extend(_go_cache_lines(component.name))
63
+
64
+ fragment_path = (
65
+ docker_context
66
+ / "dockerfile.d"
67
+ / f"{component.fragment_order:02d}-cache-{component.name}.fragment"
68
+ )
69
+ fragment_path.parent.mkdir(parents=True, exist_ok=True)
70
+ fragment_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
71
+ return stage_name
72
+
73
+
74
+ def write_component_cache_fragments(
75
+ components: list[Component],
76
+ docker_context: Path,
77
+ ) -> str | None:
78
+ """Write all default component cache fragments; return final cache stage name."""
79
+ previous = PREVIOUS_STAGE
80
+ wrote = False
81
+ for component in components:
82
+ if not component.manifests:
83
+ continue
84
+ wrote = True
85
+ previous = write_component_cache_fragment(
86
+ component, docker_context, previous_stage=previous
87
+ )
88
+ return previous if wrote else None
89
+
90
+
91
+ def _copy_component_tree(component_name: str) -> str:
92
+ return (
93
+ f"COPY --chown={RUNTIME_USER}:{RUNTIME_USER} "
94
+ f"components/{component_name}/ /tmp/cache-work/{component_name}/"
95
+ )
96
+
97
+
98
+ def _python_cache_lines(component_name: str) -> list[str]:
99
+ sync_flags = "--frozen --no-install-project --all-extras --all-groups"
100
+ if component_name != LOCAL_SHARED_PACKAGE:
101
+ sync_flags += f" --no-install-package {LOCAL_SHARED_PACKAGE}"
102
+
103
+ warm_venv = f"/tmp/uv-cache-warm-{component_name}"
104
+ wheelhouse_req = f"/tmp/wheelhouse-req-{component_name}.txt"
105
+ return [
106
+ f"ENV UV_CACHE_DIR={UV_CACHE}",
107
+ _copy_component_tree(component_name),
108
+ f"WORKDIR /tmp/cache-work/{component_name}",
109
+ # Warm the uv cache; agent/MCP custom models install from it at runtime with
110
+ # `uv sync` (see the start_server.sh block in the offline stage). Mirror the same
111
+ # dependencies into the shared wheelhouse in the same RUN (see WHEELHOUSE above for
112
+ # why DataRobot's `--no-cache` app build cannot read the uv cache): `pip download`
113
+ # fetches the exact published artifacts, so their hashes match the ones `uv export`
114
+ # writes into the requirements file at deploy time; rebuilt wheels would not. Export
115
+ # exactly what DataRobot installs: production dependencies, project and workspace
116
+ # members excluded. warm_venv must be removed in this same RUN — splitting the sync
117
+ # and the rm across RUNs would bake the multi-GB venv into the earlier layer for good.
118
+ # No --only-binary=:all: here (unlike the copier/build-deps wheelhouse downloads):
119
+ # components pull in arbitrary packages we don't control, and plenty of legitimate
120
+ # pure-Python packages have stopped publishing wheels without being any less safe to
121
+ # build offline (e.g. rouge-score >=0.0.7 is sdist-only but pure Python, needing only
122
+ # the setuptools we already bake in) — rejecting all sdists here would fail builds
123
+ # over packages that build fine, not just the genuinely risky ones.
124
+ f"RUN UV_PROJECT_ENVIRONMENT={warm_venv} \\",
125
+ f" uv sync {sync_flags} \\",
126
+ " --python ${VENV_PATH}/bin/python \\",
127
+ f" && uv pip install --no-cache --python {warm_venv}/bin/python pip \\",
128
+ " && uv export --frozen --no-dev --no-emit-local --no-emit-project \\",
129
+ f" -o {wheelhouse_req} \\",
130
+ f" && {warm_venv}/bin/python -m pip download --no-deps --no-cache-dir \\",
131
+ f" -r {wheelhouse_req} --dest {WHEELHOUSE} \\",
132
+ f" && rm -rf {warm_venv} {wheelhouse_req}",
133
+ ]
134
+
135
+
136
+ def _npm_cache_lines(component_name: str) -> list[str]:
137
+ return [
138
+ f"ENV NPM_CONFIG_CACHE={NPM_CACHE}",
139
+ _copy_component_tree(component_name),
140
+ f"WORKDIR /tmp/cache-work/{component_name}",
141
+ f"RUN npm ci --include=dev --cache {NPM_CACHE}",
142
+ ]
143
+
144
+
145
+ def _go_cache_lines(component_name: str) -> list[str]:
146
+ return [
147
+ f"ENV GOMODCACHE={GO_MOD_CACHE} GOCACHE={GO_BUILD_CACHE}",
148
+ _copy_component_tree(component_name),
149
+ f"WORKDIR /tmp/cache-work/{component_name}",
150
+ "RUN go mod download all",
151
+ ]
@@ -0,0 +1,88 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Discover recipe components from the root Taskfile."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ from dr_environment.recipe.manifests import has_any_manifest
24
+ from dr_environment.recipe.models import Component, ComponentStrategy
25
+
26
+ TASKFILE_NAMES = ("Taskfile.yml", "Taskfile.yaml")
27
+
28
+
29
+ def find_taskfile(directory: Path) -> Path | None:
30
+ for name in TASKFILE_NAMES:
31
+ candidate = directory / name
32
+ if candidate.is_file():
33
+ return candidate
34
+ return None
35
+
36
+
37
+ def has_environment_task(taskfile: Path) -> bool:
38
+ data = yaml.safe_load(taskfile.read_text(encoding="utf-8")) or {}
39
+ tasks = data.get("tasks") or {}
40
+ return "environment" in tasks
41
+
42
+
43
+ def discover_components(
44
+ recipe_path: Path,
45
+ *,
46
+ component_filter: set[str] | None = None,
47
+ skip_hooks: bool = False,
48
+ ) -> list[Component]:
49
+ """Return components from root Taskfile includes."""
50
+ root_taskfile = find_taskfile(recipe_path)
51
+ if root_taskfile is None:
52
+ raise FileNotFoundError(f"No Taskfile.yml found in {recipe_path}")
53
+
54
+ data = yaml.safe_load(root_taskfile.read_text(encoding="utf-8")) or {}
55
+ includes = data.get("includes") or {}
56
+
57
+ components: list[Component] = []
58
+ order = 10
59
+ for name, spec in includes.items():
60
+ if not isinstance(spec, dict):
61
+ continue
62
+ if spec.get("internal"):
63
+ continue
64
+ if component_filter and name not in component_filter:
65
+ continue
66
+
67
+ component_dir = Path(spec.get("dir", name))
68
+ if not component_dir.is_absolute():
69
+ component_dir = (recipe_path / component_dir).resolve()
70
+
71
+ taskfile = find_taskfile(component_dir)
72
+ strategy = ComponentStrategy.DEFAULT
73
+ if not skip_hooks and taskfile and has_environment_task(taskfile):
74
+ strategy = ComponentStrategy.HOOK
75
+ elif not has_any_manifest(component_dir):
76
+ strategy = ComponentStrategy.SKIP
77
+
78
+ components.append(
79
+ Component(
80
+ name=name,
81
+ source_dir=component_dir,
82
+ strategy=strategy,
83
+ fragment_order=order,
84
+ )
85
+ )
86
+ order += 1
87
+
88
+ return components
@@ -0,0 +1,57 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Invoke component Taskfile environment hooks."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from dr_environment.recipe.discover import find_taskfile
24
+ from dr_environment.recipe.models import Component
25
+
26
+
27
+ def run_environment_hook(component: Component, docker_context: Path) -> None:
28
+ """Run `task environment` with the documented env contract."""
29
+ taskfile = find_taskfile(component.source_dir)
30
+ if taskfile is None:
31
+ raise FileNotFoundError(f"No Taskfile for component {component.name}")
32
+
33
+ fragment_path = (
34
+ docker_context
35
+ / "dockerfile.d"
36
+ / f"{component.fragment_order:02d}-cache-{component.name}.fragment"
37
+ )
38
+ fragment_path.parent.mkdir(parents=True, exist_ok=True)
39
+ if not fragment_path.exists():
40
+ fragment_path.write_text("", encoding="utf-8")
41
+
42
+ env = os.environ.copy()
43
+ env.update(
44
+ {
45
+ "DOCKER_CONTEXT": str(docker_context.resolve()),
46
+ "COMPONENT_DIR": str(component.source_dir.resolve()),
47
+ "COMPONENT_NAME": component.name,
48
+ "DOCKERFILE_FRAGMENT": str(fragment_path.resolve()),
49
+ "COMPONENT_DEST": str((docker_context / "components" / component.name).resolve()),
50
+ }
51
+ )
52
+
53
+ subprocess.run(
54
+ ["task", "-d", str(component.source_dir), "environment"],
55
+ env=env,
56
+ check=True,
57
+ )
@@ -0,0 +1,109 @@
1
+ #
2
+ # Copyright 2026 DataRobot, Inc. and its affiliates.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Copy component manifests into docker context."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import shutil
21
+ from pathlib import Path
22
+
23
+ from dr_environment.recipe.models import Component, ComponentStrategy, Ecosystem
24
+
25
+ # Recipe convention: sibling `core/` is a symlinked local shared Python package.
26
+ LOCAL_SHARED_PACKAGE = "core"
27
+
28
+ COPY_MAP: dict[Ecosystem, tuple[str, ...]] = {
29
+ Ecosystem.PYTHON: ("pyproject.toml", "uv.lock"),
30
+ Ecosystem.NPM: ("package.json", "package-lock.json"),
31
+ Ecosystem.GO: ("go.mod", "go.sum"),
32
+ }
33
+
34
+
35
+ def strip_local_shared_python_package(pyproject_text: str) -> str:
36
+ """Remove the symlinked local `core` package from a copied pyproject.toml."""
37
+ text = re.sub(
38
+ rf'^{LOCAL_SHARED_PACKAGE} = \{{ path = "{LOCAL_SHARED_PACKAGE}"[^\n]*\n',
39
+ "",
40
+ pyproject_text,
41
+ flags=re.MULTILINE,
42
+ )
43
+
44
+ def _clean_inline_deps(match: re.Match[str]) -> str:
45
+ inner = match.group(1)
46
+ parts = [part.strip().strip('"') for part in inner.split(",") if part.strip()]
47
+ kept = [part for part in parts if part != LOCAL_SHARED_PACKAGE]
48
+ quoted = ", ".join(f'"{part}"' for part in kept)
49
+ return f"dependencies = [{quoted}]"
50
+
51
+ text = re.sub(
52
+ r"^dependencies = \[(.*?)\]\s*$",
53
+ _clean_inline_deps,
54
+ text,
55
+ flags=re.MULTILINE,
56
+ )
57
+
58
+ lines = text.splitlines()
59
+ out: list[str] = []
60
+ in_dependencies = False
61
+
62
+ for line in lines:
63
+ stripped = line.strip()
64
+ if stripped == "dependencies = [":
65
+ in_dependencies = True
66
+ elif in_dependencies and stripped == "]":
67
+ in_dependencies = False
68
+ elif in_dependencies and re.fullmatch(r'"core",?', stripped):
69
+ continue
70
+ out.append(line)
71
+
72
+ trailing_newline = "\n" if pyproject_text.endswith("\n") else ""
73
+ return "\n".join(out) + trailing_newline
74
+
75
+
76
+ def copy_component(component: Component, docker_context: Path) -> Path:
77
+ """Copy manifest files for a component into components/<name>/."""
78
+ dest = docker_context / "components" / component.dest_name
79
+ dest.mkdir(parents=True, exist_ok=True)
80
+
81
+ for info in component.manifests:
82
+ manifest_dir = info.manifest.parent
83
+ for filename in COPY_MAP[info.ecosystem]:
84
+ src = manifest_dir / filename
85
+ if not src.is_file():
86
+ continue
87
+ dest_file = dest / filename
88
+ if (
89
+ info.ecosystem == Ecosystem.PYTHON
90
+ and filename == "pyproject.toml"
91
+ and component.name != LOCAL_SHARED_PACKAGE
92
+ ):
93
+ dest_file.write_text(
94
+ strip_local_shared_python_package(src.read_text(encoding="utf-8")),
95
+ encoding="utf-8",
96
+ )
97
+ else:
98
+ shutil.copy2(src, dest_file)
99
+
100
+ return dest
101
+
102
+
103
+ def layout_components(components: list[Component], docker_context: Path) -> None:
104
+ for component in components:
105
+ if component.strategy != ComponentStrategy.DEFAULT:
106
+ continue
107
+ if not component.manifests:
108
+ continue
109
+ copy_component(component, docker_context)