<commit_before>""" Contains the MatrixLight parent classes. """

from operator import itemgetter

from mpf.core.system_wide_device import SystemWideDevice

class MatrixLight(SystemWideDevice):
    """ Represents a light connected to a traditional lamp matrix in a pinball
    machine.

    This light could be an incandescent lamp or a replacement single-color
    LED. The key is that they're connected up to a lamp matrix.

    """

    config_section = 'matrix_lights'
    collection = 'lights'
    class_label = 'light'
    machine = None

    lights_to_update = set()

    @classmethod
    def device_class_init(cls, machine):

        cls.machine = machine

        machine.validate_machine_config_section('matrix_light_settings')

        # todo make time configurable
        machine.clock.schedule_interval(cls.update_matrix_lights, 0, -100)

        machine.mode_controller.register_stop_method(cls.mode_stop)

    @classmethod
    def update_matrix_lights(cls, dt):
        del dt
        # called periodically (default at the end of every frame) to actually
        # write the new light states to the hardware
        if MatrixLight.lights_to_update:
            for light in MatrixLight.lights_to_update:
                light.update_hw_light()

            MatrixLight.lights_to_update = set()

    @classmethod
    def mode_stop(cls, mode):
        for light in cls.machine.lights:
            light.remove_from_stack_by_mode(mode)

    def __init__(self, machine, name):
        self.hw_driver = None
        super().__init__(machine, name)

        self.x = None
        self.y = None

        self.fade_in_progress = False
        self.default_fade_ms = None

        self.registered_handlers = list()

        self.stack = list()
        """A list of dicts which represents different commands that have come
        in to set this light to a certain brightness (and/or fade). Each entry
        in the list contains the following key/value pairs:

        priority: The relative priority of this brightness command. Higher
            numbers take precedent, and the highest priority entry will be the
            command that's currently active. In the event of a tie,
            whichever entry was added last wins (based on 'start_time' below).
        start_time: The clock time when this command was added. Primarily used
            to calculate fades, but also used as a tie-breaker for multiple
            entries with the same priority.
        start_brightness: Brightness this light when this command came in.
        dest_time: Clock time that represents when a fade (from
            start_brightness to dest_brightness ) will be done. If this is 0,
            that means there is no fade. When a fade is complete, this value is
             reset to 0.
        dest_brightness: Brightness of the destination this light is fading
            to. If a command comes in with no fade, then this will be the same
            as the 'brightness' below.
        brightness: The current brightness of the light based on this command.
            (0-255) This value is updated automatically as fades progress, and
            it's the value that's actually written to the hardware.
        key: An arbitrary unique identifier to keep multiple entries in the
            stack separate. If a new brightness command comes in with a key
            that already exists for an entry in the stack, that entry will be
            replaced by the new entry. The key is also used to remove entries
            from the stack (e.g. when shows or modes end and they want to
            remove their commands from the light).
        mode: Optional mode where the brightness was set. Used to remove
            entries when a mode ends.

        """

    def _initialize(self):
        self.load_platform_section('matrix_lights')

        self.hw_driver = self.platform.configure_matrixlight(self.config)

        if self.config['fade_ms'] is not None:
            self.default_fade_ms = self.config['fade_ms']
        elif self.machine.config['matrix_light_settings']:
            self.default_fade_ms = (
                self.machine.config['matrix_light_settings']['default_light_fade_ms'])
        else:
            self.default_fade_ms = 0

        if 'x' in self.config:
            self.x = self.config['x']

        if 'y' in self.config:
            self.y = self.config['y']

    # pylint: disable-msg=too-many-arguments
    def on(self, brightness=255, fade_ms=None, priority=0, key=None, mode=None,
           **kwargs):
        """Adds or updates a brightness entry in this lights's stack, which is
        how you tell this light how bright you want it to be.

        Args:
            brightness: How bright this light should be, as an int between 0
                and 255. 0 is off. 255 is full on. Note that matrix lights in
                older (even WPC) machines had slow matrix update speeds, and
                effective brightness levels will be far less than 255.
            fade_ms: Int of the number of ms you want this light to fade to the
                brightness in. A value of 0 means it's instant. A value of
                None (the default) means that it will use this lights's and/or
                the machine's default fade_ms setting.
            priority: Int value of the priority of these incoming settings. If
                this light has current settings in the stack at a higher
                priority, the settings you're adding here won't take effect.
                However they're still added to the stack, so if the higher
                priority settings are removed, then the next-highest apply.
            key: An arbitrary identifier (can be any immutable object) that's
                used to identify these settings for later removal. If any
                settings in the stack already have this key, those settings
                will be replaced with these new settings.
            mode: Optional mode instance of the mode that is setting this
                brightness. When a mode ends, entries from the stack with that
                mode will automatically be removed.
            **kwargs: Not used. Only included so this method can be used as
                an event callback since events could pass random kwargs.
        """

        del kwargs

        if self.debug:
            self.log.debug("Received on() command. brightness: %s, fade_ms: %s"
                           "priority: %s, key: %s", brightness, fade_ms,
                           priority, key)

        if fade_ms is None:
            fade_ms = self.default_fade_ms

        if priority < self._get_priority_from_key(key):
            if self.debug:
                self.log.debug("Incoming priority is lower than an existing "
                               "stack item with the same key. Not adding to "
                               "stack.")

            return

        self._add_to_stack(brightness, fade_ms, priority, key, mode)

    # pylint: disable-msg=too-many-arguments
    def _add_to_stack(self, brightness, fade_ms, priority, key, mode):
        curr_brightness = self.get_brightness()

        self.remove_from_stack_by_key(key)

        if fade_ms:
            new_brightness = curr_brightness
            dest_time = self.machine.clock.get_time() + (fade_ms / 1000)
        else:
            new_brightness = brightness
            dest_time = 0

        self.stack.append(dict(priority=priority,
                               start_time=self.machine.clock.get_time(),
                               start_brightness=curr_brightness,
                               dest_time=dest_time,
                               dest_brightness=brightness,
                               brightness=new_brightness,
                               key=key,
                               mode=mode))

        self.stack.sort(key=itemgetter('priority', 'start_time'), reverse=True)

        if self.debug:
            self.log.debug("+-------------- Adding to stack ----------------+")
            self.log.debug("priority: %s", priority)
            self.log.debug("start_time: %s", self.machine.clock.get_time())
            self.log.debug("start_brightness: %s", curr_brightness)
            self.log.debug("dest_time: %s", dest_time)
            self.log.debug("dest_brightness: %s", brightness)
            self.log.debug("brightness: %s", new_brightness)
            self.log.debug("key: %s", key)

        MatrixLight.lights_to_update.add(self)

    def clear_stack(self):
        """Removes all entries from the stack and resets this light to 'off'."""
        self.stack[:] = []

        if self.debug:
            self.log.debug("Clearing Stack")

        MatrixLight.lights_to_update.add(self)

    def remove_from_stack_by_key(self, key):
        """Removes a group of brightness settings from the stack.

        Args:
            key: The key of the settings to remove (based on the 'key'
                parameter that was originally passed to the brightness() method.)

        This method triggers a light update, so if the highest priority settings
        were removed, the light will be updated with whatever's below it. If no
        settings remain after these are removed, the light will turn off.

        """

        if self.debug:
            self.log.debug("Removing key '%s' from stack", key)

        self.stack[:] = [x for x in self.stack if x['key'] != key]
        MatrixLight.lights_to_update.add(self)

    def remove_from_stack_by_mode(self, mode):
        """Removes a group of brightness settings from the stack.

        Args:

        This method triggers a light update, so if the highest priority settings
        were removed, the light will be updated with whatever's below it. If no
        settings remain after these are removed, the light will turn off.

        """

        if self.debug:
            self.log.debug("Removing mode '%s' from stack", mode)

        self.stack[:] = [x for x in self.stack if x['mode'] != mode]
        MatrixLight.lights_to_update.add(self)

    def get_brightness(self):
        """Returns an RGBColor() instance of the 'color' setting of the highest
        color setting in the stack. This is usually the same color as the
        physical LED, but not always (since physical LEDs are updated once per
        frame, this value could vary.

        Also note the color returned is the "raw" color that does has not had
        the color correction profile applied.

        """
        try:
            return self.stack[0]['brightness']
        except IndexError:
            return 0

    def _get_priority_from_key(self, key):
        try:
            return [x for x in self.stack if x['key'] == key][0]['priority']
        except IndexError:
            return 0

    def update_hw_light(self):
        """Physically updates the light hardware object based on the
        'brightness' setting of the highest priority setting from the stack.

        This method is automatically called whenever a brightness change has been
        made (including when fades are active).

        """
        if not self.stack:
            self.off()

        # if there's a current fade, but the new command doesn't have one
        if not self.stack[0]['dest_time'] and self.fade_in_progress:
            self._stop_fade_task()

        # If the new command has a fade, but the fade task isn't running
        if self.stack[0]['dest_time'] and not self.fade_in_progress:
            self._setup_fade()

        # If there's no current fade and no new fade, or a current fade and new
        # fade
        else:
            self.hw_driver.on(self.stack[0]['brightness'])

            if self.registered_handlers:
                # Handlers are not sent brightness corrected brightnesss
                # todo make this a config option?
                for handler in self.registered_handlers:
                    handler(light_name=self.name,
                            brightness=self.stack[0]['brightness'])

    def off(self, fade_ms=0, priority=0, key=None, mode=None, **kwargs):
        """Turns this light off.

        Args:
            fade_ms: Int of the number of ms you want this light to fade to the
                brightness in. A value of 0 means it's instant. A value of
                None (the default) means that it will use this lights's and/or
                the machine's default fade_ms setting.
            priority: Int value of the priority of these incoming settings. If
                this light has current settings in the stack at a higher
                priority, the settings you're adding here won't take effect.
                However they're still added to the stack, so if the higher
                priority settings are removed, then the next-highest apply.
            key: An arbitrary identifier (can be any immutable object) that's
                used to identify these settings for later removal. If any
                settings in the stack already have this key, those settings
                will be replaced with these new settings.
            mode: Optional mode instance of the mode that is setting this
                brightness. When a mode ends, entries from the stack with that
                mode will automatically be removed.
            **kwargs: Not used. Only included so this method can be used as
                an event callback since events could pass random kwargs.
        """
        del kwargs
        self.on(brightness=0, fade_ms=fade_ms, priority=priority, key=key,
                mode=mode)

    def add_handler(self, callback):
        """Registers a handler to be called when this light changes state."""
        self.registered_handlers.append(callback)

    def remove_handler(self, callback=None):
        """Removes a handler from the list of registered handlers."""
        if not callback:  # remove all
            self.registered_handlers = []
            return

        if callback in self.registered_handlers:
            self.registered_handlers.remove(callback)

    def _setup_fade(self):

        if self.fade_in_progress:
            return

        self.fade_in_progress = True

        if self.debug:
            self.log.debug("Setting up the fade task")

        self.machine.clock.schedule_interval(self._fade_task, 1 / self.machine.config['mpf']['default_fade_hz'])

    def _fade_task(self, dt):
        del dt

        # not sure why this is needed, but sometimes the fade task tries to
        # run even though self.fade_in_progress is False. Maybe
        # clock.unschedule doesn't happen right away?
        if not self.fade_in_progress:
            return

        try:
            brightness_settings = self.stack[0]
        except IndexError:
            self._stop_fade_task()
            return

        # todo
        if not brightness_settings['dest_time']:
            return

        # figure out the ratio of how far along we are
        try:
            ratio = ((self.machine.clock.get_time() -
                      brightness_settings['start_time']) /
                     (brightness_settings['dest_time'] -
                      brightness_settings['start_time']))
        except ZeroDivisionError:
            ratio = 1.0

        if self.debug:
            self.log.debug("Fade task, ratio: %s", ratio)

        if ratio >= 1.0:  # fade is done
            self._end_fade()
            brightness_settings['brightness'] = (
                brightness_settings['dest_brightness'])
        else:
            brightness_settings['brightness'] = (
                brightness_settings['start_brightness'] +
                int((brightness_settings['dest_brightness'] -
                    brightness_settings['start_brightness']) * ratio))

        MatrixLight.lights_to_update.add(self)

    def _end_fade(self):
        # stops the fade and instantly sets the light to its destination brightness
        self._stop_fade_task()
        self.stack[0]['dest_time'] = 0

    def _stop_fade_task(self):
        # stops the fade task. Light is left in whatever state it was in
        self.fade_in_progress = False
        self.machine.clock.unschedule(self._fade_task)

        if self.debug:
            self.log.debug("Stopping fade task")
