Module soitool.soi

Includes datastructure used to represent a SOI.

Expand source code
"""Includes datastructure used to represent a SOI."""
from datetime import datetime
from PySide2.QtCore import QPoint
from rectpack import (
    newPacker,
    SORT_NONE,
    PackingMode,
    PackingBin,
    maxrects,
    skyline,
    guillotine,
)
from soitool.modules.module_base import set_module_pos


# Functions to sort modules by different criteria


def modules_sort_by_none(modules):
    """Don't sort. Implemented for completeness. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules.

    Returns
    -------
    list
        List of modules, untouched.
    """
    return modules


def modules_sort_by_area(modules):
    """Sort modules by area. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules to sort.

    Returns
    -------
    list
        List of modules sorted by area.
    """

    def module_area_key(module):
        width, height = module["widget"].get_size()
        return width * height

    return sorted(modules, key=module_area_key, reverse=True)


def modules_sort_by_width(modules):
    """Sort modules by width. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules to sort.

    Returns
    -------
    list
        List of modules sorted by width.
    """

    def module_width_key(module):
        width, _ = module["widget"].get_size()
        return width

    return sorted(modules, key=module_width_key, reverse=True)


def modules_sort_by_height(modules):
    """Sort modules by height. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules to sort.

    Returns
    -------
    list
        List of modules sorted by height.
    """

    def module_height_key(module):
        _, height = module["widget"].get_size()
        return height

    return sorted(modules, key=module_height_key, reverse=True)


# Dicts that map between name of algorithm and implementation of algorithm

STRING_TO_ALGORITHM_RECTPACK_BIN = {
    "BFF": PackingBin.BFF,
    "BBF": PackingBin.BBF,
}

STRING_TO_ALGORITHM_RECTPACK_PACK = {
    "MaxRectsBl": maxrects.MaxRectsBl,
    "SkylineBl": skyline.SkylineBl,
    "GuillotineBssfSas": guillotine.GuillotineBssfSas,
}

STRING_TO_ALGORITHM_INITIAL_SORT = {
    "none": modules_sort_by_none,
    "area": modules_sort_by_area,
    "width": modules_sort_by_width,
    "height": modules_sort_by_height,
}


class ModuleLargerThanBinError(Exception):
    """At least one module is too large for the bin during rectpack packing."""


class ModuleNameTaken(Exception):
    """Module name is already taken by an existing module."""


