diff --git a/docs/source/traits_api_reference/trait_types.rst b/docs/source/traits_api_reference/trait_types.rst index f939a0104..9ff450ee6 100644 --- a/docs/source/traits_api_reference/trait_types.rst +++ b/docs/source/traits_api_reference/trait_types.rst @@ -241,6 +241,9 @@ Traits .. autoclass:: Union :show-inheritance: +.. autoclass:: Optional + :show-inheritance: + .. autoclass:: Either :show-inheritance: diff --git a/docs/source/traits_user_manual/defining.rst b/docs/source/traits_user_manual/defining.rst index 9c923acca..5806b599e 100644 --- a/docs/source/traits_user_manual/defining.rst +++ b/docs/source/traits_user_manual/defining.rst @@ -265,7 +265,7 @@ the table. .. index:: Directory(), Disallow, Either(), Enum() .. index:: Event(), Expression(), false, File() .. index:: Instance(), List(), Method(), Module() -.. index:: Password(), Property(), Python() +.. index:: Optional(), Password(), Property(), Python() .. index:: PythonValue(), Range(), ReadOnly(), Regex() .. index:: Set() String(), This, Time() .. index:: ToolbarButton(), true, Tuple(), Type() @@ -355,6 +355,8 @@ the table. +------------------+----------------------------------------------------------+ | Module | Module([\*\*\ *metadata*]) | +------------------+----------------------------------------------------------+ +| Optional | Optional(*trait*\ [, \*\*\ *metadata*]) | ++------------------+----------------------------------------------------------+ | Password | Password([*value* = '', *minlen* = 0, *maxlen* = | | | sys.maxsize, *regex* = '', \*\*\ *metadata*]) | +------------------+----------------------------------------------------------+ @@ -700,6 +702,45 @@ The following example illustrates the difference between `Either` and `Union`:: ... primes = Union([2], None, {'3':6}, 5, 7, 11) ValueError: Union trait declaration expects a trait type or an instance of trait type or None, but got [2] instead +.. index:: Optional trait + +.. _optional: + +Optional +:::::::: +The Optional trait is a shorthand for ``Union(None, *trait*)``. It allows +the value of the trait to be either None or a specified type. The default +value of the trait is None unless specified by ``default_value``. + +.. index:: + pair: Optional trait; examples + +The following is an example of using Optional:: + + # optional.py --- Example of Optional predefined trait + + from traits.api import HasTraits, Optional, Str + + class Person(HasTraits): + name = Str + nickname = Optional(Str) + +This example defines a ``Person`` with a ``name`` and an optional ``nickname``. +Their ``nickname`` can be ``None`` or a string. For example:: + + >>> from traits.api import HasTraits, Optional, Str + >>> class Person(HasTraits): + ... name = Str + ... nickname = Optional(Str) + ... + >>> joseph = Person(name="Joseph") + >>> # Joseph has no nickname + >>> joseph.nickname is None + True + >>> joseph.nickname = "Joe" + >>> joseph.nickname + 'Joe' + .. index:: Either trait .. _either: diff --git a/traits/api.py b/traits/api.py index 441891274..5373e73de 100644 --- a/traits/api.py +++ b/traits/api.py @@ -105,6 +105,7 @@ ToolbarButton, Either, Union, + Optional, Type, Subclass, Symbol, diff --git a/traits/api.pyi b/traits/api.pyi index 9a2df5a3d..0c263cfe4 100644 --- a/traits/api.pyi +++ b/traits/api.pyi @@ -109,6 +109,7 @@ from .trait_types import ( ToolbarButton as ToolbarButton, Either as Either, Union as Union, + Optional as Optional, Type as Type, Subclass as Subclass, Symbol as Symbol, diff --git a/traits/tests/test_constant.py b/traits/tests/test_constant.py index b77948482..2a0c3e700 100644 --- a/traits/tests/test_constant.py +++ b/traits/tests/test_constant.py @@ -72,3 +72,19 @@ class TestClass(HasTraits): # Check directly that both refer to the same object. self.assertIs(obj1.c_atr, obj2.c_atr) + + @unittest.expectedFailure + def test_constant_validator(self): + """ + XFAIL: `validate` on Constant is permissive. + + See enthought/traits#1784 + """ + class TestClass(HasTraits): + attribute = Constant(123) + + a = TestClass() + const_trait = a.traits()["attribute"] + + with self.assertRaises(TraitError): + const_trait.validate(a, "attribute", 456) diff --git a/traits/tests/test_optional.py b/traits/tests/test_optional.py new file mode 100644 index 000000000..9e2f9a3ba --- /dev/null +++ b/traits/tests/test_optional.py @@ -0,0 +1,330 @@ +# (C) Copyright 2005-2024 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import unittest + +from traits.api import ( + DefaultValue, + Float, + HasTraits, + Instance, + Int, + List, + Str, + TraitError, + TraitType, + Optional, + Union, + Constant, +) +from traits.trait_types import _NoneTrait + + +class CustomClass(HasTraits): + value = Int + + +class CustomStrType(TraitType): + + #: The default value type to use. + default_value_type = DefaultValue.constant + + #: The default value. + default_value = "a string value" + + def validate(self, obj, name, value): + if not isinstance(value, Str): + return value + self.error(obj, name, value) + + +class TestOptional(unittest.TestCase): + + def test_optional_basic(self): + class TestClass(HasTraits): + attribute = Optional(Int) + + TestClass(attribute=None) + TestClass(attribute=3) + + self.assertIsNone(TestClass(attribute=None).attribute) + self.assertEqual(TestClass(attribute=3).attribute, 3) + + with self.assertRaises(TraitError): + TestClass(attribute="3") + + def test_optional_list(self): + class TestClass(HasTraits): + attribute = Optional(List(Int)) + + TestClass(attribute=None) + TestClass(attribute=[1, 2, 3]) + + with self.assertRaises(TraitError): + TestClass(attribute=3) + + def test_optional_instance(self): + class TestClass(HasTraits): + attribute = Optional(Instance(Int)) + + TestClass(attribute=None) + TestClass(attribute=Int(3)) + + with self.assertRaises(TraitError): + TestClass(attribute=3) + with self.assertRaises(TraitError): + TestClass(attribute=Int) + + def test_optional_instance_custom_class(self): + class TestClass(HasTraits): + attribute = Optional(Instance(CustomClass)) + + TestClass(attribute=None) + TestClass(attribute=CustomClass(value=5)) + + with self.assertRaises(TraitError): + TestClass(attribute=5) + + with self.assertRaises(TraitError): + TestClass(attribute=CustomClass) + + self.assertEqual( + TestClass(attribute=CustomClass(value=5)).attribute.value, 5 + ) + + self.assertIsNone(TestClass().attribute) + self.assertIsNone(TestClass(attribute=None).attribute) + + def test_optional_user_defined_type(self): + class TestClass(HasTraits): + attribute = Optional(CustomStrType) + + a = TestClass(attribute="my value") + self.assertEqual(a.attribute, "my value") + + b = TestClass() + self.assertIsNone(b.attribute) + + c = TestClass(attribute=3) + self.assertEqual(c.attribute, 3) + + def test_optional_instance_with_implicit_default_value(self): + """ + Implicit default is always ``None`` + """ + + class TestClass(HasTraits): + attribute = Optional(Int) + + self.assertIsNone(TestClass().attribute) + self.assertEqual(TestClass(attribute=3).attribute, 3) + self.assertIsNone(TestClass(attribute=None).attribute) + + def test_optional_instance_with_metadata_default_value(self): + """ + Setting the ``default_value`` at trait-level sets the default value + """ + + class TestClass(HasTraits): + attribute = Optional(Int, default_value=5) + + self.assertEqual(TestClass().attribute, 5) + self.assertEqual(TestClass(attribute=3).attribute, 3) + self.assertIsNone(TestClass(attribute=None).attribute) + + def test_optional_instance_with_type_default_value(self): + """ + Setting the ``default_value`` of the inner trait does not set the + default value of the ``Optional`` + """ + # Note: may want to warn in this case + # Discussion ref: enthought/traits#1298 + + class TestClass(HasTraits): + attribute = Optional(Int(5)) + + self.assertIsNone(TestClass().attribute) + self.assertEqual(TestClass(attribute=3).attribute, 3) + self.assertIsNone(TestClass(attribute=None).attribute) + + def test_optional_invalid_trait(self): + with self.assertRaises(ValueError) as e: + + class _TestClass(HasTraits): + attribute = Optional(123) + + self.assertEqual( + str(e.exception), + "Optional trait declaration expects a trait type or an instance " + "of trait type or None, but got 123 instead", + ) + + def test_optional_of_none(self): + with self.assertRaises(TraitError) as e: + + class _TestClass(HasTraits): + attribute = Optional(None) + + self.assertEqual(str(e.exception), "Optional type must not be None.") + + def test_optional_unspecified_arguments(self): + with self.assertRaises(TypeError) as e: + + class TestClass(HasTraits): + none = Optional() + + self.assertIn( + "missing 1 required positional argument", str(e.exception) + ) + + def test_optional_multiple_type_arguments(self): + with self.assertRaises(TypeError): + + class TestClass(HasTraits): + attribute = Optional(Int, Float) + + def test_optional_default_raise_error(self): + """ + Behaviour inherited from ``Union`` + """ + with self.assertRaises(ValueError) as e: + + class TestClass(HasTraits): + attribute = Optional(Int(), default=5) + + self.assertEqual( + str(e.exception), + "Optional default value should be set via 'default_value', not " + "'default'.", + ) + + def test_optional_inner_traits(self): + class TestClass(HasTraits): + attribute = Optional(Int(3)) + + obj = TestClass() + t1, t2 = obj.trait("attribute").inner_traits + + self.assertEqual(type(t1.trait_type), _NoneTrait) + self.assertEqual(t1.default_value(), (DefaultValue.constant, None)) + self.assertEqual(type(t2.trait_type), Int) + self.assertEqual(t2.default_value(), (DefaultValue.constant, 3)) + + def test_optional_notification(self): + class TestClass(HasTraits): + attribute = Optional(Int) + shadow_attribute = None + + def _attribute_changed(self, new): + self.shadow_attribute = new + + obj = TestClass(attribute=3) + + obj.attribute = 5 + self.assertEqual(obj.shadow_attribute, 5) + + obj.attribute = None + self.assertIsNone(obj.shadow_attribute) + + def test_optional_nested(self): + """ + You can nest ``Optional``... if you want to + """ + + class TestClass(HasTraits): + attribute = Optional(Optional(Int)) + + self.assertIsNone(TestClass(attribute=None).attribute) + self.assertIsNone(TestClass().attribute) + + obj = TestClass(attribute=3) + + obj.attribute = 5 + self.assertEqual(obj.attribute, 5) + + obj.attribute = None + self.assertIsNone(obj.attribute) + + def test_optional_union_of_optional(self): + """ + ``Union(T1, Optional(T2))`` acts like ``Union(T1, None, T2)`` + """ + class TestClass(HasTraits): + attribute = Union(Int, Optional(Float)) + + self.assertEqual(TestClass(attribute=3).attribute, 3) + self.assertEqual(TestClass(attribute=3.0).attribute, 3.0) + self.assertIsNone(TestClass(attribute=None).attribute) + self.assertEqual(TestClass().attribute, 0) + + a = TestClass(attribute=3) + a.attribute = 5 + self.assertEqual(a.attribute, 5) + a.attribute = 5.0 + self.assertEqual(a.attribute, 5.0) + a.attribute = None + self.assertIsNone(a.attribute) + + def test_optional_extend_trait(self): + class OptionalOrStr(Optional): + def validate(self, obj, name, value): + if isinstance(value, str): + return value + return super().validate(obj, name, value) + + class TestClass(HasTraits): + attribute = OptionalOrStr(Int) + + self.assertEqual(TestClass(attribute=123).attribute, 123) + self.assertEqual(TestClass(attribute="abc").attribute, "abc") + self.assertIsNone(TestClass(attribute=None).attribute) + self.assertIsNone(TestClass().attribute) + + with self.assertRaises(TraitError): + TestClass(attribute=1.23) + + @unittest.expectedFailure + def test_optional_default_value_validation(self): + """ + XFAIL: Default value is not validated against allowed types + + See discussion on enthought/traits#1784 + """ + with self.assertRaises(Exception): + # Expectation: something in here ought to fail + class TestClass(HasTraits): + attribute = Optional(Str, default_value=3.5) + + TestClass() + + @unittest.expectedFailure # See enthought/traits#1784 + def test_optional_constant_initialization(self): + class TestClass(HasTraits): + attribute = Optional(Constant(123)) + + self.assertEqual(TestClass(attribute=123).attribute, 123) + self.assertIsNone(TestClass(attribute=None).attribute) + + # Fails here - internal trait validation fails + with self.assertRaises(TraitError): + TestClass(attribute=124) + + @unittest.expectedFailure # See enthought/traits#1784 + def test_optional_constant_setting(self): + class TestClass(HasTraits): + attribute = Optional(Constant(123)) + + obj = TestClass(attribute=123) + obj.attribute = None + obj.attribute = 123 + + # Fails here - internal trait validation fails + with self.assertRaises(TraitError): + obj.attribute = 124 diff --git a/traits/tests/test_union.py b/traits/tests/test_union.py index d394d933d..c72acc9e8 100644 --- a/traits/tests/test_union.py +++ b/traits/tests/test_union.py @@ -12,7 +12,7 @@ from traits.api import ( Bytes, DefaultValue, Float, HasTraits, Instance, Int, List, Str, - TraitError, TraitType, Type, Union) + TraitError, TraitType, Type, Union, Constant) class CustomClass(HasTraits): @@ -133,6 +133,20 @@ def test_default_raise_error(self): "'default'." ) + def test_default_raise_error_subclass(self): + # Name used in error message inherited by subclass + class TestUnion(Union): + pass + + with self.assertRaises(ValueError) as exception_context: + TestUnion(Int(), Float(), default=1.0) + + self.assertEqual( + str(exception_context.exception), + "TestUnion default value should be set via 'default_value', not " + "'default'." + ) + def test_inner_traits(self): class TestClass(HasTraits): atr = Union(Float, Int, Str) @@ -214,3 +228,43 @@ class HasUnionWithList(HasTraits): has_union.trait("nested").default_value(), (DefaultValue.constant, ""), ) + + @unittest.expectedFailure + def test_union_default_value_validation(self): + """ + XFAIL: Default value is not validated against allowed types + + See discussion on enthought/traits#1784 + """ + with self.assertRaises(Exception): + # Expectation: something in here ought to fail + class TestClass(HasTraits): + attribute = Union(Int, Str, default_value=3.5) + + TestClass() + + @unittest.expectedFailure # See enthought/traits#1784 + def test_union_constant_initialization(self): + class TestClass(HasTraits): + attribute = Union(None, Constant(123)) + + self.assertEqual(TestClass(attribute=123).attribute, 123) + self.assertIsNone(TestClass(attribute=None).attribute) + + # Fails here - internal trait validation fails + with self.assertRaises(TraitError): + TestClass(attribute=124) + + @unittest.expectedFailure # See enthought/traits#1784 + def test_union_constant_setting(self): + class TestClass(HasTraits): + attribute = Union(None, Constant(123)) + + obj = TestClass(attribute=123) + + obj.attribute = None + obj.attribute = 123 + + # Fails here - internal trait validation fails + with self.assertRaises(TraitError): + obj.attribute = 124 diff --git a/traits/trait_types.py b/traits/trait_types.py index 4198dbda9..1726e85fa 100644 --- a/traits/trait_types.py +++ b/traits/trait_types.py @@ -4203,9 +4203,10 @@ def __init__(self, *traits, **metadata): trait = _NoneTrait ctrait_instance = trait_cast(trait) if ctrait_instance is None: - raise ValueError("Union trait declaration expects a trait " - "type or an instance of trait type or None," - " but got {!r} instead".format(trait)) + raise ValueError("{} trait declaration expects a trait type " + "or an instance of trait type or None, but " + "got {!r} instead" + .format(type(self).__name__, trait)) self.list_ctrait_instances.append(ctrait_instance) @@ -4213,8 +4214,8 @@ def __init__(self, *traits, **metadata): # Raise if 'default' is found in order to help code migrate to Union if "default" in metadata: raise ValueError( - "Union default value should be set via 'default_value', not " - "'default'." + f"{type(self).__name__} default value should be set via " + "'default_value', not 'default'." ) if 'default_value' in metadata: @@ -4271,6 +4272,38 @@ def get_editor(self, trait): return CompoundEditor(editors=editors) +class Optional(Union): + """ A trait whose value can be either the specified trait type or None. + ``Optional()`` is a shorthand for ``Union(None, )``. + + >>> class MyClass(HasTraits): + ... x = Optional(Int) + ... + >>> a = MyClass(x=5) + >>> a.x + 5 + >>> b = MyClass(x=None) + >>> b.x + None + + Note that the default value of the trait will be ``None`` unless specified + in ``default_value``, i.e. the default value of: + * ``Optional(Int)`` is ``None`` + * ``Optional(Int(5))`` is ``None`` + * ``Optional(Int, default_value=5)`` is ``5``. + + Parameters + ---------- + trait : a trait or value that can be converted using trait_from() + The type of item that the set contains. Must not be ``None``. + """ + def __init__(self, trait, **metadata): + if trait is None: + raise TraitError("Optional type must not be None.") + + super().__init__(_NoneTrait, trait, **metadata) + + # ------------------------------------------------------------------------------- # 'Symbol' trait: # ------------------------------------------------------------------------------- diff --git a/traits/trait_types.pyi b/traits/trait_types.pyi index 88f95685d..1e86314a4 100644 --- a/traits/trait_types.pyi +++ b/traits/trait_types.pyi @@ -597,6 +597,15 @@ class Union(_TraitType[_Any, _Any]): ... +class Optional(_TraitType[_Any, _Any]): + def __init__( + self, + trait: _Any, + **metadata: _Any + ) -> None: + ... + + class Symbol(_TraitType[_Any, _Any]): ...