Source code for scorevideo_lib.parse_log

# This file is part of scorevideo_lib: A library for working with scorevideo
# Use of this file is governed by the license in LICENSE.txt.

"""Parse log files

"""

from typing import List, Optional
import re
from datetime import timedelta
from datetime import datetime
from functools import total_ordering
from scorevideo_lib.exceptions import FileFormatError
from scorevideo_lib.base_utils import BaseOps, remove_trailing_newline


LONG_LINE = "------------------------------------------"
SHORT_LINE = "-------------------------------"
VIDEO_INFO_START = "VIDEO FILE SET"
COMMANDS_START = "COMMAND SET AND SETTINGS"
COMMANDS_HEADER = ["-------------------------------",
                   "start|stop|subject|description",
                   "-------------------------------"]
# TODO: This is hacky and not generic
POST_COMMANDS_TEXT = ["subject 1:  subject1",
                      "subject 2:  subject2",
                      "subj#:  0=either  1=subject1  2=subject2  3=both",
                      "No. of simultaneous behaviors:  one"]
RAW_START = "RAW LOG"
RAW_HEADER = [LONG_LINE,
              "frame|time(min:sec)|command",
              LONG_LINE]
FULL_START = "FULL LOG"
FULL_HEADER = [LONG_LINE,
               "frame|time(min:sec)|description|action|subject",
               LONG_LINE]
NOTES_START = "NOTES"
NOTES_HEADER = [LONG_LINE]
MARKS_HEADER = [LONG_LINE,
                "frame|time(min:sec)|mark name",
                LONG_LINE]
MARKS_START = "MARKS"
EMPTY_STR_LIST = []  # type: List[str]