<commit_msg>make light update speed configurable<commit_after>""" Contains the MatrixLight parent classes. """

from operator import itemgetter

from mpf.core.system_wide_device import SystemWideDevice

class MatrixLight(SystemWideDevice):
    """ Represents a light connected to a traditional lamp matrix in a pinball
    machine.

    This light could be an incandescent lamp or a replacement single-color
    LED. The key is that they're connected up to a lamp matrix.

    """

    config_section = 'matrix_lights'
    collection = 'lights'
    class_label = 'light'
    machine = None

    lights_to_update = set()

    @classmethod
    def device_class_init(cls, machine):

        cls.machine = machine

        machine.validate_machine_config_section('matrix_light_settings')

        # todo make time configurable
        machine.clock.schedule_interval(cls.update_matrix_lights,
                                        1 / machine.config['mpf']['default_matrix_light_hw_update_hz'],
                                        -100)

        machine.mode_controller.register_stop_method(cls.mode_stop)

    @classmethod
    def update_matrix_lights(cls, dt):
        del dt
        # called periodically (default at the end of every frame) to actually
        # write the new light states to the hardware
        if MatrixLight.lights_to_update:
            for light in MatrixLight.lights_to_update:
                light.update_hw_light()

            MatrixLight.lights_to_update = set()

    @classmethod
    def mode_stop(cls, mode):
        for light in cls.machine.lights:
            light.remove_from_stack_by_mode(mode)

    def __init__(self, machine, name):
        self.hw_driver = None
        super().__init__(machine, name)

        self.x = None
        self.y = None

        self.fade_in_progress = False
        self.default_fade_ms = None

        self.registered_handlers = list()

        self.stack = list()
        """A list of dicts which represents different commands that have come
        in to set this light to a certain brightness (and/or fade). Each entry
        in the list contains the following key/value pairs:

        priority: The relative priority of this brightness command. Higher
            numbers take precedent, and the highest priority entry will be the
            command that's currently active. In the event of a tie,
            whichever entry was added last wins (based on 'start_time' below).
        start_time: The clock time when this command was added. Primarily used
            to calculate fades, but also used as a tie-breaker for multiple
            entries with the same priority.
        start_brightness: Brightness this light when this command came in.
        dest_time: Clock time that represents when a fade (from
            start_brightness to dest_brightness ) will be done. If this is 0,
            that means there is no fade. When a fade is complete, this value is
             reset to 0.
        dest_brightness: Brightness of the destination this light is fading
            to. If a command comes in with no fade, then this will be the same
            as the 'brightness' below.
        brightness: The current brightness of the light based on this command.
            (0-255) This value is updated automatically as fades progress, and
            it's the value that's actually written to the hardware.
        key: An arbitrary unique identifier to keep multiple entries in the
            stack separate. If a new brightness command comes in with a key
            that already exists for an entry in the stack, that entry will be
            replaced by the new entry. The key is also used to remove entries
            from the stack (e.g. when shows or modes end and they want to
            remove their commands from the light).
        mode: Optional mode where the brightness was set. Used to remove
            entries when a mode ends.

        """

    def _initialize(self):
        self.load_platform_section('matrix_lights')

        self.hw_driver = self.platform.configure_matrixlight(self.config)

        if self.config['fade_ms'] is not None:
            self.default_fade_ms = self.config['fade_ms']
        elif self.machine.config['matrix_light_settings']:
            self.default_fade_ms = (
                self.machine.config['matrix_light_settings']['default_light_fade_ms'])
        else:
            self.default_fade_ms = 0

        if 'x' in self.config:
            self.x = self.config['x']

        if 'y' in self.config:
            self.y = self.config['y']

    # pylint: disable-msg=too-many-arguments
    def on(self, brightness=255, fade_ms=None, priority=0, key=None, mode=None,
           **kwargs):
        """Adds or updates a brightness entry in this lights's stack, which is
        how you tell this light how bright you want it to be.

        Args:
            brightness: How bright this light should be, as an int between 0
                and 255. 0 is off. 255 is full on. Note that matrix lights in
                older (even WPC) machines had slow matrix update speeds, and
                effective brightness levels will be far less than 255.
            fade_ms: Int of the number of ms you want this light to fade to the
                brightness in. A value of 0 means it's instant. A value of
                None (the default) means that it will use this lights's and/or
                the machine's default fade_ms setting.
            priority: Int value of the priority of these incoming settings. If
                this light has current settings in the stack at a higher
                priority, the settings you're adding here won't take effect.
                However they're still added to the stack, so if the higher
                priority settings are removed, then the next-highest apply.
            key: An arbitrary identifier (can be any immutable object) that's
                used to identify these settings for later removal. If any
                settings in the stack already have this key, those settings
                will be replaced with these new settings.
            mode: Optional mode instance of the mode that is setting this
                brightness. When a mode ends, entries from the stack with that
                mode will automatically be removed.
            **kwargs: Not used. Only included so this method can be used as
                an event callback since events could pass random kwargs.
        """

        del kwargs

        if self.debug:
            self.log.debug("Received on() command. brightness: %s, fade_ms: %s"
                           "priority: %s, key: %s", brightness, fade_ms,
                           priority, key)

        if fade_ms is None:
            fade_ms = self.default_fade_ms

        if priority < self._get_priority_from_key(key):
            if self.debug:
                self.log.debug("Incoming priority is lower than an existing "
                               "stack item with the same key. Not adding to "
                               "stack.")

            return

        self._add_to_stack(brightness, fade_ms, priority, key, mode)

    # pylint: disable-msg=too-many-arguments
    def _add_to_stack(self, brightness, fade_ms, priority, key, mode):
        curr_brightness = self.get_brightness()

        self.remove_from_stack_by_key(key)

        if fade_ms:
            new_brightness = curr_brightness
            dest_time = self.machine.clock.get_time() + (fade_ms / 1000)
        else:
            new_brightness = brightness
            dest_time = 0

        self.stack.append(dict(priority=priority,
                               start_time=self.machine.clock.get_time(),
                               start_brightness=curr_brightness,
                               dest_time=dest_time,
                               dest_brightness=brightness,
                               brightness=new_brightness,
                               key=key,
                               mode=mode))

        self.stack.sort(key=itemgetter('priority', 'start_time'), reverse=True)

        if self.debug:
            self.log.debug("+-------------- Adding to stack ----------------+")
            self.log.debug("priority: %s", priority)
            self.log.debug("start_time: %s", self.machine.clock.get_time())
            self.log.debug("start_brightness: %s", curr_brightness)
            self.log.debug("dest_time: %s", dest_time)
            self.log.debug("dest_brightness: %s", brightness)
            self.log.debug("brightness: %s", new_brightness)
            self.log.debug("key: %s", key)

        MatrixLight.lights_to_update.add(self)

    def clear_stack(self):
        """Removes all entries from the stack and resets this light to 'off'."""
        self.stack[:] = []

        if self.debug:
            self.log.debug("Clearing Stack")

        MatrixLight.lights_to_update.add(self)

    def remove_from_stack_by_key(self, key):
        """Removes a group of brightness settings from the stack.

        Args:
            key: The key of the settings to remove (based on the 'key'
                parameter that was originally passed to the brightness() method.)

        This method triggers a light update, so if the highest priority settings
        were removed, the light will be updated with whatever's below it. If no
        settings remain after these are removed, the light will turn off.

        """

        if self.debug:
            self.log.debug("Removing key '%s' from stack", key)

        self.stack[:] = [x for x in self.stack if x['key'] != key]
        MatrixLight.lights_to_update.add(self)

    def remove_from_stack_by_mode(self, mode):
        """Removes a group of brightness settings from the stack.

        Args:

        This method triggers a light update, so if the highest priority settings
        were removed, the light will be updated with whatever's below it. If no
        settings remain after these are removed, the light will turn off.

        """

        if self.debug:
            self.log.debug("Removing mode '%s' from stack", mode)

        self.stack[:] = [x for x in self.stack if x['mode'] != mode]
        MatrixLight.lights_to_update.add(self)

    def get_brightness(self):
        """Returns an RGBColor() instance of the 'color' setting of the highest
        color setting in the stack. This is usually the same color as the
        physical LED, but not always (since physical LEDs are updated once per
        frame, this value could vary.

        Also note the color returned is the "raw" color that does has not had
        the color correction profile applied.

        """
        try:
            return self.stack[0]['brightness']
        except IndexError:
            return 0

    def _get_priority_from_key(self, key):
        try:
            return [x for x in self.stack if x['key'] == key][0]['priority']
        except IndexError:
            return 0

    def update_hw_light(self):
        """Physically updates the light hardware object based on the
        'brightness' setting of the highest priority setting from the stack.

        This method is automatically called whenever a brightness change has been
        made (including when fades are active).

        """
        if not self.stack:
            self.off()

        # if there's a current fade, but the new command doesn't have one
        if not self.stack[0]['dest_time'] and self.fade_in_progress:
            self._stop_fade_task()

        # If the new command has a fade, but the fade task isn't running
        if self.stack[0]['dest_time'] and not self.fade_in_progress:
            self._setup_fade()

        # If there's no current fade and no new fade, or a current fade and new
        # fade
        else:
            self.hw_driver.on(self.stack[0]['brightness'])

            if self.registered_handlers:
                # Handlers are not sent brightness corrected brightnesss
                # todo make this a config option?
                for handler in self.registered_handlers:
                    handler(light_name=self.name,
                            brightness=self.stack[0]['brightness'])

    def off(self, fade_ms=0, priority=0, key=None, mode=None, **kwargs):
        """Turns this light off.

        Args:
            fade_ms: Int of the number of ms you want this light to fade to the
                brightness in. A value of 0 means it's instant. A value of
                None (the default) means that it will use this lights's and/or
                the machine's default fade_ms setting.
            priority: Int value of the priority of these incoming settings. If
                this light has current settings in the stack at a higher
                priority, the settings you're adding here won't take effect.
                However they're still added to the stack, so if the higher
                priority settings are removed, then the next-highest apply.
            key: An arbitrary identifier (can be any immutable object) that's
                used to identify these settings for later removal. If any
                settings in the stack already have this key, those settings
                will be replaced with these new settings.
            mode: Optional mode instance of the mode that is setting this
                brightness. When a mode ends, entries from the stack with that
                mode will automatically be removed.
            **kwargs: Not used. Only included so this method can be used as
                an event callback since events could pass random kwargs.
        """
        del kwargs
        self.on(brightness=0, fade_ms=fade_ms, priority=priority, key=key,
                mode=mode)

    def add_handler(self, callback):
        """Registers a handler to be called when this light changes state."""
        self.registered_handlers.append(callback)

    def remove_handler(self, callback=None):
        """Removes a handler from the list of registered handlers."""
        if not callback:  # remove all
            self.registered_handlers = []
            return

        if callback in self.registered_handlers:
            self.registered_handlers.remove(callback)

    def _setup_fade(self):

        if self.fade_in_progress:
            return

        self.fade_in_progress = True

        if self.debug:
            self.log.debug("Setting up the fade task")

        self.machine.clock.schedule_interval(self._fade_task, 1 / self.machine.config['mpf']['default_fade_hz'])

    def _fade_task(self, dt):
        del dt

        # not sure why this is needed, but sometimes the fade task tries to
        # run even though self.fade_in_progress is False. Maybe
        # clock.unschedule doesn't happen right away?
        if not self.fade_in_progress:
            return

        try:
            brightness_settings = self.stack[0]
        except IndexError:
            self._stop_fade_task()
            return

        # todo
        if not brightness_settings['dest_time']:
            return

        # figure out the ratio of how far along we are
        try:
            ratio = ((self.machine.clock.get_time() -
                      brightness_settings['start_time']) /
                     (brightness_settings['dest_time'] -
                      brightness_settings['start_time']))
        except ZeroDivisionError:
            ratio = 1.0

        if self.debug:
            self.log.debug("Fade task, ratio: %s", ratio)

        if ratio >= 1.0:  # fade is done
            self._end_fade()
            brightness_settings['brightness'] = (
                brightness_settings['dest_brightness'])
        else:
            brightness_settings['brightness'] = (
                brightness_settings['start_brightness'] +
                int((brightness_settings['dest_brightness'] -
                    brightness_settings['start_brightness']) * ratio))

        MatrixLight.lights_to_update.add(self)

    def _end_fade(self):
        # stops the fade and instantly sets the light to its destination brightness
        self._stop_fade_task()
        self.stack[0]['dest_time'] = 0

    def _stop_fade_task(self):
        # stops the fade task. Light is left in whatever state it was in
        self.fade_in_progress = False
        self.machine.clock.unschedule(self._fade_task)

        if self.debug:
            self.log.debug("Stopping fade task")
