Skip to content

Commit

Permalink
Merge pull request #244 from mih/validation
Browse files Browse the repository at this point in the history
New ConstraintWithPassthrough
  • Loading branch information
mih authored Feb 17, 2023
2 parents d1793a3 + 3f746ec commit 069a811
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 30 deletions.
30 changes: 16 additions & 14 deletions datalad_next/constraints/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
""""""
from __future__ import annotations

__docformat__ = 'restructuredtext'

from typing import (
Expand All @@ -11,7 +13,6 @@
if TYPE_CHECKING: # pragma: no cover
from datalad_next.datasets import Dataset

ConstraintDerived = TypeVar('ConstraintDerived', bound='Constraint')
DatasetDerived = TypeVar('DatasetDerived', bound='Dataset')


Expand Down Expand Up @@ -61,7 +62,7 @@ def short_description(self):
# used as a condensed primer for the parameter lists
raise NotImplementedError("abstract class")

def for_dataset(self, dataset: DatasetDerived) -> ConstraintDerived:
def for_dataset(self, dataset: DatasetDerived) -> Constraint:
"""Return a constraint-variant for a specific dataset context
The default implementation returns the unmodified, identical
Expand All @@ -74,6 +75,17 @@ class _MultiConstraint(Constraint):
"""Helper class to override the description methods to reported
multiple constraints
"""
def __init__(self, *constraints):
# TODO Why is EnsureNone needed? Remove if possible
from .basic import EnsureNone
self._constraints = [
EnsureNone() if c is None else c for c in constraints
]

@property
def constraints(self):
return self._constraints

def _get_description(self, attr: str, operation: str) -> str:
cs = [
getattr(c, attr)()
Expand Down Expand Up @@ -104,12 +116,7 @@ def __init__(self, *constraints):
*constraints
Alternative constraints
"""
super(AltConstraints, self).__init__()
# TODO Why is EnsureNone needed? Remove if possible
from .basic import EnsureNone
self.constraints = [
EnsureNone() if c is None else c for c in constraints
]
super().__init__(*constraints)

def __or__(self, other):
if isinstance(other, AltConstraints):
Expand Down Expand Up @@ -154,12 +161,7 @@ def __init__(self, *constraints):
*constraints
Constraints all of which must be satisfied
"""
super(Constraints, self).__init__()
# TODO Why is EnsureNone needed? Remove if possible
from .basic import EnsureNone
self.constraints = [
EnsureNone() if c is None else c for c in constraints
]
super().__init__(*constraints)

def __and__(self, other):
if isinstance(other, Constraints):
Expand Down
98 changes: 88 additions & 10 deletions datalad_next/constraints/compound.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Constraints that wrap or contain other constraints"""

from __future__ import annotations

from pathlib import Path
import sys
from typing import (
Any,
Callable,
Dict,
Generator,
)
Expand All @@ -20,9 +23,9 @@ class EnsureIterableOf(Constraint):
# TODO support a delimiter to be able to take str-lists?
def __init__(self,
iter_type: type,
item_constraint: callable,
min_len: int or None = None,
max_len: int or None = None):
item_constraint: Callable,
min_len: int | None = None,
max_len: int | None = None):
"""
Parameters
----------
Expand Down Expand Up @@ -80,9 +83,9 @@ def short_description(self):

class EnsureListOf(EnsureIterableOf):
def __init__(self,
item_constraint: callable,
min_len: int or None = None,
max_len: int or None = None):
item_constraint: Callable,
min_len: int | None = None,
max_len: int | None = None):
"""
Parameters
----------
Expand All @@ -105,9 +108,9 @@ def short_description(self):

class EnsureTupleOf(EnsureIterableOf):
def __init__(self,
item_constraint: callable,
min_len: int or None = None,
max_len: int or None = None):
item_constraint: Callable,
min_len: int | None = None,
max_len: int | None = None):
"""
Parameters
----------
Expand Down Expand Up @@ -201,7 +204,7 @@ class EnsureGeneratorFromFileLike(Constraint):
existing file to be read from.
"""

