Skip to content

Commit 62c1e5d

Browse files
committed
Load transform plugins at startup; expose in register.json
transform.py: load_transform_plugins() reads transform-plugins.yml, creates per-plugin venvs, runs discovery, and registers PluginTransformer instances. _pip_to_url() derives a human-facing URL from a pip specifier (git repos, PyPI packages, plain URLs; skips local paths). getattr with defaults on transformer.default_inputs/outputs to support duck-typed plugin transformers. postprocess.py: call load_transform_plugins() at startup and include the enriched plugin list (with discovered transformer classes, types, and urls) under transformPlugins in register.json.
1 parent 745807c commit 62c1e5d

2 files changed

Lines changed: 115 additions & 3 deletions

File tree

ogc/bblocks/postprocess.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from ogc.bblocks.schema import annotate_schema, resolve_all_schema_references, write_annotated_schema
2525
from ogc.bblocks.models import BuildingBlock, BuildingBlockRegister, ImportedBuildingBlocks, BuildingBlockError
2626
from ogc.bblocks.validate import validate_test_resources, write_report
27-
from ogc.bblocks.transform import apply_transforms, transformers
27+
from ogc.bblocks.transform import apply_transforms, load_transform_plugins, transformers
2828

2929

3030
def postprocess(registered_items_path: str | Path = 'registereditems',
@@ -52,6 +52,7 @@ def postprocess(registered_items_path: str | Path = 'registereditems',
5252

5353
_sandbox = tempfile.TemporaryDirectory(prefix='bblocks_sandbox_')
5454
sandbox_dir = Path(_sandbox.name)
55+
transform_plugins = load_transform_plugins(sandbox_dir)
5556

5657
if not isinstance(test_outputs_path, Path):
5758
test_outputs_path = Path(test_outputs_path)
@@ -467,6 +468,9 @@ def do_postprocess(bblock: BuildingBlock, light: bool = False) -> bool:
467468
output_register_json['imports'] = list(imported_bblocks.real_metadata_urls.values())
468469
output_register_json['bblocks'] = output_bblocks
469470

471+
if transform_plugins:
472+
output_register_json['transformPlugins'] = transform_plugins
473+
470474
remote_cache_dir_url = f"{base_url}{os.path.relpath(Path(remote_cache_dir).resolve(), cwd)}"
471475
output_register_json['remoteCacheDir'] = remote_cache_dir_url
472476

ogc/bblocks/transform.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
from __future__ import annotations
33

4+
import re
45
import shutil
56
import os.path
67
import subprocess
@@ -10,6 +11,8 @@
1011
from pathlib import Path
1112
from urllib.parse import urljoin
1213

14+
import yaml
15+
1316
from ogc.bblocks import mimetypes
1417
from ogc.bblocks.models import BuildingBlock, TransformMetadata, TransformResult, BuildingBlockError
1518
from ogc.bblocks.transformers import transformers
@@ -18,6 +21,32 @@
1821
_SUBPROCESS_TRANSFORM_TYPES = ('python', 'node')
1922

2023

24+
def _pip_to_url(pip_spec: str) -> str | None:
25+
"""Derive a human-facing URL from a pip install specifier, or None if not applicable."""
26+
if not pip_spec:
27+
return None
28+
# Local paths — no meaningful URL
29+
if pip_spec.startswith(('/', './', '../')):
30+
return None
31+
# Git URL: git+https://.../.git[@ref]
32+
if pip_spec.startswith('git+'):
33+
url = pip_spec[4:] # strip git+
34+
url = re.sub(r'@[^@]*$', '', url) # strip @ref
35+
url = re.sub(r'\.git$', '', url.rstrip('/')) # strip .git
36+
return url
37+
# Plain archive/wheel URL
38+
if pip_spec.startswith(('https://', 'http://')):
39+
return pip_spec
40+
# Standard package name, possibly with version specifier or extras
41+
name = re.split(r'[>=<!~\[@\s]', pip_spec)[0].strip()
42+
if name:
43+
return f'https://pypi.org/project/{name}'
44+
return None
45+
46+
_PLUGINS_FILE = 'transform-plugins.yml'
47+
48+
49+
2150
def _normalize_media_type(mt: str | dict) -> dict:
2251
if isinstance(mt, str):
2352
entry = mimetypes.lookup(mt)
@@ -87,6 +116,85 @@ def _ensure_sandbox(sandbox_dir: Path, bblock: BuildingBlock) -> None:
87116
subprocess.run(['npm', 'install', '--prefix', str(node_dir), *npm_deps], check=True)
88117

89118

119+
def load_transform_plugins(sandbox_dir: Path) -> list[dict]:
120+
"""Read transform-plugins.yml, create per-plugin venvs, and register PluginTransformers.
121+
122+
Returns the raw plugin list from transform-plugins.yml (for inclusion in register.json),
123+
or an empty list if the file does not exist or declares no plugins.
124+
"""
125+
from ogc.bblocks.transformers.plugin import PluginTransformer
126+
127+
plugins_path = Path(_PLUGINS_FILE)
128+
if not plugins_path.exists():
129+
return []
130+
131+
with open(plugins_path) as f:
132+
config = yaml.safe_load(f)
133+
134+
if not config or 'plugins' not in config:
135+
return []
136+
137+
output_plugins = []
138+
139+
for plugin in config.get('plugins', []):
140+
pip_deps = plugin.get('pip', [])
141+
if isinstance(pip_deps, str):
142+
pip_deps = [pip_deps]
143+
144+
modules = plugin.get('modules', [])
145+
if isinstance(modules, str):
146+
modules = [modules]
147+
148+
output_modules = []
149+
150+
for module_path in modules:
151+
# Create venv and run discovery via the harness
152+
venv_dir = PluginTransformer(module_path, pip_deps, []).ensure_venv(sandbox_dir)
153+
discovered = PluginTransformer.discover(venv_dir, module_path)
154+
155+
if not discovered:
156+
print(f" Warning: no transform types found in plugin '{module_path}'",
157+
file=sys.stderr)
158+
continue
159+
160+
output_transformers = []
161+
for entry in discovered:
162+
types = entry.get('types', [])
163+
if not types:
164+
continue
165+
pt = PluginTransformer(module_path, pip_deps, types)
166+
pt.default_inputs = entry.get('default_inputs', [])
167+
pt.default_outputs = entry.get('default_outputs', [])
168+
print(f" > Registered plugin '{module_path}' ({entry.get('class', '?')}) "
169+
f"for types: {types}", file=sys.stderr)
170+
for tt in types:
171+
transformers[tt] = pt
172+
output_transformers.append({
173+
'class': entry.get('class'),
174+
'types': types,
175+
'defaultInputs': pt.default_inputs,
176+
'defaultOutputs': pt.default_outputs,
177+
})
178+
179+
if output_transformers:
180+
output_modules.append({'module': module_path, 'transformers': output_transformers})
181+
182+
if output_modules:
183+
output_entry = {'modules': output_modules}
184+
original_pip = plugin.get('pip')
185+
if original_pip:
186+
output_entry['pip'] = original_pip
187+
if explicit_url := plugin.get('url'):
188+
output_entry['urls'] = [explicit_url]
189+
else:
190+
urls = [u for s in pip_deps for u in [_pip_to_url(s)] if u]
191+
if urls:
192+
output_entry['urls'] = urls
193+
output_plugins.append(output_entry)
194+
195+
return output_plugins
196+
197+
90198
def apply_transforms(bblock: BuildingBlock,
91199
outputs_path: str | Path,
92200
output_subpath='transforms',
@@ -122,8 +230,8 @@ def apply_transforms(bblock: BuildingBlock,
122230

123231
transformer = transformers.get(transform['type'])
124232
default_media_types = {
125-
'inputs': transformer.default_inputs,
126-
'outputs': transformer.default_outputs,
233+
'inputs': getattr(transformer, 'default_inputs', []),
234+
'outputs': getattr(transformer, 'default_outputs', []),
127235
} if transformer else None
128236

129237
# Normalize types

0 commit comments

Comments
 (0)