mirrored 14 minutes ago
0
Your Namefix docs c8267ce
"""
Log parser for VimGolf executor
"""

import copy
import json
import os
from abc import ABC, abstractmethod
from typing import Type

from vimgolf_gym.dataclasses import VimGolfEnvResult


class AbstractLogParser(ABC):
    def __init__(self): ...
    @abstractmethod
    def feed_line(self, line: str): ...


# We keep track of the log file attributes.
# Specifically, the file size.
# If the file size changes, we will reparse the log.
# In more advanced usage, we could seek to given point and parse from there, avoiding reparsing from the beginning.
class LogWatcher:
    def __init__(self, log_file: str, parser_class: Type[AbstractLogParser]):
        """
        Initialize the log watcher with a log file and a parser class.

        Args:
            log_file (str): The path to the log file.
            parser_class (Type[AbstractLogParser]): A class that implements the AbstractLogParser interface.

        Attributes:
            log_file (str): The path to the log file.
            parser_class (Type[AbstractLogParser]): The class of the parser.
            parser (AbstractLogParser): An instance of the parser class.
            last_filesize (int): The size of the log file when it was last checked.
            last_position (int): The last read position in the log file.
        """
        self.log_file = log_file
        self.parser_class = parser_class
        self.parser = parser_class()
        self.last_filesize = 0
        self.last_position = 0  # Track the last read position

    def update(self, style="advanced"):
        """
        Update the log watcher using one of three strategies.

        The ``advanced`` strategy is the default. It checks the file size
        and only reads new content if the file size has changed. If the file
        size is smaller than the last recorded size, it resets the parser and
        reads the entire file.

        The ``simple`` strategy reads the entire log file every time it is
        called. This is simple, but slow for large log files.

        The ``naive`` strategy reads the entire log file every time it is
        called, but it does not reset the parser. This is faster than the
        ``simple`` strategy, but it does not handle the case where the log
        file is truncated.

        Args:
            style (str, optional): The update strategy to use. Must be one of 
                ``advanced``, ``simple``, or ``naive``. Defaults to ``advanced``.
        """
        if style == "advanced":
            self.advanced_update()
        elif style == "simple":
            self.simple_update()
        elif style == "naive":
            self.naive_update()
        else:
            raise ValueError(
                "Unrecognized update option: %s (should be in advanced, simple, naive)"
                % style
            )

    def simple_update(self):
        """
        Read the entire log file and reset the parser if the file size has changed.

        The ``simple`` strategy reads the entire log file every time it is
        called. This is simple, but slow for large log files.
        """
        current_size = (
            os.path.getsize(self.log_file) if os.path.exists(self.log_file) else 0
        )
        if current_size != self.last_filesize:
            self.naive_update()
            self.last_filesize = current_size

    def naive_update(self):
        """
        Reset the parser and read the entire log file.

        This is slow for large log files, but it is simple and handles
        the case where the log file is truncated.

        """
        self.parser = self.parser_class()
        if os.path.exists(self.log_file):
            with open(self.log_file, "r") as f:
                for line in f.readlines():
                    self.parser.feed_line(line)
        self.last_filesize = (
            os.path.getsize(self.log_file) if os.path.exists(self.log_file) else 0
        )
        self.last_position = self.last_filesize

    def advanced_update(self):
        """
        Read only the new content from the log file.

        The ``advanced`` strategy reads only the new content from the log file,
        starting from the last recorded position. This is fast for large log
        files, but it requires keeping track of the last recorded position.

        If the file size has decreased, it resets the parser and reads the
        entire file.
        """
        if not os.path.exists(self.log_file):
            return

        current_size = os.path.getsize(self.log_file)

        # If file size decreased (file was truncated), do a full reset
        if current_size < self.last_filesize:
            self.naive_update()
            return

        # If file size increased, read only the new content
        if current_size > self.last_filesize:
            with open(self.log_file, "r") as f:
                f.seek(self.last_position)
                while True:
                    line = f.readline()
                    if not line:  # End of file
                        break
                    self.parser.feed_line(line)

                # Update tracking variables
                self.last_filesize = current_size
                self.last_position = f.tell()


