|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | from __future__ import annotations |
3 | 3 |
|
| 4 | +import re |
4 | 5 | import shutil |
5 | 6 | import os.path |
6 | 7 | import subprocess |
|
10 | 11 | from pathlib import Path |
11 | 12 | from urllib.parse import urljoin |
12 | 13 |
|
| 14 | +import yaml |
| 15 | + |
13 | 16 | from ogc.bblocks import mimetypes |
14 | 17 | from ogc.bblocks.models import BuildingBlock, TransformMetadata, TransformResult, BuildingBlockError |
15 | 18 | from ogc.bblocks.transformers import transformers |
|
18 | 21 | _SUBPROCESS_TRANSFORM_TYPES = ('python', 'node') |
19 | 22 |
|
20 | 23 |
|
| 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 | + |
21 | 50 | def _normalize_media_type(mt: str | dict) -> dict: |
22 | 51 | if isinstance(mt, str): |
23 | 52 | entry = mimetypes.lookup(mt) |
@@ -87,6 +116,85 @@ def _ensure_sandbox(sandbox_dir: Path, bblock: BuildingBlock) -> None: |
87 | 116 | subprocess.run(['npm', 'install', '--prefix', str(node_dir), *npm_deps], check=True) |
88 | 117 |
|
89 | 118 |
|
| 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 | + |
90 | 198 | def apply_transforms(bblock: BuildingBlock, |
91 | 199 | outputs_path: str | Path, |
92 | 200 | output_subpath='transforms', |
@@ -122,8 +230,8 @@ def apply_transforms(bblock: BuildingBlock, |
122 | 230 |
|
123 | 231 | transformer = transformers.get(transform['type']) |
124 | 232 | 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', []), |
127 | 235 | } if transformer else None |
128 | 236 |
|
129 | 237 | # Normalize types |
|
0 commit comments