# 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("test") # Create export context context = ExportContext( xml_file_path=xml_file, session_identifier="test-session-123", instrument_pid="test-instrument", dt_from=datetime.now(), dt_to=datetime.now(), ) # Mock API calls with patch("nexusLIMS.exporters.destinations.my_destination.nexus_req") as mock_req: mock_req.return_value.status_code = 201 mock_req.return_value.json.return_value = {"id": "record-123"} dest = MyDestination() result = dest.export(context) assert result.success is True assert result.record_id == "record-123" assert result.error_message is None def test_export_failure(mock_config, tmp_path): xml_file = tmp_path / "test_record.xml" xml_file.write_text("test") context = ExportContext( xml_file_path=xml_file, session_identifier="test-session-123", instrument_pid="test-instrument", dt_from=datetime.now(), dt_to=datetime.now(), ) # Mock API failure with patch("nexusLIMS.exporters.destinations.my_destination.nexus_req") as mock_req: mock_req.side_effect = Exception("Connection failed") dest = MyDestination() result = dest.export(context) assert result.success is False assert "Connection failed" in result.error_message ``` ### Integration Tests Test with a real or mock destination server: ```python # tests/integration/test_my_destination_integration.py import pytest from nexusLIMS.exporters import export_records from nexusLIMS.exporters.destinations.my_destination import MyDestination @pytest.mark.integration def test_full_export_workflow(db_session, sample_session, sample_xml_file): """Test complete export workflow with MyDestination.""" # Ensure destination is enabled dest = MyDestination() assert dest.enabled is True # Export record results = export_records([sample_xml_file], [sample_session]) # Verify results assert sample_xml_file in results my_dest_results = [r for r in results[sample_xml_file] if r.destination_name == "my_destination"] assert len(my_dest_results) == 1 assert my_dest_results[0].success is True # Verify upload_log entry from nexusLIMS.db.models import UploadLog logs = db_session.query(UploadLog).filter_by( session_identifier=sample_session.session_identifier, destination_name="my_destination" ).all() assert len(logs) == 1 assert logs[0].success is True ``` ### Test Dependencies Test inter-destination dependencies: ```python def test_dependency_on_cdcs(tmp_path): """Test that LabArchives correctly handles CDCS dependency.""" xml_file = tmp_path / "test.xml" xml_file.write_text("test") context = ExportContext( xml_file_path=xml_file, session_identifier="test-123", instrument_pid="test-instrument", dt_from=datetime.now(), dt_to=datetime.now(), ) # Simulate CDCS result from nexusLIMS.exporters.base import ExportResult cdcs_result = ExportResult( success=True, destination_name="cdcs", record_id="cdcs-456", record_url="http://cdcs.example.com/data?id=cdcs-456", ) context.previous_results["cdcs"] = cdcs_result # Export to LabArchives dest = LabArchivesDestination() result = dest.export(context) assert result.success is True assert result.metadata["included_cdcs_link"] is True ``` ## Example Implementation Here's a complete example implementing a simple local file archive destination: ```python # nexusLIMS/exporters/destinations/local_archive.py """Local file archive export destination.""" import logging import shutil from pathlib import Path from nexusLIMS import config from nexusLIMS.exporters.base import ExportContext, ExportResult _logger = logging.getLogger(__name__) class LocalArchiveDestination: """Local filesystem archive export destination. Copies exported records to a local directory for archival purposes. Priority: 50 (runs after primary destinations like CDCS) """ name = "local_archive" priority = 50 @property def enabled(self) -> bool: """Check if local archive directory is configured.""" return ( hasattr(config, "NX_LOCAL_ARCHIVE_PATH") and config.NX_LOCAL_ARCHIVE_PATH is not None ) def validate_config(self) -> tuple[bool, str | None]: """Validate that archive directory exists and is writable.""" if not hasattr(config, "NX_LOCAL_ARCHIVE_PATH"): return False, "NX_LOCAL_ARCHIVE_PATH not configured" archive_path = Path(config.NX_LOCAL_ARCHIVE_PATH) if not archive_path.exists(): return False, f"Archive directory does not exist: {archive_path}" if not archive_path.is_dir(): return False, f"Archive path is not a directory: {archive_path}" # Test write permissions test_file = archive_path / ".write_test" try: test_file.touch() test_file.unlink() except Exception as e: return False, f"Archive directory is not writable: {e}" return True, None def export(self, context: ExportContext) -> ExportResult: """Copy XML file to archive directory. File will be named: YYYY-MM-DD_INSTRUMENT_SESSION.xml """ try: # Build archive filename archive_dir = Path(config.NX_LOCAL_ARCHIVE_PATH) archive_filename = ( f"{context.dt_from.strftime('%Y-%m-%d')}_" f"{context.instrument_pid}_" f"{context.session_identifier.split('-')[0]}.xml" ) archive_path = archive_dir / archive_filename # Copy file to archive shutil.copy2(context.xml_file_path, archive_path) _logger.info( f"Archived {context.xml_file_path.name} to {archive_path}" ) return ExportResult( success=True, destination_name=self.name, record_id=archive_filename, record_url=f"file://{archive_path.absolute()}", metadata={"archive_path": str(archive_path)}, ) except Exception as e: _logger.exception( f"Failed to archive {context.xml_file_path.name} to local filesystem" ) return ExportResult( success=False, destination_name=self.name, error_message=str(e), ) ``` ## Summary Checklist When creating a new export destination, ensure you: - [ ] Create a class matching the `ExportDestination` protocol - [ ] Set `name` and `priority` class attributes - [ ] Implement `enabled` property to check configuration - [ ] Implement `validate_config()` for startup validation - [ ] Implement `export()` that never raises exceptions - [ ] Use `nexusLIMS.config` for all configuration access - [ ] Add new config variables to `nexusLIMS/config.py` - [ ] Document new config variables in `.env.example` - [ ] Handle errors gracefully with appropriate logging - [ ] Consider inter-destination dependencies (use priority) - [ ] Write unit tests for your destination - [ ] Test integration with the full export workflow - [ ] Document any special requirements or dependencies For more examples, see the CDCS destination implementation at {py:mod}`nexusLIMS.exporters.destinations.cdcs`.