Skip to content

Commit bec36e8

Browse files
committed
add functionality to load plugins dynamically
1 parent 016804e commit bec36e8

4 files changed

Lines changed: 150 additions & 54 deletions

File tree

pygeometa/schemas/__init__.py

Lines changed: 145 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -47,36 +47,117 @@
4747
import logging
4848
import os
4949

50+
from typing import Dict, Type
5051
from pygeometa.schemas.base import BaseOutputSchema
5152

5253
LOGGER = logging.getLogger(__name__)
5354
THISDIR = os.path.dirname(os.path.realpath(__file__))
5455

55-
SCHEMAS = {
56-
'dcat': 'pygeometa.schemas.dcat.DCATOutputSchema',
57-
'iso19139': 'pygeometa.schemas.iso19139.ISO19139OutputSchema',
58-
'iso19139-2': 'pygeometa.schemas.iso19139_2.ISO19139_2OutputSchema',
59-
'iso19139-hnap': 'pygeometa.schemas.iso19139_hnap.ISO19139HNAPOutputSchema', # noqa
60-
'oarec-record': 'pygeometa.schemas.ogcapi_records.OGCAPIRecordOutputSchema', # noqa
61-
'schema-org': 'pygeometa.schemas.schema_org.SchemaOrgOutputSchema',
62-
'stac-item': 'pygeometa.schemas.stac.STACItemOutputSchema',
63-
'wmo-cmp': 'pygeometa.schemas.wmo_cmp.WMOCMPOutputSchema',
64-
'wmo-wcmp2': 'pygeometa.schemas.wmo_wcmp2.WMOWCMP2OutputSchema',
65-
'wmo-wigos': 'pygeometa.schemas.wmo_wigos.WMOWIGOSOutputSchema',
66-
'cwl': 'pygeometa.schemas.cwl.CWLOutputSchema'
67-
}
56+
# runtime mapping: schema_key -> class object
57+
_DISCOVERED_SCHEMAS: Dict[str, Type[BaseOutputSchema]] = {}
58+
_DISCOVERY_DONE = False
59+
60+
61+
def _discover_schemas():
62+
"""Discover local schema packages (folders with __init__.py) in ./schemas.
63+
64+
For each discovered package module, import it and choose the first class
65+
that is a subclass of BaseOutputSchema (excluding BaseOutputSchema itself).
66+
The mapping key will be:
67+
- module attribute SCHEMA_NAME if defined, else
68+
- the package (folder) name.
69+
70+
This function is idempotent and caches results in _DISCOVERED_SCHEMAS.
71+
"""
72+
global _DISCOVERY_DONE, _DISCOVERED_SCHEMAS # noqa
73+
if _DISCOVERY_DONE:
74+
return
75+
76+
LOGGER.debug("Discovering schema packages in %s", THISDIR)
77+
pkg_name = __name__ # 'pygeometa.schemas'
78+
79+
try:
80+
entries = sorted(os.listdir(THISDIR))
81+
except OSError:
82+
entries = []
83+
84+
for entry in entries:
85+
path = os.path.join(THISDIR, entry)
86+
# look for directories with __init__.py only
87+
if not os.path.isdir(path):
88+
continue
89+
init_py = os.path.join(path, "__init__.py")
90+
if not os.path.isfile(init_py):
91+
continue
92+
93+
module_name = f"{pkg_name}.{entry}"
94+
try:
95+
# import normally so package semantics and relative imports work
96+
module = importlib.import_module(module_name)
97+
except Exception as exc:
98+
LOGGER.exception(f"Failed to import schema package {module_name}: {exc}") # noqa
99+
continue
100+
101+
# Key selection: prefer explicit module-provided `name`,
102+
# else use dotted name
103+
schema_key = getattr(module, "name", None) or module.__name__
104+
105+
# Collect candidate classes in module that are subclasses
106+
# of BaseOutputSchema
107+
candidates = []
108+
for attr_name in dir(module):
109+
try:
110+
attr = getattr(module, attr_name)
111+
except Exception:
112+
# some modules raise on attribute access; skip those
113+
continue
114+
if not isinstance(attr, type):
115+
continue
116+
# Exclude BaseOutputSchema itself
117+
if attr is BaseOutputSchema:
118+
continue
119+
try:
120+
if issubclass(attr, BaseOutputSchema):
121+
candidates.append(attr)
122+
except TypeError:
123+
# issubclass can raise if attr is not a class; ignore
124+
continue
125+
126+
if not candidates:
127+
LOGGER.warning(f"No BaseOutputSchema subclass found in {module_name}; skipping" ) # noqa
128+
continue
129+
130+
# pick the most concrete subclass (leaf in the inheritance chain)
131+
# the class with the longest method-resolution order (MRO)
132+
chosen_cls = sorted(candidates, key=lambda c: len(getattr(c, "__mro__", ())), reverse=True)[0] # noqa
133+
134+
# Ensure uniqueness of keys
135+
if schema_key in _DISCOVERED_SCHEMAS:
136+
existing = _DISCOVERED_SCHEMAS[schema_key]
137+
if existing is chosen_cls:
138+
LOGGER.debug("Schema key {schema_key} already registered with same class; skipping") # noqa
139+
continue
140+
LOGGER.warning(f"""Duplicate schema key '{schema_key}' (module {module_name}, class {chosen_cls.__name__}). # noqa
141+
Keeping first discovered: {existing.__module__}.{existing.__name__}""") # noqa
142+
continue
143+
144+
_DISCOVERED_SCHEMAS[schema_key] = chosen_cls
145+
LOGGER.info(f"Discovered schema '{schema_key}' -> {chosen_cls.__module__}.{chosen_cls.__name__}") # noqa
146+
147+
_DISCOVERY_DONE = True
68148