def __init__(self, item_constraint: callable):
def __init__(self, item_constraint: Callable):
"""
Parameters
----------
Expand Down Expand Up @@ -246,3 +249,78 @@ def _item_yielder(self, fp, close_file):
finally:
if close_file:
fp.close()


class ConstraintWithPassthrough(Constraint):
"""Regular contraint, but with a "pass-through" value that is not processed
This is different from a `Constraint() | EnsureValue(...)` construct,
because the pass-through value is not communicated. This can be useful
when a particular value must be supported for technical reasons, but
need not, or must not be included in (error) messages.
The pass-through is returned as-is, and is not processed except for an
identity check (`==`).
For almost all reporting (`__str__`, descriptions, ...) the wrapped
value constraint is used, making this class virtually invisible.
Only ``__repr__`` reflects the wrapping.
"""
def __init__(self,
constraint: Constraint,
passthrough: Any):
"""
Parameters
----------
constraint: Constraint
Any ``Constraint`` subclass instance that will be used to validate
values.
passthrough:
A value that will not be subjected to validation by the value
constraint, but is returned as-is. This value is not copied.
It is a caller's responsibility to guarantee immutability if that
is desired.
"""
super().__init__()
self._constraint = constraint
self._passthrough = passthrough

@property
def constraint(self) -> Constraint:
"""Returns the wrapped constraint instance"""
return self._constraint

@property
def passthrough(self) -> Any:
"""Returns the set pass-through value"""
return self._passthrough

def __call__(self, value) -> Any:
if value == self._passthrough:
val = value
else:
val = self._constraint(value)
return val

def __str__(self) -> str:
return self._constraint.__str__()

def __repr__(self) -> str:
return f'{self.__class__.__name__}' \
f'({self._constraint!r}, passthrough={self._passthrough!r})'

def for_dataset(self, dataset: DatasetDerived) -> Constraint:
"""Wrap the wrapped constraint again after tailoring it for the dataset
The pass-through value is re-used.
"""
return self.__class__(
self._constraint.for_dataset(dataset),
passthrough=self._passthrough,
)

def long_description(self) -> str:
return self._constraint.long_description()

def short_description(self) -> str:
return self._constraint.short_description()
9 changes: 6 additions & 3 deletions datalad_next/constraints/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
NoConstraint,
)
from .compound import (
ConstraintWithPassthrough,
EnsureIterableOf,
EnsureMapping,
)
Expand Down Expand Up @@ -84,19 +85,21 @@ def __init__(self,
super().__init__(
key=EnsureStr(
match=EnsureParameterConstraint.valid_param_name_regex),
value=constraint,
value=ConstraintWithPassthrough(
constraint,
passthrough,
),
# make it look like dict(...)
delimiter='=',
)
self._passthrough = passthrough

@property
def parameter_constraint(self):
return self._value_constraint

@property
def passthrough_value(self):
return self._passthrough
return self._value_constraint.passthrough

def __call__(self, value) -> Dict:
key, val = self._get_key_value(value)
Expand Down
31 changes: 29 additions & 2 deletions datalad_next/constraints/tests/test_compound.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
from tempfile import NamedTemporaryFile
from unittest.mock import patch

from datalad_next.datasets import Dataset
from datalad_next.utils import on_windows

from ..basic import (
EnsureInt,
EnsureBool,
)
from ..compound import (
ConstraintWithPassthrough,
EnsureIterableOf,
EnsureListOf,
EnsureTupleOf,
Expand Down Expand Up @@ -75,7 +77,7 @@ def _myiter(iter):
assert list(EnsureIterableOf(_myiter, int)(_mygen())) == [3, 1, 2]


def test_EnsureMapping():
def test_EnsureMapping(tmp_path):
true_key = 5
true_value = False

Expand Down Expand Up @@ -111,14 +113,20 @@ def test_EnsureMapping():
d = constraint(v)

# TODO test for_dataset() once we have a simple EnsurePathInDataset
# for now just looking for smoke
ds = Dataset(tmp_path)
cds = constraint.for_dataset(ds)
assert cds._key_constraint == constraint._key_constraint.for_dataset(ds)
assert cds._value_constraint == \
constraint._value_constraint.for_dataset(ds)


def test_EnsureGeneratorFromFileLike():
item_constraint = EnsureMapping(EnsureInt(), EnsureBool(), delimiter='::')
constraint = EnsureGeneratorFromFileLike(item_constraint)

assert 'items of type "mapping of int -> bool" read from a file-like' \
== constraint.short_description()
== constraint.short_description()

c = constraint(StringIO("5::yes\n1234::no\n"))
assert isgenerator(c)
Expand Down Expand Up @@ -150,3 +158,22 @@ def test_EnsureGeneratorFromFileLike():
# invalid file
with pytest.raises(ValueError) as e:
list(constraint('pytestNOTHEREdatalad'))

def test_ConstraintWithPassthrough(tmp_path):
wrapped = EnsureInt()
cwp = ConstraintWithPassthrough(wrapped, passthrough='mike')
# main purpose
assert cwp('mike') == 'mike'
assert cwp('5') == 5
# most info is coming straight from `wrapped`, the pass-through is
# meant to be transparent
assert str(cwp) == str(wrapped)
assert cwp.short_description() == wrapped.short_description()
assert cwp.long_description() == wrapped.long_description()
# but repr reveals it
assert repr(cwp).startswith('ConstraintWithPassthrough(')
# tailoring for a dataset keeps the pass-through
ds = Dataset(tmp_path)
cwp_ds = cwp.for_dataset(ds)
assert cwp_ds.passthrough == cwp.passthrough
assert cwp.constraint == wrapped.for_dataset(ds)
2 changes: 1 addition & 1 deletion datalad_next/constraints/tests/test_special_purpose.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def test_EnsureParameterConstraint_passthrough():
assert c(dict(p=None)) == {'p': None}
# even when the actual value constraint would not
with pytest.raises(TypeError):
c.parameter_constraint(None)
c.parameter_constraint.constraint(None)
# setting is retrievable
assert c.passthrough_value is None

Expand Down

0 comments on commit 069a811

Please sign in to comment.