class SOI:
    """Datastructure for SOI.

    Holds all info about an SOI necessary to view and edit it.

    ## Note about rectpack

    This class relies heavily on the rectpack library for optimal placement of
    modules when placement_strategy is 'auto'. Refer to the rectpack
    documentation for details beyond what is provided in this class:
    https://github.com/secnot/rectpack

    ## Anatomy of an SOI

    Below is an illustration of how an SOI with two pages is represented using
    the class variables.

    ```text

     SOI.WIDTH
     |
     v
     _________________

     SOI.PADDING
     |  SOI.HEADER_WIDTH
     |  |  SOI.INNER_PADDING
     |  |  |  SOI.CONTENT_WIDTH
     |  |  |  |        SOI.INNER_PADDING
     |  |  |  |        | SOI.PADDING
     |  |  |  |        | |
     v  v  v  v        v v
     _ ___ _ _________ _ _

    +---------------------+
    |                     | | <- SOI.PADDING         | <- SOI.HEIGHT
    | +---+-------------+ |                          |
    | | H |             | | | <- SOI.INNER_PADDING   |
    | | E | +---------+ | |                          |
    | | A | | MODULES | | | | <- SOI.CONTENT_HEIGHT  |
    | | D | |         | | | |                        |
    | | E | |         | | | |                        |
    | | R | |         | | | |                        |
    | |   | +---------+ | |                          |
    | |   |             | | | <- SOI.INNER_PADDDING  |
    | +---+-------------+ |                          |
    |                     | | <- SOI.PADDING         |
    +---------------------+

    +---------------------+
    |                     | | <- SOI.PADDING         | <- SOI.HEIGHT
    | +---+-------------+ |                          |
    | | H |             | | | <- SOI.INNER_PADDING   |
    | | E | +---------+ | |                          |
    | | A | | MODULES | | | | <- SOI.CONTENT_HEIGHT  |
    | | D | |         | | | |                        |
    | | E | |         | | | |                        |
    | | R | |         | | | |                        |
    | |   | +---------+ | |                          |
    | |   |             | | | <- SOI.INNER_PADDDING  |
    | +---+-------------+ |                          |
    |                     | | <- SOI.PADDING         |
    +---------------------+

    ```

    `MODULES` is where all the modules of the page will show up. Each module
    has two positions stored, one through it's `module["meta"]["x"]` and
    `module["meta"]["y"]` values, and one through the position of it's
    widget representation `module["widget"].pos()`. The "meta" position is
    relative to the page indicated by `meta["meta"]["page"]`, whereas the
    widget position is absolute and takes into consideration that the SOI is in
    practice painted onto one continious surface. They are defined as follows:

    * `module["meta"]["x"]` is a value between `0` and `SOI.CONTENT_WIDTH`.
        This value is determined by the placement strategy.
    * `module["meta"]["y"]` is a value between `0` and `SOI.CONTENT_HEIGHT`.
        This value is determined by the placement strategy.
    * `module["widget"].pos().x()` is calculated from `module["meta"]["x"]`
        as follows:

        ```text
        module["meta"]["x"] + SOI.PADDING + SOI.HEADER_WIDTH +
        SOI.INNER_PADDDING
        ```

    * `module["widget"].pos().y()` is calculated from `module["meta"]["y"]`
        as follows:

        ```text
        module["meta"]["y"] + SOI.PADDING + SOI.INNER_PADDING +
        (SOI.PADDING + SOI.HEIGHT) * (module["meta"]["page"] - 1)
        ```

    The function `SOI.update_module_widget_position` is responsible for
    updating the widget positions based on the "meta" positions, using the
    formulas above.

    ## Note about properties

    To ensure other users of SOI are properly notified, all property updates
    should happen through member functions. `update_properties` for general
    properties, and `add_module` for modules and attachments.

    Parameters
    ----------
    title : string
    description : string
    version : string
    date : string in format `YYYY-MM-DD`
    valid_from : string in format `YYYY-MM-DD`
    valid_to : string in format `YYYY-MM-DD`
    icon : string
        path to icon for SOI
    classification : string
    orientation : string
        must be one of 'landscape' and 'portrait'
    placement_strategy : string
        must be one of 'manual' and 'auto'
    algorithm_bin : string
        which bin packing algorithm should be used for rectpack. Currently the
        following are implemented: 'BFF', 'BBF'. Please refer to the
        STRING_TO_ALGORITHM_RECTPACK_BIN variable.
    algorithm_pack : string
        which packing algorithm should be used for rectpack. Currently the
        following are implemented: 'MaxRectsBl', 'SkylineBl',
        'GuillotineBssfSas'. Please refer to the
        STRING_TO_ALGORITHM_RECTPACK_PACK variable.
    algorithm_sort : string
        which sorting method should be applied to the modules before packing.
        Currently the following are implemented: 'none', 'area', 'width',
        'height'. Please refer to the STRING_TO_ALGORITHM_INITIAL_SORT
        variable.
    modules : list of modules
        initial modules of SOI
    attachments : list of attachment modules
        initial attachment modules of SOI

    """

    A4_RATIO = 1.414

    # Height is adjusted to something that will look right when real widgets
    # are placed inside the pages
    HEIGHT = 1700
    WIDTH = HEIGHT * A4_RATIO
    PADDING = 100
    HEADER_WIDTH = 110
    HEADER_HEIGHT = HEIGHT - PADDING * 2
    MODULE_PADDING = 10
    INNER_PADDING = 1
    CONTENT_WIDTH = WIDTH - PADDING * 2 - INNER_PADDING * 2 - HEADER_WIDTH
    CONTENT_HEIGHT = HEIGHT - PADDING * 2 - INNER_PADDING * 2

    @classmethod
    def construct_from_compressed_soi_file(cls, filename):
        """Construct an SOI object from a compressed SOI file."""
        with open(filename):
            pass
        raise NotImplementedError()

    @classmethod
    def construct_from_soi_file(cls, filename):
        """Construct an SOI object from a SOI file."""
        with open(filename):
            pass
        raise NotImplementedError()

    # Ignoring pylint's r0913 "Too many arguments" error here to keep this as
    # simple as possible. The proper thing would probably be to put the
    # properties in separate data classes (auto-placement stuff in one class,
    # styling in another, etc).
    # Ignoring pylint's r0914 "Too many local variables" for the same reason as
    # r0913
    # pylint: disable=r0913,r0914
    def __init__(
        self,
        title="Default SOI title",
        description="Default SOI description",
        version="1",
        date=None,
        valid_from=None,
        valid_to=None,
        icon="soitool/media/HVlogo.png",
        classification="UGRADERT",
        orientation="landscape",
        placement_strategy="auto",
        algorithm_bin="BFF",
        algorithm_pack="MaxRectsBl",
        algorithm_sort="area",
        modules=None,
        attachments=None,
    ):

        # Populate date-related arguments if they are not supplied by the user
        if date is None:
            now = datetime.date(datetime.now())
            date = now.strftime("%Y-%m-%d")

        if valid_from is None:
            valid_from = date

        if valid_to is None:
            valid_to = date

        # Reason to not just set to [] as default argument:
        # https://stackoverflow.com/questions/9526465/best-practice-for-setting-the-default-value-of-a-parameter-thats-supposed-to-be
        if modules is None:
            modules = []
        if attachments is None:
            attachments = []

        self.title = title
        self.description = description
        self.version = version
        self.date = date
        self.valid_from = valid_from
        self.valid_to = valid_to
        self.icon = icon
        self.classification = classification
        self.orientation = orientation
        self.placement_strategy = placement_strategy
        self.modules = modules
        self.attachments = attachments

        # The following properties are relevant when self.placement_strategy
        # is "auto". They are used by rectpack
        self.algorithm_bin = algorithm_bin
        self.algorithm_pack = algorithm_pack
        self.algorithm_sort = algorithm_sort

        # Prepare listener lists: list of functions to call after certain
        # events happen
        self.reorganization_listeners = []
        self.new_module_listeners = []
        self.update_property_listeners = []

        self.reorganize()

    def add_reorganization_listener(self, function):
        """Add to list of functions to be called after reorganization.

        This is useful for users of this class to handle changes to the SOI.
        As an example a class displaying an SOI can be updated after changes.

        Parameters
        ----------
        function : function
            Added to the list of functions to be called after reorganization.
        """
        self.reorganization_listeners.append(function)

    def add_new_module_listener(self, function):
        """Add to list of functions to be called after added module.

        This is useful for users of this class to handle changes to the SOI.
        As an example a class displaying an SOI can be updated after changes.

        Parameters
        ----------
        function : function
            Added to the list of functions to be called after added module.
        """
        self.new_module_listeners.append(function)

    def add_update_property_listener(self, function):
        """Add to list of functions to be called after properties change.

        This is useful for users of this class to handle changes to the SOI.
        As an example a class displaying an SOI can be updated after changes.

        Parameters
        ----------
        function : function
            Added to the list of functions to be called after properties
            change.
        """
        self.update_property_listeners.append(function)

    def update_module_widget_position(self, module):
        """Update position of module widget based on meta position.

        For reasoning on the calculations done in this function see the class
        docstring.

        Parameters
        ----------
        module : see description
            should be dict of fields "meta" and "widget", where "meta" is
            itself a dict with fields "x", "y" and "page", and "widget" is a
            widget based on "ModuleBase"
        """
        distance_to_start_of_next_soi_content_y = self.HEIGHT + self.PADDING

        scene_skip_distance_page_height = (
            distance_to_start_of_next_soi_content_y
            * (module["meta"]["page"] - 1)
        )

        new_x = (
            module["meta"]["x"]
            + self.PADDING
            + self.HEADER_WIDTH
            + self.INNER_PADDING
        )
        new_y = (
            module["meta"]["y"]
            + self.PADDING
            + scene_skip_distance_page_height
            + self.INNER_PADDING
        )

        set_module_pos(module["widget"], QPoint(new_x, new_y))

    def get_module_with_name(self, name):
        """Return module with given name.

        Parameters
        ----------
        name : str
            Name of module to look for.

        Returns
        -------
        module in self.modules, or None if no module found
        """
        for module in self.modules:
            if module["meta"]["name"] == name:
                return module
        return None

    def reorganize(self):
        """Reorganize modules using chosen strategy.

        After successfull reorganization will call all listeners

        Raises
        ------
        Exception
            If placement strategy is neither auto nor manual.
        """
        if self.placement_strategy == "auto":
            self.reorganize_rectpack()
        elif self.placement_strategy == "manual":
            # Nothing to do
            pass
        else:
            raise Exception(
                "Unknown placement strategy: {}".format(
                    self.placement_strategy
                )
            )
        self.reorganize_attachments()
        # Call listener functions
        for listener in self.reorganization_listeners:
            listener()

    def reorganize_attachments(self):
        """Reorganize attachments. Each on own page in correct order.

        Order taken from order in attachment list directly. Attachments appear
        at the top-left corner always.
        """
        for i, attachment in enumerate(self.attachments):
            first_attachment_page = (
                self.get_number_of_non_attachment_pages() + 1
            )
            attachment["meta"]["x"] = 0
            attachment["meta"]["y"] = 0
            attachment["meta"]["page"] = first_attachment_page + i
            self.update_module_widget_position(attachment)

    def get_number_of_non_attachment_pages(self):
        """Calculate how many pages non-attachment modules require.

        The minimum page count is 1.

        Returns
        -------
        int
            Number of pages required for non-attachment modules.
        """
        pages = 1
        for module in self.modules:
            if module["meta"]["page"] > pages:
                pages += 1
        return pages

    def get_rectpack_packer(self):
        """Return rectpack packer set up for this SOI.

        Returns
        -------
        packer : rectpack packer
        """
        # Based on string stored in self.algorithm_... variables fetch real
        # implementations of chosen algorithms
        chosen_algorithm_bin = STRING_TO_ALGORITHM_RECTPACK_BIN[
            self.algorithm_bin
        ]
        chosen_algorithm_pack = STRING_TO_ALGORITHM_RECTPACK_PACK[
            self.algorithm_pack
        ]

        # NOTE that initial sorting is done outside of the packer, so it is set
        # to SORT_NONE here to respect our sorting
        packer = newPacker(
            rotation=False,
            mode=PackingMode.Offline,
            bin_algo=chosen_algorithm_bin,
            sort_algo=SORT_NONE,
            pack_algo=chosen_algorithm_pack,
        )

        return packer

    def sort_modules(self):
        """Sort modules in place using chosen sorting algorithm."""
        chosen_algorithm_sort = STRING_TO_ALGORITHM_INITIAL_SORT[
            self.algorithm_sort
        ]

        self.modules = chosen_algorithm_sort(self.modules)

    def reorganize_rectpack(self):
        """Reorganize modules optimally using the rectpack library.

        Note that to add padding between the modules we add self.MODULE_PADDING
        to each width,height of rectangles sent to rectpack for packing. We
        also tell rectpack that our "bins" are self.MODULE_PADDING larger in
        each dimension. As a result of this we get self.MODULE_PADDING amount
        of padding between the packed modules.

        Raises
        ------
        ModuleLargerThanBinError
            If at least one module is too large to fit into a bin.
            A bin is a page in this context
        """
        packer = self.get_rectpack_packer()

        self.sort_modules()

        for module in self.modules:
            module_width, module_height = module["widget"].get_size()
            padded_module_width = module_width + self.MODULE_PADDING
            padded_module_height = module_height + self.MODULE_PADDING
            packer.add_rect(
                padded_module_width,
                padded_module_height,
                module["meta"]["name"],
            )

        # float("inf") to add infinite bins.
        # See https://github.com/secnot/rectpack/blob/master/README.md#api

        packer.add_bin(
            self.CONTENT_WIDTH + self.MODULE_PADDING,
            self.CONTENT_HEIGHT + self.MODULE_PADDING,
            float("inf"),
        )

        packer.pack()
        packed_rects = packer.rect_list()

        # Explode if rectpack failed to pack all rects
        if len(packed_rects) != len(self.modules):
            raise ModuleLargerThanBinError()

        # Update modules based on packed rects returned from rectpack
        for packed_rect in packed_rects:

            packed_rect_bin = packed_rect[0]
            packed_rect_x = packed_rect[1]
            packed_rect_y = packed_rect[2]
            packed_rect_id = packed_rect[5]

            corresponding_module = self.get_module_with_name(packed_rect_id)
            if corresponding_module is None:
                raise Exception("Module was lost during packing!")

            corresponding_module["meta"]["x"] = packed_rect_x
            corresponding_module["meta"]["y"] = packed_rect_y
            corresponding_module["meta"]["page"] = packed_rect_bin + 1
            self.update_module_widget_position(corresponding_module)

    def module_name_taken(self, name):
        """Return True if module with name exists, False otherwise."""
        for module in self.modules + self.attachments:
            if name == module["meta"]["name"]:
                return True
        return False

    def add_module(self, name, widget_implementation, is_attachment=False):
        """Add module to SOI, reorganize it, and notify listeners.

        This function raises an exception if the given name is taken

        Parameters
        ----------
        name : str
            Name of new module.
        widget_implementation : subclass of ModuleBase
            An instance of the widget implementation of the module.
        is_attachment : bool
            True if the module should be added as an attachment. False
            otherwise.

        Raises
        ------
        ModuleNameTaken
            If the module name is taken.
        """
        if self.module_name_taken(name):
            raise ModuleNameTaken

        # NOTE that "x", "y" and "page" under "meta" are not valid until
        # self.reorganize() has run
        to_add = {
            "meta": {"x": 0, "y": 0, "page": 0, "name": name},
            "widget": widget_implementation,
        }
        if is_attachment:
            self.attachments.append(to_add)
        else:
            self.modules.append(to_add)

        self.reorganize()
        # Call listener functions
        for listener in self.new_module_listeners:
            listener()

    # Ignoring pylint "Too many branches" error as this function is a special
    # case where the if-elif-elif-...-else makes sense
    def update_properties(self, **kwargs):  # pylint: disable=R0912
        """Update properties given as input, and call listeners.

        This function exists solely because there are listeners on the
        properties. A "cleaner" way to achieve the same goal would be to
        create a "setter" for each property and call the listeners there, but
        for bulk changes this would get unwieldy. Another way to achieve the
        goal of calling listeners after changes to properties would be to
        create a separate function that the user is expected to call after
        changing the properties directly, but this would put unnecessary
        responsibility on the user.

        All properties except "modules" and "attachements" can be updated
        using this function.

        If a change is made that affects placement of modules this function
        will call `reorganize`

        Parameters
        ----------
        **kwargs : key, value pairs of properties to update
            Accepts both a normal dict, and passing arguments as normal:
            `update_properties({'title': 'my title'})` and
            update_properties(title='my title')`. Accepted keys are properties
            of the SOI class, except 'modules' and 'attachements'. Explanation
            of **kwargs:
            https://stackoverflow.com/a/1769475/3545896

        Raises
        ------
        ValueError
            If argument not referring to SOI property
            modifiable from this function is passed.
        """
        # For every key, value pair passed in kwargs, update corresponding
        # property
        for key, value in kwargs.items():
            if key == "title":
                self.title = value
            elif key == "description":
                self.description = value
            elif key == "version":
                self.version = value
            elif key == "date":
                self.date = value
            elif key == "valid_from":
                self.valid_from = value
            elif key == "valid_to":
                self.valid_to = value
            elif key == "icon":
                self.icon = value
            elif key == "classification":
                self.classification = value
            elif key == "orientation":
                self.orientation = value
            elif key == "placement_strategy":
                self.placement_strategy = value
            elif key == "algorithm_bin":
                self.algorithm_bin = value
            elif key == "algorithm_pack":
                self.algorithm_pack = value
            elif key == "algorithm_sort":
                self.algorithm_sort = value
            else:
                raise ValueError(
                    f"Unknown property name {key} passed with value {value}"
                )

        for listener in self.update_property_listeners:
            listener()

        # If any properties relating to module placement were touched,
        # reorganize
        if (
            "placement_strategy" in kwargs
            or "algorithm_bin" in kwargs
            or "algorithm_pack" in kwargs
            or "algorithm_sort" in kwargs
        ):
            self.reorganize()

