Skip to content

Commit 745807c

Browse files
committed
Add plugin transform type infrastructure
_plugin_harness.py: standalone script run inside each plugin's isolated venv. Supports two modes: --discover <module> (returns transformer class metadata as JSON) and <metadata_json> (runs a transform, returning a JSON result object with output, success, stderr, and binary fields). plugin.py: PluginTransformer class that creates and manages a per-plugin venv under sandbox_dir/plugins/<module>/venv, runs discovery via the harness, and dispatches transform calls as subprocesses. transform-plugins.schema.yaml: schema for transform-plugins.yml, covering pip deps, module paths, and optional url override.
1 parent 44b7eb2 commit 745807c

3 files changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
$schema: https://json-schema.org/draft/2020-12/schema
2+
title: transform-plugins.yml
3+
description: Declares external transformer plugins to load at postprocessing startup.
4+
type: object
5+
properties:
6+
plugins:
7+
type: array
8+
items:
9+
type: object
10+
required:
11+
- modules
12+
properties:
13+
pip:
14+
description: >
15+
One or more pip install specifiers for packages that provide the plugin modules.
16+
Accepts any specifier that `pip install` understands (package name, version
17+
constraint, GitHub URL, etc.).
18+
oneOf:
19+
- type: string
20+
- type: array
21+
items:
22+
type: string
23+
minItems: 1
24+
modules:
25+
description: >
26+
One or more dotted Python module paths to import. Each module is scanned for
27+
classes that implement the Transformer duck type (a non-empty `transform_types`
28+
list and a callable `transform` method).
29+
oneOf:
30+
- type: string
31+
- type: array
32+
items:
33+
type: string
34+
minItems: 1
35+
url:
36+
type: string
37+
format: uri
38+
description: >
39+
URL for this plugin (e.g. its repository or PyPI page). If omitted, the
40+
postprocessor attempts to derive one automatically from the `pip` specifier.
41+
Provide this to override the derived URL or to supply one when auto-detection
42+
is not possible (e.g. for local file installs).
43+
additionalProperties: false
44+
additionalProperties: false
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Harness for plugin transform types. Two modes:
4+
5+
Discover:
6+
python _plugin_harness.py --discover <module_path>
7+
8+
Scans the module for transformer classes and writes a JSON array to stdout:
9+
[{"types": [...], "default_inputs": [...], "default_outputs": [...]}, ...]
10+
11+
Transform:
12+
python _plugin_harness.py <metadata_json>
13+
14+
Reads input data from stdin, runs the transform, and writes a JSON object
15+
to stdout:
16+
{"success": true, "output": "<str>", "binary": false, "stderr": null}
17+
{"success": true, "output": "<base64>", "binary": true, "stderr": null}
18+
{"success": false, "output": null, "binary": false, "stderr": "<str>"}
19+
20+
The metadata object passed to transform() has these attributes:
21+
type (str) transform type identifier
22+
transform_content (str) code/script declared in transforms.yaml
23+
input_data (str) example snippet text
24+
source_mime_type (str)
25+
target_mime_type (str)
26+
metadata (dict) extra metadata (keys starting with _ excluded)
27+
sandbox_dir None always None in subprocess context
28+
"""
29+
import importlib
30+
import inspect
31+
import json
32+
import sys
33+
from base64 import b64encode
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Helpers
38+
# ---------------------------------------------------------------------------
39+
40+
def _transformer_classes(module):
41+
"""Yield (cls,) for every transformer class defined in module."""
42+
module_name = module.__name__
43+
for _, cls in inspect.getmembers(module, inspect.isclass):
44+
if cls.__module__ != module_name:
45+
continue
46+
types = getattr(cls, 'transform_types', None)
47+
if types and isinstance(types, list) and all(isinstance(t, str) for t in types):
48+
yield cls
49+
50+
51+
class _Meta:
52+
"""Minimal TransformMetadata-compatible namespace passed to plugin transform()."""
53+
__slots__ = ('type', 'transform_content', 'input_data',
54+
'source_mime_type', 'target_mime_type', 'metadata', 'sandbox_dir')
55+
56+
57+
# ---------------------------------------------------------------------------
58+
# Discover mode
59+
# ---------------------------------------------------------------------------
60+
61+
def _discover(module_path: str) -> None:
62+
module = importlib.import_module(module_path)
63+
result = [
64+
{
65+
'class': cls.__name__,
66+
'types': list(cls.transform_types),
67+
'default_inputs': list(getattr(cls, 'default_inputs', None) or []),
68+
'default_outputs': list(getattr(cls, 'default_outputs', None) or []),
69+
}
70+
for cls in _transformer_classes(module)
71+
]
72+
print(json.dumps(result))
73+
74+
75+
# ---------------------------------------------------------------------------
76+
# Transform mode
77+
# ---------------------------------------------------------------------------
78+
79+
def _transform(meta_json: str) -> None:
80+
meta_dict = json.loads(meta_json)
81+
82+
m = _Meta()
83+
m.type = meta_dict['type']
84+
m.transform_content = meta_dict['transform_content']
85+
m.source_mime_type = meta_dict['source_mime_type']
86+
m.target_mime_type = meta_dict['target_mime_type']
87+
m.metadata = meta_dict.get('metadata', {})
88+
m.input_data = sys.stdin.buffer.read().decode('utf-8')
89+
m.sandbox_dir = None
90+
91+
module_path = meta_dict['module']
92+
transform_type = meta_dict['type']
93+
module = importlib.import_module(module_path)
94+
95+
transformer = next(
96+
(cls() for cls in _transformer_classes(module)
97+
if transform_type in cls.transform_types),
98+
None,
99+
)
100+
101+
if transformer is None:
102+
print(json.dumps({
103+
'success': False, 'output': None, 'binary': False,
104+
'stderr': f"No transformer found for type '{transform_type}' in '{module_path}'",
105+
}))
106+
return
107+
108+
try:
109+
result = transformer.transform(m)
110+
except Exception as e:
111+
print(json.dumps({
112+
'success': False, 'output': None, 'binary': False, 'stderr': str(e),
113+
}))
114+
return
115+
116+
if result is None:
117+
print(json.dumps({'success': True, 'output': None, 'binary': False, 'stderr': None}))
118+
return
119+
120+
if isinstance(result, bytes):
121+
print(json.dumps({
122+
'success': True,
123+
'output': b64encode(result).decode('ascii'),
124+
'binary': True,
125+
'stderr': None,
126+
}))
127+
else:
128+
print(json.dumps({'success': True, 'output': result, 'binary': False, 'stderr': None}))
129+
130+
131+
# ---------------------------------------------------------------------------
132+
# Entry point
133+
# ---------------------------------------------------------------------------
134+
135+
if __name__ == '__main__':
136+
if len(sys.argv) < 2:
137+
print('Usage: _plugin_harness.py --discover <module> | <metadata_json>',
138+
file=sys.stderr)
139+
sys.exit(1)
140+
141+
if sys.argv[1] == '--discover':
142+
if len(sys.argv) < 3:
143+
print('Usage: _plugin_harness.py --discover <module>', file=sys.stderr)
144+
sys.exit(1)
145+
_discover(sys.argv[2])
146+
else:
147+
_transform(sys.argv[1])

ogc/bblocks/transformers/plugin.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
import sys
6+
from base64 import b64decode
7+
from pathlib import Path
8+
9+
from ogc.bblocks.models import TransformMetadata, TransformResult
10+
11+
_HARNESS = Path(__file__).parent / '_plugin_harness.py'
12+
13+
14+
class PluginTransformer:
15+
16+
def __init__(self, module_path: str, pip_deps: list[str], transform_types: list[str]):
17+
self.module_path = module_path
18+
self.pip_deps = pip_deps
19+
self.transform_types = transform_types
20+
self.default_inputs: list = []
21+
self.default_outputs: list = []
22+
23+
def ensure_venv(self, sandbox_dir: Path) -> Path:
24+
slug = self.module_path.replace('.', '_')
25+
venv_dir = sandbox_dir / 'plugins' / slug / 'venv'
26+
if not venv_dir.exists():
27+
print(f" > Setting up plugin venv for '{self.module_path}'"
28+
+ (f" (pip: {self.pip_deps})" if self.pip_deps else ""),
29+
file=sys.stderr)
30+
subprocess.run([sys.executable, '-m', 'venv', str(venv_dir)], check=True)
31+
if self.pip_deps:
32+
pip_bin = venv_dir / 'bin' / 'pip'
33+
subprocess.run(
34+
[str(pip_bin), 'install', '--quiet',
35+
'--disable-pip-version-check', *self.pip_deps],
36+
check=True,
37+
)
38+
return venv_dir
39+
40+
@staticmethod
41+
def discover(venv_dir: Path, module_path: str) -> list[dict]:
42+
"""Run --discover and return the list of transformer class descriptors."""
43+
python_bin = venv_dir / 'bin' / 'python'
44+
result = subprocess.run(
45+
[str(python_bin), str(_HARNESS), '--discover', module_path],
46+
capture_output=True, text=True,
47+
)
48+
if result.returncode != 0:
49+
return []
50+
try:
51+
return json.loads(result.stdout.strip())
52+
except Exception:
53+
return []
54+
55+
def transform(self, metadata: TransformMetadata) -> TransformResult:
56+
if not metadata.sandbox_dir:
57+
return TransformResult(
58+
output=None, success=False,
59+
stderr='Plugin transforms require a sandbox directory',
60+
)
61+
62+
venv_dir = self.ensure_venv(metadata.sandbox_dir)
63+
python_bin = venv_dir / 'bin' / 'python'
64+
65+
meta_dict = {
66+
'type': metadata.type,
67+
'module': self.module_path,
68+
'transform_content': metadata.transform_content,
69+
'source_mime_type': metadata.source_mime_type,
70+
'target_mime_type': metadata.target_mime_type,
71+
'metadata': {k: v for k, v in (metadata.metadata or {}).items()
72+
if not k.startswith('_')},
73+
}
74+
75+
result = subprocess.run(
76+
[str(python_bin), str(_HARNESS), json.dumps(meta_dict)],
77+
input=(metadata.input_data.encode('utf-8')
78+
if isinstance(metadata.input_data, str)
79+
else metadata.input_data),
80+
capture_output=True,
81+
)
82+
83+
try:
84+
data = json.loads(result.stdout)
85+
except Exception:
86+
stderr = result.stderr.decode('utf-8', errors='replace') or 'Unexpected harness error'
87+
return TransformResult(output=None, success=False, stderr=stderr)
88+
89+
output = data.get('output')
90+
if output is not None and data.get('binary'):
91+
output = b64decode(output)
92+
93+
return TransformResult(
94+
output=output,
95+
success=data.get('success', False),
96+
stderr=data.get('stderr') or None,
97+
binary=bool(data.get('binary')),
98+
)

0 commit comments

Comments
 (0)