Skip to content

Commit 6c2377d

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

2 files changed

Lines changed: 122 additions & 53 deletions

File tree

pygeometa/schemas/__init__.py

Lines changed: 119 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -46,37 +46,97 @@
4646
import importlib
4747
import logging
4848
import os
49+
import pkgutil
50+
from typing import Dict, Type, List
4951

5052
from pygeometa.schemas.base import BaseOutputSchema
5153

5254
LOGGER = logging.getLogger(__name__)
5355
THISDIR = os.path.dirname(os.path.realpath(__file__))
5456

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-
}
57+
# runtime mapping: schema_key -> class object
58+
_DISCOVERED_SCHEMAS: Dict[str, Type[BaseOutputSchema]] = {}
59+
_DISCOVERY_DONE = False
6860

6961

62+
def _discover_schemas():
63+
"""Discover local schema packages (folders with __init__.py) in ./schemas.
64+
65+
For each discovered package module, import it and choose the first class
66+
that is a subclass of BaseOutputSchema (excluding BaseOutputSchema itself).
67+
The mapping key will be:
68+
- module attribute SCHEMA_NAME if defined, else
69+
- the package (folder) name.
70+
71+
This function is idempotent and caches results in _DISCOVERED_SCHEMAS.
72+
"""
73+
global _DISCOVERY_DONE, _DISCOVERED_SCHEMAS
74+
if _DISCOVERY_DONE:
75+
return
76+
77+
LOGGER.debug("Discovering schema packages in %s", THISDIR)
78+
pkg_name = __name__ # 'pygeometa.schemas'
79+
# Use pkgutil to iterate subpackages defined by filesystem packages adjacent to this file.
80+
# pkgutil.walk_packages won't work directly without package loader, so we handle dirs.
81+
try:
82+
entries = sorted(os.listdir(THISDIR))
83+
except OSError:
84+
entries = []
85+
86+
for entry in entries:
87+
path = os.path.join(THISDIR, entry)
88+
# look for directories with __init__.py only
89+
if not os.path.isdir(path):
90+
continue
91+
init_py = os.path.join(path, "__init__.py")
92+
if not os.path.isfile(init_py):
93+
continue
94+
95+
module_name = f"{pkg_name}.{entry}"
96+
try:
97+
module = importlib.import_module(module_name)
98+
except Exception as exc: # defensive: log and skip bad packages
99+
LOGGER.exception("Failed to import schema package %s: %s", module_name, exc)
100+
continue
101+
102+
# allow module to explicitly provide a name
103+
schema_key = getattr(module, "name", entry)
104+
105+
# find first class that subclasses BaseOutputSchema
106+
found_cls = None
107+
for attr_name in dir(module):
108+
try:
109+
attr = getattr(module, attr_name)
110+
except Exception:
111+
continue
112+
if isinstance(attr, type) and issubclass(attr, BaseOutputSchema) and attr is not BaseOutputSchema:
113+
found_cls = attr
114+
break
115+
116+
if found_cls is None:
117+
LOGGER.warning("No BaseOutputSchema subclass found in %s; skipping", module_name)
118+
continue
119+
120+
if schema_key in _DISCOVERED_SCHEMAS:
121+
LOGGER.warning("Duplicate schema key '%s' (from %s). Keeping first discovered.", schema_key, module_name)
122+
continue
123+
124+
_DISCOVERED_SCHEMAS[schema_key] = found_cls
125+
LOGGER.info("Discovered schema '%s' -> %s.%s", schema_key, module_name, found_cls.__name__)
126+
127+
_DISCOVERY_DONE = True
128+
70129
def get_supported_schemas(details: bool = False,
71130
include_autodetect: bool = False) -> list:
72131
"""
73-
Get supported schemas
132+
Get supported schemas.
74133
75134
:param details: provide read/write details
76135
:param include_autodetect: include magic auto detection mode
77136
78-
:returns: list of supported schemas
137+
:returns: list of supported schemas (strings) or details matrix
79138
"""
139+
_discover_schemas()
80140

81141
def has_mode(plugin: BaseOutputSchema, mode: str) -> bool:
82142
enabled = False
@@ -90,36 +150,47 @@ def has_mode(plugin: BaseOutputSchema, mode: str) -> bool:
90150

91151
return enabled
92152

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

97155
if not details:
156+
schema_names = []
157+
for cls in _DISCOVERED_SCHEMAS.values():
158+
try:
159+
schema_names.append(cls().name)
160+
except Exception:
161+
continue
98162
if include_autodetect:
99-
schemas_keys = list(SCHEMAS.keys())
100-
schemas_keys.append('autodetect')
101-
return schemas_keys
102-
else:
103-
return SCHEMAS.keys()
163+
schema_names.append("autodetect")
164+
return schema_names
104165

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')
166+
schema_matrix = []
167+
for key, cls in _DISCOVERED_SCHEMAS.items():
168+
nm = key
169+
try:
170+
schema_inst = cls()
171+
nm = schema_inst.name
172+
can_read = has_mode(schema_inst, "import_")
173+
can_write = has_mode(schema_inst, "write")
174+
description = getattr(schema_inst, "description", "")
175+
except Exception:
176+
LOGGER.exception("Error instantiating schema class for key %s", key)
177+
can_read = False
178+
can_write = False
179+
description = ""
109180

110181
schema_matrix.append({
111-
'id': key,
112-
'description': schema.description,
113-
'read': can_read,
114-
'write': can_write
182+
"id": nm,
183+
"description": description,
184+
"read": can_read,
185+
"write": can_write
115186
})
116187

117188
if include_autodetect:
118189
schema_matrix.append({
119-
'id': 'autodetect',
120-
'description': 'Auto schema detection',
121-
'read': True,
122-
'write': False
190+
"id": "autodetect",
191+
"description": "Auto schema detection",
192+
"read": True,
193+
"write": False
123194
})
124195

125196
return schema_matrix
@@ -133,28 +204,25 @@ def load_schema(schema_name: str) -> BaseOutputSchema:
133204
134205
:returns: plugin object
135206
"""
207+
_discover_schemas()
208+
LOGGER.debug("Available schemas: %s", list(_DISCOVERED_SCHEMAS.keys()))
136209

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

139-
if schema_name not in SCHEMAS.keys():
140-
msg = f'Schema {schema_name} not found'
214+
if schema_name not in _DISCOVERED_SCHEMAS:
215+
msg = f"Schema {schema_name} not found"
141216
LOGGER.exception(msg)
142217
raise InvalidSchemaError(msg)
143218

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_()
219+
cls = _DISCOVERED_SCHEMAS[schema_name]
220+
try:
221+
return cls()
222+
except Exception as exc:
223+
msg = f"Failed to instantiate schema {schema_name}: {exc}"
224+
LOGGER.exception(msg)
225+
raise InvalidSchemaError(msg)
158226

159227

160228
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)

0 commit comments

Comments
 (0)