Functions

def modules_sort_by_area(modules)

Sort modules by area. See SOI.sort_modules.

Parameters

modules : list
List of modules to sort.

Returns

list
List of modules sorted by area.
Expand source code
def modules_sort_by_area(modules):
    """Sort modules by area. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules to sort.

    Returns
    -------
    list
        List of modules sorted by area.
    """

    def module_area_key(module):
        width, height = module["widget"].get_size()
        return width * height

    return sorted(modules, key=module_area_key, reverse=True)
def modules_sort_by_height(modules)

Sort modules by height. See SOI.sort_modules.

Parameters

modules : list
List of modules to sort.

Returns

list
List of modules sorted by height.
Expand source code
def modules_sort_by_height(modules):
    """Sort modules by height. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules to sort.

    Returns
    -------
    list
        List of modules sorted by height.
    """

    def module_height_key(module):
        _, height = module["widget"].get_size()
        return height

    return sorted(modules, key=module_height_key, reverse=True)
def modules_sort_by_none(modules)

Don't sort. Implemented for completeness. See SOI.sort_modules.

Parameters

modules : list
List of modules.

Returns

list
List of modules, untouched.
Expand source code
def modules_sort_by_none(modules):
    """Don't sort. Implemented for completeness. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules.

    Returns
    -------
    list
        List of modules, untouched.
    """
    return modules
def modules_sort_by_width(modules)

Sort modules by width. See SOI.sort_modules.

Parameters

modules : list
List of modules to sort.

Returns

list
List of modules sorted by width.
Expand source code
def modules_sort_by_width(modules):
    """Sort modules by width. See SOI.sort_modules.

    Parameters
    ----------
    modules : list
        List of modules to sort.

    Returns
    -------
    list
        List of modules sorted by width.
    """

    def module_width_key(module):
        width, _ = module["widget"].get_size()
        return width

    return sorted(modules, key=module_width_key, reverse=True)

Classes

class ModuleLargerThanBinError (...)

At least one module is too large for the bin during rectpack packing.

Expand source code
class ModuleLargerThanBinError(Exception):
    """At least one module is too large for the bin during rectpack packing."""

Ancestors

  • builtins.Exception
  • builtins.BaseException
class ModuleNameTaken (...)

Module name is already taken by an existing module.

Expand source code
class ModuleNameTaken(Exception):
    """Module name is already taken by an existing module."""

