Source code for mosromgr.moselements

# mosromgr: Python library for managing MOS running orders
# Copyright 2021 BBC
# SPDX-License-Identifier: Apache-2.0

from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from datetime import datetime, timedelta
from typing import Optional, Union, List, Dict

from dateutil.parser import parse


def _get_story_offsets(all_stories: Optional[List[Element]]) -> Optional[Dict[str, float]]:
    """
    Create a dict of {story_id: story_offset}
    """
    story_offsets = {}
    if all_stories:
        t = 0
        for story in all_stories:
            story_offsets[story.find('storyID').text] = t
            t += _get_story_duration(story)
        return story_offsets


def _get_story_duration(story_tag: Element) -> Optional[float]:
    """
    Return the sum of the text time and media time, or return None if not found
    """
    try:
        metadata = story_tag.find('mosExternalMetadata')
        payload = metadata.find('mosPayload')
    except AttributeError:
        return

    try:
        return float(payload.find('StoryDuration').text)
    except AttributeError:
        pass

    text_time = payload.find('TextTime')
    media_time = payload.find('MediaTime')
    if text_time is not None or media_time is not None:
        text_time = float(text_time.text) if text_time is not None else 0
        media_time = float(media_time.text) if media_time is not None else 0
        return text_time + media_time


def _is_technical_note(p: Element) -> bool:
    """
    Return ``True`` if the text in a paragraph element is surrounded by round
    ``()`` or angle ``<>`` brackets.
    """
    text = p.text.strip()
    if text.startswith('(') and text.endswith(')'):
        return True
    if text.startswith('<') and text.endswith('>'):
        return True
    return False

def _get_tag_text(tag: Element) -> str:
    """
    If a ``<p>`` tag contains text, return it, otherwise return an empty string
    (rather than ``None``).
    """
    if tag.text is not None:
        return tag.text
    return ''


[docs]class MosElement: """ Abstract base class for MOS elements """ def __init__(self, xml: Element, *, id: Optional[str] = None, slug: Optional[str] = None): self._xml = xml self._id = id self._slug = slug self._id_tag = None self._slug_tag = None def __repr__(self): try: short_id = self.id.split(',')[-1] return f"<{self.__class__.__name__} {short_id}>" except AttributeError: return f"<{self.__class__.__name__}>"
[docs] def __str__(self): """ The XML string """ return ElementTree.tostring(self.xml, encoding='unicode')
@property def xml(self) -> Element: """ The XML element """ return self._xml @property def id(self) -> Optional[str]: """ The element ID (if present in the XML) """ if self._id is None: try: self._id = self.xml.find(self._id_tag).text except AttributeError: self._id = None return self._id @property def slug(self) -> Optional[str]: """ The element slug (if present in the XML) """ try: self._slug = self.xml.find(self._slug_tag).text except AttributeError: return return self._slug
[docs]class Item(MosElement): """ This class represents an Item element within any :class:`~mosromgr.mostypes.MosFile` object, providing data relating to an item within a :class:`Story`. The Item ID and Item slug (and more) are exposed as properties, and the XML element is provided for further introspection. """ def __init__(self, xml: Element, *, id: Optional[str] = None, slug: Optional[str] = None): super().__init__(xml, id=id, slug=slug) self._id_tag = 'itemID' self._slug_tag = 'itemSlug' @property def id(self) -> Optional[str]: """ The Item ID (if present in the XML) """ return super().id @property def slug(self) -> Optional[str]: """ The Item slug (if present in the XML) """ return super().slug @property def type(self) -> Optional[str]: """ The Item's object type (if present in the XML) """ obj_type = self.xml.find('objType') if obj_type is not None: return obj_type.text @property def object_id(self) -> Optional[str]: """ The Item's Object ID (if present in the XML) """ obj_id = self.xml.find('objID') if obj_id is not None: return obj_id.text @property def mos_id(self) -> Optional[str]: """ The Item's MOS ID (if present in the XML) """ mos_id = self.xml.find('mosID') if mos_id is not None: return mos_id.text @property def note(self) -> Optional[str]: """ The item note text (if present in the XML) """ try: metadata = self.xml.find('mosExternalMetadata') mos_payload = metadata.find('mosPayload') note = mos_payload.find(".//studioCommand[@type='note']") return note.find('text').text except AttributeError: return
[docs]class Story(MosElement): """ This class represents a Story element within any :class:`~mosromgr.mostypes.MosFile` object, providing data relating to the story. The Story ID, Story slug, duration and more are exposed as properties, and the XML element is provided for further introspection. """ def __init__(self, xml: Element, *, id: Optional[str] = None, slug: Optional[str] = None, duration: Optional[float] = None, unknown_items: bool = False, all_stories: Optional[List[Element]] = None, prog_start_time: Optional[datetime] = None ): super().__init__(xml, id=id, slug=slug) self._id_tag = 'storyID' self._slug_tag = 'storySlug' self._duration = duration self._unknown_items = unknown_items self._prog_start_time = prog_start_time self._story_offsets = _get_story_offsets(all_stories) @property def id(self) -> Optional[str]: """ The Story ID (if present in the XML) """ return super().id @property def slug(self) -> Optional[str]: """ The Story slug (if present in the XML) """ return super().slug @property def items(self) -> Optional[List[Item]]: """ List of :class:`Item` elements found within the story (can be ``None`` if not available in the XML) """ if self._unknown_items: return return [ Item(item_tag) for item_tag in self.xml.findall('item') ] @property def duration(self) -> float: """ The story duration (the sum of the text time and media time found within ``mosExternalMetadata->mosPayload``), in seconds """ return _get_story_duration(self.xml) @property def offset(self) -> Optional[float]: """ The time offset of the story in seconds (if available in the XML) """ try: return self._story_offsets.get(self.id) except AttributeError: return @property def start_time(self) -> Optional[datetime]: """ The transmission start time of the story (if present in the XML) """ try: metadata = self.xml.find('mosExternalMetadata') mos_payload = metadata.find('mosPayload') start_time = mos_payload.find('StoryStarted').text return parse(start_time) except AttributeError: pass prog_start_time = self._prog_start_time offset = self.offset if prog_start_time is None or offset is None: return return self._prog_start_time + timedelta(seconds=self.offset) @property def end_time(self) -> Optional[datetime]: """ The transmission end time of the story (if present in the XML) """ try: metadata = self.xml.find('mosExternalMetadata') mos_payload = metadata.find('mosPayload') end_time = mos_payload.find('StoryEnded').text return parse(end_time) except AttributeError: pass if self.start_time is not None and self.duration is not None: return self.start_time + timedelta(seconds=self.duration) @property def script(self) -> List[str]: """ A list of strings found in paragraph tags within the story body, excluding any empty paragraphs or technical notes in brackets. """ return [ p.text.strip() for p in self.xml.findall('p') if p.text and p.text.strip() and not _is_technical_note(p) ] @property def body(self) -> List[Union[Item, str]]: """ A list of elements found in the story body. Each item in the list is either a string (representing a ``<p>`` tag) or an :class:`Item` object (representing an ``<item>`` tag). Unlike :attr:`script`, this does not exclude empty paragraph tags, which will be represented by empty strings. """ return [ Item(tag) if tag.tag == 'item' else _get_tag_text(tag) for tag in self.xml if tag.tag in ('item', 'p') ]