[docs]class Log(BaseOps): """Store a parsed version of a log file This version stores only the information contained in the log, not any information tied to a particular file (e.g. file name, reference to file, number of spaces separating columns). Attributes: full: A list of :py:class:`BehaviorFull` objects, each representing a line from the log file's ``FULL`` section marks: A list of :py:class:`Mark` objects, each representing a mark from the log file """ def __init__(self) -> None: """Initialize instance attributes as ``None`` """ # self.header = None # self.video_info = None # self.commands = None # self.raw = None temp_behav = [BehaviorFull(" 0 00:00.00 null either ")] self.full = temp_behav # type: List[BehaviorFull] # self.notes = None self.marks = [Mark(0, timedelta(0), "")] # type: List[Mark]
[docs] @classmethod def from_log(cls, log: "Log") -> "Log": """Create a :py:class:`Log` object from another :py:class:`Log` object Args: log: The object to copy Returns: A copy of the ``log`` parameter """ new_log = Log() # new_log.header = log.header # new_log.video_info = log.video_info # new_log.commands = log.commands # new_log.raw = log.raw new_log.full = log.full.copy() # new_log.notes = log.notes new_log.marks = log.marks.copy() return new_log
[docs] @classmethod def from_raw_log(cls, log: "RawLog") -> "Log": """Create a :py:class:`Log`` from a :py:class:`RawLog` object In the process, the log lines are parsed into their respective objects. This process is lossy. Args: log: The object to parse and to create the object from Returns: A parsed version of ``log`` """ new_log = Log() new_log.full = [BehaviorFull(line) for line in log.full] new_log.marks = [Mark.from_line(line) for line in log.marks] return new_log
[docs] @classmethod def from_file(cls, log_file) -> "Log": """Create a :py:class:`Log` object from a file Args: log_file: File to read from Returns: A parsed representation of ``log_file`` """ raw = RawLog.from_file(log_file) return cls.from_raw_log(raw)
[docs] def sort_lists(self) -> None: """Sort the lists of parsed material as applicable Returns: None """ self.marks.sort() self.full.sort()
[docs] def extend(self, log: "Log") -> None: """Add each element of each section of a log to the current log. Args: log: Log to add elements from Returns: None """ self.marks.extend(log.marks) self.full.extend(log.full)
[docs]class RawLog(BaseOps): """Store an interpreted form of a log file and perform operations on it Attributes: header: List of the lines in the header section video_info: List of the lines in the video info section commands: List of the lines in the commands section raw: List of the lines in the raw log section full: List of the lines in the full log section notes: List of the lines in the notes section marks: List of the lines in the marks section """ # pylint: disable=too-many-instance-attributes # In this case, it is reasonable to have an instance attribute per section def __init__(self) -> None: self.header = [] # type: List[str] self.video_info = [] # type: List[str] self.commands = [] # type: List[str] self.raw = [] # type: List[str] self.full = [] # type: List[str] self.notes = [] # type: List[str] self.marks = [] # type: List[str]
[docs] @classmethod def from_file(cls, log_file) -> "RawLog": """Parse log file into its sections. Populate the attributes of the RawLog class by using the get_section_* static methods to extract sections that are stored in attributes. Args: log_file: An open file object that points to the log file to read. """ log = RawLog() log.header = RawLog.get_section_header(log_file) log.video_info = RawLog.get_section_video_info(log_file) log.commands = RawLog.get_section_commands(log_file) log.raw = RawLog.get_section_raw(log_file) log.full = RawLog.get_section_full(log_file) log.notes = RawLog.get_section_notes(log_file) log.marks = RawLog.get_section_marks(log_file) log_file.seek(0) return log
[docs] @classmethod def from_raw_log(cls, raw_log: "RawLog") -> "RawLog": """Make a copy of a :py:class:`RawLog` object by copying each attribute Args: raw_log: Object to copy Returns: Copy of ``raw_log`` """ new_log = RawLog() new_log.header = raw_log.header new_log.video_info = raw_log.video_info new_log.commands = raw_log.commands new_log.raw = raw_log.raw new_log.full = raw_log.full new_log.notes = raw_log.notes new_log.marks = raw_log.marks return new_log
[docs] @staticmethod def get_section_header(log_file) -> List[str]: """Get the header section of a log. Extract the top section (top two lines) of a log. This section includes a statement that the log was created by scorevideo and the name of the log file. Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the header in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ header = [] for _ in range(2): header.append(log_file.readline().rstrip()) return header
[docs] @staticmethod def get_section_video_info(log_file) -> List[str]: """Get the video info section of a log. Extract the video info section (headed by the line "VIDEO FILE SET" of a log. This section includes information about the video including format, directory, name, start and end frames, duration, frame rate (FPS), and number of subjects Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ return RawLog.get_section(log_file, VIDEO_INFO_START, [], "")
[docs] @staticmethod def get_section_commands(log_file) -> List[str]: """Get the commands section of a log. Extract the commands section (headed by the line "COMMAND SET AND SETTINGS") used in generating the log file. This section specifies the key commands (letters) used to signal the beginning and end of each behavior. Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ header = COMMANDS_HEADER end = SHORT_LINE return RawLog.get_section(log_file, COMMANDS_START, header, end)
[docs] @staticmethod def get_section_raw(log_file) -> List[str]: """Get the raw log section of a log. Extract the section of the log that contains the raw scoring log. This section contains the frame number and time of each scored behavior along with the key command that was scored for that behavior Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ header = RAW_HEADER end = LONG_LINE return RawLog.get_section(log_file, RAW_START, header, end)
[docs] @staticmethod def get_section_full(log_file) -> List[str]: """Get the full log section of a log. Extract the section of the log that contains the full scoring log. This section contains the frame number and time of each scored behavior along with the full name assigned to that behavior in the commands section Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ header = FULL_HEADER end = LONG_LINE return RawLog.get_section(log_file, FULL_START, header, end)
[docs] @staticmethod def get_section_notes(log_file) -> List[str]: """Get the notes section of a log. Extract the notes section of the log, which contains arbitrary notes specified by the researcher during scoring, one per line. Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ header = NOTES_HEADER end = LONG_LINE return RawLog.get_section(log_file, NOTES_START, header, end)
[docs] @staticmethod def get_section_marks(log_file) -> List[str]: """Get the marks section of a log. Extract the marks section of the log, which stores the frame number and time at which the video starts and stops. Additional marks can be added here, such as when statistical analysis should begin or when fish started behaving. Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ header = MARKS_HEADER end = LONG_LINE return RawLog.get_section(log_file, MARKS_START, header, end)
[docs] @staticmethod def get_section(log_file, start: str, header: List[str], end: str) \ -> List[str]: """Get an arbitrary section from a log file. Extract an arbitrary section from a log file. The section is defined by a line at its start and a line at its end, neither of which are considered part of the section (not returned). A header section is also specified, the lines of which will be checked and excluded from the section. A header starts on the line immediately following the start line. If the header is not found, or if a line in it does not match, a FileFormatError is raised. If the end of the file is unexpectedly found before completing a section, a FileFormatError is raised. Args: log_file: An open file object that points to the log file to read. The file object must be ready to be read, and it should be at the start of the file. start: Line that signals the start of the section header: List of lines that form a header to the section. If no header should be present, pass an empty list. end: Line that signals the end of the section Returns: A list of the lines making up the section in sequential order, with each line a separate element in the list. Newlines or return carriages are stripped from the ends of lines. """ line = log_file.readline() while remove_trailing_newline(line) != start: # readline returns '' at the end of a file and '\n' for blank lines if line == "": raise FileFormatError("The start line '" + start + "' was not found in " + log_file.name) else: line = log_file.readline() for header_line in header: found_line = remove_trailing_newline(log_file.readline()) if header_line != found_line: raise FileFormatError.from_lines(log_file.name, found_line, header_line) section = [] line = log_file.readline() while not remove_trailing_newline(line) == end: if line == "": raise FileFormatError("The end line '" + end + "' was not found in " + log_file.name) section.append(remove_trailing_newline(line)) line = log_file.readline() return section
[docs] @staticmethod def section_to_strings(start: str, header: List[str], body: List[str], end: Optional[str], trailing: List[str] = None) -> List[str]: """Combine a section's components into a list of strings for writing Args: start: The invariant line that signals the start of the section header: Any invariant header lines that follow ``start`` body: The variably body of the section end: The invariant line that signals the end of the section trailing: Any lines that follow the ``end`` line Returns: A list of strings suitable for writing to a file. Note that the strings do not end in a newline. """ full = [] if start is not None: full.append(start) if header is not None: full.extend(header) if body is not None: full.extend(body) if end is not None: full.append(end) if trailing is not None: full.extend(trailing) full.append("") return full
[docs] def to_lines(self) -> List[str]: """Convert the current RawLog into the strings for writing to a file Returns: A list of strings (that do not end in newlines) that can be written to a file to create a properly-formatted log file. """ lines = [] lines.extend(self.header) lines.append("") sections = [ (VIDEO_INFO_START, EMPTY_STR_LIST, self.video_info, None, None), (COMMANDS_START, COMMANDS_HEADER, self.commands, SHORT_LINE, POST_COMMANDS_TEXT), (RAW_START, RAW_HEADER, self.raw, LONG_LINE, None), (FULL_START, FULL_HEADER, self.full, LONG_LINE, None), (NOTES_START, NOTES_HEADER, self.notes, LONG_LINE, None), (MARKS_START, MARKS_HEADER, self.marks, LONG_LINE, None) ] for start, header, body, end, trailing in sections: lines.extend(RawLog.section_to_strings(start, header, body, end, trailing)) return lines[:-1]
def __str__(self): """Wrapper that returns the result of calling :py:meth:`RawLog.to_lines` """ return str(self.to_lines())
[docs]class SectionItem(BaseOps): """Superclass for entries in a section of a log """
[docs] @staticmethod def validate_frame(frame: str) -> bool: """Check whether ``frame`` represents a valid frame number A valid frame number is any integer. Specifically, any ``frame`` that is composed solely of one or more digits 0-9 is accepted. Negative frames are allowed and denoted by a prefix of ``-``. >>> SectionItem.validate_frame("-5") True >>> SectionItem.validate_frame("05") True >>> SectionItem.validate_frame("hi5") False >>> SectionItem.validate_frame("50") True >>> SectionItem.validate_frame(" 50 ") False Args: frame: Potential frame number to validate Returns: ``True`` if ``frame`` is a valid frame number, ``False`` otherwise """ if frame[0] == "-": frame = frame[1:] return re.fullmatch(r"\A[0-9]+\Z", frame) is not None
[docs] @staticmethod def validate_time(time_str: str) -> bool: """Check whether ``time_str`` represents a valid log time stamp The following formats are accepted where ``#`` represents a digit 0-9 * ``#:##.##`` * ``##:##.##`` * ``#:##:##.##`` * ``##:##:##.##`` A prefix of ``-`` is also allowed. TODO: Check whether the minute and hour values are valid (i.e. <60) Args: time_str: The potential time representation to validate Returns: ``True`` if ``time_str`` is a valid time, ``False`` otherwise """ num_colons = time_str.count(":") if time_str[0] == "-": time_str = time_str[1:] if num_colons == 1: return re.fullmatch(r"\A[0-9]{1,2}:[0-9]{2}\.[0-9]{2}\Z", time_str) is not None if num_colons == 2: return re.fullmatch(r"\A[0-9]{1,2}:[0-9]{2}:[0-9]{2}\.[0-9]{2}\Z", time_str) is not None return False
[docs] @staticmethod def validate_description(desc: str) -> bool: """Check whether ``desc`` is a valid behavior description To be valid, ``desc`` must be made exclusively of digits, letters, commas, and spaces. >>> SectionItem.validate_description("Some Description 3!") False >>> SectionItem.validate_description("Some Description, 3") True >>> SectionItem.validate_description("Some Description 3") True >>> SectionItem.validate_description("Some Description 3 here") True >>> SectionItem.validate_description("Some \\n Description 3!") False Args: desc: The potential behavior description to check Returns: ``True`` if ``desc`` is valid, ``False`` otherwise """ return re.fullmatch(r"\A[0-9A-Za-z ,]+\Z", desc) is not None
[docs] @staticmethod def split_line(line: str) -> List[str]: """Split a RawLog file line in a section into its elements Elements must be separated by at least two spaces >>> SectionItem.split_line(" hi 4 test >?why my4 j ") ['hi', '4', 'test', '>?why', 'my4 j'] Args: line: Line to split Returns: A list of the elements in the provided line """ split = re.split(r"\s{2,}", line) split[0] = split[0].lstrip() split[-1] = split[-1].rstrip() return [elem for elem in split if elem != ""]
[docs] @staticmethod def str_to_timedelta(time_str: str) -> timedelta: """Convert a string representation of a time into a :py:class:timedelta >>> SectionItem.str_to_timedelta("30:00.03") datetime.timedelta(seconds=1800, microseconds=30000) Args: time_str: String representation of the time or duration Returns: :py:class:timedelta object that represents the same duration or time as ``time_str`` does. """ neg = False if time_str[0] == "-": neg = True time_str = time_str[1:] split_time = time_str.split(":") secs = float(split_time[-1]) # MM:SS.SS -> [MM, SS.SS] if len(split_time) == 2: secs += int(split_time[0]) * 60 # HH:MM:SS.SS -> [HH, MM, SS.SS] elif len(split_time) == 3: secs += int(split_time[0]) * 60 * 60 secs += int(split_time[1]) * 60 if neg: secs = -secs return timedelta(seconds=secs)
[docs]@total_ordering class BehaviorFull(SectionItem): """Store an interpreted representation of a behavior from the full section Attributes: frame: A positive integer representing the frame number on which the behavior was scored. time: A :py:class:timedelta object that represents the time elapsed from the start of the clip to the behavior being scored. This is a representation of the time listed in the log line. description: The name of the behavior that appears as the second-to-last element in the provided line subject: Always the string ``either`` startend: Optional, either ``start`` or ``end`` """ def __init__(self, behavior_line: str) -> None: """Create a new :py:class:`BehaviorFull` object from the provided line. >>> behav = BehaviorFull(" 1769 0:58.97 Flee from male either ") >>> behav.frame 1769 >>> behav.time datetime.timedelta(seconds=58, microseconds=970000) >>> behav.description 'Flee from male' >>> behav.subject 'either' >>> print(behav.startend) None Args: behavior_line: A line from the ``FULL LOG`` section of a log file Returns: None Raises: TypeError: When the provided line does not conform to the expected format. Notably, all the elements of the line must be separated from each other by at least 2 spaces. """ line = SectionItem.split_line(behavior_line) line_error = "The line '" + behavior_line + "' is not a valid line " \ "from the FULL LOG section" if len(line) > 5: err = "{} (num elements: {} > 5)".format(line_error, len(line)) raise TypeError(err) elif len(line) < 4: err = "{} (num elements: {} < 4)".format(line_error, len(line)) raise TypeError(err) elif not SectionItem.validate_frame(line[0]): err = "{} ('{}' is not a valid frame number)".format(line_error, line[0]) raise TypeError(err) elif not SectionItem.validate_time(line[1]): err = "{} ('{}' is not a valid time)".format(line_error, line[1]) raise TypeError(err) elif not SectionItem.validate_description(line[2]): err = "{} ('{}' is not a valid behavior)".format(line_error, line[2]) raise TypeError(err) elif not BehaviorFull.validate_subject(line[3]): err = "{} ('{}' is not a valid subject)".format(line_error, line[3]) raise TypeError(err) self.frame = int(line[0]) self.time = SectionItem.str_to_timedelta(line[1]) self.description = line[2] self.subject = line[3] self.startend = line[4] if len(line) == 5 else None
[docs] @staticmethod def validate_subject(subject: str) -> bool: """Check whether ``subject`` is a valid subject element To be valid, ``subject`` must be exactly ``either`` >>> BehaviorFull.validate_subject("either") True >>> BehaviorFull.validate_subject(" either") False Args: subject: Potential subject element of a log to check Returns: ``True`` if ``subject`` is valid, ``False`` otherwise """ return subject == "either"
def __lt__(self, other: "BehaviorFull"): if self.frame != other.frame: return self.frame < other.frame if self.time != other.time: return self.time < other.time if self.description != other.description: return self.description < other.description if self.subject != other.subject: return self.subject < other.subject return False
[docs]@total_ordering class Mark(SectionItem): """Store a ``mark`` from the ``MARKS`` section Attributes: frame: An integer representing the frame number at which the mark is placed time: A :py:class:timedelta object that represents the time elapsed from the start of the clip to the mark. This is a representation of the time listed in the log line. Negative times are supported and are represented as their absolute times prefixed with a ``-``. name: Name of the mark that describes its meaning """ def __init__(self, frame: int, time: timedelta, name: str) -> None: self.frame = frame self.time = time self.name = name
[docs] @classmethod def from_line(cls, line: str) -> "Mark": """Create a new :py:class:Mark from a provided line from the log file >>> mark = Mark.from_line("54001 30:00.03 video end") >>> mark.frame 54001 >>> mark.time datetime.timedelta(seconds=1800, microseconds=30000) >>> mark.name 'video end' Args: line: A line from the ``MARKS`` section of a log file Returns: None Raises: TypeError: When the provided line does not conform to the expected format. Notably, all 3 elements of the line must be separated from each other by at least 2 spaces. """ elems = SectionItem.split_line(line) line_error = "The line '{}' is not a valid line from the MARKS section"\ .format(line) if len(elems) < 3: err = "{} (num elements: {} < 3)".format(line_error, len(elems)) raise TypeError(err) elif len(elems) > 3: err = "{} (num elements: {} > 3)".format(line_error, len(elems)) raise TypeError(err) elif not SectionItem.validate_frame(elems[0]): err = "{} (frame '{}' is not valid)".format(line_error, elems[0]) raise TypeError(err) elif not SectionItem.validate_time(elems[1]): err = "{} (time '{}' is not valid)".format(line_error, elems[1]) raise TypeError(err) elif not SectionItem.validate_description(elems[2]): err = "{} (mark name '{}' is invalid)".format(line_error, elems[2]) raise TypeError(err) frame = int(elems[0]) time = SectionItem.str_to_timedelta(elems[1]) name = elems[2] return cls(frame, time, name)
[docs] @staticmethod def time_to_str(time: timedelta) -> str: """Converts a :py:class:`timedelta` object into a string >>> Mark.time_to_str(timedelta(seconds=1800.07)) '30:00.07' >>> Mark.time_to_str(timedelta(seconds=4.4557)) '0:04.45' >>> Mark.time_to_str(timedelta(seconds=3600.5)) '1:00:00.50' >>> Mark.time_to_str(timedelta(seconds=-1800.07)) '-30:00.07' Args: time: The time to turn into a string. Returns: A string representation of the time, with 2 decimal-places of second precision. The result is truncated if necessary. Raises: ValueError: Raised if time is greater than 1 day. """ secs = datetime.utcfromtimestamp(abs(time.total_seconds())) if abs(time) < timedelta(seconds=60): # Under 1 minute (all can be expressed in secs microsecs) time_str = "0:" + secs.strftime("%S.%f") elif abs(time) < timedelta(seconds=60 * 60): # Under 1 hour (all can be expressed in mins, secs, microsecs) time_str = secs.strftime("%M:%S.%f") if time_str[0] == "0": time_str = time_str[1:] elif abs(time) < timedelta(days=1): # Under 1 day (all can be expressed in hrs, mins, secs, microsecs) time_str = secs.strftime("%H:%M:%S.%f") if time_str[0] == "0": time_str = time_str[1:] else: raise ValueError("The duration '{}' is too long (must be < 1 day)" .format(str(time))) # Truncate time_str to cut off all but 2 most significant decimal places time_str = time_str[:time_str.index(".") + 3] # Add negative sign for negative times if time.total_seconds() < 0: time_str = "-" + time_str return time_str
[docs] def to_line(self, other_line: str) -> str: """Converts a :py:class:Mark object into a log line in the MARKS section ``other_line`` is used as a template. It should come from the log file the returned line will be inserted into. Only loose error checking is performed, and invalid lines may produce undefined output. Similarly, if the constructed line cannot fit into the format prescribed by ``other_line``, the output is undefined. >>> mark = Mark(734, timedelta(seconds=1800.07), "video end") >>> mark.to_line(" 1 0:00.03 video start") '734 30:00.07 video end' Args: other_line: A line from the MARKS section into which the resulting string could be inserted. This defines the format this method will attempt to match. Returns: A log line that could be inserted into the MARKS section of the log from which ``other_line`` came. Raises: ValueError: Raised if ``other_line`` is invalid or the mark's time is greater than 1 day """ match = re.search(r"\A(\s*\S+)(\s{2,}\S+)(\s{2,})(?:\S+\s*)+", other_line) if match is None: err = "other_line '{}' is not a valid line from the MARKS section".\ format(other_line) raise ValueError(err) # match.group(n) returns the string in other_line that was matched by # the n-th parenthesized group in the regular expression. Note that # `(?: ... )` does not count as a group in this context frame_col_width = len(match.group(1)) time_col_width = len(match[2]) time_name_sep_width = len(match[3]) time_str = Mark.time_to_str(self.time) # Creates a template like "{0:>frame_col_width}{1:>time_col_width} {2}" # Both the frame and time columns are right-justified and of lengths # fixed by the variables frame_col_width and time_col_width. 0, 1, and 2 # are indices that define the locations to fill each arg of .format(...) template = "{0:>" + str(frame_col_width) + "}{1:>" + \ str(time_col_width) + "}" + (" " * time_name_sep_width) + \ "{2}" return template.format(self.frame, time_str, self.name)
[docs] def to_line_tab(self) -> str: """Converts a :py:class:Mark object into a log line in the MARKS section The resulting line is delimited by 4 spaces. >>> mark = Mark(734, timedelta(seconds=1800.07), "video end") >>> mark.to_line_tab() '734 30:00.07 video end' Returns: A log line that could be inserted into the MARKS section of the log from which ``other_line`` came. Note that since the line has a fixed delimiter, this line may not appear to match the columns in the file. However, this delimitation is assumed by some other programs for ``scorevideo`` logs, including ``behaviorcode``. Raises: ValueError: Raised if ``other_line`` is invalid or the mark's time is greater than 1 day """ time_str = Mark.time_to_str(self.time) delim = ' ' return f"{self.frame}{delim}{time_str}{delim}{self.name}"
def __lt__(self, other: "Mark"): """Determine if one :py:class:`Mark` is less than another Ordering is performed with the ``<`` operator on the class's attributes in the following descending order of priority: * :py:attr:`Mark.frame` * :py:attr:`Mark.time` * :py:attr:`Mark.name` Args: other: The :py:class:`Mark` to compare to ``self`` Returns: ``True`` if and only if ``self`` comes before ``other`` """ if self.frame != other.frame: return self.frame < other.frame if self.time != other.time: return self.time < other.time if self.name != other.name: return self.name < other.name return False