Skip to content

Commit 96ee567

Browse files
committed
Allow None defaults when allow_none=False
Default values can now be None even when allow_none=False Enables flexible initialization while maintaining runtime validation Refactored validation logic to eliminate code duplication Maintains Python descriptor protocol compatibility
1 parent 3076ce2 commit 96ee567

3 files changed

Lines changed: 132 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
* If `allow_none` is set to `False`, `None` is considered an invalid value.
2929
* The default value for `allow_none` is `False`, except for `FloatArrayItem`, `ColorItem` and `ChoiceItem` and its subclasses, where it is set to `True` by default.
3030

31+
* Enhanced default value handling for `DataItem` objects:
32+
* Default values can now be `None` even when `allow_none=False` is set on the item.
33+
* This allows developers to use `None` as a sensible default value while still preventing users from setting `None` at runtime.
34+
* This feature provides better flexibility for data item initialization without compromising runtime validation.
35+
* The implementation uses a clean internal architecture that separates default value setting from regular value setting, maintaining the standard Python descriptor protocol.
36+
3137
* Improved type handling in `IntItem` and `FloatItem`:
3238
* `IntItem` and `FloatItem` now automatically convert NumPy numeric types (like `np.int32` or `np.float64`) to native Python types (`int` or `float`) during validation
3339
* `FloatItem` now accepts integer values and silently converts them to float values

guidata/dataset/datatypes.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,8 @@ def set_default(self, instance: DataSet) -> None:
550550
instance (DataSet): instance of the DataSet
551551
"""
552552
try:
553-
self.__set__(instance, deepcopy(self._default))
553+
value = deepcopy(self._default)
554+
self._set_value_with_validation(instance, value, force_allow_none=True)
554555
except ValueError as exc:
555556
# Convert generic ValueError to a more specific DataItemValidationError
556557
# to provide clearer context when setting default values fails
@@ -585,12 +586,26 @@ def __set__(self, instance: Any, value: Any) -> None:
585586
instance (Any): instance of the DataSet
586587
value (Any): value to set
587588
"""
589+
self._set_value_with_validation(instance, value, force_allow_none=False)
590+
591+
def _set_value_with_validation(
592+
self, instance: Any, value: Any, force_allow_none: bool = False
593+
) -> None:
594+
"""Internal method to set data item's value with validation
595+
596+
Args:
597+
instance (Any): instance of the DataSet
598+
value (Any): value to set
599+
force_allow_none (bool): if True, allow None values even when
600+
allow_none is False (used for default values)
601+
"""
588602
vmode = get_validation_mode()
589603

590-
# If value is None and allow_none is True, skip validation
604+
# Determine if validation should be skipped
591605
allow_none = self.get_prop("data", "allow_none", False)
606+
skip_validation = value is None and (allow_none or force_allow_none)
592607

593-
if vmode != ValidationMode.DISABLED and not (value is None and allow_none):
608+
if vmode != ValidationMode.DISABLED and not skip_validation:
594609
try:
595610
self.check_value(value, raise_exception=True)
596611
except NotImplementedError:
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see guidata/LICENSE for details)
5+
6+
"""
7+
Test None default values with allow_none=False
8+
9+
This test verifies that DataItem objects can have None as default values
10+
even when allow_none=False is set, while still preventing users from
11+
setting None values at runtime.
12+
"""
13+
14+
import pytest
15+
16+
from guidata.config import ValidationMode, set_validation_mode
17+
from guidata.dataset import DataSet
18+
from guidata.dataset.dataitems import StringItem
19+
from guidata.dataset.datatypes import DataItemValidationError
20+
21+
22+
class _TestDataSet(DataSet):
23+
"""Test dataset for None default values"""
24+
25+
name = StringItem("Name", default=None, allow_none=False)
26+
description = StringItem("Description", default=None, allow_none=True)
27+
title = StringItem("Title", default="Default Title", allow_none=False)
28+
29+
30+
def test_none_defaults_creation():
31+
"""Test that datasets can be created with None defaults even when
32+
allow_none=False"""
33+
# Set validation to strict mode to catch any issues
34+
set_validation_mode(ValidationMode.STRICT)
35+
36+
# This should work: None default is allowed even when allow_none=False
37+
dataset = _TestDataSet()
38+
39+
# Verify the default values are set correctly
40+
assert dataset.name is None
41+
assert dataset.description is None
42+
assert dataset.title == "Default Title"
43+
44+
45+
def test_allow_none_true_accepts_none():
46+
"""Test that items with allow_none=True accept None values at runtime"""
47+
set_validation_mode(ValidationMode.STRICT)
48+
dataset = _TestDataSet()
49+
50+
# This should work: allow_none=True
51+
dataset.description = None
52+
assert dataset.description is None
53+
54+
# Should also accept valid string values
55+
dataset.description = "Test Description"
56+
assert dataset.description == "Test Description"
57+
58+
59+
def test_allow_none_false_rejects_none():
60+
"""Test that items with allow_none=False reject None values at runtime"""
61+
set_validation_mode(ValidationMode.STRICT)
62+
dataset = _TestDataSet()
63+
64+
# This should fail: allow_none=False
65+
with pytest.raises(DataItemValidationError):
66+
dataset.name = None
67+
68+
69+
def test_allow_none_false_accepts_valid_values():
70+
"""Test that items with allow_none=False accept valid non-None values"""
71+
set_validation_mode(ValidationMode.STRICT)
72+
dataset = _TestDataSet()
73+
74+
# This should work: valid string value
75+
dataset.name = "Test Name"
76+
assert dataset.name == "Test Name"
77+
78+
# Test changing values
79+
dataset.name = "Another Name"
80+
assert dataset.name == "Another Name"
81+
82+
83+
def test_validation_mode_disabled():
84+
"""Test that validation is skipped when validation mode is disabled"""
85+
set_validation_mode(ValidationMode.DISABLED)
86+
dataset = _TestDataSet()
87+
88+
# Even with allow_none=False, None should be accepted in disabled mode
89+
dataset.name = None
90+
assert dataset.name is None
91+
92+
93+
def test_validation_mode_enabled_warnings():
94+
"""Test that warnings are shown instead of exceptions in enabled mode"""
95+
set_validation_mode(ValidationMode.ENABLED)
96+
dataset = _TestDataSet()
97+
98+
# Should show warnings but not raise exceptions
99+
with pytest.warns(UserWarning):
100+
dataset.name = None
101+
assert dataset.name is None
102+
103+
104+
@pytest.fixture(autouse=True)
105+
def reset_validation_mode():
106+
"""Reset validation mode after each test"""
107+
yield
108+
set_validation_mode(ValidationMode.DISABLED) # Reset to default

0 commit comments

Comments
 (0)