Ancestors

  • builtins.Exception
  • builtins.BaseException
class SOI (title='Default SOI title', description='Default SOI description', version='1', date=None, valid_from=None, valid_to=None, icon='soitool/media/HVlogo.png', classification='UGRADERT', orientation='landscape', placement_strategy='auto', algorithm_bin='BFF', algorithm_pack='MaxRectsBl', algorithm_sort='area', modules=None, attachments=None)

Datastructure for SOI.

Holds all info about an SOI necessary to view and edit it.

Note about rectpack

This class relies heavily on the rectpack library for optimal placement of modules when placement_strategy is 'auto'. Refer to the rectpack documentation for details beyond what is provided in this class: https://github.com/secnot/rectpack

Anatomy of an SOI

Below is an illustration of how an SOI with two pages is represented using the class variables.


 SOI.WIDTH
 |
 v
 _________________

 SOI.PADDING
 |  SOI.HEADER_WIDTH
 |  |  SOI.INNER_PADDING
 |  |  |  SOI.CONTENT_WIDTH
 |  |  |  |        SOI.INNER_PADDING
 |  |  |  |        | SOI.PADDING
 |  |  |  |        | |
 v  v  v  v        v v
 _ ___ _ _________ _ _

+---------------------+
|                     | | <- SOI.PADDING         | <- SOI.HEIGHT
| +---+-------------+ |                          |
| | H |             | | | <- SOI.INNER_PADDING   |
| | E | +---------+ | |                          |
| | A | | MODULES | | | | <- SOI.CONTENT_HEIGHT  |
| | D | |         | | | |                        |
| | E | |         | | | |                        |
| | R | |         | | | |                        |
| |   | +---------+ | |                          |
| |   |             | | | <- SOI.INNER_PADDDING  |
| +---+-------------+ |                          |
|                     | | <- SOI.PADDING         |
+---------------------+

+---------------------+
|                     | | <- SOI.PADDING         | <- SOI.HEIGHT
| +---+-------------+ |                          |
| | H |             | | | <- SOI.INNER_PADDING   |
| | E | +---------+ | |                          |
| | A | | MODULES | | | | <- SOI.CONTENT_HEIGHT  |
| | D | |         | | | |                        |
| | E | |         | | | |                        |
| | R | |         | | | |                        |
| |   | +---------+ | |                          |
| |   |             | | | <- SOI.INNER_PADDDING  |
| +---+-------------+ |                          |
|                     | | <- SOI.PADDING         |
+---------------------+

MODULES is where all the modules of the page will show up. Each module has two positions stored, one through it's module["meta"]["x"] and module["meta"]["y"] values, and one through the position of it's widget representation module["widget"].pos(). The "meta" position is relative to the page indicated by meta["meta"]["page"], whereas the widget position is absolute and takes into consideration that the SOI is in practice painted onto one continious surface. They are defined as follows:

  • module["meta"]["x"] is a value between 0 and SOI.CONTENT_WIDTH. This value is determined by the placement strategy.
  • module["meta"]["y"] is a value between 0 and SOI.CONTENT_HEIGHT. This value is determined by the placement strategy.
  • module["widget"].pos().x() is calculated from module["meta"]["x"] as follows:

    text module["meta"]["x"] + SOI.PADDING + SOI.HEADER_WIDTH + SOI.INNER_PADDDING

  • module["widget"].pos().y() is calculated from module["meta"]["y"] as follows:

    text module["meta"]["y"] + SOI.PADDING + SOI.INNER_PADDING + (SOI.PADDING + SOI.HEIGHT) * (module["meta"]["page"] - 1)

The function SOI.update_module_widget_position() is responsible for updating the widget positions based on the "meta" positions, using the formulas above.

Note about properties

To ensure other users of SOI are properly notified, all property updates should happen through member functions. update_properties for general properties, and add_module for modules and attachments.

Parameters

title : string
 
description : string
 
version : string
 
date : string in format YYYY`-`MM`-`DD
 
valid_from : string in format YYYY`-`MM`-`DD
 
valid_to : string in format YYYY`-`MM`-`DD
 
icon : string
path to icon for SOI
classification : string
 