69149

70150
def get_supported_schemas(details: bool = False,
71151
include_autodetect: bool = False) -> list:
72152
"""
73-
Get supported schemas
153+
Get supported schemas.
74154
75155
:param details: provide read/write details
76156
:param include_autodetect: include magic auto detection mode
77157
78-
:returns: list of supported schemas
158+
:returns: list of supported schemas (strings) or details matrix
79159
"""
160+
_discover_schemas()
80161

81162
def has_mode(plugin: BaseOutputSchema, mode: str) -> bool:
82163
enabled = False
@@ -90,36 +171,47 @@ def has_mode(plugin: BaseOutputSchema, mode: str) -> bool:
90171

91172
return enabled
92173

93-
schema_matrix = []
94-
95174
LOGGER.debug('Generating list of supported schemas')
96175

97176
if not details:
177+
schema_names = []
178+
for cls in _DISCOVERED_SCHEMAS.values():
179+
try:
180+
schema_names.append(cls().name)
181+
except Exception:
182+
continue
98183
if include_autodetect:
99-
schemas_keys = list(SCHEMAS.keys())
100-
schemas_keys.append('autodetect')
101-
return schemas_keys
102-
else:
103-
return SCHEMAS.keys()
184+
schema_names.append("autodetect")
185+
return schema_names
104186

105-
for key in SCHEMAS.keys():
106-
schema = load_schema(key)
107-
can_read = has_mode(schema, 'import_')
108-
can_write = has_mode(schema, 'write')
187+
schema_matrix = []
188+
for key, cls in _DISCOVERED_SCHEMAS.items():
189+
nm = key
190+
try:
191+
schema_inst = cls()
192+
nm = schema_inst.name
193+
can_read = has_mode(schema_inst, "import_")
194+
can_write = has_mode(schema_inst, "write")
195+
description = getattr(schema_inst, "description", "")
196+
except Exception:
197+
LOGGER.exception(f"Error instantiating schema class for key {key}") # noqa
198+
can_read = False
199+
can_write = False
200+
description = ""
109201

