# Writing Export Destination Plugins ```{versionadded} 2.4.0 ``` This guide explains how to create custom export destination plugins for the NexusLIMS multi-destination export framework. ## Overview Export destinations are plugins that receive built NexusLIMS XML records and export them to external repository systems (CDCS, LabArchives, eLabFTW, etc.). The framework uses protocol-based typing for automatic plugin discovery—no inheritance or registration required. ## Quick Start 1. Create a Python file in `nexusLIMS/exporters/destinations/` 2. Define a class matching the `ExportDestination` protocol 3. The plugin will be auto-discovered when the exporters package is imported ## Protocol Requirements Your export destination class must implement the `ExportDestination` protocol: ```python from nexusLIMS.exporters.base import ExportContext, ExportResult class MyDestination: """My custom export destination.""" # Required class attributes name = "my_destination" # Unique identifier priority = 80 # Export priority (0-1000, higher runs first) @property def enabled(self) -> bool: """Return True if this destination is configured and ready.""" # Check if required configuration is present ... def validate_config(self) -> tuple[bool, str | None]: """Validate configuration at startup. Returns ------- tuple[bool, str | None] (is_valid, error_message) - is_valid: True if configuration is valid - error_message: None if valid, descriptive error if invalid """ ... def export(self, context: ExportContext) -> ExportResult: """Export record to this destination. CRITICAL: Must never raise exceptions. All errors must be caught and returned as ExportResult with success=False. Parameters ---------- context : ExportContext Contains xml_file_path, session_identifier, instrument_pid, dt_from, dt_to, user, and previous_results from other destinations Returns ------- ExportResult Result of the export attempt (success or failure) """ ... ``` ### Required Attributes - **`name`** (str): Unique identifier for this destination (lowercase, alphanumeric + underscores) - **`priority`** (int): Export priority (0-1000). Higher priority destinations run first. Use this to manage inter-destination dependencies. ### Required Methods #### `enabled` property Returns `bool` indicating whether the destination is configured and ready to use. Typically checks for required environment variables (API keys, URLs, etc.). ```python @property def enabled(self) -> bool: from nexusLIMS import config return ( hasattr(config, 'MY_API_KEY') and config.MY_API_KEY is not None ) ``` #### `validate_config()` method Performs startup-time configuration validation. Called during initialization to provide detailed error feedback. Should test authentication, connectivity, etc. Returns `(is_valid, error_message)` tuple. ```python def validate_config(self) -> tuple[bool, str | None]: from nexusLIMS import config if not hasattr(config, 'MY_API_KEY'): return False, "MY_API_KEY not configured" if not config.MY_API_KEY: return False, "MY_API_KEY is empty" # Test authentication try: self._test_connection() except Exception as e: return False, f"Connection test failed: {e}" return True, None ``` #### `export()` method Performs the actual export. **Must never raise exceptions**—all errors must be caught and returned as `ExportResult` with `success=False` and `error_message` set. ```python def export(self, context: ExportContext) -> ExportResult: try: # Read XML content with context.xml_file_path.open(encoding="utf-8") as f: xml_content = f.read() # Upload to destination record_id, record_url = self._upload(xml_content, context.session_identifier) return ExportResult( success=True, destination_name=self.name, record_id=record_id, record_url=record_url, ) except Exception as e: _logger.exception(f"Failed to export to {self.name}: {context.xml_file_path.name}") return ExportResult( success=False, destination_name=self.name, error_message=str(e), ) ``` ## Configuration Patterns ### Reading Configuration Always use the `nexusLIMS.config` module, never `os.getenv()` directly: ```python from nexusLIMS import config # Good api_key = config.MY_API_KEY # Bad - do not use import os api_key = os.getenv("MY_API_KEY") ``` ### Adding New Configuration Variables Add new environment variables to `nexusLIMS/config.py`: ```python class Settings(BaseSettings): # ... existing fields ... MY_DESTINATION_API_KEY: str | None = Field( None, description="API key for MyDestination service", ) MY_DESTINATION_URL: AnyHttpUrl | None = Field( None, description="Base URL for MyDestination API", ) ``` Document the new variables in `.env.example`: ```bash # MyDestination Configuration MY_DESTINATION_API_KEY=your_api_key_here MY_DESTINATION_URL=https://api.mydestination.com ``` ### Optional vs Required Configuration Use `Field(None, ...)` for optional configuration (destination won't be enabled if missing). Use `Field(..., ...)` for required configuration (will cause validation error if missing—only use this if the variable is needed for NexusLIMS core functionality). ## Error Handling ### Never Raise Exceptions from `export()` The `export()` method is called in a loop across all destinations. If it raises an exception, it will break the entire export process. **Always catch exceptions and return ExportResult**. ```python def export(self, context: ExportContext) -> ExportResult: try: # Do export work ... return ExportResult(success=True, ...) except SpecificError as e: # Log specific errors at appropriate level _logger.warning(f"Specific error: {e}") return ExportResult(success=False, error_message=str(e), ...) except Exception as e: # Catch-all for unexpected errors _logger.exception(f"Unexpected error in {self.name}") return ExportResult(success=False, error_message=str(e), ...) ``` ### Logging Best Practices - Use `_logger.info()` for successful exports - Use `_logger.warning()` for expected errors (e.g., network timeout, invalid credentials) - Use `_logger.exception()` for unexpected errors (includes traceback) - Include context in log messages (file name, session identifier, etc.) ```python import logging _logger = logging.getLogger(__name__) def export(self, context: ExportContext) -> ExportResult: try: # ... export logic ... _logger.info( f"Successfully exported {context.xml_file_path.name} to {self.name}: {record_url}" ) return ExportResult(success=True, ...) except Exception as e: _logger.exception( f"Failed to export {context.xml_file_path.name} to {self.name}" ) return ExportResult(success=False, error_message=str(e), ...) ``` (inter-destination-dependencies)= ## Inter-Destination Dependencies Destinations may depend on results from other destinations (e.g., LabArchives including a CDCS link). Handle this using priority ordering and `context.previous_results`. ### How It Works 1. **Priority ordering**: Destinations run in priority order (highest first) 2. **Result accumulation**: Each destination's result is added to `context.previous_results` before the next destination runs 3. **Access previous results**: Lower-priority destinations can access results from higher-priority destinations ### Priority Management If destination A needs data from destination B, give B a **higher priority**: - CDCS: priority 100 (runs FIRST) - LabArchives: priority 90 (runs SECOND, can see CDCS result) - eLabFTW: priority 85 (runs THIRD, can see both CDCS and LabArchives) - LocalArchive: priority 80 (runs LAST, can see all others) ### Accessing Previous Results Use `context.get_result(destination_name)` or `context.has_successful_export(destination_name)`: ```python class LabArchivesDestination: name = "labarchives" priority = 90 # Lower than CDCS (100), so runs AFTER CDCS def export(self, context: ExportContext) -> ExportResult: try: # Build base content content = self._build_entry(context) # Check if CDCS export succeeded and include link if context.has_successful_export("cdcs"): cdcs_result = context.get_result("cdcs") content += f"
CDCS Record: {cdcs_result.record_id}
" _logger.info(f"Including CDCS link in LabArchives: {cdcs_result.record_url}") else: _logger.info("CDCS export did not succeed, skipping link in LabArchives") # Upload to LabArchives notebook_id = self._upload(content) return ExportResult( success=True, destination_name=self.name, record_id=notebook_id, metadata={"included_cdcs_link": context.has_successful_export("cdcs")}, ) except Exception as e: ... ``` ### Graceful Degradation Dependencies should be **optional**. If the dependency failed, gracefully degrade rather than failing: ```python if context.has_successful_export("cdcs"): # Include enhanced content with CDCS link content += cdcs_link_section else: # Still complete export, just without CDCS link _logger.info("CDCS not available, proceeding without link") ``` ### Available Result Fields The `ExportResult` object includes: - `record_id`: Destination-specific record identifier (str) - `record_url`: Direct URL to view the record (str) - `metadata`: Custom metadata dict with destination-specific details (dict) - `success`: Whether the export succeeded (bool) - `timestamp`: When the export occurred (datetime) - `error_message`: Error message if failed (str | None) ### Best Practices 1. **Document dependencies**: Add a docstring comment noting priority requirements 2. **Optional dependencies**: Always handle the case where the dependency failed 3. **Log decisions**: Log when including/excluding dependent content 4. **Use metadata**: Track what was included in the `metadata` field for debugging ```python class LabArchivesDestination: """LabArchives ELN export destination. Priority: 90 (must run after CDCS at priority 100 to include CDCS links) Dependencies: - CDCS (optional): Includes CDCS record link if available """ name = "labarchives" priority = 90 ... ``` ## Testing Strategies ### Unit Tests Test your destination in isolation using mocks: ```python # tests/unit/test_exporters/test_my_destination.py import pytest from pathlib import Path from datetime import datetime from unittest.mock import Mock, patch from nexusLIMS.exporters.destinations.my_destination import MyDestination from nexusLIMS.exporters.base import ExportContext @pytest.fixture def mock_config(): with patch("nexusLIMS.exporters.destinations.my_destination.config") as mock_cfg: mock_cfg.MY_API_KEY = "test_key" mock_cfg.MY_API_URL = "http://localhost:8000" yield mock_cfg def test_enabled_with_config(mock_config): dest = MyDestination() assert dest.enabled is True def test_enabled_without_config(): with patch("nexusLIMS.exporters.destinations.my_destination.config") as mock_cfg: mock_cfg.MY_API_KEY = None dest = MyDestination() assert dest.enabled is False def test_export_success(mock_config, tmp_path): # Create test XML file xml_file = tmp_path / "test_record.xml" xml_file.write_text("