Repository: datalad/datalad-next

Issue:
Implement `AltConstraints.__or__` not `__ior__`
This is the sibling of https://github.com/datalad/datalad/issues/7164.

I think the real issue is that the current implementation matches the semantics of `__ior__()` and not `__or__()`.

And this needs to be fixed.

Discussion:

Patch:
diff --git a/datalad_next/commands/download.py b/datalad_next/commands/download.py
index 03d464b..4f91ccd 100644
--- a/datalad_next/commands/download.py
+++ b/datalad_next/commands/download.py
@@ -8,7 +8,6 @@ from pathlib import (
     Path,
     PurePosixPath,
 )
-from typing import Dict
 from urllib.parse import urlparse
 
 import datalad
@@ -27,6 +26,7 @@ from datalad_next.exceptions import (
 )
 from datalad_next.utils import ensure_list
 from datalad_next.constraints import (
+    AnyOf,
     EnsureChoice,
     EnsureGeneratorFromFileLike,
     EnsureJSON,
@@ -34,10 +34,8 @@ from datalad_next.constraints import (
     EnsureMapping,
     EnsurePath,
     EnsureURL,
-    EnsureParsedURL,
     EnsureValue,
 )
-from datalad_next.constraints.base import AltConstraints
 from datalad_next.constraints.dataset import EnsureDataset
 from datalad_next.url_operations.any import AnyUrlOperations
 
@@ -127,10 +125,7 @@ class Download(ValidatedInterface):
     # - a single item
     # - as a list of items
     # - a list given in a file, or via stdin (or any file-like in Python)
-    #
-    # Must not OR: https://github.com/datalad/datalad/issues/7164
-    #spec=spec_item_constraint | EnsureListOf(spec_item_constraint)# \
-    spec_constraint = AltConstraints(
+    spec_constraint = AnyOf(
         spec_item_constraint,
         EnsureListOf(spec_item_constraint),
         EnsureGeneratorFromFileLike(
diff --git a/datalad_next/constraints/__init__.py b/datalad_next/constraints/__init__.py
index 1791ded..e6e97e0 100644
--- a/datalad_next/constraints/__init__.py
+++ b/datalad_next/constraints/__init__.py
@@ -13,7 +13,12 @@
    dataset
    exceptions
 """
-
+from .base import (
+    AllOf,
+    AnyOf,
+    Constraint,
+    DatasetParameter,
+)
 # expose constraints with direct applicability, but not
 # base and helper classes
 from .basic import (
diff --git a/datalad_next/constraints/base.py b/datalad_next/constraints/base.py
index 6603aa4..78cd888 100644
--- a/datalad_next/constraints/base.py
+++ b/datalad_next/constraints/base.py
@@ -5,8 +5,7 @@ from __future__ import annotations
 
 __docformat__ = 'restructuredtext'
 
-__all__ = ['Constraint', 'Constraints', 'AltConstraints',
-           'DatasetParameter']
+__all__ = ['Constraint', 'AllOf', 'AnyOf', 'DatasetParameter']
 
 from .exceptions import ConstraintError
 
@@ -53,10 +52,10 @@ class Constraint:
             raise ConstraintError(self, value, msg)
 
     def __and__(self, other):
-        return Constraints(self, other)
+        return AllOf(self, other)
 
     def __or__(self, other):
-        return AltConstraints(self, other)
+        return AnyOf(self, other)
 
     def __call__(self, value):
         # do any necessary checks or conversions, potentially catch exceptions
@@ -115,7 +114,7 @@ class _MultiConstraint(Constraint):
             return doc
 
 
-class AltConstraints(_MultiConstraint):
+class AnyOf(_MultiConstraint):
     """Logical OR for constraints.
 
     An arbitrary number of constraints can be given. They are evaluated in the
@@ -134,11 +133,12 @@ class AltConstraints(_MultiConstraint):
         super().__init__(*constraints)
 
     def __or__(self, other):
-        if isinstance(other, AltConstraints):
-            self.constraints.extend(other.constraints)
+        constraints = list(self.constraints)
+        if isinstance(other, AnyOf):
+            constraints.extend(other.constraints)
         else:
-            self.constraints.append(other)
-        return self
+            constraints.append(other)
+        return AnyOf(*constraints)
 
     def __call__(self, value):
         e_list = []
@@ -159,7 +159,7 @@ class AltConstraints(_MultiConstraint):
         return self._get_description('short_description', 'or')
 
 
-class Constraints(_MultiConstraint):
+class AllOf(_MultiConstraint):
     """Logical AND for constraints.
 
     An arbitrary number of constraints can be given. They are evaluated in the
@@ -179,11 +179,12 @@ class Constraints(_MultiConstraint):
         super().__init__(*constraints)
 
     def __and__(self, other):
-        if isinstance(other, Constraints):
-            self.constraints.extend(other.constraints)
+        constraints = list(self.constraints)
+        if isinstance(other, AllOf):
+            constraints.extend(other.constraints)
         else:
-            self.constraints.append(other)
-        return self
+            constraints.append(other)
+        return AllOf(*constraints)
 
     def __call__(self, value):
         for c in (self.constraints):
@@ -195,3 +196,9 @@ class Constraints(_MultiConstraint):
 
     def short_description(self):
         return self._get_description('short_description', 'and')
+
+
+# keep for backward compatibility
+Constraints = AllOf
+AltConstraints = AnyOf
+


Test patch:
diff --git a/datalad_next/constraints/tests/test_base.py b/datalad_next/constraints/tests/test_base.py
index 56be147..fb89605 100644
--- a/datalad_next/constraints/tests/test_base.py
+++ b/datalad_next/constraints/tests/test_base.py
@@ -2,15 +2,17 @@ import pytest
 
 from ..base import (
     Constraint,
-    Constraints,
-    AltConstraints,
+    AllOf,
+    AnyOf,
 )
 from ..basic import (
+    EnsureDType,
     EnsureInt,
     EnsureFloat,
     EnsureBool,
     EnsureNone,
     EnsureRange,
+    EnsureStr,
 )
 
 
@@ -30,9 +32,9 @@ def test_base():
 
 def test_constraints():
     # this should always work
-    c = Constraints(EnsureFloat())
+    c = AllOf(EnsureFloat())
     assert c(7.0) == 7.0
-    c = Constraints(EnsureFloat(), EnsureRange(min=4.0))
+    c = AllOf(EnsureFloat(), EnsureRange(min=4.0))
     assert c(7.0) == 7.0
     # __and__ form
     c = EnsureFloat() & EnsureRange(min=4.0)
@@ -41,7 +43,7 @@ def test_constraints():
     assert c(7.0) == 7.0
     with pytest.raises(ValueError):
         c(3.9)
-    c = Constraints(EnsureFloat(), EnsureRange(min=4), EnsureRange(max=9))
+    c = AllOf(EnsureFloat(), EnsureRange(min=4), EnsureRange(max=9))
     assert c(7.0) == 7.0
     with pytest.raises(ValueError):
         c(3.9)
@@ -55,14 +57,19 @@ def test_constraints():
     with pytest.raises(ValueError):
         c(9.01)
     # and reordering should not have any effect
-    c = Constraints(EnsureRange(max=4), EnsureRange(min=9), EnsureFloat())
+    c = AllOf(EnsureRange(max=4), EnsureRange(min=9), EnsureFloat())
     with pytest.raises(ValueError):
         c(3.99)
     with pytest.raises(ValueError):
         c(9.01)
     # smoke test concat AND constraints
-    c = Constraints(EnsureRange(max=10), EnsureRange(min=5)) & \
-            Constraints(EnsureRange(max=6), EnsureRange(min=2))
+    c1 = AllOf(EnsureRange(max=10), EnsureRange(min=5))
+    c2 = AllOf(EnsureRange(max=6), EnsureRange(min=2))
+    c = c1 & c2
+    # make sure that neither c1, nor c2 is modified
+    assert len(c1.constraints) == 2
+    assert len(c2.constraints) == 2
+    assert len(c.constraints) == 4
     assert c(6) == 6
     with pytest.raises(ValueError):
         c(4)
@@ -70,11 +77,11 @@ def test_constraints():
 
 def test_altconstraints():
     # this should always work
-    c = AltConstraints(EnsureFloat())
+    c = AnyOf(EnsureFloat())
     # passes the docs through
     assert c.short_description() == EnsureFloat().short_description()
     assert c(7.0) == 7.0
-    c = AltConstraints(EnsureFloat(), EnsureNone())
+    c = AnyOf(EnsureFloat(), EnsureNone())
     # wraps docs in parenthesis to help appreciate the scope of the
     # OR'ing
     assert c.short_description().startswith(
@@ -86,7 +93,7 @@ def test_altconstraints():
     c = c | EnsureInt()
     assert c.short_description(), '(float or None or int)'
     # OR with an alternative combo also extends
-    c = c | AltConstraints(EnsureBool(), EnsureInt())
+    c = c | AnyOf(EnsureBool(), EnsureInt())
     # yes, no de-duplication
     assert c.short_description(), '(float or None or int or bool or int)'
     # spot check long_description, must have some number
@@ -97,7 +104,7 @@ def test_altconstraints():
     assert c(None) is None
 
     # this should always fail
-    c = Constraints(EnsureRange(min=0, max=4), EnsureRange(min=9, max=11))
+    c = AllOf(EnsureRange(min=0, max=4), EnsureRange(min=9, max=11))
     with pytest.raises(ValueError):
         c(7.0)
     c = EnsureRange(min=0, max=4) | EnsureRange(min=9, max=11)
@@ -108,14 +115,25 @@ def test_altconstraints():
     with pytest.raises(ValueError):
         c(-1.0)
 
+    # verify no inplace modification
+    c1 = EnsureInt() | EnsureStr()
+    c2 = c1 | EnsureDType(c1)
+    # OR'ing does not "append" the new alternative to c1.
+    assert len(c1.constraints) == 2
+    # at the same time, c2 does not contain an AnyOf
+    # as an internal constraint, because this would be needless
+    # complexity re the semantics of OR
+    assert len(c2.constraints) == 3
+
 
 def test_both():
     # this should always work
-    c = AltConstraints(
-        Constraints(
+    c = AnyOf(
+        AllOf(
             EnsureFloat(),
             EnsureRange(min=7.0, max=44.0)),
-        EnsureNone())
+        EnsureNone(),
+    )
     assert c(7.0) == 7.0
     assert c(None) is None
     # this should always fail
diff --git a/datalad_next/constraints/tests/test_cmdarg_validation.py b/datalad_next/constraints/tests/test_cmdarg_validation.py
index 2e60171..b735085 100644
--- a/datalad_next/constraints/tests/test_cmdarg_validation.py
+++ b/datalad_next/constraints/tests/test_cmdarg_validation.py
@@ -25,7 +25,7 @@ from .. import (
     EnsureValue,
 )
 from ..base import (
-    AltConstraints,
+    AnyOf,
     Constraint,
 )
 from ..dataset import EnsureDataset
@@ -49,10 +49,7 @@ class BasicCmdValidator(EnsureCommandParameterization):
     spec_item_constraint = url2path_constraint | url_constraint \
         | (EnsureJSON() & url2path_constraint)
 
-    # Must not OR: https://github.com/datalad/datalad/issues/7164
-    #spec_constraint = \
-    #    spec_item_constraint | EnsureListOf(spec_item_constraint)
-    spec_constraint = AltConstraints(
+    spec_constraint = AnyOf(
         EnsureListOf(spec_item_constraint),
         EnsureGeneratorFromFileLike(spec_item_constraint),
         spec_item_constraint,