orientation : string
must be one of 'landscape' and 'portrait'
placement_strategy : string
must be one of 'manual' and 'auto'
algorithm_bin : string
which bin packing algorithm should be used for rectpack. Currently the following are implemented: 'BFF', 'BBF'. Please refer to the STRING_TO_ALGORITHM_RECTPACK_BIN variable.
algorithm_pack : string
which packing algorithm should be used for rectpack. Currently the following are implemented: 'MaxRectsBl', 'SkylineBl', 'GuillotineBssfSas'. Please refer to the STRING_TO_ALGORITHM_RECTPACK_PACK variable.
algorithm_sort : string
which sorting method should be applied to the modules before packing. Currently the following are implemented: 'none', 'area', 'width', 'height'. Please refer to the STRING_TO_ALGORITHM_INITIAL_SORT variable.
modules : list of modules
initial modules of SOI
attachments : list of attachment modules
initial attachment modules of SOI
Expand source code
class SOI:
    """Datastructure for SOI.

    Holds all info about an SOI necessary to view and edit it.

    ## Note about rectpack

    This class relies heavily on the rectpack library for optimal placement of
    modules when placement_strategy is 'auto'. Refer to the rectpack
    documentation for details beyond what is provided in this class:
    https://github.com/secnot/rectpack

    ## Anatomy of an SOI

    Below is an illustration of how an SOI with two pages is represented using
    the class variables.

    ```text

     SOI.WIDTH
     |
     v
     _________________

     SOI.PADDING
     |  SOI.HEADER_WIDTH
     |  |  SOI.INNER_PADDING
     |  |  |  SOI.CONTENT_WIDTH
     |  |  |  |        SOI.INNER_PADDING
     |  |  |  |        | SOI.PADDING
     |  |  |  |        | |
     v  v  v  v        v v
     _ ___ _ _________ _ _

    +---------------------+
    |                     | | <- SOI.PADDING         | <- SOI.HEIGHT
    | +---+-------------+ |                          |
    | | H |             | | | <- SOI.INNER_PADDING   |
    | | E | +---------+ | |                          |
    | | A | | MODULES | | | | <- SOI.CONTENT_HEIGHT  |
    | | D | |         | | | |                        |
    | | E | |         | | | |                        |
    | | R | |         | | | |                        |
    | |   | +---------+ | |                          |
    | |   |             | | | <- SOI.INNER_PADDDING  |
    | +---+-------------+ |                          |
    |                     | | <- SOI.PADDING         |
    +---------------------+

    +---------------------+
    |                     | | <- SOI.PADDING         | <- SOI.HEIGHT
    | +---+-------------+ |                          |
    | | H |             | | | <- SOI.INNER_PADDING   |
    | | E | +---------+ | |                          |
    | | A | | MODULES | | | | <- SOI.CONTENT_HEIGHT  |
    | | D | |         | | | |                        |
    | | E | |         | | | |                        |
    | | R | |         | | | |                        |
    | |   | +---------+ | |                          |
    | |   |             | | | <- SOI.INNER_PADDDING  |
    | +---+-------------+ |                          |
    |                     | | <- SOI.PADDING         |
    +---------------------+

    ```

    `MODULES` is where all the modules of the page will show up. Each module
    has two positions stored, one through it's `module["meta"]["x"]` and
    `module["meta"]["y"]` values, and one through the position of it's
    widget representation `module["widget"].pos()`. The "meta" position is
    relative to the page indicated by `meta["meta"]["page"]`, whereas the
    widget position is absolute and takes into consideration that the SOI is in
    practice painted onto one continious surface. They are defined as follows:

    * `module["meta"]["x"]` is a value between `0` and `SOI.CONTENT_WIDTH`.
        This value is determined by the placement strategy.
    * `module["meta"]["y"]` is a value between `0` and `SOI.CONTENT_HEIGHT`.
        This value is determined by the placement strategy.
    * `module["widget"].pos().x()` is calculated from `module["meta"]["x"]`
        as follows:

        ```text
        module["meta"]["x"] + SOI.PADDING + SOI.HEADER_WIDTH +
        SOI.INNER_PADDDING
        ```

    * `module["widget"].pos().y()` is calculated from `module["meta"]["y"]`
        as follows:

        ```text
        module["meta"]["y"] + SOI.PADDING + SOI.INNER_PADDING +
        (SOI.PADDING + SOI.HEIGHT) * (module["meta"]["page"] - 1)
        ```

    The function `SOI.update_module_widget_position` is responsible for
    updating the widget positions based on the "meta" positions, using the
    formulas above.

    ## Note about properties

    To ensure other users of SOI are properly notified, all property updates
    should happen through member functions. `update_properties` for general
    properties, and `add_module` for modules and attachments.

    Parameters
    ----------
    title : string
    description : string
    version : string
    date : string in format `YYYY-MM-DD`
    valid_from : string in format `YYYY-MM-DD`
    valid_to : string in format `YYYY-MM-DD`
    icon : string
        path to icon for SOI
    classification : string
    orientation : string
        must be one of 'landscape' and 'portrait'
    placement_strategy : string
        must be one of 'manual' and 'auto'
    algorithm_bin : string
        which bin packing algorithm should be used for rectpack. Currently the
        following are implemented: 'BFF', 'BBF'. Please refer to the
        STRING_TO_ALGORITHM_RECTPACK_BIN variable.
    algorithm_pack : string
        which packing algorithm should be used for rectpack. Currently the
        following are implemented: 'MaxRectsBl', 'SkylineBl',
        'GuillotineBssfSas'. Please refer to the
        STRING_TO_ALGORITHM_RECTPACK_PACK variable.
    algorithm_sort : string
        which sorting method should be applied to the modules before packing.
        Currently the following are implemented: 'none', 'area', 'width',
        'height'. Please refer to the STRING_TO_ALGORITHM_INITIAL_SORT
        variable.
    modules : list of modules
        initial modules of SOI
    attachments : list of attachment modules
        initial attachment modules of SOI

    """

    A4_RATIO = 1.414

    # Height is adjusted to something that will look right when real widgets
    # are placed inside the pages
    HEIGHT = 1700
    WIDTH = HEIGHT * A4_RATIO
    PADDING = 100
    HEADER_WIDTH = 110
    HEADER_HEIGHT = HEIGHT - PADDING * 2
    MODULE_PADDING = 10
    INNER_PADDING = 1
    CONTENT_WIDTH = WIDTH - PADDING * 2 - INNER_PADDING * 2 - HEADER_WIDTH
    CONTENT_HEIGHT = HEIGHT - PADDING * 2 - INNER_PADDING * 2

    @classmethod
    def construct_from_compressed_soi_file(cls, filename):
        """Construct an SOI object from a compressed SOI file."""
        with open(filename):
            pass
        raise NotImplementedError()

    @classmethod
    def construct_from_soi_file(cls, filename):
        """Construct an SOI object from a SOI file."""
        with open(filename):
            pass
        raise NotImplementedError()

    # Ignoring pylint's r0913 "Too many arguments" error here to keep this as
    # simple as possible. The proper thing would probably be to put the
    # properties in separate data classes (auto-placement stuff in one class,
    # styling in another, etc).
    # Ignoring pylint's r0914 "Too many local variables" for the same reason as
    # r0913
    # pylint: disable=r0913,r0914
    def __init__(
        self,
        title="Default SOI title",
        description="Default SOI description",
        version="1",
        date=None,
        valid_from=None,
        valid_to=None,
        icon="soitool/media/HVlogo.png",
        classification="UGRADERT",
        orientation="landscape",
        placement_strategy="auto",
        algorithm_bin="BFF",
        algorithm_pack="MaxRectsBl",
        algorithm_sort="area",
        modules=None,
        attachments=None,
    ):

        # Populate date-related arguments if they are not supplied by the user
        if date is None:
            now = datetime.date(datetime.now())
            date = now.strftime("%Y-%m-%d")

        if valid_from is None:
            valid_from = date

        if valid_to is None:
            valid_to = date

        # Reason to not just set to [] as default argument:
        # https://stackoverflow.com/questions/9526465/best-practice-for-setting-the-default-value-of-a-parameter-thats-supposed-to-be
        if modules is None:
            modules = []
        if attachments is None:
            attachments = []

        self.title = title
        self.description = description
        self.version = version
        self.date = date
        self.valid_from = valid_from
        self.valid_to = valid_to
        self.icon = icon
        self.classification = classification
        self.orientation = orientation
        self.placement_strategy = placement_strategy
        self.modules = modules
        self.attachments = attachments

        # The following properties are relevant when self.placement_strategy
        # is "auto". They are used by rectpack
        self.algorithm_bin = algorithm_bin
        self.algorithm_pack = algorithm_pack
        self.algorithm_sort = algorithm_sort

        # Prepare listener lists: list of functions to call after certain
        # events happen
        self.reorganization_listeners = []
        self.new_module_listeners = []
        self.update_property_listeners = []

        self.reorganize()

    def add_reorganization_listener(self, function):
        """Add to list of functions to be called after reorganization.

        This is useful for users of this class to handle changes to the SOI.
        As an example a class displaying an SOI can be updated after changes.

        Parameters
        ----------
        function : function
            Added to the list of functions to be called after reorganization.
        """
        self.reorganization_listeners.append(function)

    def add_new_module_listener(self, function):
        """Add to list of functions to be called after added module.

        This is useful for users of this class to handle changes to the SOI.
        As an example a class displaying an SOI can be updated after changes.

        Parameters
        ----------
        function : function
            Added to the list of functions to be called after added module.
        """
        self.new_module_listeners.append(function)

    def add_update_property_listener(self, function):
        """Add to list of functions to be called after properties change.

        This is useful for users of this class to handle changes to the SOI.
        As an example a class displaying an SOI can be updated after changes.

        Parameters
        ----------
        function : function
            Added to the list of functions to be called after properties
            change.
        """
        self.update_property_listeners.append(function)

    def update_module_widget_position(self, module):
        """Update position of module widget based on meta position.

        For reasoning on the calculations done in this function see the class
        docstring.

        Parameters
        ----------
        module : see description
            should be dict of fields "meta" and "widget", where "meta" is
            itself a dict with fields "x", "y" and "page", and "widget" is a
            widget based on "ModuleBase"
        """
        distance_to_start_of_next_soi_content_y = self.HEIGHT + self.PADDING

        scene_skip_distance_page_height = (
            distance_to_start_of_next_soi_content_y
            * (module["meta"]["page"] - 1)
        )

        new_x = (
            module["meta"]["x"]
            + self.PADDING
            + self.HEADER_WIDTH
            + self.INNER_PADDING
        )
        new_y = (
            module["meta"]["y"]
            + self.PADDING
            + scene_skip_distance_page_height
            + self.INNER_PADDING
        )

        set_module_pos(module["widget"], QPoint(new_x, new_y))

    def get_module_with_name(self, name):
        """Return module with given name.

        Parameters
        ----------
        name : str
            Name of module to look for.

        Returns
        -------
        module in self.modules, or None if no module found
        """
        for module in self.modules:
            if module["meta"]["name"] == name:
                return module
        return None

    def reorganize(self):
        """Reorganize modules using chosen strategy.

        After successfull reorganization will call all listeners

        Raises
        ------
        Exception
            If placement strategy is neither auto nor manual.
        """
        if self.placement_strategy == "auto":
            self.reorganize_rectpack()
        elif self.placement_strategy == "manual":
            # Nothing to do
            pass
        else:
            raise Exception(
                "Unknown placement strategy: {}".format(
                    self.placement_strategy
                )
            )
        self.reorganize_attachments()
        # Call listener functions
        for listener in self.reorganization_listeners:
            listener()

    def reorganize_attachments(self):
        """Reorganize attachments. Each on own page in correct order.

        Order taken from order in attachment list directly. Attachments appear
        at the top-left corner always.
        """
        for i, attachment in enumerate(self.attachments):
            first_attachment_page = (
                self.get_number_of_non_attachment_pages() + 1
            )
            attachment["meta"]["x"] = 0
            attachment["meta"]["y"] = 0
            attachment["meta"]["page"] = first_attachment_page + i
            self.update_module_widget_position(attachment)

    def get_number_of_non_attachment_pages(self):
        """Calculate how many pages non-attachment modules require.

        The minimum page count is 1.

        Returns
        -------
        int
            Number of pages required for non-attachment modules.
        """
        pages = 1
        for module in self.modules:
            if module["meta"]["page"] > pages:
                pages += 1
        return pages

    def get_rectpack_packer(self):
        """Return rectpack packer set up for this SOI.

        Returns
        -------
        packer : rectpack packer
        """
        # Based on string stored in self.algorithm_... variables fetch real
        # implementations of chosen algorithms
        chosen_algorithm_bin = STRING_TO_ALGORITHM_RECTPACK_BIN[
            self.algorithm_bin
        ]
        chosen_algorithm_pack = STRING_TO_ALGORITHM_RECTPACK_PACK[
            self.algorithm_pack
        ]

        # NOTE that initial sorting is done outside of the packer, so it is set
        # to SORT_NONE here to respect our sorting
        packer = newPacker(
            rotation=False,
            mode=PackingMode.Offline,
            bin_algo=chosen_algorithm_bin,
            sort_algo=SORT_NONE,
            pack_algo=chosen_algorithm_pack,
        )

        return packer

    def sort_modules(self):
        """Sort modules in place using chosen sorting algorithm."""
        chosen_algorithm_sort = STRING_TO_ALGORITHM_INITIAL_SORT[
            self.algorithm_sort
        ]

        self.modules = chosen_algorithm_sort(self.modules)

    def reorganize_rectpack(self):
        """Reorganize modules optimally using the rectpack library.

        Note that to add padding between the modules we add self.MODULE_PADDING
        to each width,height of rectangles sent to rectpack for packing. We
        also tell rectpack that our "bins" are self.MODULE_PADDING larger in
        each dimension. As a result of this we get self.MODULE_PADDING amount
        of padding between the packed modules.

        Raises
        ------
        ModuleLargerThanBinError
            If at least one module is too large to fit into a bin.
            A bin is a page in this context
        """
        packer = self.get_rectpack_packer()

        self.sort_modules()

        for module in self.modules:
            module_width, module_height = module["widget"].get_size()
            padded_module_width = module_width + self.MODULE_PADDING
            padded_module_height = module_height + self.MODULE_PADDING
            packer.add_rect(
                padded_module_width,
                padded_module_height,
                module["meta"]["name"],
            )

        # float("inf") to add infinite bins.
        # See https://github.com/secnot/rectpack/blob/master/README.md#api

        packer.add_bin(
            self.CONTENT_WIDTH + self.MODULE_PADDING,
            self.CONTENT_HEIGHT + self.MODULE_PADDING,
            float("inf"),
        )

        packer.pack()
        packed_rects = packer.rect_list()

        # Explode if rectpack failed to pack all rects
        if len(packed_rects) != len(self.modules):
            raise ModuleLargerThanBinError()

        # Update modules based on packed rects returned from rectpack
        for packed_rect in packed_rects:

            packed_rect_bin = packed_rect[0]
            packed_rect_x = packed_rect[1]
            packed_rect_y = packed_rect[2]
            packed_rect_id = packed_rect[5]

            corresponding_module = self.get_module_with_name(packed_rect_id)
            if corresponding_module is None:
                raise Exception("Module was lost during packing!")

            corresponding_module["meta"]["x"] = packed_rect_x
            corresponding_module["meta"]["y"] = packed_rect_y
            corresponding_module["meta"]["page"] = packed_rect_bin + 1
            self.update_module_widget_position(corresponding_module)

    def module_name_taken(self, name):
        """Return True if module with name exists, False otherwise."""
        for module in self.modules + self.attachments:
            if name == module["meta"]["name"]:
                return True
        return False

    def add_module(self, name, widget_implementation, is_attachment=False):
        """Add module to SOI, reorganize it, and notify listeners.

        This function raises an exception if the given name is taken

        Parameters
        ----------
        name : str
            Name of new module.
        widget_implementation : subclass of ModuleBase
            An instance of the widget implementation of the module.
        is_attachment : bool
            True if the module should be added as an attachment. False
            otherwise.

        Raises
        ------
        ModuleNameTaken
            If the module name is taken.
        """
        if self.module_name_taken(name):
            raise ModuleNameTaken

        # NOTE that "x", "y" and "page" under "meta" are not valid until
        # self.reorganize() has run
        to_add = {
            "meta": {"x": 0, "y": 0, "page": 0, "name": name},
            "widget": widget_implementation,
        }
        if is_attachment:
            self.attachments.append(to_add)
        else:
            self.modules.append(to_add)

        self.reorganize()
        # Call listener functions
        for listener in self.new_module_listeners:
            listener()

    # Ignoring pylint "Too many branches" error as this function is a special
    # case where the if-elif-elif-...-else makes sense
    def update_properties(self, **kwargs):  # pylint: disable=R0912
        """Update properties given as input, and call listeners.

        This function exists solely because there are listeners on the
        properties. A "cleaner" way to achieve the same goal would be to
        create a "setter" for each property and call the listeners there, but
        for bulk changes this would get unwieldy. Another way to achieve the
        goal of calling listeners after changes to properties would be to
        create a separate function that the user is expected to call after
        changing the properties directly, but this would put unnecessary
        responsibility on the user.

        All properties except "modules" and "attachements" can be updated
        using this function.

        If a change is made that affects placement of modules this function
        will call `reorganize`

        Parameters
        ----------
        **kwargs : key, value pairs of properties to update
            Accepts both a normal dict, and passing arguments as normal:
            `update_properties({'title': 'my title'})` and
            update_properties(title='my title')`. Accepted keys are properties
            of the SOI class, except 'modules' and 'attachements'. Explanation
            of **kwargs:
            https://stackoverflow.com/a/1769475/3545896

        Raises
        ------
        ValueError
            If argument not referring to SOI property
            modifiable from this function is passed.
        """
        # For every key, value pair passed in kwargs, update corresponding
        # property
        for key, value in kwargs.items():
            if key == "title":
                self.title = value
            elif key == "description":
                self.description = value
            elif key == "version":
                self.version = value
            elif key == "date":
                self.date = value
            elif key == "valid_from":
                self.valid_from = value
            elif key == "valid_to":
                self.valid_to = value
            elif key == "icon":
                self.icon = value
            elif key == "classification":
                self.classification = value
            elif key == "orientation":
                self.orientation = value
            elif key == "placement_strategy":
                self.placement_strategy = value
            elif key == "algorithm_bin":
                self.algorithm_bin = value
            elif key == "algorithm_pack":
                self.algorithm_pack = value
            elif key == "algorithm_sort":
                self.algorithm_sort = value
            else:
                raise ValueError(
                    f"Unknown property name {key} passed with value {value}"
                )

        for listener in self.update_property_listeners:
            listener()

        # If any properties relating to module placement were touched,
        # reorganize
        if (
            "placement_strategy" in kwargs
            or "algorithm_bin" in kwargs
            or "algorithm_pack" in kwargs
            or "algorithm_sort" in kwargs
        ):
            self.reorganize()

