Skip to content

Commit c1bda3d

Browse files
committed
Add get_transformer builtin for cross-bblock transform composition
Allows a Python transform to call another bblock's Python transform by calling get_transformer(bblock_id, transform_id), which returns a callable accepting the content to transform and an optional extra_metadata dict. Implementation: - _build_transforms_registry() in transform.py collects all Python transforms from local and imported bblocks into a registry dict - The registry is serialized into each persistent subprocess harness as a module-level constant (_TRANSFORMS_REGISTRY) - get_transformer() in the harness looks up the target transform, compiles its code (cached), installs any pip dependencies, and returns a callable that executes the code in an isolated namespace with bblock-level context and _nested_transform=True on the metadata - Cycle detection raises RuntimeError immediately if a transform is already executing in the current call stack
1 parent 8c64c21 commit c1bda3d

3 files changed

Lines changed: 146 additions & 6 deletions

File tree

ogc/bblocks/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,7 @@ class TransformMetadata:
846846
sandbox_dir: Path | None = None
847847
id: str | None = None
848848
ctx: TransformContext | None = None
849+
transforms_registry: dict | None = None
849850

850851

851852
@dataclasses.dataclass

ogc/bblocks/transform.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,47 @@ def cleanup_sandbox(sandbox_dir: Path, bblocks: list[BuildingBlock]) -> None:
296296
# temporarily renamed) — leave sandboxes intact to avoid a full rebuild on rename-back
297297

298298