<|endoftext|>


<commit_before>from copy import deepcopy

import queue
import logging
from distutils.version import LooseVersion

from kivy.clock import Clock

import mpf.core.bcp.bcp_socket_client as bcp
from mpfmc._version import __bcp_version__
from mpfmc.core.bcp_server import BCPServer

class BcpProcessor(object):
    def __init__(self, mc):
        self.mc = mc
        self.log = logging.getLogger('BcpProcessor')

        self.socket_thread = None
        self.connected = False
        self.receive_queue = queue.Queue()
        self.sending_queue = queue.Queue()

        if self.mc.options['bcp']:
            self.mc.events.add_handler('init_done', self._start_socket_thread)
            self.enabled = True
        else:
            self.enabled = False

        self.debug_log = self.mc.machine_config['bcp']['debug']

        self.bcp_commands = {'error': self._bcp_error,
                             'goodbye': self._bcp_goodbye,
                             'hello': self._bcp_hello,
                             'machine_variable': self._bcp_machine_variable,
                             'mode_start': self._bcp_mode_start,
                             'mode_stop': self._bcp_mode_stop,
                             'player_added': self._bcp_player_add,
                             'player_turn_start': self._bcp_player_turn_start,
                             'player_variable': self._bcp_player_variable,
                             'reset': self._bcp_reset,
                             'settings': self._bcp_settings,
                             'switch': self._bcp_switch,
                             'trigger': self._bcp_trigger,
                             }

        self.mc.events.add_handler('client_connected', self._client_connected)

        Clock.schedule_interval(self._get_from_queue, 0)

    def _client_connected(self, **kwargs):
        del kwargs
        self.send("monitor_machine_vars")
        self.send("monitor_player_vars")
        self.register_trigger("ball_started")
        self.register_trigger("ball_ended")
        self.register_trigger("player_add_success")
        self.register_trigger("player_turn_start")
        self.register_trigger("master_volume_increase")
        self.register_trigger("master_volume_decrease")
        self.connected = True

    def register_trigger(self, event):
        """Register a trigger for events from MPF."""
        self.send("register_trigger", event=event)

    def _start_socket_thread(self, **kwargs):
        del kwargs

        if self.socket_thread:
            return

        self.socket_thread = BCPServer(self.mc, self.receive_queue,
                                       self.sending_queue)
        self.socket_thread.daemon = True
        self.socket_thread.start()

        self.mc.events.remove_handler(self._start_socket_thread)

    def send(self, bcp_command, callback=None, rawbytes=None, **kwargs):
        """Sends a BCP command to the connected pinball controller.

        Note that if the BCP server is not running, this method will just throw
        the BCP away. (It will still call the callback in that case.

        Args:
            bcp_command: String of the BCP command name.
            callback: Optional callback method that will be called when the
                command is sent.
            **kwargs: Optional additional kwargs will be added to the BCP
                command string.

        """
        if self.enabled:
            if not self.mc.bcp_client_connected:
                raise AssertionError("Not connected to MPF.")

            self.sending_queue.put(
                (bcp.encode_command_string(bcp_command, **kwargs), rawbytes))

        if callback:
            callback()

    def receive_bcp_message(self, msg):
        """Receives an incoming BCP message to be processed.

        Note this method is intended for testing. Usually BCP messages are
        handled by the BCP Server thread, but for test purposes it's possible
        to run mpf-mc without the BCP Server, so in that case you can use this
        method to send BCP messages into the mpf-mc.

        Args:
            msg: A string of the BCP message (in the standard BCP format:
                command?param1=value1&param2=value2...

        """
        cmd, kwargs = bcp.decode_command_string(msg)
        self.receive_queue.put((cmd, kwargs))

    def _get_from_queue(self, dt):
        """Gets and processes all queued up incoming BCP commands."""
        del dt

        while not self.receive_queue.empty():
            cmd, kwargs = self.receive_queue.get(False)
            self._process_command(cmd, **kwargs)

    def _process_command(self, bcp_command, **kwargs):

        if self.debug_log:
            if 'rawbytes' in kwargs:
                debug_kwargs = deepcopy(kwargs)
                debug_kwargs['rawbytes'] = '<{} bytes>'.format(
                    len(debug_kwargs.pop('rawbytes')))

                self.log.debug("Processing command: %s %s", bcp_command,
                               debug_kwargs)
            else:
                self.log.debug("Processing command: %s %s", bcp_command,
                               kwargs)

        # Can't use try/except KeyError here becasue there could be a KeyError
        # in the callback which we don't want it to swallow.
        if bcp_command in self.bcp_commands:
            self.bcp_commands[bcp_command](**kwargs)
        else:
            self.log.warning("Received invalid BCP command: %s", bcp_command[:9])
            # self.send('error',message='invalid command', command=bcp_command)

    def _bcp_hello(self, **kwargs):
        """Processes an incoming BCP 'hello' command."""
        try:
            if LooseVersion(kwargs['version']) == (
                    LooseVersion(__bcp_version__)):
                self.send('hello', version=__bcp_version__)
            else:
                self.send('hello', version='unknown protocol version')
        except KeyError:
            self.log.warning("Received invalid 'version' parameter with 'hello'")

    def _bcp_goodbye(self, **kwargs):
        """Processes an incoming BCP 'goodbye' command."""
        # if self.config['mpf-mc']['exit_on_disconnect']:
        #     self.socket_thread.sending_thread.stop()
        #     sys.exit()

        pass  # todo

    def _bcp_mode_start(self, name=None, priority=0, **kwargs):
        """Processes an incoming BCP 'mode_start' command."""
        del kwargs

        if not name:
            return
            # todo raise error

        if name == 'game':
            self.mc.game_start()

        if name in self.mc.modes:
            self.mc.modes[name].start(mode_priority=int(priority))

    def _bcp_mode_stop(self, name, **kwargs):
        """Processes an incoming BCP 'mode_stop' command."""
        del kwargs
        if not name:
            return
            # todo raise error

        if name == 'game':
            self.mc.game_end()

        if name in self.mc.modes:
            self.mc.modes[name].stop()

    def _bcp_settings(self, settings, **kwargs):
        del kwargs
        for setting in settings:
            self.mc.settings.add_setting(setting)

    def _bcp_error(self, **kwargs):
        """Processes an incoming BCP 'error' command."""
        del kwargs
        self.log.warning('Received error command from client')

    def _bcp_player_add(self, player_num, **kwargs):
        """Processes an incoming BCP 'player_add' command."""
        del kwargs
        self.mc.add_player(int(player_num))

    # pylint: disable-msg=too-many-arguments
    def _bcp_player_variable(self, name, value, prev_value, change, player_num,
                             **kwargs):
        """Processes an incoming BCP 'player_variable' command."""
        del prev_value
        del change
        del kwargs
        self.mc.update_player_var(name, value, int(player_num))

    def send_machine_var_to_mpf(self, name, value):
        """Set machine var in MPF via BCP."""
        self.send("set_machine_var", name=name, value=value)

    def _bcp_machine_variable(self, name, value, change=True, prev_value=None,
                              **kwargs):
        """Processes an incoming BCP 'machine_variable' command."""
        del kwargs
        self.mc.receive_machine_var_update(name, value, change, prev_value)

    def _bcp_player_turn_start(self, player_num, **kwargs):
        """Processes an incoming BCP 'player_turn_start' command."""
        del kwargs
        self.mc.player_start_turn(int(player_num))

    def _bcp_trigger(self, name, **kwargs):
        """Processes an incoming BCP 'trigger' command."""
        self.mc.events.post(name, **kwargs)

    def _bcp_switch(self, name, state, **kwargs):
        """Processes an incoming BCP 'switch' command."""
        del kwargs
        if int(state):
            self.mc.events.post('switch_' + name + '_active')
            '''event: switch_(name)_active
            desc: Posted on MPF-MC only (e.g. not in MPF) when the MC receives
            a BCP "switch" active command. Useful for video modes and graphical
            menu navigation. Note that this is not posted for every switch all
            the time, rather, only for switches that have been configured to
            send events to BCP.
            '''
        else:
            self.mc.events.post('switch_' + name + '_inactive')
            '''event: switch_(name)_inactive
            desc: Posted on MPF-MC only (e.g. not in MPF) when the MC receives
            a BCP "switch" inactive command. Useful for video modes and graphical
            menu navigation. Note that this is not posted for every switch all
            the time, rather, only for switches that have been configured to
            send events to BCP.
            '''

    def _bcp_reset(self, **kwargs):
        del kwargs
        self.mc.reset()