Class variables

var A4_RATIO

Convert a string or number to a floating point number, if possible.

var CONTENT_HEIGHT

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var CONTENT_WIDTH

Convert a string or number to a floating point number, if possible.

var HEADER_HEIGHT

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var HEADER_WIDTH

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var HEIGHT

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var INNER_PADDING

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var MODULE_PADDING

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var PADDING

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
var WIDTH

Convert a string or number to a floating point number, if possible.

Static methods

def construct_from_compressed_soi_file(filename)

Construct an SOI object from a compressed SOI file.

Expand source code
@classmethod
def construct_from_compressed_soi_file(cls, filename):
    """Construct an SOI object from a compressed SOI file."""
    with open(filename):
        pass
    raise NotImplementedError()
def construct_from_soi_file(filename)

Construct an SOI object from a SOI file.

Expand source code
@classmethod
def construct_from_soi_file(cls, filename):
    """Construct an SOI object from a SOI file."""
    with open(filename):
        pass
    raise NotImplementedError()

Methods

def add_module(self, name, widget_implementation, is_attachment=False)

Add module to SOI, reorganize it, and notify listeners.

This function raises an exception if the given name is taken

Parameters

name : str
Name of new module.
widget_implementation : subclass of ModuleBase
An instance of the widget implementation of the module.
is_attachment : bool
True if the module should be added as an attachment. False otherwise.

