Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,9 @@ uv run tangle api published-components experimental-search \

### Local components

`generate from-python` converts a local Python function into a component YAML using inline source by default, or `--mode bundle` to embed local dependency modules. Common options include `--function`, `--output`, `--name`, `--image`, `--dependencies-from`, `--strip-code`, `--use-legacy-naming`, and `--resolve-root`.
`generate from-python` converts a local Python function into a component YAML using inline source by default, or `--mode bundle` to embed local dependency modules with zlib/Base64. Common options include `--function`, `--output`, `--name`, `--image`, `--dependencies-from`, `--strip-code`, `--use-legacy-naming`, and `--resolve-root`.

Opt in to `--mode bundle-bz2` for bz2/Base85 compression, which can reduce large Python bundles. The same mode is accepted by `@task(mode="bundle-bz2")` and `local_from_python.mode` during hydration. It requires Python's `_bz2` extension in the runtime image. Encoded braces are escaped in the generated Python literal so Jinja hydration cannot interpret the payload as a template. Both bundle modes still use one command-line argument, so sufficiently large bundles can still exceed Linux's per-argument limit.

`bump-version` increments or sets component version metadata in YAML and updates/regenerates a referenced Python source when the component contains `python_original_code_path` annotations.

Expand Down
27 changes: 15 additions & 12 deletions packages/tangle-cli/src/tangle_cli/component_from_func.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""
Component YAML generator from Python functions.

Converts Python functions into Tangle component YAML files. Supports two modes:
Converts Python functions into Tangle component YAML files. Supports three modes:

- **inline** (default): Single-file components with source code embedded directly.
- **bundle**: Multi-file components with local dependency modules serialized via
zlib-compressed source text and injected into sys.modules at runtime.
zlib/Base64 source text and injected into sys.modules at runtime.
- **bundle-bz2**: Opt-in bz2/Base85 variant for smaller embedded payloads.

Key functions:
- generate_component_yaml() - Top-level entry point for YAML generation
Expand Down Expand Up @@ -1856,7 +1857,7 @@ def _build_pip_install_command(deps: list[str]) -> list[str]:

def _build_python_source(
spec: FunctionSpec,
mode: Literal["inline", "bundle"],
mode: Literal["inline", "bundle", "bundle-bz2"],
bundled_modules_b64: str | None = None,
) -> str:
"""Build the full Python source code to embed in the YAML.
Expand All @@ -1881,8 +1882,8 @@ def _build_python_source(
parts.append(_SERIALIZE_STR_HELPER)

# For bundle mode: add sys.modules injection from compressed embedded source text
if mode == "bundle" and bundled_modules_b64:
parts.append(ModuleBundler.build_injection(bundled_modules_b64))
if mode in {"bundle", "bundle-bz2"} and bundled_modules_b64:
parts.append(ModuleBundler.build_injection(bundled_modules_b64, mode=mode))

# Add the source code (type-hint-stripped)
# Use full module source when available — this preserves helper functions defined
Expand Down Expand Up @@ -1926,7 +1927,7 @@ def build_component_dict(
container_image: str,
dependencies: list[str],
annotations: dict[str, str],
mode: Literal["inline", "bundle"] = "inline",
mode: Literal["inline", "bundle", "bundle-bz2"] = "inline",
bundled_modules_b64: str | None = None,
) -> dict[str, Any]:
"""Build the complete component YAML dict.
Expand All @@ -1937,7 +1938,8 @@ def build_component_dict(
dependencies: List of pip dependencies
annotations: Metadata annotations dict
mode: Generation mode
bundled_modules_b64: Base64-encoded pickled modules (bundle mode only)
bundled_modules_b64: Encoded module sources from ``ModuleBundler.encode``
using the same mode (bundle modes only)

Returns:
Dict representing the full component YAML structure.
Expand Down Expand Up @@ -2035,7 +2037,7 @@ def generate_component_yaml(
container_image: str,
function_name: str | None = None,
dependencies_from: Path | None = None,
mode: Literal["inline", "bundle"] = "inline",
mode: Literal["inline", "bundle", "bundle-bz2"] = "inline",
custom_name: str | None = None,
custom_annotations: dict[str, str] | None = None,
strip_code: bool = False,
Expand All @@ -2062,7 +2064,8 @@ def generate_component_yaml(
container_image: Docker image reference
function_name: Function to extract (auto-detected if None)
dependencies_from: Path to pyproject.toml with pip dependencies
mode: "inline" for single-file, "bundle" for multi-file
mode: "inline" for single-file, "bundle" for zlib/Base64 multi-file,
"bundle-bz2" for opt-in bz2/Base85 multi-file
custom_name: Override the component name
custom_annotations: Additional annotations to merge
strip_code: Omit python_original_code annotation
Expand Down Expand Up @@ -2098,7 +2101,7 @@ def generate_component_yaml(
# Only add resolve_root to sys.path in bundle mode — in inline mode the
# sibling modules won't be embedded, so letting the import succeed would
# produce YAML that fails at runtime in the container.
extra_paths = [resolve_root] if resolve_root and mode == "bundle" else None
extra_paths = [resolve_root] if resolve_root and mode in {"bundle", "bundle-bz2"} else None
module = load_python_module(file_path, extra_sys_path=extra_paths)
func = get_function_from_module(module, resolved_func_name)

Expand Down Expand Up @@ -2226,15 +2229,15 @@ def _path_annotation(path: Path) -> str:
# 5. Handle bundle mode — embed source text of local modules
# (not bytecode, which is Python-version-specific)
bundled_modules_b64: str | None = None
if mode == "bundle":
if mode in {"bundle", "bundle-bz2"}:
module_sources = ModuleBundler.collect_sources(
file_path,
resolve_root=resolve_root,
pip_deps=deps,
source=spec.module_source_stripped,
)
if module_sources:
bundled_modules_b64 = ModuleBundler.encode(module_sources)
bundled_modules_b64 = ModuleBundler.encode(module_sources, mode=mode)
if bundled_modules_b64:
sorted_names = sorted(module_sources.keys(), key=lambda k: (k.count("."), k))
annotations["bundled_modules"] = json.dumps(sorted_names)
Expand Down
5 changes: 3 additions & 2 deletions packages/tangle-cli/src/tangle_cli/component_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def generate_component_yaml(
container_image: str,
function_name: str | None = None,
dependencies_from: Path | None = None,
mode: Literal["inline", "bundle"] = "inline",
mode: Literal["inline", "bundle", "bundle-bz2"] = "inline",
custom_name: str | None = None,
custom_annotations: dict[str, str] | None = None,
strip_code: bool = False,
Expand All @@ -138,7 +138,8 @@ def generate_component_yaml(
container_image: Container image to place in the component spec.
function_name: Function to generate, or ``None`` to auto-detect.
dependencies_from: Optional dependency file for pip installs.
mode: ``"inline"`` or ``"bundle"`` generation mode.
mode: ``"inline"``, ``"bundle"`` (zlib/Base64), or opt-in
``"bundle-bz2"`` (bz2/Base85) generation mode.
custom_name: Optional component name override.
custom_annotations: Optional metadata annotations to merge.
strip_code: Omit original source annotations when true.
Expand Down
4 changes: 2 additions & 2 deletions packages/tangle-cli/src/tangle_cli/components_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ def _components_generate_from_python_impl(

generator = ComponentGenerator(logger=logger, verbose=True)
selected_mode = args.mode or "inline"
if selected_mode not in {"inline", "bundle"}:
raise SystemExit("--mode must be 'inline' or 'bundle'")
if selected_mode not in {"inline", "bundle", "bundle-bz2"}:
raise SystemExit("--mode must be 'inline', 'bundle', or 'bundle-bz2'")
python_path = pathlib.Path(args.python_file)
output_path = generator.determine_output_path(
python_path,
Expand Down
60 changes: 45 additions & 15 deletions packages/tangle-cli/src/tangle_cli/module_bundler.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,18 @@ def collect_sources(
return result

@staticmethod
def encode(module_sources: dict[str, str]) -> str | None:
"""Compress and base64-encode a dict of module sources for embedding.
def encode(
module_sources: dict[str, str],
*,
mode: Literal["bundle", "bundle-bz2"] = "bundle",
) -> str | None:
"""Compress and encode module sources for embedding.

``bundle`` retains the zlib/Base64 format. Opt-in ``bundle-bz2`` uses
bz2/Base85 to reduce the single command-line argument carrying the
payload. This postpones, but does not remove, Linux's per-argument
size limit. Both codecs are in the standard library; ``bundle-bz2``
additionally requires Python's optional ``_bz2`` extension at runtime.

Modules are sorted so that dependencies execute before dependents.
We perform a topological sort over the module-level import graph
Expand All @@ -257,39 +267,59 @@ def encode(module_sources: dict[str, str]) -> str | None:

Args:
module_sources: ``{module_name: source_text}`` dict.
mode: Bundle format, also passed to ``build_injection``.

Returns:
Base64-encoded string, or ``None`` if *module_sources* is empty.
Encoded string, or ``None`` if *module_sources* is empty.
"""
if not module_sources:
return None
import zlib
ordered_names = _topological_order(module_sources)
ordered = {name: module_sources[name] for name in ordered_names}
sources_json = json.dumps(ordered)
compressed = zlib.compress(sources_json.encode(), level=9)
return base64.b64encode(compressed).decode("ascii")
if mode == "bundle":
import zlib

@staticmethod
def build_injection(bundled_modules_b64: str) -> str:
"""Return a Python snippet that decodes and injects bundled modules into ``sys.modules``.
return base64.b64encode(zlib.compress(sources_json.encode(), level=9)).decode("ascii")
if mode == "bundle-bz2":
import bz2

The snippet is self-contained: it imports ``sys``, ``types``, ``base64``,
``json``, and ``zlib``, then decompresses the embedded blob and registers
each module via ``types.ModuleType`` + ``exec``.
return base64.b85encode(bz2.compress(sources_json.encode(), compresslevel=9)).decode("ascii")
raise ValueError(f"Unsupported bundle mode: {mode}")

@staticmethod
def build_injection(
bundled_modules_b64: str,
*,
mode: Literal["bundle", "bundle-bz2"] = "bundle",
) -> str:
"""Return a self-contained snippet that decodes and injects bundled modules.

Args:
bundled_modules_b64: Base64 string produced by ``encode``.
bundled_modules_b64: Encoded string produced by ``encode``. The
name is kept for keyword-call compatibility.
mode: Bundle format used by ``encode`` (defaults to zlib/Base64).
"""
if mode == "bundle":
compression, decoder = "zlib", "b64decode"
elif mode == "bundle-bz2":
compression, decoder = "bz2", "b85decode"
else:
raise ValueError(f"Unsupported bundle mode: {mode}")

# Hydration may parse the generated YAML as Jinja before Python runs.
# Python hex escapes preserve Base85 bytes without exposing any Jinja
# opening delimiters ({{, {%, {#}), even through repeated rendering.
payload_literal = repr(bundled_modules_b64).replace("{", "\\x7b")
return textwrap.dedent(f"""\
# --- Inject local dependency modules from embedded source ---
import sys
import types
import base64
import json
import zlib
import {compression}

_EMBEDDED_MODULES = json.loads(zlib.decompress(base64.b64decode({repr(bundled_modules_b64)})))
_EMBEDDED_MODULES = json.loads({compression}.decompress(base64.{decoder}({payload_literal})))
# Pass 1: register all modules in sys.modules (without executing source)
# so transitive imports between bundled modules can resolve in any order.
_module_objs = {{}}
Expand Down
7 changes: 4 additions & 3 deletions packages/tangle-cli/src/tangle_cli/python_pipeline/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ def task(
mode: Optional local-from-python generation mode. ``None``
preserves the hydrator default (currently ``inline``).
Use ``"bundle"`` to ask hydrate-time codegen to embed
first-party imports using the existing module bundler.
first-party imports using zlib/Base64, or ``"bundle-bz2"`` for
the opt-in bz2/Base85 format.
resolve_root: Optional module resolution root for bundle mode.
Relative strings are resolved relative to the task source
file, then emitted into
Expand Down Expand Up @@ -170,8 +171,8 @@ def task(
# still resolved relative to the @task source file.
raw_dependencies_from = effective_deps_raw
raw_resolve_root = resolve_root
if mode is not None and mode not in {"inline", "bundle"}:
raise ValueError("@task(mode=...) must be 'inline', 'bundle', or None")
if mode is not None and mode not in {"inline", "bundle", "bundle-bz2"}:
raise ValueError("@task(mode=...) must be 'inline', 'bundle', 'bundle-bz2', or None")

if unwrap is None:
unwrap_names: tuple[str, ...] = ()
Expand Down
2 changes: 1 addition & 1 deletion packages/tangle-cli/src/tangle_cli/version_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ def bump_version(
generation_mode = annotations.get("tangle_cli_generation_mode") or (
"bundle" if annotations.get("bundled_modules") else "inline"
)
if generation_mode not in {"inline", "bundle"}:
if generation_mode not in {"inline", "bundle", "bundle-bz2"}:
error = f"Unsupported generation mode: {generation_mode}"
log.error(f"❌ {error}")
return {"status": "failed", "yaml_file": str(yaml_path), "error": error}
Expand Down
100 changes: 100 additions & 0 deletions tests/test_bundle_hydration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Bundled payloads must survive YAML/Jinja hydration before Python decodes them."""

import os
import subprocess
import sys

import pytest
import yaml

from tangle_cli.component_from_func import generate_component_yaml
from tangle_cli.module_bundler import ModuleBundler
from tangle_cli.pipeline_hydrator import PipelineHydrator
from tangle_cli.utils import dump_yaml


@pytest.mark.parametrize("mode", ["bundle", "bundle-bz2"])
@pytest.mark.parametrize(
("delimiter", "value"),
[("{{", "value-510"), ("{%", "value-537"), ("{#", "value-187")],
)
def test_delimiter_bearing_bundle_survives_hydration_and_execution(tmp_path, mode, delimiter, value):
# These small real sources produce each Jinja opening delimiter in Base85.
# Assert the precondition so a codec change cannot silently weaken coverage.
helper_source = f"VALUE = {value!r}\n"
encoded = ModuleBundler.encode({"bundle_payload_helper": helper_source}, mode="bundle-bz2")
assert encoded is not None and delimiter in encoded

helper = tmp_path / "bundle_payload_helper.py"
helper.write_text(helper_source, encoding="utf-8")
component_source = tmp_path / "component.py"
component_source.write_text(
"from bundle_payload_helper import VALUE\n\n"
"def report(prefix: str):\n"
" print(prefix + VALUE)\n",
encoding="utf-8",
)
template = tmp_path / "component.yaml.j2"
assert generate_component_yaml(
component_source,
template,
container_image="python:3.12",
function_name="report",
mode=mode,
)
generated = yaml.safe_load(template.read_text(encoding="utf-8"))
generated["name"] = "{{ component_name }}"
template.write_text(dump_yaml(generated), encoding="utf-8")
program = generated["implementation"]["container"]["command"][-1]
# Follow the actual componentRef -> template_file -> render_template path,
# rather than directly instantiating a different Jinja environment in a test.
(tmp_path / "component-config.yaml").write_text(
dump_yaml({"template_file": template.name, "component_name": "Rendered bundle"}),
encoding="utf-8",
)
pipeline = tmp_path / "pipeline.yaml"
pipeline.write_text(
dump_yaml({
"name": "Bundle hydration",
"implementation": {"graph": {"tasks": {
"report": {"componentRef": {"url": "file://./component-config.yaml"}},
}}},
}),
encoding="utf-8",
)
hydrated = PipelineHydrator(error_policy="raise").hydrate_file(pipeline)
component = hydrated.data["implementation"]["graph"]["tasks"]["report"]["componentRef"]["spec"]
assert component["name"] == "Rendered bundle"
assert component["implementation"]["container"]["command"][-1] == program

# Hex escapes stay inert across another real hydration pass, unlike Jinja
# raw blocks that disappear on the first pass.
template.write_text(dump_yaml(component), encoding="utf-8")
rehydrated = PipelineHydrator(error_policy="raise").hydrate_file(pipeline)
component = rehydrated.data["implementation"]["graph"]["tasks"]["report"]["componentRef"]["spec"]
assert component["implementation"]["container"]["command"][-1] == program

# No source files or project import path are available to the subprocess.
# Execute the generated sh bootstrap and argparse wrapper, not just the codec.
helper.unlink()
component_source.unlink()
runtime = tmp_path / "runtime"
runtime.mkdir()
command = component["implementation"]["container"]["command"]
completed = subprocess.run(
command + ["--prefix", "hydrated:"],
cwd=runtime,
env={"PATH": os.pathsep.join([os.path.dirname(sys.executable), os.defpath]), "TMPDIR": str(runtime)},
capture_output=True,
text=True,
timeout=15,
)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.strip() == f"hydrated:{value}"
assert all(opener not in program for opener in ("{{", "{%", "{#"))
if mode == "bundle-bz2":
assert r"\x7b" in program
assert "base64.b85decode" in program
else:
assert "base64.b64decode" in program
assert "import bz2" not in program
14 changes: 12 additions & 2 deletions tests/test_component_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ def bad_authoring() -> str:
assert not (tmp_path / "bad-authoring.yaml").exists()


def test_bundle_mode_with_local_imports(monkeypatch, tmp_path: Path):
@pytest.mark.parametrize("mode", ["bundle", "bundle-bz2"])
@pytest.mark.parametrize("use_cli", [False, True])
def test_bundle_mode_with_local_imports(monkeypatch, tmp_path: Path, mode, use_cli):
monkeypatch.setattr("tangle_cli.utils._fill_from_ci_env", lambda info: None)
helpers_dir = tmp_path / "helpers"
helpers_dir.mkdir()
Expand All @@ -278,11 +280,19 @@ def my_component(name: str) -> str:
''', encoding="utf-8")
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\ndependencies = []\n', encoding="utf-8")

assert regenerate_yaml(py_file, image="python:3.12", function_name="my_component", mode="bundle") is True
if use_cli:
run_app(cli.build_app(), [
"sdk", "components", "generate", "from-python", str(py_file),
"--image", "python:3.12", "--function", "my_component", "--mode", mode,
])
else:
assert regenerate_yaml(py_file, image="python:3.12", function_name="my_component", mode=mode) is True

generated = yaml.safe_load((tmp_path / "my-component.yaml").read_text(encoding="utf-8"))
program = generated["implementation"]["container"]["command"][-1]
assert generated["name"] == "My component"
assert generated["metadata"]["annotations"]["tangle_cli_generation_mode"] == mode
assert ("base64.b85decode" if mode == "bundle-bz2" else "base64.b64decode") in program
assert "_EMBEDDED_MODULES" in program
assert "helpers.utils" in program

Expand Down
Loading
Loading