<commit_msg>BCP changes<commit_after>from copy import deepcopy

import queue
import logging
from distutils.version import LooseVersion

from kivy.clock import Clock

import mpf.core.bcp.bcp_socket_client as bcp
from mpfmc._version import __bcp_version__
from mpfmc.core.bcp_server import BCPServer

class BcpProcessor(object):
    def __init__(self, mc):
        self.mc = mc
        self.log = logging.getLogger('BcpProcessor')

        self.socket_thread = None
        self.connected = False
        self.receive_queue = queue.Queue()
        self.sending_queue = queue.Queue()

        if self.mc.options['bcp']:
            self.mc.events.add_handler('init_done', self._start_socket_thread)
            self.enabled = True
        else:
            self.enabled = False

        self.debug_log = self.mc.machine_config['bcp']['debug']

        self.bcp_commands = {'error': self._bcp_error,
                             'goodbye': self._bcp_goodbye,
                             'hello': self._bcp_hello,
                             'machine_variable': self._bcp_machine_variable,
                             'mode_start': self._bcp_mode_start,
                             'mode_stop': self._bcp_mode_stop,
                             'player_added': self._bcp_player_add,
                             'player_turn_start': self._bcp_player_turn_start,
                             'player_variable': self._bcp_player_variable,
                             'reset': self._bcp_reset,
                             'settings': self._bcp_settings,
                             'switch': self._bcp_switch,
                             'trigger': self._bcp_trigger,
                             }

        self.mc.events.add_handler('client_connected', self._client_connected)

        Clock.schedule_interval(self._get_from_queue, 0)

    def _client_connected(self, **kwargs):
        del kwargs
        self.send(bcp_command="monitor", category="machine_vars")
        self.send(bcp_command="monitor", category="player_vars")
        self.send(bcp_command="monitor", category="modes")
        self.send(bcp_command="monitor", category="core_events")
        self.register_trigger("master_volume_increase")
        self.register_trigger("master_volume_decrease")
        self.connected = True

    def register_trigger(self, event):
        """Register a trigger for events from MPF."""
        self.send("register_trigger", event=event)

    def _start_socket_thread(self, **kwargs):
        del kwargs

        if self.socket_thread:
            return

        self.socket_thread = BCPServer(self.mc, self.receive_queue,
                                       self.sending_queue)
        self.socket_thread.daemon = True
        self.socket_thread.start()

        self.mc.events.remove_handler(self._start_socket_thread)

    def send(self, bcp_command, callback=None, rawbytes=None, **kwargs):
        """Sends a BCP command to the connected pinball controller.

        Note that if the BCP server is not running, this method will just throw
        the BCP away. (It will still call the callback in that case.

        Args:
            bcp_command: String of the BCP command name.
            callback: Optional callback method that will be called when the
                command is sent.
            **kwargs: Optional additional kwargs will be added to the BCP
                command string.

        """
        if self.enabled:
            if not self.mc.bcp_client_connected:
                raise AssertionError("Not connected to MPF.")

            self.sending_queue.put(
                (bcp.encode_command_string(bcp_command, **kwargs), rawbytes))

        if callback:
            callback()

    def receive_bcp_message(self, msg):
        """Receives an incoming BCP message to be processed.

        Note this method is intended for testing. Usually BCP messages are
        handled by the BCP Server thread, but for test purposes it's possible
        to run mpf-mc without the BCP Server, so in that case you can use this
        method to send BCP messages into the mpf-mc.

        Args:
            msg: A string of the BCP message (in the standard BCP format:
                command?param1=value1&param2=value2...

        """
        cmd, kwargs = bcp.decode_command_string(msg)
        self.receive_queue.put((cmd, kwargs))

    def _get_from_queue(self, dt):
        """Gets and processes all queued up incoming BCP commands."""
        del dt

        while not self.receive_queue.empty():
            cmd, kwargs = self.receive_queue.get(False)
            self._process_command(cmd, **kwargs)

    def _process_command(self, bcp_command, **kwargs):

        if self.debug_log:
            if 'rawbytes' in kwargs:
                debug_kwargs = deepcopy(kwargs)
                debug_kwargs['rawbytes'] = '<{} bytes>'.format(
                    len(debug_kwargs.pop('rawbytes')))

                self.log.debug("Processing command: %s %s", bcp_command,
                               debug_kwargs)
            else:
                self.log.debug("Processing command: %s %s", bcp_command,
                               kwargs)

        # Can't use try/except KeyError here because there could be a KeyError
        # in the callback which we don't want it to swallow.
        if bcp_command in self.bcp_commands:
            self.bcp_commands[bcp_command](**kwargs)
        else:
            self.log.warning("Received invalid BCP command: %s", bcp_command[:9])
            # self.send('error',message='invalid command', command=bcp_command)

    def _bcp_hello(self, **kwargs):
        """Processes an incoming BCP 'hello' command."""
        try:
            if LooseVersion(kwargs['version']) == (
                    LooseVersion(__bcp_version__)):
                self.send('hello', version=__bcp_version__)
            else:
                self.send('hello', version='unknown protocol version')
        except KeyError:
            self.log.warning("Received invalid 'version' parameter with 'hello'")

    def _bcp_goodbye(self, **kwargs):
        """Processes an incoming BCP 'goodbye' command."""
        # if self.config['mpf-mc']['exit_on_disconnect']:
        #     self.socket_thread.sending_thread.stop()
        #     sys.exit()

        pass  # todo

    def _bcp_mode_start(self, name=None, priority=0, **kwargs):
        """Processes an incoming BCP 'mode_start' command."""
        del kwargs

        if not name:
            return
            # todo raise error

        if name == 'game':
            self.mc.game_start()

        if name in self.mc.modes:
            self.mc.modes[name].start(mode_priority=int(priority))

    def _bcp_mode_stop(self, name, **kwargs):
        """Processes an incoming BCP 'mode_stop' command."""
        del kwargs
        if not name:
            return
            # todo raise error

        if name == 'game':
            self.mc.game_end()

        if name in self.mc.modes:
            self.mc.modes[name].stop()

    def _bcp_settings(self, settings, **kwargs):
        del kwargs
        for setting in settings:
            self.mc.settings.add_setting(setting)

    def _bcp_error(self, **kwargs):
        """Processes an incoming BCP 'error' command."""
        del kwargs
        self.log.warning('Received error command from client')

    def _bcp_player_add(self, player_num, **kwargs):
        """Processes an incoming BCP 'player_add' command."""
        del kwargs
        self.mc.add_player(int(player_num))

    # pylint: disable-msg=too-many-arguments
    def _bcp_player_variable(self, name, value, prev_value, change, player_num,
                             **kwargs):
        """Processes an incoming BCP 'player_variable' command."""
        del prev_value
        del change
        del kwargs
        self.mc.update_player_var(name, value, int(player_num))

    def send_machine_var_to_mpf(self, name, value):
        """Set machine var in MPF via BCP."""
        self.send("set_machine_var", name=name, value=value)

    def _bcp_machine_variable(self, name, value, change=True, prev_value=None,
                              **kwargs):
        """Processes an incoming BCP 'machine_variable' command."""
        del kwargs
        self.mc.receive_machine_var_update(name, value, change, prev_value)

    def _bcp_player_turn_start(self, player_num, **kwargs):
        """Processes an incoming BCP 'player_turn_start' command."""
        del kwargs
        self.mc.player_start_turn(int(player_num))

    def _bcp_trigger(self, name, **kwargs):
        """Processes an incoming BCP 'trigger' command."""
        self.mc.events.post(name, **kwargs)

    def _bcp_switch(self, name, state, **kwargs):
        """Processes an incoming BCP 'switch' command."""
        del kwargs
        if int(state):
            self.mc.events.post('switch_' + name + '_active')
            '''event: switch_(name)_active
            desc: Posted on MPF-MC only (e.g. not in MPF) when the MC receives
            a BCP "switch" active command. Useful for video modes and graphical
            menu navigation. Note that this is not posted for every switch all
            the time, rather, only for switches that have been configured to
            send events to BCP.
            '''
        else:
            self.mc.events.post('switch_' + name + '_inactive')
            '''event: switch_(name)_inactive
            desc: Posted on MPF-MC only (e.g. not in MPF) when the MC receives
            a BCP "switch" inactive command. Useful for video modes and graphical
            menu navigation. Note that this is not posted for every switch all
            the time, rather, only for switches that have been configured to
            send events to BCP.
            '''

    def _bcp_reset(self, **kwargs):
        del kwargs
        self.mc.reset()
