pylantir 0.2.3__py3-none-any.whl → 0.3.1__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.
pylantir/api_server.py CHANGED
@@ -21,7 +21,8 @@ from typing import List, Optional, Dict, Any
21
21
  from datetime import datetime, timedelta
22
22
 
23
23
  try:
24
- from fastapi import FastAPI, HTTPException, Depends, status, Query
24
+ from fastapi import FastAPI, HTTPException, Depends, Query
25
+ from fastapi import status as http_status
25
26
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
26
27
  from fastapi.middleware.cors import CORSMiddleware
27
28
  from pydantic import BaseModel, validator
@@ -144,6 +145,7 @@ class WorklistItemResponse(BaseModel):
144
145
  protocol_name: Optional[str]
145
146
  station_name: Optional[str]
146
147
  performed_procedure_step_status: Optional[str]
148
+ data_source: Optional[str] = None
147
149
 
148
150
  class Config:
149
151
  from_attributes = True
@@ -169,6 +171,7 @@ class WorklistItemCreate(BaseModel):
169
171
  protocol_name: Optional[str] = None
170
172
  station_name: Optional[str] = None
171
173
  performed_procedure_step_status: str = "SCHEDULED"
174
+ data_source: Optional[str] = None
172
175
 
173
176
  @validator('performed_procedure_step_status')
174
177
  def validate_status(cls, v):
@@ -195,6 +198,7 @@ class WorklistItemUpdate(BaseModel):
195
198
  protocol_name: Optional[str] = None
196
199
  station_name: Optional[str] = None
197
200
  performed_procedure_step_status: Optional[str] = None
201
+ data_source: Optional[str] = None
198
202
 
199
203
  @validator('performed_procedure_step_status')
200
204
  def validate_status(cls, v):
