Source code for nexusLIMS.exporters.destinations.labarchives

"""LabArchives export destination plugin.

Exports NexusLIMS XML records to a LabArchives electronic lab notebook by
creating a page per session with an HTML summary entry and the full XML record
attached as a file.
"""

from __future__ import annotations

import base64
import logging
import re
import traceback
from collections import Counter
from pathlib import Path

from nexusLIMS.config import settings
from nexusLIMS.exporters.base import ExportContext, ExportResult
from nexusLIMS.utils.labarchives import (
    LabArchivesAuthenticationError,
    LabArchivesClient,
    LabArchivesError,
    get_labarchives_client,
)

_logger = logging.getLogger(__name__)


[docs] class LabArchivesDestination: """LabArchives export destination plugin. Uploads NexusLIMS XML records to a LabArchives instance. For each session, creates a page under ``NexusLIMS Records/{instrument}/`` in the configured notebook (or the user's Inbox if no notebook is configured) with: - An HTML-formatted session summary as a text entry - The full XML record attached as a file Attributes ---------- name : str Destination identifier: "labarchives" priority : int Export priority: 90 (runs after CDCS at 100, allowing access to CDCS URLs) """ name = "labarchives" priority = 90 @property def enabled(self) -> bool: """Check if LabArchives is configured and enabled. Returns ------- bool True if ``NX_LABARCHIVES_ACCESS_KEY_ID``, ``NX_LABARCHIVES_ACCESS_PASSWORD``, ``NX_LABARCHIVES_USER_ID``, and ``NX_LABARCHIVES_URL`` are all configured. """ return all( [ settings.NX_LABARCHIVES_ACCESS_KEY_ID, settings.NX_LABARCHIVES_ACCESS_PASSWORD, settings.NX_LABARCHIVES_USER_ID, settings.NX_LABARCHIVES_URL, ] )
[docs] def validate_config(self) -> tuple[bool, str | None]: """Validate LabArchives configuration. Checks that required fields are present and tests authentication by making a lightweight API call. Returns ------- tuple[bool, str | None] ``(is_valid, error_message)`` — ``error_message`` is ``None`` on success. """ for attr, label in [ ("NX_LABARCHIVES_ACCESS_KEY_ID", "NX_LABARCHIVES_ACCESS_KEY_ID"), ("NX_LABARCHIVES_ACCESS_PASSWORD", "NX_LABARCHIVES_ACCESS_PASSWORD"), ("NX_LABARCHIVES_USER_ID", "NX_LABARCHIVES_USER_ID"), ("NX_LABARCHIVES_URL", "NX_LABARCHIVES_URL"), ]: if not getattr(settings, attr, None): return False, f"{label} not configured" # Test authentication by making a live API call try: # pragma: no cover client = get_labarchives_client() nbid = settings.NX_LABARCHIVES_NOTEBOOK_ID if nbid: # Verify notebook is accessible client.get_tree_level(nbid, "0") else: # No notebook configured — just verify credentials with a minimal call client.get_tree_level("0", "0") except LabArchivesAuthenticationError as e: # pragma: no cover return False, f"LabArchives authentication failed: {e}" except LabArchivesError as e: # pragma: no cover # Non-auth errors (e.g. notebook not found) still mean config is usable _logger.debug("LabArchives validate_config non-fatal error: %s", e) except Exception as e: # pragma: no cover return False, f"LabArchives configuration error: {e}" return True, None # pragma: no cover
[docs] def export(self, context: ExportContext) -> ExportResult: """Export record to LabArchives. Creates a notebook page with an HTML summary entry and attaches the XML record file. Never raises exceptions — all errors are caught and returned as :class:`~nexusLIMS.exporters.base.ExportResult` with ``success=False``. Parameters ---------- context : ExportContext Export context with file path, session metadata, and results from higher-priority destinations (e.g., CDCS). Returns ------- ExportResult Result of the export attempt. """ try: client = get_labarchives_client() # Find or create target page nbid, page_tree_id = self._find_or_create_page(client, context) # Collect preview images from activities previews = self._collect_previews(context) # Build and upload HTML summary entry html_summary = self._build_html_summary(context, previews=previews) entry_id = client.add_entry(nbid, page_tree_id, html_summary) _logger.info( "Created LabArchives entry %s on page %s", entry_id, page_tree_id ) # Upload XML file as attachment xml_bytes = context.xml_file_path.read_bytes() filename = context.xml_file_path.name client.add_attachment( nbid, page_tree_id, filename, xml_bytes, caption="NexusLIMS XML record", ) _logger.info("Uploaded XML attachment %s to LabArchives", filename) # Build a best-effort entry URL entry_url = _build_entry_url( base_url=str(settings.NX_LABARCHIVES_URL), nbid=nbid, page_tree_id=page_tree_id, ) return ExportResult( success=True, destination_name=self.name, record_id=entry_id, record_url=entry_url, ) except Exception: _logger.exception( "Failed to export to LabArchives: %s", context.xml_file_path.name, ) return ExportResult( success=False, destination_name=self.name, error_message=traceback.format_exc(), )
# ------------------------------------------------------------------ # # Private helpers # # ------------------------------------------------------------------ # def _find_or_create_page( self, client: LabArchivesClient, context: ExportContext, ) -> tuple[str, str]: """Find or create the target notebook page for this session. When ``NX_LABARCHIVES_NOTEBOOK_ID`` is configured, navigates to (or creates) the path ``NexusLIMS Records/{instrument_pid}/`` and creates a new page named ``{YYYY-MM-DD} — {session_identifier}``. When no notebook ID is configured, returns ``("0", "0")`` which causes the API calls to target the user's Inbox. Parameters ---------- client : LabArchivesClient Authenticated API client context : ExportContext Export context with session metadata Returns ------- tuple[str, str] ``(nbid, page_tree_id)`` """ nbid = settings.NX_LABARCHIVES_NOTEBOOK_ID if not nbid: # No notebook configured — upload to Inbox return ("0", "0") # Find or create "NexusLIMS Records" folder at root root_nodes = client.get_tree_level(nbid, "0") nexuslims_folder_id = _find_node_by_text(root_nodes, "NexusLIMS Records") if nexuslims_folder_id is None: nexuslims_folder_id = client.insert_folder(nbid, "0", "NexusLIMS Records") _logger.info("Created 'NexusLIMS Records' folder in notebook %s", nbid) # Find or create instrument sub-folder instrument_nodes = client.get_tree_level(nbid, nexuslims_folder_id) instrument_folder_id = _find_node_by_text( instrument_nodes, context.instrument_pid ) if instrument_folder_id is None: instrument_folder_id = client.insert_folder( nbid, nexuslims_folder_id, context.instrument_pid ) _logger.info( "Created instrument folder '%s' in LabArchives", context.instrument_pid, ) # Create a new page for this session page_name = f"{context.dt_from:%Y-%m-%d} \u2014 {context.session_identifier}" page_tree_id = client.insert_page(nbid, instrument_folder_id, page_name) _logger.info("Created page '%s' (tree_id=%s)", page_name, page_tree_id) return (nbid, page_tree_id) def _collect_previews(self, context: ExportContext) -> list[tuple[str, bytes]]: """Collect (name, image_bytes) pairs from context activities. Iterates over all activities and their preview paths, reading image bytes for each available preview. Multi-signal files that appear more than once in an activity's file list are labelled ``"(N of M)"``. Parameters ---------- context : ExportContext Export context with activities Returns ------- list[tuple[str, bytes]] List of ``(caption_name, image_bytes)`` pairs, one per available preview. """ previews: list[tuple[str, bytes]] = [] for act in context.activities: file_counts: Counter = Counter(act.files) file_seen: Counter = Counter() for fname, preview_path in zip(act.files, act.previews): file_seen[fname] += 1 if not preview_path or not Path(preview_path).exists(): continue name = Path(fname).name if file_counts[fname] > 1: name = f"{name} ({file_seen[fname]} of {file_counts[fname]})" try: previews.append((name, Path(preview_path).read_bytes())) except Exception: _logger.debug("Could not read preview: %s", preview_path) return previews def _build_html_summary( self, context: ExportContext, previews: list[tuple[str, bytes]] | None = None, ) -> str: """Build HTML summary content for the notebook entry. Parameters ---------- context : ExportContext Export context with session metadata previews : list[tuple[str, bytes]] | None Optional list of ``(caption, image_bytes)`` pairs to embed as a preview gallery (4 images per row, base64 data URIs). Returns ------- str HTML-formatted session summary """ lines = [ "<h1>NexusLIMS Microscopy Session</h1>", "<h2>Session Details</h2>", "<ul>", f"<li><strong>Session ID</strong>: {context.session_identifier}</li>", f"<li><strong>Instrument</strong>: {context.instrument_pid}</li>", ] if context.user: lines.append(f"<li><strong>User</strong>: {context.user}</li>") lines.extend( [ f"<li><strong>Start</strong>: {context.dt_from.isoformat()}</li>", f"<li><strong>End</strong>: {context.dt_to.isoformat()}</li>", "</ul>", ] ) # Add CDCS link if available cdcs_result = context.get_result("cdcs") if cdcs_result and cdcs_result.success and cdcs_result.record_url: lines.extend( [ "<h2>Related Records</h2>", "<ul>", f'<li><a href="{cdcs_result.record_url}">View in CDCS</a></li>', "</ul>", ] ) if previews: lines.append("<h2>Preview Images</h2>") lines.append('<table style="border-collapse:collapse;">') for i, (name, img_bytes) in enumerate(previews): if i % 4 == 0: if i > 0: lines.append("</tr>") lines.append("<tr>") b64 = base64.b64encode(img_bytes).decode() lines.append( f'<td style="padding:8px;text-align:center;vertical-align:top;">' f'<img src="data:image/png;base64,{b64}" alt="{name}" ' f'style="max-width:250px;max-height:250px;border:1px solid #ccc;">' f"<br><small>{name}</small></td>" ) lines.append("</tr></table>") lines.append("<h2>Files</h2>") lines.append("<p>The following data files are referenced in this record:</p>") # Collect unique file locations across all activities, preserving order. # Strip NX_INSTRUMENT_DATA_PATH prefix so paths match the XML <location> # element (relative to the instrument storage root). instr_root = str(settings.NX_INSTRUMENT_DATA_PATH) seen: set[str] = set() file_names: list[str] = [] for act in context.activities: for fname in act.files: if fname not in seen: seen.add(fname) rel = fname.replace(instr_root, "") file_names.append(rel) if file_names: lines.append("<ul>") for name in file_names: lines.append(f"<li>{name}</li>") lines.append("</ul>") return "\n".join(lines)
# ------------------------------------------------------------------ # # Module-level helpers # # ------------------------------------------------------------------ # def _find_node_by_text( nodes: list[dict], display_text: str, ) -> str | None: """Return tree_id of the first node matching display_text, or None.""" for node in nodes: if node.get("display_text") == display_text: return node["tree_id"] return None _LA_API_HOST = "api.labarchives.com" _LA_WEB_HOST = "mynotebook.labarchives.com" def _build_entry_url(base_url: str, nbid: str, page_tree_id: str) -> str: """Build a best-effort URL to the LabArchives notebook page. Parameters ---------- base_url : str API base URL (e.g. ``"https://api.labarchives.com/api"``). For the cloud LabArchives service, ``api.labarchives.com`` is replaced with ``mynotebook.labarchives.com`` so the link opens in the web interface. For other hosts, the ``/api`` path suffix is stripped. nbid : str Notebook ID page_tree_id : str Tree ID of the page Returns ------- str URL pointing to the page in the LabArchives web interface (best effort) """ # For cloud LabArchives: swap API host → web host and drop the /api path. # For self-hosted instances: just strip the /api suffix. base = re.sub(r"/api/?$", "", base_url.rstrip("/")) base = base.replace(_LA_API_HOST, _LA_WEB_HOST) if nbid and nbid != "0": return f"{base}/#/{nbid}/{page_tree_id}" return base