al-data-core 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ from al_data_core.config_processor import DataCoreConfig
2
+ from al_data_core.tasks_processor import TaskProcessor
3
+ from al_data_core.external_handler_base import ExternalHandlerBase, discover_handlers
4
+
5
+ __all__ = ['DataCoreConfig', 'TaskProcessor', 'ExternalHandlerBase', 'discover_handlers']
@@ -0,0 +1,4 @@
1
+ from al_data_core.dags_installer import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,34 @@
1
+ from al_data_utils import airflow_utils
2
+ from al_data_utils.logging_utils import info
3
+
4
+
5
+ def run_azure_as_refresh(azure_as_cfg: dict, model_cfg: dict) -> None:
6
+ """
7
+ Refresh a single Azure Analysis Services semantic model.
8
+
9
+ Parameters
10
+ ----------
11
+ azure_as_cfg : dict
12
+ Must contain ``connection_variable`` — the Airflow variable name that
13
+ holds a dict with keys: tenant_id, client_id, client_secret, rollout,
14
+ server_name.
15
+ model_cfg : dict
16
+ Must contain ``name`` (model name) and ``refresh_request`` (the XMLA
17
+ refresh payload dict passed to SemanticModels.refresh_models).
18
+ """
19
+ from al_data_utils.azure_as_utils import SemanticModels
20
+
21
+ conn = airflow_utils.get_variable_value_dict(azure_as_cfg['connection_variable'])
22
+
23
+ sm = SemanticModels(
24
+ tenant_id=conn['tenant_id'],
25
+ client_id=conn['client_id'],
26
+ client_secret=conn['client_secret'],
27
+ rollout=conn['rollout'],
28
+ server_name=conn['server_name'],
29
+ )
30
+
31
+ model_name = model_cfg['name']
32
+ info(f'Starting Azure AS refresh for model: {model_name}')
33
+ sm.refresh_models([(model_name, model_cfg['refresh_request'])])
34
+ info(f'Azure AS refresh completed for model: {model_name}')
@@ -0,0 +1,359 @@
1
+ from pathlib import Path
2
+ import os
3
+ import json
4
+
5
+ from al_data_utils import config_utils
6
+ from al_data_utils.logging_utils import info, warning
7
+ from al_data_utils import airflow_utils
8
+ from al_data_utils.sql_utils import UnifiedSQLClient, SQLDialect
9
+
10
+
11
+ class DataCoreConfig:
12
+ """
13
+ Loads project-specific data_core_config.yaml and manages ETL queue,
14
+ task execution parameters, and logging via the config DB.
15
+
16
+ Parameters
17
+ ----------
18
+ config_file_path : str
19
+ Absolute path to data_core_config.yaml.
20
+ sql_queries_path : str
21
+ Absolute path to the root of the sql_queries folder
22
+ (i.e. the folder that contains scope sub-folders such as ods_wms/, dw_crm/).
23
+ """
24
+
25
+ def __init__(self, config_file_path: str, sql_queries_path: str):
26
+ self.config = {}
27
+ self.etl_queue = {}
28
+ self.config_file_path = config_file_path
29
+ self.sql_queries_global_path = sql_queries_path
30
+
31
+ self.get_config_yaml()
32
+
33
+ config_connection_variable = self.config['CONFIG']['config_connection_variable']
34
+ self.conn_config = airflow_utils.get_variable_value_dict(config_connection_variable)
35
+
36
+ mappings = self.config['CONFIG']['mappings']
37
+ self.map_config_get_tables_list = mappings['config_get_tables_list']
38
+ self.map_source_config_parameters = mappings['config_parameters']
39
+ self.map_source_config_fn_get_param_values = mappings['config_fn_get_param_values']
40
+ self.map_source_config_calc_date_ranges = mappings['config_calc_date_ranges']
41
+ self.map_source_config_update_param_values = mappings['config_update_param_values']
42
+
43
+ self.conn = UnifiedSQLClient(dialect=SQLDialect.MSSQL, params=self.conn_config)
44
+ info(
45
+ f'DataCoreConfig initialized: config_file_path={self.config_file_path}, '
46
+ f'sql_queries_path={self.sql_queries_global_path}, '
47
+ f'config_connection_variable={config_connection_variable}'
48
+ )
49
+
50
+ # ------------------------------------------------------------------
51
+ # Config helpers
52
+ # ------------------------------------------------------------------
53
+
54
+ def get_config_yaml(self):
55
+ info(f'Loading config yaml from {self.config_file_path}')
56
+ self.config = config_utils.load_config(self.config_file_path)
57
+
58
+ # ------------------------------------------------------------------
59
+ # ETL queue
60
+ # ------------------------------------------------------------------
61
+
62
+ def get_etl_queue(self, scope_codes: list, param_id: int = -1) -> list[dict]:
63
+ """Return rows from the config DB that describe which tasks to run."""
64
+ where_condition = (
65
+ f' tbls.param_id = {param_id}'
66
+ if param_id != -1
67
+ else f""" tbls.scope_code IN ({', '.join(repr(code) for code in scope_codes)})
68
+ AND tbls.enabled = 1"""
69
+ )
70
+
71
+ sql_query = f"""
72
+ select param_id,
73
+ scope_code,
74
+ seq_code,
75
+ param_code,
76
+ enabled,
77
+ destination_table,
78
+ select_query,
79
+ param_value_1,
80
+ param_value_2
81
+ from {self.map_config_get_tables_list} as tbls
82
+ where
83
+ {where_condition}
84
+ order by seq_code, scope_code, param_code
85
+ """
86
+
87
+ result = self.conn.execute_command_with_result_to_dicts(sql_query)
88
+ info(
89
+ f'get_etl_queue(scope_codes={scope_codes}, param_id={param_id}) '
90
+ f'returned {len(result)} row(s)'
91
+ )
92
+ return result
93
+
94
+ def get_task_execution_params(self, param_id: int) -> list[dict]:
95
+ sql_query = f"""
96
+ select params.param_id as param_id,
97
+ rng.param1 as param_value_1,
98
+ rng.param2 as param_value_2
99
+ from {self.map_source_config_parameters} (nolock) as params
100
+ cross apply {self.map_source_config_fn_get_param_values}(params.scope_code,
101
+ params.param_code,
102
+ null, 0) as params_value
103
+ cross apply {self.map_source_config_calc_date_ranges}(params_value.param_value_1,
104
+ params_value.param_value_2,
105
+ params.date_range_limit) as rng
106
+ where params.param_id = {param_id}
107
+ order by rng.param1
108
+ """
109
+ result = self.conn.execute_command_with_result_to_dicts(sql_query)
110
+ info(f'get_task_execution_params(param_id={param_id}) returned {len(result)} row(s)')
111
+ return result
112
+
113
+ # ------------------------------------------------------------------
114
+ # SQL file helpers
115
+ # ------------------------------------------------------------------
116
+
117
+ def get_file_content(self, scope_code: str, param_code: str) -> str:
118
+ """Read the .sql file for a given scope/param from the sql_queries folder."""
119
+ file_path = os.path.join(
120
+ self.sql_queries_global_path, scope_code.lower(), f'{param_code}.sql'
121
+ )
122
+
123
+ if os.path.exists(file_path):
124
+ info(f'Loading SQL file: {file_path}')
125
+ with open(file_path, 'r', encoding='utf-8') as f:
126
+ return f.read()
127
+
128
+ # fallback: try exact case
129
+ file_path = os.path.join(
130
+ self.sql_queries_global_path, scope_code, f'{param_code}.sql'
131
+ )
132
+ info(f'Lowercased scope path not found, falling back to exact case: {file_path}')
133
+ with open(file_path, 'r', encoding='utf-8') as f:
134
+ return f.read()
135
+
136
+ def get_task_detail_from_config(self, scope_code: str, param_code: str) -> dict:
137
+ info(f'Getting task detail for scope_code={scope_code}, param_code={param_code}')
138
+ self.get_config_yaml()
139
+ task_details = dict(self.config.get(scope_code, {}))
140
+ task_details['command'] = self.get_file_content(scope_code, param_code)
141
+ return task_details
142
+
143
+ def get_scope_config(self, scope_code: str) -> dict:
144
+ """
145
+ Return the top-level YAML section for scope_code (e.g. enabled,
146
+ handler, source_type), without loading a .sql command file. Used
147
+ for dispatch decisions that apply before a SQL file is relevant,
148
+ such as TaskProcessor checking for a handler folder.
149
+ """
150
+ self.get_config_yaml()
151
+ return dict(self.config.get(scope_code, {}))
152
+
153
+ def get_source_connection_variable(self, scope_code: str) -> str:
154
+ return self.config[scope_code]['source_connection_variable']
155
+
156
+ def get_destination_connection_variable(self, scope_code: str) -> str:
157
+ return self.config[scope_code]['destination_connection_variable']
158
+
159
+ def get_sources_config(self):
160
+ """
161
+ Populate self.etl_queue for every enabled source in the YAML that
162
+ declares a sql_source_folder. Used by projects that scan all sources
163
+ at once rather than driving tasks from the config DB queue.
164
+ """
165
+ self.get_config_yaml()
166
+ for k, v in self.config.items():
167
+ if not v.get('enabled', ''):
168
+ info(f'Source {k} is not enabled, skipping')
169
+ continue
170
+ self.etl_queue[k] = {}
171
+ if 'sql_source_folder' in v:
172
+ self.etl_queue[k]['source_connection_variable'] = v.get(
173
+ 'source_connection_variable', ''
174
+ )
175
+ self.etl_queue[k]['destination_connection_variable'] = v.get(
176
+ 'destination_connection_variable', ''
177
+ )
178
+ self.scan_for_queries(v['sql_source_folder'], source_name=k)
179
+ else:
180
+ info(f'Source {k} has no sql_source_folder configured, skipping query scan')
181
+
182
+ def scan_for_queries(
183
+ self,
184
+ sql_source_folder: str,
185
+ source_name: str,
186
+ include_subfolders: bool = True,
187
+ root_key: str = '',
188
+ ):
189
+ """
190
+ Walk sql_queries_global_path/sql_source_folder and load all .sql files
191
+ (excluding files prefixed with _disabled) into self.etl_queue[source_name]['queries'].
192
+ """
193
+ sql_path = os.path.join(self.sql_queries_global_path, sql_source_folder)
194
+ base = Path(sql_path)
195
+ pattern = '**/*.sql' if include_subfolders else '*.sql'
196
+
197
+ self.etl_queue.setdefault(source_name, {}).setdefault('queries', {})
198
+
199
+ loaded, skipped = 0, 0
200
+ for p in sorted(base.glob(pattern)):
201
+ if not p.is_file():
202
+ continue
203
+ name = p.stem
204
+ if not name.startswith('_disabled'):
205
+ with p.open('r', encoding='utf-8') as f:
206
+ self.etl_queue[source_name]['queries'][name] = f.read()
207
+ loaded += 1
208
+ else:
209
+ skipped += 1
210
+
211
+ info(
212
+ f'scan_for_queries({sql_path}) for source_name={source_name}: '
213
+ f'loaded {loaded} query file(s), skipped {skipped} disabled file(s)'
214
+ )
215
+
216
+ # ------------------------------------------------------------------
217
+ # Tasks config file (pre-generation for DAG structure)
218
+ # ------------------------------------------------------------------
219
+
220
+ def run_generate_dag_tasks_config_file(self, destination_config_file: str):
221
+ """
222
+ Query the config DB and write a YAML file describing all DAG tasks.
223
+ Call this once (e.g. via a setup task or CLI) before the DAG runs,
224
+ so the DAG file can build its task graph without hitting the DB at
225
+ Airflow scheduler parse time.
226
+ """
227
+ dags_list = self.config.get('DAGS', {})
228
+ config_dict = {}
229
+
230
+ if not dags_list:
231
+ warning(f'No DAGS section found in {self.config_file_path}, nothing to generate')
232
+
233
+ info(f'Generating tasks config for {len(dags_list)} DAG(s): {list(dags_list.keys())}')
234
+
235
+ for dag_name, dag_details in dags_list.items():
236
+ if not dag_details:
237
+ warning(
238
+ f'DAG {dag_name}: empty configuration under DAGS in '
239
+ f'{self.config_file_path} (check YAML indentation/content under this key), '
240
+ 'skipping'
241
+ )
242
+ continue
243
+
244
+ scope_codes = dag_details.get('scopes', [])
245
+ schedule_interval = dag_details.get('schedule_interval', '')
246
+ tags = dag_details.get('tags', [])
247
+
248
+ if not scope_codes:
249
+ warning(f'DAG {dag_name}: no "scopes" defined, this DAG will never get any tasks')
250
+
251
+ unknown_scopes = [code for code in scope_codes if code not in self.config]
252
+ if unknown_scopes:
253
+ warning(
254
+ f'DAG {dag_name}: scope_codes {unknown_scopes} have no matching top-level '
255
+ f'section in {self.config_file_path} — check for typos'
256
+ )
257
+
258
+ if not schedule_interval:
259
+ warning(f'DAG {dag_name}: no "schedule_interval" defined')
260
+
261
+ dag_node = config_dict.setdefault(dag_name, {})
262
+ azure_as = dag_details.get('azure_as', None)
263
+ dag_node['params'] = {'schedule_interval': schedule_interval, 'tags': tags, 'azure_as': azure_as}
264
+ dag_tasks = dag_node.setdefault('tasks', {})
265
+
266
+ info(
267
+ f'DAG {dag_name}: scope_codes={scope_codes}, '
268
+ f'schedule_interval={schedule_interval}, tags={tags}, azure_as={azure_as}'
269
+ )
270
+
271
+ task_count = 0
272
+ for t in self.get_etl_queue(scope_codes=scope_codes):
273
+ seq_code = t['seq_code']
274
+ scope_code = t['scope_code']
275
+ param_code = t['param_code']
276
+ param_id = t['param_id']
277
+
278
+ task_name = f'{scope_code}__{param_code}'
279
+ record = {'param_code': param_code, 'param_id': param_id}
280
+
281
+ dag_tasks.setdefault(seq_code, {}).setdefault(scope_code, {})[
282
+ task_name
283
+ ] = record
284
+ task_count += 1
285
+
286
+ info(f'DAG {dag_name}: added {task_count} task(s)')
287
+ if task_count == 0:
288
+ warning(
289
+ f'DAG {dag_name}: no tasks found for scope_codes={scope_codes} — '
290
+ 'check that the config DB has enabled rows for these scopes'
291
+ )
292
+
293
+ import yaml as _yaml
294
+ os.makedirs(os.path.dirname(destination_config_file), exist_ok=True)
295
+ with open(destination_config_file, 'w', encoding='utf-8') as f:
296
+ _yaml.dump(config_dict, f, default_flow_style=False, allow_unicode=True)
297
+
298
+ print(f'Tasks config written to: {destination_config_file}')
299
+ info(
300
+ f'Generated DAG tasks config file:\n\n{_yaml.dump(config_dict, default_flow_style=False, allow_unicode=True)}\n'
301
+ )
302
+
303
+ # ------------------------------------------------------------------
304
+ # Logging
305
+ # ------------------------------------------------------------------
306
+
307
+ def log_insert_record(self, scope_code: str, event_name: str) -> int:
308
+ sql_query = f"""declare @log_id int;
309
+ exec log.upsert_events @operation = 'I', @scope_code = '{scope_code}', @event_name = '{event_name}', @log_id = @log_id Output;
310
+ select @log_id as log_id;
311
+ """
312
+
313
+ result = self.conn.execute_command_with_result_to_dicts(sql_query)
314
+ info(
315
+ f'Log record created for scope_code={scope_code}, event_name={event_name}, '
316
+ f'log_id={result[0].get("log_id", 0) if result else -1}'
317
+ )
318
+
319
+ if result:
320
+ return int(result[0].get('log_id', 0))
321
+ return -1
322
+
323
+ def log_update_record(self, results: dict, param_id: int = -1) -> None:
324
+ sql_query = f"""
325
+ exec log.upsert_events
326
+ @operation = 'U',
327
+ @log_id = {results.get('log_id', 0)},
328
+ @inserted = {results.get('ins_rc', 0)},
329
+ @updated = {results.get('upd_rc', 0)},
330
+ @deleted = {results.get('del_rc', 0)},
331
+ @event_desc = '{results.get('event_desc', '')}',
332
+ @error_code = {results.get('error_code', 0)},
333
+ @error_message = '{results.get('error_message', '')}';
334
+ """
335
+
336
+ self.conn.execute_command(sql_query)
337
+ info(f'Log updated for log_id={results.get("log_id", 0)}')
338
+
339
+ max_param_value = results.get('max_param_value', None)
340
+ if max_param_value is not None and param_id != -1:
341
+ sql_update_param = f"""
342
+ declare @error_code int;
343
+ declare @error_message nvarchar(4000);
344
+ declare @upd_date_to datetime = null;
345
+ declare @upd_date_fr datetime = null;
346
+ declare @result int;
347
+
348
+ exec @result = {self.map_source_config_update_param_values}
349
+ {param_id},
350
+ @upd_date_to,
351
+ @upd_date_fr,
352
+ '{max_param_value}',
353
+ @error_code output,
354
+ @error_message output;
355
+ """
356
+ self.conn.execute_command(sql_update_param)
357
+ info(
358
+ f'Parameter last_param_value updated for param_id={param_id} to {max_param_value}'
359
+ )
@@ -0,0 +1,131 @@
1
+ """
2
+ al-data-core DAGs installer CLI
3
+
4
+ Usage:
5
+ al-data-core copy-dags <target_dir> [--version <ver>] [--force]
6
+ al-data-core list-versions
7
+ python -m al_data_core copy-dags <target_dir> [--version <ver>]
8
+
9
+ Commands:
10
+ copy-dags Copy data_core_dag.py and data_core_config_engine_dag.py
11
+ to the specified Airflow dags folder.
12
+ Existing files are overwritten if contents differ.
13
+ Use --force to overwrite regardless of content.
14
+ list-versions List all available template versions.
15
+ """
16
+
17
+ import argparse
18
+ import filecmp
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+
23
+ def _templates_dir() -> Path:
24
+ return Path(__file__).parent / 'templates'
25
+
26
+
27
+ def _version_key(name: str) -> tuple:
28
+ stripped = name.lstrip('v')
29
+ try:
30
+ return tuple(int(x) for x in stripped.split('.'))
31
+ except ValueError:
32
+ return (0, 0, 0)
33
+
34
+
35
+ def _available_versions() -> list[str]:
36
+ base = _templates_dir()
37
+ versions = [p.name for p in base.iterdir() if p.is_dir()]
38
+ return sorted(versions, key=_version_key)
39
+
40
+
41
+ def _latest_version() -> str:
42
+ versions = _available_versions()
43
+ if not versions:
44
+ raise RuntimeError('No template versions found in the package.')
45
+ return versions[-1]
46
+
47
+
48
+ def _copy_dags(target: str, version: str | None, force: bool = False) -> None:
49
+ ver = version or _latest_version()
50
+ src_dir = _templates_dir() / ver
51
+
52
+ if not src_dir.exists():
53
+ available = ', '.join(_available_versions()) or 'none'
54
+ raise SystemExit(
55
+ f"Version '{ver}' not found. Available versions: {available}"
56
+ )
57
+
58
+ dest_dir = Path(target)
59
+ dest_dir.mkdir(parents=True, exist_ok=True)
60
+
61
+ print(f'Copying templates version {ver} -> {dest_dir}\n')
62
+ copied = updated = skipped = 0
63
+ for src in sorted(src_dir.glob('*.py')):
64
+ dest = dest_dir / src.name
65
+ if dest.exists() and not force:
66
+ if filecmp.cmp(src, dest, shallow=False):
67
+ print(f' skip {src.name} (identical)')
68
+ skipped += 1
69
+ else:
70
+ shutil.copy2(src, dest)
71
+ print(f' update {src.name} -> {dest}')
72
+ updated += 1
73
+ else:
74
+ shutil.copy2(src, dest)
75
+ print(f' copy {src.name} -> {dest}')
76
+ copied += 1
77
+
78
+ print(f'\nDone. {copied} copied, {updated} updated, {skipped} skipped.')
79
+
80
+
81
+ def _list_versions() -> None:
82
+ versions = _available_versions()
83
+ if not versions:
84
+ print('No template versions found.')
85
+ return
86
+ latest = versions[-1]
87
+ for v in versions:
88
+ marker = ' (latest)' if v == latest else ''
89
+ print(f' {v}{marker}')
90
+
91
+
92
+ def main() -> None:
93
+ parser = argparse.ArgumentParser(
94
+ prog='al-data-core',
95
+ description='al-data-core DAGs installer',
96
+ )
97
+ sub = parser.add_subparsers(dest='command', required=True)
98
+
99
+ copy_cmd = sub.add_parser(
100
+ 'copy-dags',
101
+ help='Copy template DAG files to the Airflow dags folder',
102
+ )
103
+ copy_cmd.add_argument(
104
+ 'target',
105
+ nargs='?',
106
+ default='/opt/airflow/dags',
107
+ help='Destination directory (default: /opt/airflow/dags)',
108
+ )
109
+ copy_cmd.add_argument(
110
+ '--version',
111
+ default=None,
112
+ help='Template version to copy (default: latest)',
113
+ )
114
+ copy_cmd.add_argument(
115
+ '--force',
116
+ action='store_true',
117
+ help='Overwrite existing files even if contents are identical',
118
+ )
119
+
120
+ sub.add_parser('list-versions', help='List available template versions')
121
+
122
+ args = parser.parse_args()
123
+
124
+ if args.command == 'copy-dags':
125
+ _copy_dags(args.target, args.version, args.force)
126
+ elif args.command == 'list-versions':
127
+ _list_versions()
128
+
129
+
130
+ if __name__ == '__main__':
131
+ main()
@@ -0,0 +1,97 @@
1
+ import importlib.util
2
+ import inspect
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class HandlerContext:
10
+ """Everything a handler needs, passed in by the engine at run time.
11
+
12
+ Handlers must not hardcode connection names, schemas, paths, etc. — read
13
+ them from ``self.context`` (populated by TaskProcessor before ``proceed()``).
14
+ """
15
+
16
+ scope_code: str = ''
17
+ param_code: str = ''
18
+ scope_config: dict = field(default_factory=dict)
19
+ config_file_path: str = ''
20
+ params_details: dict = field(default_factory=dict)
21
+ debug: bool = False
22
+
23
+
24
+ class ExternalHandlerBase(ABC):
25
+ """
26
+ Base class for project-specific task handlers (any scope whose logic is
27
+ just "run this project-supplied code").
28
+
29
+ Subclass this in your project, in the folder named by the scope's
30
+ ``handler`` value in data_core_config.yaml (e.g.
31
+ ``handler: data_core_handler_api/britix24``). Two layouts are supported:
32
+
33
+ * one file per param_code (file name == param_code) — one task, one file;
34
+ * a single generic handler serving every param_code in the scope — when the
35
+ folder holds exactly one handler file, it is used for all of the scope's
36
+ tasks and distinguishes them via ``self.context.param_code``.
37
+
38
+ Before calling ``proceed()``, the engine constructs the handler with no
39
+ arguments and sets ``self.context`` (a HandlerContext). Read run-time
40
+ settings from there rather than hardcoding them::
41
+
42
+ class BitrixIngestHandler(ExternalHandlerBase):
43
+ def proceed(self) -> None:
44
+ scope = self.context.scope_config
45
+ entity = scope['entities'][self.context.param_code]
46
+ ...
47
+
48
+ discover_handlers() picks the class up automatically — no manual
49
+ registration. The constructor must work with no arguments.
50
+ """
51
+
52
+ # Set by TaskProcessor after construction, before proceed().
53
+ context: 'HandlerContext | None' = None
54
+
55
+ @abstractmethod
56
+ def proceed(self) -> None: ...
57
+
58
+
59
+ def discover_handlers(folder_path: str) -> dict[str, type[ExternalHandlerBase]]:
60
+ """
61
+ Scan folder_path for `<param_code>.py` modules and return
62
+ {param_code: HandlerClass} for use as TaskProcessor's handler_map.
63
+
64
+ Each module must define exactly one ExternalHandlerBase subclass. Files
65
+ whose name starts with '_' (e.g. __init__.py) are skipped. Missing
66
+ folder_path is not an error — handlers are optional per project.
67
+ """
68
+ handler_map: dict[str, type[ExternalHandlerBase]] = {}
69
+ folder = Path(folder_path)
70
+ if not folder.is_dir():
71
+ return handler_map
72
+
73
+ for file_path in sorted(folder.glob('*.py')):
74
+ if file_path.stem.startswith('_'):
75
+ continue
76
+
77
+ spec = importlib.util.spec_from_file_location(
78
+ f'al_data_core._external_handlers.{file_path.stem}', file_path
79
+ )
80
+ module = importlib.util.module_from_spec(spec)
81
+ spec.loader.exec_module(module)
82
+
83
+ handlers = [
84
+ obj
85
+ for _, obj in inspect.getmembers(module, inspect.isclass)
86
+ if issubclass(obj, ExternalHandlerBase) and obj is not ExternalHandlerBase
87
+ ]
88
+
89
+ if len(handlers) != 1:
90
+ raise ValueError(
91
+ f'{file_path} must define exactly one ExternalHandlerBase subclass, '
92
+ f'found {len(handlers)}'
93
+ )
94
+
95
+ handler_map[file_path.stem] = handlers[0]
96
+
97
+ return handler_map