<|endoftext|>


<commit_before>#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright (C) 2017-2020  The SymbiFlow Authors.
#
# Use of this source code is governed by a ISC-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/ISC
#
# SPDX-License-Identifier: ISC

from collections import namedtuple
import enum

class ValueFormat(enum.Enum):
    """ Number format used for a FASM value. """
    PLAIN = 0
    VERILOG_DECIMAL = 1
    VERILOG_HEX = 2
    VERILOG_BINARY = 3
    VERILOG_OCTAL = 4

ValueFormat.PLAIN.__doc__ = \
    "A decimal number without size or radix e.g. 42"
ValueFormat.VERILOG_DECIMAL.__doc__ = \
    "A decimal number with optional size e.g. 8'd42"
ValueFormat.VERILOG_HEX.__doc__ = \
    "A hexadecimal number with optional size e.g. 8'h2a"
ValueFormat.VERILOG_BINARY.__doc__ = \
    "A binary number with optional size e.g. 8'b00101010"
ValueFormat.VERILOG_OCTAL.__doc__ = \
    "An octal number with optional size e.g. 8'o52"

SetFasmFeature = namedtuple(
    'SetFasmFeature', 'feature start end value value_format')
SetFasmFeature.__doc__ = """
Python version of a SetFasmFeature line such as:
  feature[31:0] = 42

feature is a string e.g. 'feature'

start and end are ints e.g 31, 0
When FeatureAddress is missing, start=None and
end=None.

value is an int e.g. 42
When FeatureValue is missing, value=1.

value_format determines how to output the value e.g. ValueFormat.PLAIN
It should be a ValueFormat or None.
If it is None, the value must be 1 and the value will
be omitted from output.
"""

Annotation = namedtuple('Annotation', 'name value')
Annotation.__doc__ = """
Python version of an Annotation, such as:
  { name = "value" }

Both name and value are strings (not None),
holding the name and value, respectively.
"""

FasmLine = namedtuple('FasmLine', 'set_feature annotations comment')
FasmLine.__doc__ = """
Python version of a FasmLine.

set_feature should be a SetFasmFeature or None.
annotations should be a list of Annotation or None.
comment should a string or None.
"""
<commit_msg>Document members of named_tuples.<commit_after>#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright (C) 2017-2020  The SymbiFlow Authors.
#
# Use of this source code is governed by a ISC-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/ISC
#
# SPDX-License-Identifier: ISC

from collections import namedtuple
import enum

class ValueFormat(enum.Enum):
    """ Number format used for a FASM value. """
    PLAIN = 0
    VERILOG_DECIMAL = 1
    VERILOG_HEX = 2
    VERILOG_BINARY = 3
    VERILOG_OCTAL = 4

ValueFormat.PLAIN.__doc__ = \
    "A decimal number without size or radix e.g. 42"
ValueFormat.VERILOG_DECIMAL.__doc__ = \
    "A decimal number with optional size e.g. 8'd42"
ValueFormat.VERILOG_HEX.__doc__ = \
    "A hexadecimal number with optional size e.g. 8'h2a"
ValueFormat.VERILOG_BINARY.__doc__ = \
    "A binary number with optional size e.g. 8'b00101010"
ValueFormat.VERILOG_OCTAL.__doc__ = \
    "An octal number with optional size e.g. 8'o52"

SetFasmFeature = namedtuple(
    'SetFasmFeature', 'feature start end value value_format')
SetFasmFeature.__doc__ = """
Python version of a SetFasmFeature line such as:
  feature[31:0] = 42

feature is a string e.g. 'feature'

start and end are ints e.g 31, 0
When FeatureAddress is missing, start=None and
end=None.

value is an int e.g. 42
When FeatureValue is missing, value=1.

value_format determines how to output the value e.g. ValueFormat.PLAIN
It should be a ValueFormat or None.
If it is None, the value must be 1 and the value will
be omitted from output.
"""
SetFasmFeature.feature.__doc__ = "Feature name (string)"
SetFasmFeature.start.__doc__ = \
    "Starting value of the feature range (int or None)"
SetFasmFeature.end.__doc__ = \
    "Ending value of the feature range (int or None)"
SetFasmFeature.value.__doc__ = \
    "FeatureValue describing the value, or None"
SetFasmFeature.value_format.__doc__ = \
    "ValueFormat describing the format of the value, or None."

Annotation = namedtuple('Annotation', 'name value')
Annotation.__doc__ = """
Python version of an Annotation, such as:
  { name = "value" }

Both name and value are strings (not None),
holding the name and value, respectively.
"""
Annotation.name.__doc__ = "Annotation name (string)"
Annotation.value.__doc__ = "Annotation value (string)"

FasmLine = namedtuple('FasmLine', 'set_feature annotations comment')
FasmLine.__doc__ = """
Python version of a FasmLine such as:
feature[31:0] = 42 { name = "value" } # comment

set_feature should be a SetFasmFeature or None.
annotations should be a list of Annotation or None.
comment should a string or None, e.g. " comment"
"""
FasmLine.set_feature.__doc__ = "SetFasmFeature or None"
FasmLine.annotations.__doc__ = "List of Annotation or None"
FasmLine.comment.__doc__ = \
    "String or none containing the line comment (after '#')"
<|endoftext|>


<commit_before># Copyright 2013 Daniel Narvaez
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import subprocess
import time