Raises

ModuleNameTaken
If the module name is taken.
Expand source code
def add_module(self, name, widget_implementation, is_attachment=False):
    """Add module to SOI, reorganize it, and notify listeners.

    This function raises an exception if the given name is taken

    Parameters
    ----------
    name : str
        Name of new module.
    widget_implementation : subclass of ModuleBase
        An instance of the widget implementation of the module.
    is_attachment : bool
        True if the module should be added as an attachment. False
        otherwise.

    Raises
    ------
    ModuleNameTaken
        If the module name is taken.
    """
    if self.module_name_taken(name):
        raise ModuleNameTaken

    # NOTE that "x", "y" and "page" under "meta" are not valid until
    # self.reorganize() has run
    to_add = {
        "meta": {"x": 0, "y": 0, "page": 0, "name": name},
        "widget": widget_implementation,
    }
    if is_attachment:
        self.attachments.append(to_add)
    else:
        self.modules.append(to_add)

    self.reorganize()
    # Call listener functions
    for listener in self.new_module_listeners:
        listener()
def add_new_module_listener(self, function)

Add to list of functions to be called after added module.

This is useful for users of this class to handle changes to the SOI. As an example a class displaying an SOI can be updated after changes.

Parameters

function : function
Added to the list of functions to be called after added module.
Expand source code
def add_new_module_listener(self, function):
    """Add to list of functions to be called after added module.

    This is useful for users of this class to handle changes to the SOI.
    As an example a class displaying an SOI can be updated after changes.

    Parameters
    ----------
    function : function
        Added to the list of functions to be called after added module.
    """
    self.new_module_listeners.append(function)
def add_reorganization_listener(self, function)

Add to list of functions to be called after reorganization.

This is useful for users of this class to handle changes to the SOI. As an example a class displaying an SOI can be updated after changes.

Parameters

function : function
Added to the list of functions to be called after reorganization.
Expand source code
def add_reorganization_listener(self, function):
    """Add to list of functions to be called after reorganization.

    This is useful for users of this class to handle changes to the SOI.
    As an example a class displaying an SOI can be updated after changes.

    Parameters
    ----------
    function : function
        Added to the list of functions to be called after reorganization.
    """
    self.reorganization_listeners.append(function)
def add_update_property_listener(self, function)

Add to list of functions to be called after properties change.

This is useful for users of this class to handle changes to the SOI. As an example a class displaying an SOI can be updated after changes.

Parameters

function : function
Added to the list of functions to be called after properties change.
Expand source code
def add_update_property_listener(self, function):
    """Add to list of functions to be called after properties change.

    This is useful for users of this class to handle changes to the SOI.
    As an example a class displaying an SOI can be updated after changes.

    Parameters
    ----------
    function : function
        Added to the list of functions to be called after properties
        change.
    """
    self.update_property_listeners.append(function)
def get_module_with_name(self, name)

Return module with given name.

Parameters

name : str
Name of module to look for.

Returns

module in self.modules, or None if no module found
 
Expand source code
def get_module_with_name(self, name):
    """Return module with given name.

    Parameters
    ----------
    name : str
        Name of module to look for.

    Returns
    -------
    module in self.modules, or None if no module found
    """
    for module in self.modules:
        if module["meta"]["name"] == name:
            return module
    return None
def get_number_of_non_attachment_pages(self)

Calculate how many pages non-attachment modules require.

The minimum page count is 1.

Returns

int
Number of pages required for non-attachment modules.
Expand source code
def get_number_of_non_attachment_pages(self):
    """Calculate how many pages non-attachment modules require.

    The minimum page count is 1.

    Returns
    -------
    int
        Number of pages required for non-attachment modules.
    """
    pages = 1
    for module in self.modules:
        if module["meta"]["page"] > pages:
            pages += 1
    return pages
def get_rectpack_packer(self)

Return rectpack packer set up for this SOI.

Returns

packer : rectpack packer
 
Expand source code
def get_rectpack_packer(self):
    """Return rectpack packer set up for this SOI.

    Returns
    -------
    packer : rectpack packer
    """
    # Based on string stored in self.algorithm_... variables fetch real
    # implementations of chosen algorithms
    chosen_algorithm_bin = STRING_TO_ALGORITHM_RECTPACK_BIN[
        self.algorithm_bin
    ]
    chosen_algorithm_pack = STRING_TO_ALGORITHM_RECTPACK_PACK[
        self.algorithm_pack
    ]

    # NOTE that initial sorting is done outside of the packer, so it is set
    # to SORT_NONE here to respect our sorting
    packer = newPacker(
        rotation=False,
        mode=PackingMode.Offline,
        bin_algo=chosen_algorithm_bin,
        sort_algo=SORT_NONE,
        pack_algo=chosen_algorithm_pack,
    )

    return packer
def module_name_taken(self, name)

Return True if module with name exists, False otherwise.

Expand source code
def module_name_taken(self, name):
    """Return True if module with name exists, False otherwise."""
    for module in self.modules + self.attachments:
        if name == module["meta"]["name"]:
            return True
    return False
def reorganize(self)

Reorganize modules using chosen strategy.

After successfull reorganization will call all listeners

Raises

Exception
If placement strategy is neither auto nor manual.
Expand source code
def reorganize(self):
    """Reorganize modules using chosen strategy.

    After successfull reorganization will call all listeners

    Raises
    ------
    Exception
        If placement strategy is neither auto nor manual.
    """
    if self.placement_strategy == "auto":
        self.reorganize_rectpack()
    elif self.placement_strategy == "manual":
        # Nothing to do
        pass
    else:
        raise Exception(
            "Unknown placement strategy: {}".format(
                self.placement_strategy
            )
        )
    self.reorganize_attachments()
    # Call listener functions
    for listener in self.reorganization_listeners:
        listener()
def reorganize_attachments(self)

Reorganize attachments. Each on own page in correct order.

Order taken from order in attachment list directly. Attachments appear at the top-left corner always.