110202
schema_matrix.append({
111-
'id': key,
112-
'description': schema.description,
113-
'read': can_read,
114-
'write': can_write
203+
"id": nm,
204+
"description": description,
205+
"read": can_read,
206+
"write": can_write
115207
})
116208

117209
if include_autodetect:
118210
schema_matrix.append({
119-
'id': 'autodetect',
120-
'description': 'Auto schema detection',
121-
'read': True,
122-
'write': False
211+
"id": "autodetect",
212+
"description": "Auto schema detection",
213+
"read": True,
214+
"write": False
123215
})
124216

125217
return schema_matrix
@@ -133,28 +225,30 @@ def load_schema(schema_name: str) -> BaseOutputSchema:
133225
134226
:returns: plugin object
135227
"""
228+
_discover_schemas()
229+
LOGGER.debug("Available schemas: %s", list(_DISCOVERED_SCHEMAS.keys()))
136230

137-
LOGGER.debug(f'Schemas: {SCHEMAS.keys()}')
231+
# allow 'autodetect' to be handled elsewhere (kept for parity)
232+
if schema_name == "autodetect":
233+
raise InvalidSchemaError("Autodetect is not a concrete schema")
138234

139-
if schema_name not in SCHEMAS.keys():
140-
msg = f'Schema {schema_name} not found'
235+
cls = None
236+
for v in _DISCOVERED_SCHEMAS.values():
237+
if v().name == schema_name:
238+
cls = v
239+
break
240+
241+
if not cls:
242+
msg = f"Schema {schema_name} not found"
141243
LOGGER.exception(msg)
142244
raise InvalidSchemaError(msg)
143245

144-
name = SCHEMAS[schema_name]
145-
146-
if '.' in name: # dotted path
147-
packagename, classname = name.rsplit('.', 1)
148-
else:
149-
raise InvalidSchemaError(f'Schema path {name} not found')
150-
151-
LOGGER.debug(f'package name: {packagename}')
152-
LOGGER.debug(f'class name: {classname}')
153-
154-
module = importlib.import_module(packagename)
155-
class_ = getattr(module, classname)
156-
157-
return class_()
246+
try:
247+
return cls()
248+
except Exception as exc:
249+
msg = f"Failed to instantiate schema {schema_name}: {exc}"
250+
LOGGER.exception(msg)
251+
raise InvalidSchemaError(msg)
158252

159253

160254
class InvalidSchemaError(Exception):

pygeometa/schemas/ogcapi_records/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
import os
4848
from typing import Union
4949

50-
from pygeometa import __version__
50+
5151
from pygeometa.core import get_charstring
5252
from pygeometa.helpers import generate_datetime, json_dumps
5353
from pygeometa.schemas.base import BaseOutputSchema
@@ -247,7 +247,8 @@ def write(self, mcf: dict, stringify: str = True) -> Union[dict, str]:
247247
for value in mcf['distribution'].values():
248248
record['links'].append(self.generate_link(value))
249249

250-
record['generated_by'] = f'pygeometa {__version__}'
250+
from importlib.metadata import version
251+
record['generated_by'] = f'pygeometa {version("pygeometa")}'
251252

252253
if stringify:
253254
return json_dumps(record)

pygeometa/schemas/wmo_wcmp2/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def __init__(self):
7171
super().__init__()
7272

7373
self.description = description
74+
self.name = 'wmo-wcmp2'
7475

7576
def write(self, mcf: dict, stringify: str = True) -> Union[dict, str]:
7677
"""

tests/run_tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ def test_transform_metadata(self):
482482
"""test metadata transform"""
483483

484484
with open(get_abspath('md-SMJP01RJTD-gmd.xml')) as fh:
485-
m = transform_metadata('iso19139', 'oarec-record', fh.read())
485+
m = transform_metadata('autodetect', 'oarec-record', fh.read())
486486

487487
m = json.loads(m)
488488
self.assertEqual(

0 commit comments

Comments
 (0)