299+
def _build_transforms_registry(bblocks_register: BuildingBlockRegister) -> dict:
300+
registry = {}
301+
302+
for bblock_id, bblock in bblocks_register.bblocks.items():
303+
transforms = bblock.transforms
304+
if not transforms:
305+
continue
306+
python_transforms = {
307+
t['id']: {'code': t['code'], 'metadata': t.get('metadata') or {}}
308+
for t in transforms if t.get('type') == 'python' and t.get('code')
309+
}
310+
if not python_transforms:
311+
continue
312+
bblock_metadata = json.loads(json.dumps(bblock.metadata, default=str))
313+
registry[bblock_id] = {
314+
'name': bblock.metadata.get('name', bblock_id),
315+
'version': bblock.metadata.get('version'),
316+
'bblock_metadata': bblock_metadata,
317+
'transforms': python_transforms,
318+
}
319+
320+
for bblock_id, raw in bblocks_register.imported_bblocks.items():
321+
transforms = raw.get('transforms') or []
322+
python_transforms = {
323+
t['id']: {'code': t['code'], 'metadata': t.get('metadata') or {}}
324+
for t in transforms if t.get('type') == 'python' and t.get('code')
325+
}
326+
if not python_transforms:
327+
continue
328+
raw_copy = {k: v for k, v in raw.items() if k != 'register'}
329+
bblock_metadata = json.loads(json.dumps(raw_copy, default=str))
330+
registry[bblock_id] = {
331+
'name': raw.get('name', bblock_id),
332+
'version': raw.get('version'),
333+
'bblock_metadata': bblock_metadata,
334+
'transforms': python_transforms,
335+
}
336+
337+
return registry
338+
339+
299340
def apply_transforms(bblock: BuildingBlock,
300341
outputs_path: str | Path,
301342
output_subpath='transforms',
@@ -316,6 +357,8 @@ def apply_transforms(bblock: BuildingBlock,
316357
shutil.rmtree(output_dir, ignore_errors=True)
317358
output_dir.mkdir(parents=True, exist_ok=True)
318359

360+
transforms_registry = _build_transforms_registry(bblocks_register) if bblocks_register else {}
361+
319362
# Collects ValidationReportItems per profile across all snippets/transforms,
320363
# so we can write one consolidated _report.json per profile at the end.
321364
# format: profile_id -> (profile_bblock, profile_subdir, [ValidationReportItem])
@@ -447,7 +490,8 @@ def apply_transforms(bblock: BuildingBlock,
447490
input_data=snippet['code'],
448491
sandbox_dir=transform_sandboxes.get(
449492
transform['id'], sandbox_dir),
450-
ctx=ctx)
493+
ctx=ctx,
494+
transforms_registry=transforms_registry)
451495

452496
try:
453497
result = transformer.transform(transform_metadata)

ogc/bblocks/transformers/python.py

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,106 @@
2222
_cache_lock = Lock()
2323

2424

25-
def _build_persistent_harness(transform_content: str) -> str:
25+
def _build_persistent_harness(transform_content: str, transforms_registry: dict) -> str:
26+
registry_json = json.dumps(transforms_registry)
2627
return f"""\
2728
import sys as _sys, json as _json, types as _types, base64 as _b64, io as _io, traceback as _tb
2829
2930
_TRANSFORM_CODE = compile({repr(transform_content)}, '<transform>', 'exec')
31+
_TRANSFORMS_REGISTRY = _json.loads({repr(registry_json)})
3032
3133
_real_stdout = _sys.stdout.buffer
3234
35+
# Module-level state for get_transformer
36+
_cycle_set = set()
37+
_callable_cache = {{}}
38+
_compiled_code_cache = {{}}
39+
40+
41+
def get_transformer(bblock_id, transform_id):
42+
key = (bblock_id, transform_id)
43+
if key in _cycle_set:
44+
raise RuntimeError(
45+
f"Cycle detected: transform {{transform_id!r}} of {{bblock_id!r}} is already executing"
46+
)
47+
48+
if key not in _callable_cache:
49+
bblock_entry = _TRANSFORMS_REGISTRY.get(bblock_id)
50+
if bblock_entry is None:
51+
raise KeyError(f"Building block {{bblock_id!r}} not found in transforms registry")
52+
transform_entry = bblock_entry['transforms'].get(transform_id)
53+
if transform_entry is None:
54+
raise KeyError(
55+
f"Transform {{transform_id!r}} not found for building block {{bblock_id!r}}"
56+
)
57+
58+
code_str = transform_entry['code']
59+
if code_str not in _compiled_code_cache:
60+
_compiled_code_cache[code_str] = compile(
61+
code_str, f'<transform:{{bblock_id}}/{{transform_id}}>', 'exec'
62+
)
63+
compiled = _compiled_code_cache[code_str]
64+
65+
deps = (transform_entry.get('metadata') or {{}}).get('dependencies', {{}})
66+
pip_deps = deps.get('pip', [])
67+
if isinstance(pip_deps, str):
68+
pip_deps = [pip_deps]
69+
if pip_deps:
70+
import subprocess as _subproc
71+
_subproc.run(
72+
[_sys.executable, '-m', 'pip', 'install', '--disable-pip-version-check', *pip_deps],
73+
check=True,
74+
)
75+
76+
def _make_callable(_key=key, _bblock_id=bblock_id, _transform_id=transform_id,
77+
_compiled=compiled, _entry=transform_entry, _bb=bblock_entry):
78+
def _callable(content, extra_metadata=None):
79+
_cycle_set.add(_key)
80+
try:
81+
_base_meta = dict(_entry.get('metadata') or {{}})
82+
if extra_metadata:
83+
_base_meta.update(extra_metadata)
84+
_base_meta['_nested_transform'] = True
85+
86+
_ctx = _types.SimpleNamespace(
87+
bblock_id=_bblock_id,
88+
bblock_name=_bb.get('name'),
89+
bblock_version=_bb.get('bblock_metadata', {{}}).get('version'),
90+
bblock_tags=_bb.get('bblock_metadata', {{}}).get('tags', []),
91+
bblock_metadata=_bb.get('bblock_metadata', {{}}),
92+
)
93+
_tm = _types.SimpleNamespace(
94+
source_mime_type=None,
95+
target_mime_type=None,
96+
metadata=_types.SimpleNamespace(**_base_meta),
97+
context=_ctx,
98+
)
99+
100+
if isinstance(content, bytes):
101+
try:
102+
_input = content.decode('utf-8')
103+
except UnicodeDecodeError:
104+
_input = content
105+
else:
106+
_input = content
107+
108+
_ns = {{
109+
'transform_metadata': _tm,
110+
'input_data': _input,
111+
'output_data': None,
112+
'get_transformer': get_transformer,
113+
}}
114+
exec(_compiled, _ns)
115+
return _ns.get('output_data')
116+
finally:
117+
_cycle_set.discard(_key)
118+
return _callable
119+
120+
_callable_cache[key] = _make_callable()
121+
122+
return _callable_cache[key]
123+
124+
33125
for _line in _sys.stdin:
34126
_line = _line.strip()
35127
if not _line:
@@ -50,7 +142,8 @@ def _build_persistent_harness(transform_content: str) -> str:
50142
_sys.stdout = _capture
51143
_sys.stderr = _capture
52144
53-
_ns = {{'transform_metadata': transform_metadata, 'input_data': input_data, 'output_data': None}}
145+
_ns = {{'transform_metadata': transform_metadata, 'input_data': input_data, 'output_data': None,
146+
'get_transformer': get_transformer}}
54147
try:
55148
exec(_TRANSFORM_CODE, _ns)
56149
output_data = _ns.get('output_data')
@@ -78,17 +171,18 @@ def _build_persistent_harness(transform_content: str) -> str:
78171

79172
class _PersistentProcess:
80173

81-
def __init__(self, python_bin: Path, transform_content: str):
174+
def __init__(self, python_bin: Path, transform_content: str, transforms_registry: dict):
82175
self._python_bin = python_bin
83176
self._transform_content = transform_content
177+
self._transforms_registry = transforms_registry
84178
self._proc: subprocess.Popen | None = None
85179
self._harness_path: Path | None = None
86180
self._lock = Lock()
87181
self._start()
88182

89183
def _start(self):
90184
if self._harness_path is None:
91-
harness = _build_persistent_harness(self._transform_content)
185+
harness = _build_persistent_harness(self._transform_content, self._transforms_registry)
92186
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
93187
f.write(harness)
94188
self._harness_path = Path(f.name)
@@ -166,11 +260,12 @@ def transform(self, metadata: TransformMetadata) -> TransformResult:
166260
if isinstance(transform_content, bytes):
167261
transform_content = transform_content.decode('utf-8')
168262

263+
transforms_registry = metadata.transforms_registry or {}
169264
cache_key = (str(python_bin), transform_content)
170265
with _cache_lock:
171266
proc = _process_cache.get(cache_key)
172267
if proc is None:
173-
proc = _PersistentProcess(python_bin, transform_content)
268+
proc = _PersistentProcess(python_bin, transform_content, transforms_registry)
174269
_process_cache[cache_key] = proc
175270

176271
transform_metadata_dict = {

0 commit comments

Comments
 (0)