# -*- coding: utf-8 -*-
import re
from . import transformers as T
from . import exceptions
__all__ = [
'Field',
'TransformerField',
'StringField',
'IntegerField',
'FloatField',
'NumberField',
'IndexedCoordinatesField',
'AdjacencyListField',
'EdgeListField',
'MatrixField',
'EdgeDataField',
'DepotsField',
'DemandsField',
'ToursField',
]
[docs]class Field:
"""Contains base functionality for all fields.
The default value can be a callable, in which case it is invoked for each
call to :func:`get_default_value`. The default can be set on an instance
or as a class attribute, but the class attribute is only checked when the
field is initially created.
:param str keyword: keyword (typically all caps)
:param default: a default value or callable that will return a default
"""
default = None
def __init__(self, keyword, *, default=None):
self.keyword = keyword
self.default = default or self.__class__.default
def __repr__(self):
return f'<{self.__class__.__qualname__}({repr(self.keyword)})>'
[docs] def get_default_value(self):
"""Return the default value.
Callables are called for a default value to return each time.
:return: the default value
:rtype: Any
"""
if callable(self.default):
return self.default()
return self.default
[docs] def parse(self, text):
"""Convert text into a value.
This must be implemented in a subclass.
:param str text:
:return: a value
"""
raise NotImplementedError()
[docs] def render(self, value):
"""Convert a value into text.
This must be implemented in a subclass.
:param value: a value
:return: text
:rtype: str
"""
raise NotImplementedError()
[docs] def validate(self, value):
"""Validate a value.
Raise an exception if the value fails validation.
The default implementation does nothing.
:param value: a value
:raises Exception: if the value does not pass validation
"""
[docs]class StringField(TransformerField):
"""Simple string field."""
[docs]class IntegerField(TransformerField):
"""Simple integer field."""
default = 0
[docs]class FloatField(TransformerField):
"""Simple float field."""
default = 0.0
[docs]class NumberField(TransformerField):
"""Number field, supporting ints and floats."""
default = 0
[docs]class IndexedCoordinatesField(TransformerField):
"""Field for coordinates by index.
When given, ``dimensions`` stipulates the possible valid dimensionalities
for the coordinates. For exapmle, ``dimensions=(2, 3)`` indicates the
coordinates are either all 2d or all 3d, whereas ``dimensions=2`` indicates
all coordinates must be 2d. The check is only enforced during validation.
:param dimensions: one or more valid dimensionalities
"""
default = dict
def __init__(self, *args, dimensions=None, **kwargs):
super().__init__(*args, **kwargs)
self.dimensions = self._tuplize(dimensions)
@staticmethod
def _tuplize(dimensions):
# helper to accept either a tuple, a single value, or None
try:
return tuple(iter(dimensions))
except Exception:
return (dimensions,) if dimensions else None
[docs] def validate(self, value):
super().validate(value)
cards = set(len(coord) for coord in value.values())
if self.dimensions and cards not in ({dim} for dim in self.dimensions):
error = ('all coordinates must have the same dimensionality '
f'and it must be one of {self.dimensions}')
raise exceptions.ValidationError(error)
[docs]class AdjacencyListField(TransformerField):
"""Field for an adjancency list."""
default = dict
[docs]class EdgeListField(TransformerField):
"""Field for a list of edges."""
default = list
[docs]class MatrixField(TransformerField):
"""Field for a matrix of numbers."""
default = list
[docs]class EdgeDataField(TransformerField):
"""Field for edge data."""
default = dict
[docs]class DepotsField(TransformerField):
"""Field for depots."""
default = list
[docs]class DemandsField(TransformerField):
"""Field for demands."""
default = dict
[docs]class ToursField(Field):
"""Field for one or more tours."""
default = list
def __init__(self, *args, require_terminal=True):
super().__init__(*args)
self.terminal = '-1'
self.require_terminal = require_terminal
self._end_terminals = re.compile(rf'(?:(?:\s+|\b|^){self.terminal})+$')
self._any_terminal = re.compile(rf'(?:\s+|\b){self.terminal}(?:\b|\s+)') # noqa: E501
[docs] def parse(self, text):
"""Parse the text into a list of tours.
:param str text: text to parse
:return: tours
:rtype: list
"""
text = text.strip()
if not text:
return []
match = self._end_terminals.search(text)
# terminal must terminate, if required
if not match and self.require_terminal:
terminal = text.split()[-1]
error = (f'must terminate in "{self.terminal}", '
f'not {repr(terminal)}')
raise exceptions.ParsingError(error)
# trim the terminal if present
if match:
text = text[:match.start()]
# split the texts and filter out the empties
texts = self._any_terminal.split(text)
texts = list(filter(None, texts))
if not texts:
return []
# convert the tours from texts to integer lists
tours = []
for text in texts:
try:
tour = [int(n) for n in text.strip().split()]
except ValueError as e:
error = f'could not convert text to node index: {repr(e)}'
raise exceptions.ParsingError(error)
else:
tours.append(tour)
return tours
[docs] def render(self, tours):
"""Render the tours as text.
:param list tours: tours to render
:return: rendered text
:rtype: str
"""
if not tours:
return ''
tour_strings = []
for tour in tours:
if tour:
tour_string = ' '.join(str(i) for i in tour)
tour_strings.append(f'{tour_string} -1')
if tour_strings:
tour_strings += ['-1']
return '\n'.join(tour_strings)