import plog

def run(args, retry=0, watch_log=None):
    logging.info("Running command %s" % " ".join(args))

    tries = 0
    while tries < retry + 1:
        tries = tries + 1

        process = plog.LoggedProcess(args)
        process.execute()

        result = process.wait(watch_log=watch_log)
        if result != 0:
            if tries < retry + 1:
                print("Retrying (attempt %d) in 1 minute" % tries)
                time.sleep(60)
            else:
                raise subprocess.CalledProcessError(result, args)
        else:
            break

def run_with_runner(cmd):
    os.environ[config.runner_variable] = cmd
    return run(config.runner_bin)
<commit_msg>Missing imports<commit_after># Copyright 2013 Daniel Narvaez
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os
import logging
import subprocess
import time

import plog

from osbuild import config

def run(args, retry=0, watch_log=None):
    logging.info("Running command %s" % " ".join(args))

    tries = 0
    while tries < retry + 1:
        tries = tries + 1

        process = plog.LoggedProcess(args)
        process.execute()

        result = process.wait(watch_log=watch_log)
        if result != 0:
            if tries < retry + 1:
                print("Retrying (attempt %d) in 1 minute" % tries)
                time.sleep(60)
            else:
                raise subprocess.CalledProcessError(result, args)
        else:
            break

def run_with_runner(cmd):
    os.environ[config.runner_variable] = cmd
    return run(config.runner_bin)
<|endoftext|>


<commit_before>__version__ = 'v3.6.0'

__all__ = [
    '__version__',
]
<commit_msg>bump version to v3.7.0<commit_after>__version__ = 'v3.7.0'

__all__ = [
    '__version__',
]
<|endoftext|>


<commit_before>import lzma
import struct
from datetime import datetime, timezone, timedelta
from typing import List, Optional
import base64
from dataclasses import dataclass

from osrparse.utils import (Mod, GameMode, ReplayEvent, ReplayEventOsu,
    ReplayEventCatch, ReplayEventMania, ReplayEventTaiko, Key, KeyMania,
    KeyTaiko, LifeBarState)

class _Unpacker:
    """
    Helper class for dealing with the ``.osr`` format. Not intended to be used
    by consumers.
    """
    def __init__(self, replay_data):
        self.replay_data = replay_data
        self.offset = 0

    def string_length(self, binarystream):
        result = 0
        shift = 0
        while True:
            byte = binarystream[self.offset]
            self.offset += 1
            result = result |((byte & 0b01111111) << shift)
            if (byte & 0b10000000) == 0x00:
                break
            shift += 7
        return result

    def unpack_string(self):
        if self.replay_data[self.offset] == 0x00:
            self.offset += 1
        elif self.replay_data[self.offset] == 0x0b:
            self.offset += 1
            string_length = self.string_length(self.replay_data)
            offset_end = self.offset + string_length
            string = self.replay_data[self.offset:offset_end].decode("utf-8")
            self.offset = offset_end
            return string
        else:
            raise ValueError("Expected the first byte of a string to be 0x00 "
                f"or 0x0b, but got {self.replay_data[self.offset]}")

    def unpack_once(self, specifier):
        # always use little endian
        specifier = f"<{specifier}"
        unpacked = struct.unpack_from(specifier, self.replay_data, self.offset)
        self.offset += struct.calcsize(specifier)
        # `struct.unpack_from` always returns a tuple, even if there's only one
        # element
        return unpacked[0]

    def unpack_timestamp(self):
        ticks = self.unpack_once("q")
        timestamp = datetime.min + timedelta(microseconds=ticks/10)
        timestamp = timestamp.replace(tzinfo=timezone.utc)
        return timestamp

    def unpack_play_data(self, mode):
        replay_length = self.unpack_once("i")
        offset_end = self.offset + replay_length
        data = self.replay_data[self.offset:offset_end]
        data = lzma.decompress(data, format=lzma.FORMAT_AUTO)
        data = data.decode("ascii")
        replay_data = self.parse_replay_data(data, mode)
        self.offset = offset_end
        return replay_data

    @staticmethod
    def parse_replay_data(replay_data_str, mode):
        # remove trailing comma to make splitting easier
        replay_data_str = replay_data_str[:-1]
        events = [event.split('|') for event in replay_data_str.split(',')]

        play_data = []
        for event in events:
            time_delta = int(event[0])
            x = event[1]
            y = event[2]
            keys = int(event[3])

            if mode is GameMode.STD:
                keys = Key(keys)
                event = ReplayEventOsu(time_delta, float(x), float(y), keys)
            if mode is GameMode.TAIKO:
                event = ReplayEventTaiko(time_delta, int(x), KeyTaiko(keys))
            if mode is GameMode.CTB:
                event = ReplayEventCatch(time_delta, float(x), int(keys) == 1)
            if mode is GameMode.MANIA:
                event = ReplayEventMania(time_delta, KeyMania(keys))
            play_data.append(event)

        return play_data

    def unpack_replay_id(self):
        # old replays had replay_id stored as a short (4 bytes) instead of a
        # long (8 bytes), so fall back to short if necessary.
        # lazer checks against the gameversion before trying to parse as a
        # short, but there may be some weirdness with replays that were set
        # during this time but downloaded later having actually correct (long)
        # replay_ids, since they were likely manually migrated at some point
        # after the switch to long took place.
        # See:
        # https://github.com/ppy/osu/blob/84e1ff79a0736aa6c7a44804b585ab1c54a843
        # 99/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs#L78-L81
        try:
            replay_id = self.unpack_once("q")
        except struct.error:
            replay_id = self.unpack_once("l")
        return replay_id

    def unpack_life_bar(self):
        life_bar = self.unpack_string()
        if not life_bar:
            return None

        # remove trailing comma to make splitting easier
        life_bar = life_bar[:-1]
        states = [state.split("|") for state in life_bar.split(",")]

        return [LifeBarState(int(s[0]), float(s[1])) for s in states]

    def unpack(self):
        mode = GameMode(self.unpack_once("b"))
        game_version = self.unpack_once("i")
        beatmap_hash = self.unpack_string()
        username = self.unpack_string()
        replay_hash = self.unpack_string()
        count_300 = self.unpack_once("h")
        count_100 = self.unpack_once("h")
        count_50 = self.unpack_once("h")
        count_geki = self.unpack_once("h")
        count_katu = self.unpack_once("h")
        count_miss = self.unpack_once("h")
        score = self.unpack_once("i")
        max_combo = self.unpack_once("h")
        perfect = self.unpack_once("?")
        mods = Mod(self.unpack_once("i"))
        life_bar_graph = self.unpack_life_bar()
        timestamp = self.unpack_timestamp()
        play_data = self.unpack_play_data(mode)
        replay_id = self.unpack_replay_id()

        rng_seed = None
        if play_data[-1].time_delta == -12345:
            rng_seed = play_data[-1].keys.value
            del play_data[-1]

        return Replay(mode, game_version, beatmap_hash, username,
            replay_hash, count_300, count_100, count_50, count_geki, count_katu,
            count_miss, score, max_combo, perfect, mods, life_bar_graph,
            timestamp, play_data, replay_id, rng_seed)

class _Packer:
    def __init__(self, replay, *, dict_size=None, mode=None):
        self.replay = replay
        self.dict_size = dict_size or 1 << 21
        self.mode = mode or lzma.MODE_FAST

    def pack_byte(self, data):
        return struct.pack("<B", data)

    def pack_short(self, data):
        return struct.pack("<H", data)

    def pack_int(self, data):
        return struct.pack("<I", data)

    def pack_long(self, data):
        return struct.pack("<Q", data)

    def pack_ULEB128(self, data):
        # https://github.com/mohanson/leb128
        r, i = [], len(data)

        while True:
            byte = i & 0x7f
            i = i >> 7

            if (i == 0 and byte & 0x40 == 0) or (i == -1 and byte & 0x40 != 0):
                r.append(byte)
                return b"".join(map(self.pack_byte, r))

            r.append(0x80 | byte)

    def pack_string(self, data):
        if data:
            return (self.pack_byte(11) + self.pack_ULEB128(data) +
                data.encode("utf-8"))
        return self.pack_byte(11) + self.pack_byte(0)

    def pack_timestamp(self):
        # windows ticks starts at year 0001, in contrast to unix time (1970).
        # 62135596800 is the number of seconds between these two years and is
        # added to account for this difference.
        # The factor of 10000000 converts seconds to ticks.

        ticks = (62135596800 + self.replay.timestamp.timestamp()) * 10000000
        ticks = int(ticks)
        return self.pack_long(ticks)

    def pack_life_bar(self):
        text = ""
        if self.replay.life_bar_graph is None:
            return self.pack_string(text)

        for state in self.replay.life_bar_graph:
            life = state.life
            # store 0 or 1 instead of 0.0 or 1.0
            if int(life) == life:
                life = int(state.life)

            text += f"{state.time}|{life},"

        return self.pack_string(text)

    def pack_replay_data(self):
        data = ""
        for event in self.replay.replay_data:
            t = event.time_delta
            if isinstance(event, ReplayEventOsu):
                data += f"{t}|{event.x}|{event.y}|{event.keys.value},"
            elif isinstance(event, ReplayEventTaiko):
                data += f"{t}|{event.x}|0|{event.keys.value},"
            elif isinstance(event, ReplayEventCatch):
                data += f"{t}|{event.x}|0|{int(event.dashing)},"
            elif isinstance(event, ReplayEventMania):
                data += f"{t}|{event.keys.value}|0|0,"

        if self.replay.rng_seed:
            data += f"-12345|0|0|{self.replay.rng_seed},"

        filters = [
            {
                "id": lzma.FILTER_LZMA1,
                "dict_size": self.dict_size,
                "mode": self.mode
            }
        ]

        data = data.encode("ascii")
        compressed = lzma.compress(data, format=lzma.FORMAT_ALONE,
            filters=filters)

        return self.pack_int(len(compressed)) + compressed

    def pack(self):
        r = self.replay
        data = b""

        data += self.pack_byte(r.mode.value)
        data += self.pack_int(r.game_version)
        data += self.pack_string(r.beatmap_hash)
        data += self.pack_string(r.username)
        data += self.pack_string(r.replay_hash)
        data += self.pack_short(r.count_300)
        data += self.pack_short(r.count_100)
        data += self.pack_short(r.count_50)
        data += self.pack_short(r.count_geki)
        data += self.pack_short(r.count_katu)
        data += self.pack_short(r.count_miss)
        data += self.pack_int(r.score)
        data += self.pack_short(r.max_combo)
        data += self.pack_byte(r.perfect)
        data += self.pack_int(r.mods.value)
        data += self.pack_life_bar()
        data += self.pack_timestamp()
        data += self.pack_replay_data()
        data += self.pack_long(r.replay_id)

        return data