@@ -296,7 +300,7 @@ async def get_current_user(
296
300
 
297
301
  if payload is None:
298
302
  raise HTTPException(
299
- status_code=status.HTTP_401_UNAUTHORIZED,
303
+ status_code=http_status.HTTP_401_UNAUTHORIZED,
300
304
  detail="Invalid authentication token",
301
305
  headers={"WWW-Authenticate": "Bearer"},
302
306
  )
@@ -304,7 +308,7 @@ async def get_current_user(
304
308
  username = payload.get("sub")
305
309
  if username is None:
306
310
  raise HTTPException(
307
- status_code=status.HTTP_401_UNAUTHORIZED,
311
+ status_code=http_status.HTTP_401_UNAUTHORIZED,
308
312
  detail="Invalid token payload",
309
313
  headers={"WWW-Authenticate": "Bearer"},
310
314
  )
@@ -312,7 +316,7 @@ async def get_current_user(
312
316
  user = auth_db.query(User).filter(User.username == username).first()
313
317
  if user is None or not user.is_active:
314
318
  raise HTTPException(
315
- status_code=status.HTTP_401_UNAUTHORIZED,
319
+ status_code=http_status.HTTP_401_UNAUTHORIZED,
316
320
  detail="User not found or inactive",
317
321
  headers={"WWW-Authenticate": "Bearer"},
318
322
  )
@@ -324,7 +328,7 @@ async def get_current_user(
324
328
  except Exception as e:
325
329
  lgr.error(f"Authentication error: {e}")
326
330
  raise HTTPException(
327
- status_code=status.HTTP_401_UNAUTHORIZED,
331
+ status_code=http_status.HTTP_401_UNAUTHORIZED,
328
332
  detail="Authentication failed",
329
333
  headers={"WWW-Authenticate": "Bearer"},
330
334
  )
@@ -344,7 +348,7 @@ def require_permission(action: str, resource: str = "worklist"):
344
348
  def permission_checker(current_user: User = Depends(get_current_user)) -> User:
345
349
  if not current_user.has_permission(action, resource):
346
350
  raise HTTPException(
347
- status_code=status.HTTP_403_FORBIDDEN,
351
+ status_code=http_status.HTTP_403_FORBIDDEN,
348
352
  detail=f"Insufficient permissions for {action} on {resource}"
349
353
  )
350
354
  return current_user
@@ -364,7 +368,7 @@ async def login(
364
368
 
365
369
  if not user:
366
370
  raise HTTPException(
367
- status_code=status.HTTP_401_UNAUTHORIZED,
371
+ status_code=http_status.HTTP_401_UNAUTHORIZED,
368
372
  detail="Invalid username or password",
369
373
  headers={"WWW-Authenticate": "Bearer"},
370
374
  )
@@ -390,7 +394,7 @@ async def login(
390
394
  except Exception as e:
391
395
  lgr.error(f"Login error: {e}")
392
396
  raise HTTPException(
393
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
397
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
394
398
  detail="Login failed"
395
399
  )
396
400
 
@@ -437,7 +441,7 @@ async def get_worklist_items(
437
441
  except Exception as e:
438
442
  lgr.error(f"Error retrieving worklist items: {e}")
439
443
  raise HTTPException(
440
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
444
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
441
445
  detail="Failed to retrieve worklist items"
442
446
  )
443
447
 
pylantir/cli/run.py CHANGED
@@ -160,6 +160,7 @@ def parse_args():
160
160
  def load_config(config_path=None):
161
161
  """
162
162
  Load configuration file, either from a user-provided path or the default package location.
163
+ Auto-converts legacy configuration format to new data_sources format.
163
164
 
164
165
  Args:
165
166
  config_path (str | Path, optional): Path to the configuration JSON file.
@@ -176,6 +177,35 @@ def load_config(config_path=None):
176
177
  with config_path.open("r") as f:
177
178
  config_data = json.load(f)
178
179
  lgr.info(f"Loaded configuration from {config_path}")
180
+
181
+ # Auto-convert legacy configuration format
182
+ if "data_sources" not in config_data and "redcap2wl" in config_data:
183
+ lgr.warning(
184
+ "Legacy configuration format detected. "
185
+ "Consider migrating to 'data_sources' format for better flexibility. "
186
+ "See config/mwl_config_multi_source_example.json for reference."
187
+ )
188
+
189
+ # Convert legacy format to data_sources array
190
+ legacy_source = {
191
+ "name": "redcap_legacy",
192
+ "type": "redcap",
193
+ "enabled": True,
194
+ "sync_interval": config_data.get("db_update_interval", 60),
195
+ "operation_interval": config_data.get(
196
+ "operation_interval",
197
+ {"start_time": [0, 0], "end_time": [23, 59]}
198
+ ),
199
+ "config": {
200
+ "site_id": config_data.get("site"),
201
+ "protocol": config_data.get("protocol", {}),
202
+ },
203
+ "field_mapping": config_data.get("redcap2wl", {})
204
+ }
205
+
206
+ config_data["data_sources"] = [legacy_source]
207
+ lgr.info("Auto-converted legacy configuration to data_sources format")
208
+
179
209
  return config_data
180
210
 
181
211
  except FileNotFoundError:
@@ -285,52 +315,286 @@ def main() -> None:
285
315
  # Load configuration into environment variables
286
316
  update_env_with_config(config)
287
317
 
288
-
289
318
  from ..mwl_server import run_mwl_server
290
- from ..redcap_to_db import sync_redcap_to_db_repeatedly
291
-
292
- # Extract the database update interval (default to 60 seconds if missing)
293
- db_update_interval = config.get("db_update_interval", 60)
294
-
295
- # Extract the operation interval (default from 00:00 to 23:59 hours if missing)
296
- operation_interval = config.get("operation_interval", {"start_time": [0,0], "end_time": [23,59]})
297
319
 
298
320
  # Extract allowed AE Titles (default to empty list if missing)
299
321
  allowed_aet = config.get("allowed_aet", [])
300
322
 
301
- # Extract the site id
302
- site = config.get("site", None)
303
-
304
- # Extract the redcap to worklist mapping
305
- redcap2wl = config.get("redcap2wl", {})
323
+ # Check if using new data_sources format or legacy format
324
+ if "data_sources" in config:
325
+ # NEW: Multi-source orchestration using plugin architecture
326
+ lgr.info("Using new data_sources configuration format")
306
327
 
307
- # EXtract protocol mapping
308
- protocol = config.get("protocol", {})
328
+ from ..data_sources import get_plugin
329
+ from ..data_sources.base import PluginError
330
+ from ..redcap_to_db import STOP_EVENT
331
+ import threading
309
332
 
310
- # Create and update the MWL database
311
- with ThreadPoolExecutor(max_workers=2) as executor:
312
- future = executor.submit(
313
- sync_redcap_to_db_repeatedly,
314
- site_id=site,
315
- protocol=protocol,
316
- redcap2wl=redcap2wl,
317
- interval=db_update_interval,
318
- operation_interval=operation_interval,
319
- )
333
+ data_sources = config.get("data_sources", [])
334
+ enabled_sources = [src for src in data_sources if src.get("enabled", True)]
320
335
 
321
- # sync_redcap_to_db(
322
- # mri_visit_mapping=mri_visit_session_mapping,
323
- # site_id=site,
324
- # protocol=protocol,
325
- # redcap2wl=redcap2wl,
326
- # )
327
-
328
- run_mwl_server(
329
- host=args.ip,
330
- port=args.port,
331
- aetitle=args.AEtitle,
332
- allowed_aets=allowed_aet,
333
- )
336
+ if not enabled_sources:
337
+ lgr.warning("No enabled data sources found in configuration")
338
+ else:
339
+ lgr.info(f"Found {len(enabled_sources)} enabled data source(s)")
340
+
341
+ def sync_data_source_repeatedly(source_config):
342
+ """
343
+ Generic sync loop for any data source plugin.
344
+
345
+ This function works with any plugin type (REDCap, CSV, API, etc.)
346
+ by using the plugin interface rather than source-specific code.
347
+ """
348
+ source_name = source_config.get("name", "unknown")
349
+ source_type = source_config.get("type", "unknown")
350
+
351
+ try:
352
+ # Get and instantiate the plugin
353
+ PluginClass = get_plugin(source_type)
354
+ lgr.info(f"[{source_name}] Initializing {source_type} plugin")
355
+
356
+ plugin = PluginClass()
357
+
358
+ # Validate plugin configuration
359
+ plugin_config = dict(source_config.get("config", {}))
360
+ if "field_mapping" in source_config:
361
+ plugin_config["field_mapping"] = source_config.get("field_mapping")
362
+ if "window_mode" in source_config:
363
+ plugin_config["window_mode"] = source_config.get("window_mode")
364
+ if "daily_window" in source_config:
365
+ plugin_config["daily_window"] = source_config.get("daily_window")
366
+
367
+ is_valid, error_msg = plugin.validate_config(plugin_config)
368
+ if not is_valid:
369
+ lgr.error(f"[{source_name}] Configuration validation failed: {error_msg}")
370
+ return
371
+
372
+ # Extract sync settings
373
+ sync_interval = source_config.get("sync_interval", 60)
374
+ operation_interval = source_config.get("operation_interval", {
375
+ "start_time": [0, 0],
376
+ "end_time": [23, 59]
377
+ })
378
+
379
+ lgr.info(f"[{source_name}] Starting sync loop (interval: {sync_interval}s)")
380
+
381
+ # Import database and sync utilities
382
+ from ..db_setup import Session
383
+ from ..models import WorklistItem
384
+ from ..redcap_to_db import generate_instance_uid, cleanup_memory_and_connections
385
+ from datetime import datetime, time as dt_time, timedelta
386
+ import logging
387
+
388
+ # Parse operation interval
389
+ start_h, start_m = operation_interval.get("start_time", [0, 0])
390
+ end_h, end_m = operation_interval.get("end_time", [23, 59])
391
+ start_time = dt_time(start_h, start_m)
392
+ end_time = dt_time(end_h, end_m)
393
+
394
+ last_sync_date = datetime.now().date() - timedelta(days=1)
395
+ interval_sync = sync_interval + 300 # Overlap to avoid missing data
396
+
397
+ # Sync loop
398
+ while not STOP_EVENT.is_set():
399
+ is_first_run = False
400
+ extended_interval = sync_interval
401
+
402
+ now_dt = datetime.now().replace(second=0, microsecond=0)
403
+ now_time = now_dt.time()
404
+ today_date = now_dt.date()
405
+
406
+ # Only sync within operation interval
407
+ if start_time <= now_time <= end_time:
408
+ is_first_run = (last_sync_date != today_date)
409
+
410
+ if is_first_run and (last_sync_date is not None):
411
+ yesterday = last_sync_date
412
+ dt_end_yesterday = datetime.combine(yesterday, end_time)
413
+ dt_start_today = datetime.combine(today_date, start_time)
414
+ delta = dt_start_today - dt_end_yesterday
415
+ extended_interval = delta.total_seconds()
416
+ # temporary increase interval to cover gap since last sync
417
+ # extended_interval += 6000000
418
+ logging.info(f"[{source_name}] First sync of the day at {now_time}")
419
+
420
+ # Fetch entries using plugin
421
+ try:
422
+ fetch_interval = extended_interval if is_first_run else interval_sync
423
+ field_mapping = source_config.get("field_mapping", {})
424
+
425
+ lgr.debug(f"[{source_name}] Fetching entries (interval: {fetch_interval}s)")
426
+ entries = plugin.fetch_entries(
427
+ field_mapping=field_mapping,
428
+ interval=fetch_interval
429
+ )
430
+
431
+ if entries:
432
+ lgr.info(f"[{source_name}] Fetched {len(entries)} entries")
433
+
434
+ # Get source-specific config
435
+ site_id = source_config.get("config", {}).get("site_id")
436
+ protocol = config.get("protocol", {})
437
+
438
+ # Process entries (source-agnostic)
439
+ session = Session()
440
+ try:
441
+ def _format_date(value):
442
+ if value is None:
443
+ return None
444
+ if hasattr(value, "strftime"):
445
+ return value.strftime("%Y-%m-%d")
446
+ value_str = str(value).strip()
447
+ if "-" in value_str and len(value_str) >= 10:
448
+ return value_str[:10]
449
+ if len(value_str) == 8 and value_str.isdigit():
450
+ return f"{value_str[0:4]}-{value_str[4:6]}-{value_str[6:8]}"
451
+ return value_str
452
+
453
+ def _format_time(value):
454
+ if value is None:
455
+ return None
456
+ if hasattr(value, "strftime"):
457
+ return value.strftime("%H:%M")
458
+ value_str = str(value).strip()
459
+ if ":" in value_str:
460
+ parts = value_str.split(":")
461
+ if len(parts) >= 2:
462
+ hh = parts[0].zfill(2)
463
+ mm = parts[1].zfill(2)
464
+ return f"{hh}:{mm}"
465
+ if len(value_str) == 6 and value_str.isdigit():
466
+ return f"{value_str[0:2]}:{value_str[2:4]}"
467
+ if len(value_str) == 4 and value_str.isdigit():
468
+ return f"{value_str[0:2]}:{value_str[2:4]}"
469
+ return value_str
470
+
471
+ for record in entries:
472
+ patient_id = record.get("patient_id")
473
+ lgr.info(f"[{source_name}] Processing record for patient_id: {patient_id}")
474
+ if not patient_id:
475
+ lgr.info(f"[{source_name}] Skipping record with missing patient_id")
476
+ continue
477
+
478
+ existing_entry = session.query(WorklistItem).filter_by(patient_id=patient_id).first()
479
+
480
+ scheduled_start_date = _format_date(record.get("scheduled_start_date"))
481
+ scheduled_start_time = _format_time(record.get("scheduled_start_time"))
482
+
483
+ if existing_entry:
484
+ existing_entry.data_source = record.get("data_source") or source_name
485
+ existing_entry.scheduled_start_date = scheduled_start_date
486
+ existing_entry.scheduled_start_time = scheduled_start_time
487
+ else:
488
+ new_entry = WorklistItem(
489
+ study_instance_uid=record.get("study_instance_uid") or generate_instance_uid(),
490
+ patient_name=record.get("patient_name"),
491
+ patient_id=patient_id,
492
+ patient_birth_date=record.get("patient_birth_date"),
493
+ patient_sex=record.get("patient_sex"),
494
+ patient_weight_lb=record.get("patient_weight_lb"),
495
+ accession_number=record.get("accession_number"),
496
+ referring_physician_name=record.get("referring_physician_name"),
497
+ modality=record.get("modality", "MR"),
498
+ study_description=record.get("study_description"),
499
+ scheduled_station_aetitle=record.get("scheduled_station_aetitle"),
500
+ scheduled_start_date=scheduled_start_date,
501
+ scheduled_start_time=scheduled_start_time,
502
+ performing_physician=record.get("performing_physician"),
503
+ procedure_description=record.get("procedure_description"),
504
+ protocol_name=record.get("protocol_name") or protocol.get(site_id, "DEFAULT_PROTOCOL"),
505
+ station_name=record.get("station_name"),
506
+ hisris_coding_designator=record.get("hisris_coding_designator"),
507
+ performed_procedure_step_status=record.get(
508
+ "performed_procedure_step_status"
509
+ ) or "SCHEDULED",
510
+ data_source=record.get("data_source") or source_name
511
+ )
512
+ session.add(new_entry)
513
+
514
+ session.commit()
515
+ lgr.info(f"[{source_name}] Sync completed successfully")
516
+ except Exception as e:
517
+ session.rollback()
518
+ lgr.error(f"[{source_name}] Database error: {e}")
519
+ finally:
520
+ session.expunge_all()
521
+ session.close()
522
+ cleanup_memory_and_connections()
523
+
524
+ last_sync_date = today_date
525
+
526
+ except Exception as e:
527
+ lgr.error(f"[{source_name}] Sync error: {e}")
528
+ import traceback
529
+ traceback.print_exc()
530
+
531
+ # Wait before next iteration
532
+ STOP_EVENT.wait(sync_interval)
533
+
534
+ lgr.info(f"[{source_name}] Exiting sync loop (STOP_EVENT set)")
535
+
536
+ except PluginError as e:
537
+ lgr.error(f"[{source_name}] Plugin error: {e}")
538
+ except Exception as e:
539
+ lgr.error(f"[{source_name}] Unexpected error: {e}")
540
+ import traceback
541
+ traceback.print_exc()
542
+
543
+ # Start a thread for each enabled data source
544
+ max_workers = len(enabled_sources) + 1 # +1 for MWL server
545
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
546
+ # Submit sync tasks for each data source
547
+ for source in enabled_sources:
548
+ source_name = source.get("name", "unknown")
549
+ lgr.info(f"Starting background sync for data source: {source_name}")
550
+ executor.submit(sync_data_source_repeatedly, source)
551
+
552
+ # Start the MWL server in the main thread
553
+ run_mwl_server(
554
+ host=args.ip,
555
+ port=args.port,
556
+ aetitle=args.AEtitle,
557
+ allowed_aets=allowed_aet,
558
+ )
559
+
560
+ else:
561
+ # LEGACY: Fall back to old single-source configuration
562
+ lgr.warning("Using legacy configuration format. Consider migrating to data_sources format.")
563
+
564
+ from ..redcap_to_db import sync_redcap_to_db_repeatedly
565
+
566
+ # Extract the database update interval (default to 60 seconds if missing)
567
+ db_update_interval = config.get("db_update_interval", 60)
568
+
569
+ # Extract the operation interval (default from 00:00 to 23:59 hours if missing)
570
+ operation_interval = config.get("operation_interval", {"start_time": [0,0], "end_time": [23,59]})
571
+
572
+ # Extract the site id
573
+ site = config.get("site", None)
574
+
575
+ # Extract the redcap to worklist mapping
576
+ redcap2wl = config.get("redcap2wl", {})
577
+
578
+ # Extract protocol mapping
579
+ protocol = config.get("protocol", {})
580
+
581
+ # Create and update the MWL database
582
+ with ThreadPoolExecutor(max_workers=2) as executor:
583
+ future = executor.submit(
584
+ sync_redcap_to_db_repeatedly,
585
+ site_id=site,
586
+ protocol=protocol,
587
+ redcap2wl=redcap2wl,
588
+ interval=db_update_interval,
589
+ operation_interval=operation_interval,
590
+ )
591
+
592
+ run_mwl_server(
593
+ host=args.ip,
594
+ port=args.port,
595
+ aetitle=args.AEtitle,
596
+ allowed_aets=allowed_aet,
597
+ )
334
598
 
335
599
  if (args.command == "query-db"):
336
600
  from ..mwl_server import run_mwl_server
@@ -374,14 +638,16 @@ def main() -> None:
374
638
  try:
375
639
  # Check if API dependencies are available
376
640
  import uvicorn
377
- from ..api_server import app
378
- from ..auth_db_setup import init_auth_database, create_initial_admin_user
379
641
 
380
642
  # Load configuration for database setup
381
643
  config = load_config(args.pylantir_config)
382
644
  update_env_with_config(config)
383
645
  users_db_path = config.get("users_db_path") # Optional users database path
384
646
 
647
+ # Import API app after env vars are set (DB_PATH, DB_ECHO, etc.)
648
+ from ..api_server import app
649
+ from ..auth_db_setup import init_auth_database, create_initial_admin_user
650
+
385
651
  # Initialize authentication database with configured path
386
652
  init_auth_database(users_db_path)
387
653
  create_initial_admin_user(users_db_path)
@@ -482,7 +748,7 @@ def main() -> None:
482
748
  email = args.email or input("Enter email (optional): ") or None
483
749
  full_name = args.full_name or input("Enter full name (optional): ") or None
484
750
  password = args.password or getpass.getpass("Enter password for new user: ")
485
-
751
+
486
752
  # Get user role with interactive prompt
487
753
  if args.role == "read": # Default value, prompt for role
488
754
  print("\nAvailable user roles:")
@@ -0,0 +1,65 @@
1
+ {
2
+ "db_path": "~/Desktop/worklist.db",
3
+ "db_echo": "False",
4
+ "db_update_interval": 60,
5
+ "operation_interval": {
6
+ "start_time": [
7
+ 0,
8
+ 0
9
+ ],
10
+ "end_time": [
11
+ 23,
12
+ 59
13
+ ]
14
+ },
15
+ "allowed_aet": [
16
+ "MRI_SCANNER",
17
+ "CT_SCANNER"
18
+ ],
19
+ "data_sources": [
20
+ {
21
+ "type": "calpendo",
22
+ "enabled": true,
23
+ "config": {
24
+ "base_url": "https://sfc-calgary.calpendo.com",
25
+ "resources": [
26
+ "3T Diagnostic",
27
+ "EEG Lab"
28
+ ],
29
+ "status_filter": "Approved",
30
+ "lookback_multiplier": 2,
31
+ "timezone": "America/Edmonton",
32
+ "resource_modality_mapping": {
33
+ "3T": "MR",
34
+ "EEG": "EEG",
35
+ "Mock": "OT"
36
+ },
37
+ "field_mapping": {
38
+ "patient_id": {
39
+ "source_field": "title",
40
+ "_extract": {
41
+ "pattern": "^([A-Z0-9]+)_.*",
42
+ "group": 1
43
+ }
44
+ },
45
+ "patient_name": {
46
+ "source_field": "title",
47
+ "_extract": {
48
+ "pattern": "^[A-Z0-9]+_(.+)$",
49
+ "group": 1
50
+ }
51
+ },
52
+ "study_description": {
53
+ "source_field": "properties.project.formattedName",
54
+ "_extract": {
55
+ "pattern": "^([^(]+)",
56
+ "group": 1
57
+ }
58
+ },
59
+ "accession_number": "id",
60
+ "study_instance_uid": "id"
61
+ }
62
+ }
63
+ }
64
+ ]
65
+ }
@@ -53,5 +53,7 @@
53
53
  "cors_allow_headers": [
54
54
  "*"
55
55
  ]
56
- }
56
+ },
57
+ "_comment_data_sources": "Optional: Configure data sources to fetch worklist entries from external systems. See calpendo_config_example.json for detailed configuration.",
58
+ "data_sources": []
57
59
  }
@@ -0,0 +1,84 @@
1
+ """
2
+ Data Source Plugin Registry
3
+
4
+ This module provides the plugin registry system for discovering and loading
5
+ data source plugins.
6
+
7
+ Version: 1.0.0
8
+ """
9
+
10
+ from .base import DataSourcePlugin, PluginError, PluginConfigError, PluginFetchError
11
+ from .redcap_plugin import REDCapPlugin
12
+ from .calpendo_plugin import CalendoPlugin
13
+ from typing import Type, Dict, List
14
+ import logging
15
+
16
+ lgr = logging.getLogger(__name__)
17
+
18
+ # Plugin Registry - maps source type names to plugin classes
19
+ PLUGIN_REGISTRY: Dict[str, Type[DataSourcePlugin]] = {
20
+ "redcap": REDCapPlugin,
21
+ "calpendo": CalendoPlugin,
22
+ }
23
+
24
+
25
+ def register_plugin(source_type: str, plugin_class: Type[DataSourcePlugin]) -> None:
26
+ """
27
+ Register a plugin class in the registry.
28
+
29
+ Args:
30
+ source_type: Type identifier for the plugin (e.g., "redcap", "csv")
31
+ plugin_class: Plugin class inheriting from DataSourcePlugin
32
+
33
+ Raises:
34
+ ValueError: If source_type already registered or plugin_class is invalid
35
+ """
36
+ if source_type in PLUGIN_REGISTRY:
37
+ raise ValueError(f"Plugin type '{source_type}' is already registered")
38
+
39
+ if not issubclass(plugin_class, DataSourcePlugin):
40
+ raise ValueError(f"Plugin class must inherit from DataSourcePlugin")
41
+
42
+ PLUGIN_REGISTRY[source_type] = plugin_class
43
+ lgr.info(f"Registered plugin: {source_type} -> {plugin_class.__name__}")
44
+
45
+
46
+ def get_plugin(source_type: str) -> Type[DataSourcePlugin]:
47
+ """
48
+ Retrieve a plugin class from the registry.
49
+
50
+ Args:
51
+ source_type: Type identifier for the plugin
52
+
53
+ Returns:
54
+ Plugin class for instantiation
55
+
56
+ Raises:
57
+ ValueError: If source_type is not registered
58
+ """
59
+ if source_type not in PLUGIN_REGISTRY:
60
+ available_types = list(PLUGIN_REGISTRY.keys())
61
+ raise ValueError(
62
+ f"Unknown data source type: '{source_type}'. "
63
+ f"Available types: {available_types}"
64
+ )
65
+
66
+ return PLUGIN_REGISTRY[source_type]
67
+
68
+
69
+ def list_available_plugins() -> List[str]:
70
+ """Return list of registered plugin type names."""
71
+ return list(PLUGIN_REGISTRY.keys())
72
+
73
+
74
+ # Export public API
75
+ __all__ = [
76
+ 'DataSourcePlugin',
77
+ 'PluginError',
78
+ 'PluginConfigError',
79
+ 'PluginFetchError',
80
+ 'PLUGIN_REGISTRY',
81
+ 'register_plugin',
82
+ 'get_plugin',
83
+ 'list_available_plugins',
84
+ ]