#!/usr/bin/env -S uv run --script # # /// script # requires-python = ">=3.12" # dependencies = [ # "pillow", # "scilog", # ] # /// import argparse import re from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple from PIL import Image, UnidentifiedImageError from scilog import SciLog, LogbookMessage def get_image_size(file_path): """ Args: file_path: Returns: Raises: OSError: If the file does not exist. PIL.Image.UnidentifiedImageError: If the image is not recognized. """ with Image.open(file_path) as img: return img.size re_single_nbsp = re.compile(r"(? str: """ Replace multiple spaces with the corresponding number of   entities. Args: s: string Returns: string """ def nbsp(mo): return " " * len(mo.group()) return re_rep_pre_spc.sub(nbsp, s) class ScilogIngestor: def __init__(self): super().__init__() self._author: str = "" self._pgroup: str = "" self._logbook: str = "" self._message: str = "" self._tags: List[str] = [] self._attachments: List[str] = [] self._url: str = "" self._user: str = "" self._password: str = "" self._location: str = "" self._scilog: Optional[SciLog] = None def prepare(self): pass @staticmethod def _adjust_image_size(file_info): try: filepath = file_info['filepath'] except KeyError: return try: w, h = get_image_size(filepath) except (OSError, UnidentifiedImageError): return else: # limit image size while w > 400: w //= 2 file_info['style'] = {'width': f'{w}px', 'height': ''} def convert_plain(self, mesg: str) -> str: """ convert plain-text entry to HTML - Convert plain text message to HTML paragraph with formatting. - Replace line feeds by
tags. - Replace tabs and spaces. Args: mesg: Returns: string """ mesg = mesg.replace("&", "&").replace("<", "<").replace(">", ">") mesg = mesg.replace("\n", "
") mesg = mesg.expandtabs(8) mesg = replace_pre_spaces(mesg) mesg = "

" + mesg + "

" return mesg @staticmethod def is_pgroup(value: str) -> str: value = value.lower() if len(value) == 6 and value[0] in {"e", "p"} and int(value[1:]): return "p" + value[1:] else: return "" @property def scilog(self) -> SciLog: """ Return a SciLog instance. Returns: SciLog """ if self._scilog is None: options = {"username": self._user, "password": self._password} url = self._url self._scilog = SciLog(url, options=options) return self._scilog def ingest_message(self): """ ingest message into scilog this method ingests the current message into scilog. """ log = self.scilog try: logbook_name = self._logbook except KeyError: lb_filter = {"ownerGroup": self._pgroup, "deleted": False} else: lb_filter = {"ownerGroup": self._pgroup, "name": logbook_name, "deleted": False} logbooks = log.get_logbooks(where=lb_filter, limit=10) try: logbook = logbooks[0] except IndexError: raise ValueError(f"no logbook found for {lb_filter}") else: if len(logbooks) > 1: raise ValueError(f"multiple logbooks found for {lb_filter}") else: log.select_logbook(logbook) msg = LogbookMessage() if self._message[0] == '<': msg.add_text(self._message) else: for line in self._message.split('\n'): msg.add_text(line) for att in self._attachments: msg.add_file(att) file_info = msg._content.files[-1] self._adjust_image_size(file_info) if self._tags: msg.add_tag(self._tags) log.send_logbook_message(msg) def load_attributes_from_file(self, attributes_file): """ Load attributes, tags and attachment paths from a file. Each line of the file declares a key:value pair. The keys are: - author: (required) Value is the e-mail address of the author. - pgroup: (required) Value is the p-group of the logbook. - logbook: (required) Value is the name of the logbook. - location: (not used) Value is the name of the location. Currently not used. - tag: (optional) Value is a tag to be added to the snippet. Can occur multiple times. Duplicates are ignored. Spaces and commas are removed. - attachment: (optional) Value is the path of an attachment. Can occur multiple times. Duplicates are ignored. :param attributes_file: :return: """ unique_tags = set() unique_attachments = set() with open(attributes_file, "rt", encoding="utf8") as f: for line in f.readlines(): try: key, val = line.split(":", 1) key = key.strip() val = val.strip() except ValueError: continue if key == "tag": val = val.replace(" ", "") val = val.replace(",", "") if val not in unique_tags: self._tags.append(val) unique_tags.add(val) elif key == "attachment": if val not in unique_attachments: self._attachments.append(val) unique_attachments.add(val) elif key == "author": self._author = val elif key == "pgroup": self._pgroup = val elif key == "location": self._location = val elif key == "logbook": self._logbook = val def load_message_from_file(self, message_file): """ Load message from file If the file starts with a `<`, the content is assumed to be HTML. Otherwise, it is assumed to be plain text and will be tagged and escaped. :param message_file: :return: """ with open(message_file, "rt", encoding="utf8") as f: self._message = "\n".join(f.readlines()) def load_credentials_from_file(self, credentials_file): """ Load the credentials from a file. The file must contain three lines, each in the form key:value. The keys are `url`, `user`, and `password`. The URL must start with `https://` and end with `/api/v1`. :param credentials_file: path of the credentials file. If None, it is read from `.scilog.cred` the home directory. Make sure to protect the credentials from unauthorized access! :return: None """ if credentials_file is None: credentials_file = Path.home() / ".scilog.cred" if not Path(credentials_file).exists(): raise ValueError("Missing credentials") with open(credentials_file, "rt", encoding="utf8") as f: for line in f.readlines(): try: key, val = line.split(":", 1) key = key.strip() val = val.strip() except ValueError: continue if key == "user": self._user = val elif key == "password": self._password = val elif key == "url": self._url = val def validate(self): """ Perform a number of basic validity checks on the attributes. :return: :raise ValueError if a problem is found. """ if not re.match(r"[^@]+@[^@]+\.[^@]+", self._author): raise ValueError("Invalid email address.") if not self._user or not self._password: raise ValueError("Invalid credentials.") if not self.is_pgroup(self._pgroup): raise ValueError("Invalid pgroup.") if not self._logbook: raise ValueError("Empty logbook name.") if not self._message: raise ValueError("Empty message.") if not re.match(r"https://[a-zA-Z0-9]+\.[a-zA-Z0-9.]+(:[0-9]+)?/api/v1", self._url): raise ValueError("Invalid URL.") def run(self): self.validate() self.ingest_message() def main(attributes_file, message_file, credentials_file): ingestor = ScilogIngestor() ingestor.load_attributes_from_file(attributes_file) ingestor.load_message_from_file(message_file) ingestor.load_credentials_from_file(credentials_file) ingestor.run() print("Message successfully transmitted") def parse_args(): parser = argparse.ArgumentParser( description="Simple file interface to ingest an entry into a SciLog logbook", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) parser.add_argument("-c", "--credentials", default=None, help="credentials file, UTF-8 encoded") parser.add_argument("attributes", help="attributes file, UTF-8 encoded") parser.add_argument("message", help="message file, UTF-8 encoded") clargs = parser.parse_args() return clargs if __name__ == "__main__": clargs = parse_args() main(clargs.attributes, clargs.message, clargs.credentials)