@dataclass
class Replay:
    """
    A replay found in a ``.osr`` file, or following the osr format. To create a
    replay, you likely want ``Replay.from_path``, ``Replay.from_file``, or
    ``Replay.from_string``.
    """
    mode: GameMode
    game_version: int
    beatmap_hash: str
    username: str
    replay_hash: str
    count_300: int
    count_100: int
    count_50: int
    count_geki: int
    count_katu: int
    count_miss: int
    score: int
    max_combo: int
    perfect: bool
    mods: Mod
    life_bar_graph: Optional[List[LifeBarState]]
    timestamp: datetime
    replay_data: List[ReplayEvent]
    replay_id: int
    rng_seed: Optional[int]

    @staticmethod
    def from_path(path):
        """
        Creates a new ``Replay`` object from the ``.osr`` file at the given
        ``path``.

        Parameters
        ----------
        path: str or os.PathLike
            The path to the osr file to read from.

        Returns
        -------
        Replay
            The parsed replay object.
        """
        with open(path, "rb") as f:
            return Replay.from_file(f)

    @staticmethod
    def from_file(file):
        """
        Creates a new ``Replay`` object from an open file object.

        Parameters
        ----------
        file: file-like
           The file object to read from.

        Returns
        -------
        Replay
            The parsed replay object.
        """
        data = file.read()
        return Replay.from_string(data)

    @staticmethod
    def from_string(data):
        """
        Creates a new ``Replay`` object from a string containing ``.osr`` data.

        Parameters
        ----------
        data: str
           The data to parse.

        Returns
        -------
        Replay
            The parsed replay object.
        """
        return _Unpacker(data).unpack()

    def write_path(self, path, *, dict_size=None, mode=None):
        """
        Writes the replay to the given ``path``.

        Parameters
        ----------
        path: str or os.PathLike
           The path to where to write the replay.

        Notes
        -----
        This uses the current values of any attributes, and so can be used to
        create an edited version of a replay, by first reading a replay, editing
        an attribute, then writing the replay back to its file.
        """
        with open(path, "wb") as f:
            self.write_file(f, dict_size=dict_size, mode=mode)

    def write_file(self, file, *, dict_size=None, mode=None):
        """
        Writes the replay to an open file object.

        Parameters
        ----------
        file: file-like
           The file object to write to.
        """
        packed = self.pack(dict_size=dict_size, mode=mode)
        file.write(packed)

    def pack(self, *, dict_size=None, mode=None):
        """
        Returns the text representing this ``Replay``, in ``.osr`` format.
        The text returned by this method is suitable for writing to a file as a
        valid ``.osr`` file.

        Returns
        -------
        str
            The text representing this ``Replay``, in ``.osr`` format.
        """
        return _Packer(self, dict_size=dict_size, mode=mode).pack()

def parse_replay_data(data_string, *, decoded=False, decompressed=False,
    mode=GameMode.STD) -> List[ReplayEvent]:
    """
    Parses the replay data portion of a replay from a string. This method is
    siutable for use with the replay data returned by api v1's ``/get_replay``
    endpoint, for instance.

    Parameters
    ----------
    data_string: str or bytes
        The replay data to parse.
    decoded: bool
        Whether ``data_string`` has already been decoded from a b64
        representation. Api v1 returns a base 64 encoded string, for instance.
    decompressed: bool
        Whether ``data_string`` has already been both decompressed from lzma,
        and decoded to ascii.
        |br|
        For instance, the following two calls are equivalent:
        ```
        >>> parse_replay_data(lzma_string, decoded=True)
        >>> ...
        >>> lzma_string = lzma.decompress(lzma_string).decode("ascii")
        >>> parse_replay_data(lzma_string, decompressed=True)
        ```
        |br|
        If ``decompressed`` is ``True``, ``decoded`` is automatically set to
        ``True`` as well (ie, if ``decompressed`` is ``True``, we will assume
        ``data_string`` is not base 64 encoded).
    mode: GameMode
        What mode to parse the replay data as.
    """
    # assume the data is already decoded if it's been decompressed
    if not decoded and not decompressed:
        data_string = base64.b64decode(data_string)
    if not decompressed:
        data_string = lzma.decompress(data_string, format=lzma.FORMAT_AUTO)
        data_string = data_string.decode("ascii")
    return _Unpacker.parse_replay_data(data_string, mode)
<commit_msg>text -> data<commit_after>import lzma
import struct
from datetime import datetime, timezone, timedelta
from typing import List, Optional
import base64
from dataclasses import dataclass

from osrparse.utils import (Mod, GameMode, ReplayEvent, ReplayEventOsu,
    ReplayEventCatch, ReplayEventMania, ReplayEventTaiko, Key, KeyMania,
    KeyTaiko, LifeBarState)

class _Unpacker:
    """
    Helper class for dealing with the ``.osr`` format. Not intended to be used
    by consumers.
    """
    def __init__(self, replay_data):
        self.replay_data = replay_data
        self.offset = 0

    def string_length(self, binarystream):
        result = 0
        shift = 0
        while True:
            byte = binarystream[self.offset]
            self.offset += 1
            result = result |((byte & 0b01111111) << shift)
            if (byte & 0b10000000) == 0x00:
                break
            shift += 7
        return result

    def unpack_string(self):
        if self.replay_data[self.offset] == 0x00:
            self.offset += 1
        elif self.replay_data[self.offset] == 0x0b:
            self.offset += 1
            string_length = self.string_length(self.replay_data)
            offset_end = self.offset + string_length
            string = self.replay_data[self.offset:offset_end].decode("utf-8")
            self.offset = offset_end
            return string
        else:
            raise ValueError("Expected the first byte of a string to be 0x00 "
                f"or 0x0b, but got {self.replay_data[self.offset]}")

    def unpack_once(self, specifier):
        # always use little endian
        specifier = f"<{specifier}"
        unpacked = struct.unpack_from(specifier, self.replay_data, self.offset)
        self.offset += struct.calcsize(specifier)
        # `struct.unpack_from` always returns a tuple, even if there's only one
        # element
        return unpacked[0]

    def unpack_timestamp(self):
        ticks = self.unpack_once("q")
        timestamp = datetime.min + timedelta(microseconds=ticks/10)
        timestamp = timestamp.replace(tzinfo=timezone.utc)
        return timestamp

    def unpack_play_data(self, mode):
        replay_length = self.unpack_once("i")
        offset_end = self.offset + replay_length
        data = self.replay_data[self.offset:offset_end]
        data = lzma.decompress(data, format=lzma.FORMAT_AUTO)
        data = data.decode("ascii")
        replay_data = self.parse_replay_data(data, mode)
        self.offset = offset_end
        return replay_data

    @staticmethod
    def parse_replay_data(replay_data_str, mode):
        # remove trailing comma to make splitting easier
        replay_data_str = replay_data_str[:-1]
        events = [event.split('|') for event in replay_data_str.split(',')]

        play_data = []
        for event in events:
            time_delta = int(event[0])
            x = event[1]
            y = event[2]
            keys = int(event[3])

            if mode is GameMode.STD:
                keys = Key(keys)
                event = ReplayEventOsu(time_delta, float(x), float(y), keys)
            if mode is GameMode.TAIKO:
                event = ReplayEventTaiko(time_delta, int(x), KeyTaiko(keys))
            if mode is GameMode.CTB:
                event = ReplayEventCatch(time_delta, float(x), int(keys) == 1)
            if mode is GameMode.MANIA:
                event = ReplayEventMania(time_delta, KeyMania(keys))
            play_data.append(event)

        return play_data

    def unpack_replay_id(self):
        # old replays had replay_id stored as a short (4 bytes) instead of a
        # long (8 bytes), so fall back to short if necessary.
        # lazer checks against the gameversion before trying to parse as a
        # short, but there may be some weirdness with replays that were set
        # during this time but downloaded later having actually correct (long)
        # replay_ids, since they were likely manually migrated at some point
        # after the switch to long took place.
        # See:
        # https://github.com/ppy/osu/blob/84e1ff79a0736aa6c7a44804b585ab1c54a843
        # 99/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs#L78-L81
        try:
            replay_id = self.unpack_once("q")
        except struct.error:
            replay_id = self.unpack_once("l")
        return replay_id

    def unpack_life_bar(self):
        life_bar = self.unpack_string()
        if not life_bar:
            return None

        # remove trailing comma to make splitting easier
        life_bar = life_bar[:-1]
        states = [state.split("|") for state in life_bar.split(",")]

        return [LifeBarState(int(s[0]), float(s[1])) for s in states]

    def unpack(self):
        mode = GameMode(self.unpack_once("b"))
        game_version = self.unpack_once("i")
        beatmap_hash = self.unpack_string()
        username = self.unpack_string()
        replay_hash = self.unpack_string()
        count_300 = self.unpack_once("h")
        count_100 = self.unpack_once("h")
        count_50 = self.unpack_once("h")
        count_geki = self.unpack_once("h")
        count_katu = self.unpack_once("h")
        count_miss = self.unpack_once("h")
        score = self.unpack_once("i")
        max_combo = self.unpack_once("h")
        perfect = self.unpack_once("?")
        mods = Mod(self.unpack_once("i"))
        life_bar_graph = self.unpack_life_bar()
        timestamp = self.unpack_timestamp()
        play_data = self.unpack_play_data(mode)
        replay_id = self.unpack_replay_id()

        rng_seed = None
        if play_data[-1].time_delta == -12345:
            rng_seed = play_data[-1].keys.value
            del play_data[-1]

        return Replay(mode, game_version, beatmap_hash, username,
            replay_hash, count_300, count_100, count_50, count_geki, count_katu,
            count_miss, score, max_combo, perfect, mods, life_bar_graph,
            timestamp, play_data, replay_id, rng_seed)