class VimGolfLogWatcher(LogWatcher):
    def __init__(self, log_file: str, update_style="advanced"):
        """
        Initialize a VimGolfLogWatcher with a log file and an update style.

        The VimGolfLogWatcher is a LogWatcher that is specialized for
        reading VimGolf logs.

        Args:
            log_file (str): The path to the log file.
            update_style (str, optional): The update strategy to use. Must be one of 
                ``advanced``, ``simple``, or ``naive``. Defaults to ``advanced``.

        Returns:
            [return_type]: [description of return value]
        """
        super().__init__(log_file=log_file, parser_class=VimGolfLogParser)
        self.parser: VimGolfLogParser
        self.update_style = update_style

    def default_update(self):
        """
        Call the update method with the default update style.

        This method is a convenience wrapper around the update method,
        calling it with the default update style specified in the
        constructor.

        See the update method for more details.
        """
        self.update(style=self.update_style)

    @property
    def success(self):
        """
        Check if the vimgolf challenge has been solved successfully.

        This property checks if the vimgolf challenge has been solved
        successfully. It updates the log watcher if necessary before
        checking the success status.

        Returns:
            bool: True if the challenge has been solved successfully, False otherwise.
        """
        self.default_update()
        return self.parser.success

    def get_best_success_result(self):
        """
        Return the best success result in the log watcher.

        This method returns the best success result in the log watcher,
        updating the log watcher if necessary before returning the result.

        Returns:
            VimGolfEnvResult: The best success result in the log watcher, or None if 
            there is no success result.
        """
        self.default_update()
        return self.parser.get_best_success_result()

    def get_last_success_result(self):
        """
        Return the last success result in the log watcher.

        This method returns the last success result in the log watcher,
        updating the log watcher if necessary before returning the result.

        Returns:
            VimGolfEnvResult: The last success result in the log watcher, or None if 
            there is no success result.
        """
        self.default_update()
        return self.parser.get_last_success_result()

    @property
    def results(self):
        """
        The results of the vimgolf challenge environment.

        This property returns the results of the vimgolf challenge environment,
        updating the log watcher if necessary before returning the results.

        Returns:
            list[VimGolfEnvResult]: The results of the vimgolf challenge environment, or an empty list if
            there are no results.
        """
        self.default_update()
        return self.parser.results

    @property
    def success_results(self):
        """
        The successful results of the vimgolf challenge environment.

        This property returns the successful results of the vimgolf challenge
        environment, updating the log watcher if necessary before returning the
        results.

        Returns:
            list[VimGolfEnvResult]: The successful results of the vimgolf challenge environment, or an
            empty list if there are no successful results.
        """
        self.default_update()
        return self.parser.success_results


class VimGolfLogParser(AbstractLogParser):
    def __init__(self):
        """
        Initialize the VimGolfLogParser.

        The VimGolfLogParser is initialized with an empty list of results.
        """
        self.results: list[VimGolfEnvResult] = []

    def feed_line(self, line: str):
        """
        Feed a line to the parser.

        The line should be a JSON-formatted string. The parser will attempt to
        parse the line as a JSON object, and if it is a dictionary, it will
        check if the dictionary has an "event_type" key with value
        "vimgolf_result". If so, it will attempt to parse the value of the
        "event_data" key as a VimGolfEnvResult object and append it to the
        results list.

        If the line is not a valid JSON object, or if the JSON object does not
        have the correct structure, the line will be ignored.

        Args:
            line (str): The line of text to feed to the parser.
        """
        try:
            data = json.loads(line.strip())
            if type(data) == dict:
                if data.get("event_type", None) == "vimgolf_result":
                    event_data = data.get("event_data", None)
                    if type(event_data) == dict:
                        parsed_result = VimGolfEnvResult.parse_obj(event_data)
                        self.results.append(parsed_result)
        except json.JSONDecodeError:
            ...

    @property
    def success_results(self):
        """
        The successful results of the vimgolf challenge environment.

        This property returns the successful results of the vimgolf challenge
        environment, which are the results in the results list where the
        correct attribute is True.

        Returns:
            list[VimGolfEnvResult]: The successful results of the vimgolf challenge environment, or an
            empty list if there are no successful results.
        """
        return [it for it in self.results if it.correct]

    @property
    def success(self):
        """
        Check if the vimgolf challenge has been solved successfully.

        This property checks if the vimgolf challenge has been solved
        successfully. It returns True if there are any successful results in
        the results list, and False otherwise.

        Returns:
            bool: True if the challenge has been solved successfully, False otherwise.
        """
        return len(self.success_results) != 0

    def get_last_success_result(self):
        """
        Return the last success result in the log watcher.

        This method returns the last success result in the log watcher,
        which is the last result in the success_results list.

        Returns:
            VimGolfEnvResult: The last success result in the log watcher, or None if there is no
            success result.
        """
        success_results = self.success_results
        if success_results:
            return success_results[-1]

    def get_best_success_result(self):
        """Return the result with the lowest score"""
        success_results = copy.deepcopy(self.success_results)
        if success_results:
            success_results.sort(key=lambda x: x.score)
            return success_results[0]