Expand source code
def reorganize_attachments(self):
    """Reorganize attachments. Each on own page in correct order.

    Order taken from order in attachment list directly. Attachments appear
    at the top-left corner always.
    """
    for i, attachment in enumerate(self.attachments):
        first_attachment_page = (
            self.get_number_of_non_attachment_pages() + 1
        )
        attachment["meta"]["x"] = 0
        attachment["meta"]["y"] = 0
        attachment["meta"]["page"] = first_attachment_page + i
        self.update_module_widget_position(attachment)
def reorganize_rectpack(self)

Reorganize modules optimally using the rectpack library.

Note that to add padding between the modules we add self.MODULE_PADDING to each width,height of rectangles sent to rectpack for packing. We also tell rectpack that our "bins" are self.MODULE_PADDING larger in each dimension. As a result of this we get self.MODULE_PADDING amount of padding between the packed modules.

Raises

ModuleLargerThanBinError
If at least one module is too large to fit into a bin. A bin is a page in this context
Expand source code
def reorganize_rectpack(self):
    """Reorganize modules optimally using the rectpack library.

    Note that to add padding between the modules we add self.MODULE_PADDING
    to each width,height of rectangles sent to rectpack for packing. We
    also tell rectpack that our "bins" are self.MODULE_PADDING larger in
    each dimension. As a result of this we get self.MODULE_PADDING amount
    of padding between the packed modules.

    Raises
    ------
    ModuleLargerThanBinError
        If at least one module is too large to fit into a bin.
        A bin is a page in this context
    """
    packer = self.get_rectpack_packer()

    self.sort_modules()

    for module in self.modules:
        module_width, module_height = module["widget"].get_size()
        padded_module_width = module_width + self.MODULE_PADDING
        padded_module_height = module_height + self.MODULE_PADDING
        packer.add_rect(
            padded_module_width,
            padded_module_height,
            module["meta"]["name"],
        )

    # float("inf") to add infinite bins.
    # See https://github.com/secnot/rectpack/blob/master/README.md#api

    packer.add_bin(
        self.CONTENT_WIDTH + self.MODULE_PADDING,
        self.CONTENT_HEIGHT + self.MODULE_PADDING,
        float("inf"),
    )

    packer.pack()
    packed_rects = packer.rect_list()

    # Explode if rectpack failed to pack all rects
    if len(packed_rects) != len(self.modules):
        raise ModuleLargerThanBinError()

    # Update modules based on packed rects returned from rectpack
    for packed_rect in packed_rects:

        packed_rect_bin = packed_rect[0]
        packed_rect_x = packed_rect[1]
        packed_rect_y = packed_rect[2]
        packed_rect_id = packed_rect[5]

        corresponding_module = self.get_module_with_name(packed_rect_id)
        if corresponding_module is None:
            raise Exception("Module was lost during packing!")

        corresponding_module["meta"]["x"] = packed_rect_x
        corresponding_module["meta"]["y"] = packed_rect_y
        corresponding_module["meta"]["page"] = packed_rect_bin + 1
        self.update_module_widget_position(corresponding_module)
def sort_modules(self)

Sort modules in place using chosen sorting algorithm.

Expand source code
def sort_modules(self):
    """Sort modules in place using chosen sorting algorithm."""
    chosen_algorithm_sort = STRING_TO_ALGORITHM_INITIAL_SORT[
        self.algorithm_sort
    ]

    self.modules = chosen_algorithm_sort(self.modules)
def update_module_widget_position(self, module)

Update position of module widget based on meta position.

For reasoning on the calculations done in this function see the class docstring.

Parameters

module : see description
should be dict of fields "meta" and "widget", where "meta" is itself a dict with fields "x", "y" and "page", and "widget" is a widget based on "ModuleBase"
Expand source code
def update_module_widget_position(self, module):
    """Update position of module widget based on meta position.

    For reasoning on the calculations done in this function see the class
    docstring.

    Parameters
    ----------
    module : see description
        should be dict of fields "meta" and "widget", where "meta" is
        itself a dict with fields "x", "y" and "page", and "widget" is a
        widget based on "ModuleBase"
    """
    distance_to_start_of_next_soi_content_y = self.HEIGHT + self.PADDING

    scene_skip_distance_page_height = (
        distance_to_start_of_next_soi_content_y
        * (module["meta"]["page"] - 1)
    )

    new_x = (
        module["meta"]["x"]
        + self.PADDING
        + self.HEADER_WIDTH
        + self.INNER_PADDING
    )
    new_y = (
        module["meta"]["y"]
        + self.PADDING
        + scene_skip_distance_page_height
        + self.INNER_PADDING
    )

    set_module_pos(module["widget"], QPoint(new_x, new_y))
def update_properties(self, **kwargs)

Update properties given as input, and call listeners.

This function exists solely because there are listeners on the properties. A "cleaner" way to achieve the same goal would be to create a "setter" for each property and call the listeners there, but for bulk changes this would get unwieldy. Another way to achieve the goal of calling listeners after changes to properties would be to create a separate function that the user is expected to call after changing the properties directly, but this would put unnecessary responsibility on the user.

All properties except "modules" and "attachements" can be updated using this function.

If a change is made that affects placement of modules this function will call reorganize

Parameters

**kwargs : key, value pairs of properties to update
Accepts both a normal dict, and passing arguments as normal: update_properties({'title': 'my title'}) and update_properties(title='my title')`. Accepted keys are properties of the SOI class, except 'modules' and 'attachements'. Explanation of **kwargs: https://stackoverflow.com/a/1769475/3545896

Raises

ValueError
If argument not referring to SOI property modifiable from this function is passed.
Expand source code
def update_properties(self, **kwargs):  # pylint: disable=R0912
    """Update properties given as input, and call listeners.

    This function exists solely because there are listeners on the
    properties. A "cleaner" way to achieve the same goal would be to
    create a "setter" for each property and call the listeners there, but
    for bulk changes this would get unwieldy. Another way to achieve the
    goal of calling listeners after changes to properties would be to
    create a separate function that the user is expected to call after
    changing the properties directly, but this would put unnecessary
    responsibility on the user.

    All properties except "modules" and "attachements" can be updated
    using this function.

    If a change is made that affects placement of modules this function
    will call `reorganize`

    Parameters
    ----------
    **kwargs : key, value pairs of properties to update
        Accepts both a normal dict, and passing arguments as normal:
        `update_properties({'title': 'my title'})` and
        update_properties(title='my title')`. Accepted keys are properties
        of the SOI class, except 'modules' and 'attachements'. Explanation
        of **kwargs:
        https://stackoverflow.com/a/1769475/3545896

    Raises
    ------
    ValueError
        If argument not referring to SOI property
        modifiable from this function is passed.
    """
    # For every key, value pair passed in kwargs, update corresponding
    # property
    for key, value in kwargs.items():
        if key == "title":
            self.title = value
        elif key == "description":
            self.description = value
        elif key == "version":
            self.version = value
        elif key == "date":
            self.date = value
        elif key == "valid_from":
            self.valid_from = value
        elif key == "valid_to":
            self.valid_to = value
        elif key == "icon":
            self.icon = value
        elif key == "classification":
            self.classification = value
        elif key == "orientation":
            self.orientation = value
        elif key == "placement_strategy":
            self.placement_strategy = value
        elif key == "algorithm_bin":
            self.algorithm_bin = value
        elif key == "algorithm_pack":
            self.algorithm_pack = value
        elif key == "algorithm_sort":
            self.algorithm_sort = value
        else:
            raise ValueError(
                f"Unknown property name {key} passed with value {value}"
            )

    for listener in self.update_property_listeners:
        listener()

    # If any properties relating to module placement were touched,
    # reorganize
    if (
        "placement_strategy" in kwargs
        or "algorithm_bin" in kwargs
        or "algorithm_pack" in kwargs
        or "algorithm_sort" in kwargs
    ):
        self.reorganize()