class _Packer:
    def __init__(self, replay, *, dict_size=None, mode=None):
        self.replay = replay
        self.dict_size = dict_size or 1 << 21
        self.mode = mode or lzma.MODE_FAST

    def pack_byte(self, data):
        return struct.pack("<B", data)

    def pack_short(self, data):
        return struct.pack("<H", data)

    def pack_int(self, data):
        return struct.pack("<I", data)

    def pack_long(self, data):
        return struct.pack("<Q", data)

    def pack_ULEB128(self, data):
        # https://github.com/mohanson/leb128
        r, i = [], len(data)

        while True:
            byte = i & 0x7f
            i = i >> 7

            if (i == 0 and byte & 0x40 == 0) or (i == -1 and byte & 0x40 != 0):
                r.append(byte)
                return b"".join(map(self.pack_byte, r))

            r.append(0x80 | byte)

    def pack_string(self, data):
        if data:
            return (self.pack_byte(11) + self.pack_ULEB128(data) +
                data.encode("utf-8"))
        return self.pack_byte(11) + self.pack_byte(0)

    def pack_timestamp(self):
        # windows ticks starts at year 0001, in contrast to unix time (1970).
        # 62135596800 is the number of seconds between these two years and is
        # added to account for this difference.
        # The factor of 10000000 converts seconds to ticks.

        ticks = (62135596800 + self.replay.timestamp.timestamp()) * 10000000
        ticks = int(ticks)
        return self.pack_long(ticks)

    def pack_life_bar(self):
        data = ""
        if self.replay.life_bar_graph is None:
            return self.pack_string(data)

        for state in self.replay.life_bar_graph:
            life = state.life
            # store 0 or 1 instead of 0.0 or 1.0
            if int(life) == life:
                life = int(state.life)

            data += f"{state.time}|{life},"

        return self.pack_string(data)

    def pack_replay_data(self):
        data = ""
        for event in self.replay.replay_data:
            t = event.time_delta
            if isinstance(event, ReplayEventOsu):
                data += f"{t}|{event.x}|{event.y}|{event.keys.value},"
            elif isinstance(event, ReplayEventTaiko):
                data += f"{t}|{event.x}|0|{event.keys.value},"
            elif isinstance(event, ReplayEventCatch):
                data += f"{t}|{event.x}|0|{int(event.dashing)},"
            elif isinstance(event, ReplayEventMania):
                data += f"{t}|{event.keys.value}|0|0,"

        if self.replay.rng_seed:
            data += f"-12345|0|0|{self.replay.rng_seed},"

        filters = [
            {
                "id": lzma.FILTER_LZMA1,
                "dict_size": self.dict_size,
                "mode": self.mode
            }
        ]

        data = data.encode("ascii")
        compressed = lzma.compress(data, format=lzma.FORMAT_ALONE,
            filters=filters)

        return self.pack_int(len(compressed)) + compressed

    def pack(self):
        r = self.replay
        data = b""

        data += self.pack_byte(r.mode.value)
        data += self.pack_int(r.game_version)
        data += self.pack_string(r.beatmap_hash)
        data += self.pack_string(r.username)
        data += self.pack_string(r.replay_hash)
        data += self.pack_short(r.count_300)
        data += self.pack_short(r.count_100)
        data += self.pack_short(r.count_50)
        data += self.pack_short(r.count_geki)
        data += self.pack_short(r.count_katu)
        data += self.pack_short(r.count_miss)
        data += self.pack_int(r.score)
        data += self.pack_short(r.max_combo)
        data += self.pack_byte(r.perfect)
        data += self.pack_int(r.mods.value)
        data += self.pack_life_bar()
        data += self.pack_timestamp()
        data += self.pack_replay_data()
        data += self.pack_long(r.replay_id)

        return data

@dataclass
class Replay:
    """
    A replay found in a ``.osr`` file, or following the osr format. To create a
    replay, you likely want ``Replay.from_path``, ``Replay.from_file``, or
    ``Replay.from_string``.
    """
    mode: GameMode
    game_version: int
    beatmap_hash: str
    username: str
    replay_hash: str
    count_300: int
    count_100: int
    count_50: int
    count_geki: int
    count_katu: int
    count_miss: int
    score: int
    max_combo: int
    perfect: bool
    mods: Mod
    life_bar_graph: Optional[List[LifeBarState]]
    timestamp: datetime
    replay_data: List[ReplayEvent]
    replay_id: int
    rng_seed: Optional[int]

    @staticmethod
    def from_path(path):
        """
        Creates a new ``Replay`` object from the ``.osr`` file at the given
        ``path``.

        Parameters
        ----------
        path: str or os.PathLike
            The path to the osr file to read from.

        Returns
        -------
        Replay
            The parsed replay object.
        """
        with open(path, "rb") as f:
            return Replay.from_file(f)

    @staticmethod
    def from_file(file):
        """
        Creates a new ``Replay`` object from an open file object.

        Parameters
        ----------
        file: file-like
           The file object to read from.

        Returns
        -------
        Replay
            The parsed replay object.
        """
        data = file.read()
        return Replay.from_string(data)

    @staticmethod
    def from_string(data):
        """
        Creates a new ``Replay`` object from a string containing ``.osr`` data.

        Parameters
        ----------
        data: str
           The data to parse.

        Returns
        -------
        Replay
            The parsed replay object.
        """
        return _Unpacker(data).unpack()

    def write_path(self, path, *, dict_size=None, mode=None):
        """
        Writes the replay to the given ``path``.

        Parameters
        ----------
        path: str or os.PathLike
           The path to where to write the replay.

        Notes
        -----
        This uses the current values of any attributes, and so can be used to
        create an edited version of a replay, by first reading a replay, editing
        an attribute, then writing the replay back to its file.
        """
        with open(path, "wb") as f:
            self.write_file(f, dict_size=dict_size, mode=mode)

    def write_file(self, file, *, dict_size=None, mode=None):
        """
        Writes the replay to an open file object.

        Parameters
        ----------
        file: file-like
           The file object to write to.
        """
        packed = self.pack(dict_size=dict_size, mode=mode)
        file.write(packed)

    def pack(self, *, dict_size=None, mode=None):
        """
        Returns the text representing this ``Replay``, in ``.osr`` format.
        The text returned by this method is suitable for writing to a file as a
        valid ``.osr`` file.

        Returns
        -------
        str
            The text representing this ``Replay``, in ``.osr`` format.
        """
        return _Packer(self, dict_size=dict_size, mode=mode).pack()

def parse_replay_data(data_string, *, decoded=False, decompressed=False,
    mode=GameMode.STD) -> List[ReplayEvent]:
    """
    Parses the replay data portion of a replay from a string. This method is
    siutable for use with the replay data returned by api v1's ``/get_replay``
    endpoint, for instance.

    Parameters
    ----------
    data_string: str or bytes
        The replay data to parse.
    decoded: bool
        Whether ``data_string`` has already been decoded from a b64
        representation. Api v1 returns a base 64 encoded string, for instance.
    decompressed: bool
        Whether ``data_string`` has already been both decompressed from lzma,
        and decoded to ascii.
        |br|
        For instance, the following two calls are equivalent:
        ```
        >>> parse_replay_data(lzma_string, decoded=True)
        >>> ...
        >>> lzma_string = lzma.decompress(lzma_string).decode("ascii")
        >>> parse_replay_data(lzma_string, decompressed=True)
        ```
        |br|
        If ``decompressed`` is ``True``, ``decoded`` is automatically set to
        ``True`` as well (ie, if ``decompressed`` is ``True``, we will assume
        ``data_string`` is not base 64 encoded).
    mode: GameMode
        What mode to parse the replay data as.
    """
    # assume the data is already decoded if it's been decompressed
    if not decoded and not decompressed:
        data_string = base64.b64decode(data_string)
    if not decompressed:
        data_string = lzma.decompress(data_string, format=lzma.FORMAT_AUTO)
        data_string = data_string.decode("ascii")
    return _Unpacker.parse_replay_data(data_string, mode)
<|endoftext|>