diff --git a/ladybug/_datacollectionbase.py b/ladybug/_datacollectionbase.py index fa7abf16..06ba611d 100644 --- a/ladybug/_datacollectionbase.py +++ b/ladybug/_datacollectionbase.py @@ -222,11 +222,11 @@ def is_in_data_type_range(self, raise_exception=True): def to_mutable(self): """Get a mutable version of this collection.""" - return self.mutable_class(self.header, self.values, self.datetimes) + return self.mutable_class(self.header.duplicate(), self.values, self.datetimes) def to_immutable(self): """Get an immutable version of this collection.""" - new_obj = self.immutable_class(self.header, self.values, self.datetimes) + new_obj = self.immutable_class(self.header.duplicate(), self.values, self.datetimes) new_obj._validated_a_period = self._validated_a_period return new_obj @@ -408,9 +408,9 @@ def filter_by_conditional_statement(self, statement): col_obj = self.mutable_class try: collection = col_obj(self.header.duplicate(), _filt_values, _filt_datetimes) - except AssertionError as e: - raise AssertionError('No value meets the conditional statement.' - '\n{}'.format(e)) + except ValueError as e: + raise ValueError('No value meets the conditional statement.' + '\n{}'.format(e)) collection._validated_a_period = self._validated_a_period return collection @@ -435,8 +435,8 @@ def filter_by_range(self, greater_than=float('-inf'), less_than=float('inf')): col_obj = self.mutable_class try: collection = col_obj(self.header.duplicate(), _filt_values, _filt_datetimes) - except AssertionError as e: - raise AssertionError( + except ValueError as e: + raise ValueError( 'No value was found in the range {} to {}.\n{}'.format( greater_than, less_than, e)) collection._validated_a_period = self._validated_a_period @@ -565,9 +565,9 @@ def filter_collections_by_statement(data_collections, statement): data_collections, statement) try: collections = [coll.filter_by_pattern(pattern) for coll in data_collections] - except AssertionError as e: - raise AssertionError('No value meets the conditional statement.' - '\n{}'.format(e)) + except ValueError as e: + raise ValueError('No value meets the conditional statement.' + '\n{}'.format(e)) return collections @staticmethod @@ -1057,13 +1057,15 @@ def _filter_by_pattern(self, pattern): def _check_values(self, values): """Check values whenever they come through the values setter.""" - assert isinstance(values, Iterable) and not \ - isinstance(values, (str, dict, bytes, bytearray)), \ - 'values should be a list or tuple. Got {}'.format(type(values)) - assert len(values) == len(self.datetimes), \ - 'Length of values list must match length of datetimes list. ' \ - '{} != {}'.format(len(values), len(self.datetimes)) - assert len(values) > 0, 'Data Collection must include at least one value' + if not isinstance(values, Iterable) or isinstance( + values, (str, dict, bytes, bytearray)): + raise TypeError('values should be a list or tuple. Got {}'.format(type(values))) + if len(values) != len(self.datetimes): + raise ValueError( + 'Length of values list must match length of datetimes list. ' + '{} != {}'.format(len(values), len(self.datetimes))) + if len(values) == 0: + raise ValueError('Data Collection must include at least one value') def _check_aligned_header(self, data_type, unit): """Check the header inputs whenever get_aligned_collection is called.""" diff --git a/ladybug/datacollection.py b/ladybug/datacollection.py index e7634d8d..150d40bd 100644 --- a/ladybug/datacollection.py +++ b/ladybug/datacollection.py @@ -914,11 +914,11 @@ def group_by_month(self): def to_mutable(self): """Get an mutable version of this collection.""" - return self.mutable_class(self.header, self.values) + return self.mutable_class(self.header.duplicate(), self.values) def to_immutable(self): """Get an immutable version of this collection.""" - return self.immutable_class(self.header, self.values) + return self.immutable_class(self.header.duplicate(), self.values) def get_aligned_collection(self, value=0, data_type=None, unit=None, mutable=None): """Return a Collection aligned with this one composed of one repeated value. @@ -1029,12 +1029,14 @@ def _get_analysis_period_subset(self, a_per): def _check_values(self, values): """Check values whenever they come through the values setter.""" - assert isinstance(values, Iterable) and not isinstance( - values, (str, dict, bytes, bytearray)), \ - 'values should be a list or tuple. Got {}'.format(type(values)) - assert len(values) == len(self.header.analysis_period), 'Length of ' \ - 'values does not match that expected by the header analysis_period.'\ - ' {} != {}'.format(len(values), len(self.header.analysis_period)) + if not isinstance(values, Iterable) or isinstance( + values, (str, dict, bytes, bytearray)): + raise TypeError('values should be a list or tuple. Got {}'.format(type(values))) + if len(values) != len(self.header.analysis_period): + raise ValueError( + 'Length of values does not match that expected by the header ' + 'analysis_period. {} != {}'.format( + len(values), len(self.header.analysis_period))) @property def is_continuous(self): diff --git a/ladybug/datacollectionimmutable.py b/ladybug/datacollectionimmutable.py index d0f094f6..41ad257a 100644 --- a/ladybug/datacollectionimmutable.py +++ b/ladybug/datacollectionimmutable.py @@ -45,6 +45,33 @@ def _mutable_message(self): return 'values are immutable for {}.\nUse to_mutable() method to get a ' \ 'mutable version of this collection.'.format(self.__class__.__name__) + def convert_to_unit(self, unit): + """Not available for immutable collections; use to_unit() instead.""" + raise AttributeError(self._mutable_message) + + def convert_to_ip(self): + """Not available for immutable collections; use to_ip() instead.""" + raise AttributeError(self._mutable_message) + + def convert_to_si(self): + """Not available for immutable collections; use to_si() instead.""" + raise AttributeError(self._mutable_message) + + def to_unit(self, unit): + new_data_c = self.to_mutable() + new_data_c.convert_to_unit(unit) + return new_data_c + + def to_ip(self): + new_data_c = self.to_mutable() + new_data_c.convert_to_ip() + return new_data_c + + def to_si(self): + new_data_c = self.to_mutable() + new_data_c.convert_to_si() + return new_data_c + def to_immutable(self): """Get an immutable version of this collection.""" return self.duplicate() @@ -76,7 +103,8 @@ def convert_to_culled_timestep(self, timestep=1): def to_mutable(self): """Get a mutable version of this collection.""" - new_obj = HourlyDiscontinuousCollection(self.header, self.values, self.datetimes) + new_obj = HourlyDiscontinuousCollection( + self.header.duplicate(), self.values, self.datetimes) new_obj._validated_a_period = self._validated_a_period return new_obj @@ -89,7 +117,7 @@ class HourlyContinuousCollectionImmutable( @property def mutable_class(self): """Get the immutable class for this type of data collection.""" - return DailyCollection + return HourlyContinuousCollection def convert_to_culled_timestep(self, timestep=1): """This method is not available for immutable collections.""" @@ -97,7 +125,7 @@ def convert_to_culled_timestep(self, timestep=1): def to_mutable(self): """Get a mutable version of this collection.""" - new_obj = HourlyContinuousCollection(self.header, self.values) + new_obj = HourlyContinuousCollection(self.header.duplicate(), self.values) new_obj._validated_a_period = self._validated_a_period return new_obj @@ -118,7 +146,8 @@ def mutable_class(self): def to_mutable(self): """Get a mutable version of this collection.""" - new_obj = DailyCollection(self.header, self.values, self.datetimes) + new_obj = DailyCollection( + self.header.duplicate(), self.values, self.datetimes) new_obj._validated_a_period = self._validated_a_period return new_obj @@ -135,7 +164,8 @@ def mutable_class(self): def to_mutable(self): """Get a mutable version of this collection.""" - new_obj = MonthlyCollection(self.header, self.values, self.datetimes) + new_obj = MonthlyCollection( + self.header.duplicate(), self.values, self.datetimes) new_obj._validated_a_period = self._validated_a_period return new_obj @@ -152,6 +182,7 @@ def mutable_class(self): def to_mutable(self): """Get a mutable version of this collection.""" - new_obj = MonthlyPerHourCollection(self.header, self.values, self.datetimes) + new_obj = MonthlyPerHourCollection( + self.header.duplicate(), self.values, self.datetimes) new_obj._validated_a_period = self._validated_a_period return new_obj diff --git a/setup.py b/setup.py index 42ebb2e2..48c6b027 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: IronPython", "Operating System :: OS Independent" diff --git a/tests/datacollection_test.py b/tests/datacollection_test.py index 91d7cfd8..b9a8afa3 100644 --- a/tests/datacollection_test.py +++ b/tests/datacollection_test.py @@ -68,6 +68,26 @@ def test_init_incorrect(): [v1, v2], {'1': 1, '2': 2}) +def test_values_validation_uses_explicit_exceptions(): + """Validate collection values even when Python assertions are disabled.""" + a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13) + header = Header(Temperature(), 'C', a_per) + datetimes = [DateTime(6, 21, 12), DateTime(6, 21, 13)] + + with pytest.raises(TypeError, match='values should be'): + BaseCollection(header, '20,25', datetimes) + with pytest.raises(ValueError, match='Length of values list'): + BaseCollection(header, [20], datetimes) + with pytest.raises(ValueError, match='at least one value'): + BaseCollection(header, [], []) + + annual_header = Header(Temperature(), 'C', AnalysisPeriod()) + with pytest.raises(TypeError, match='values should be'): + HourlyContinuousCollection(annual_header, '20') + with pytest.raises(ValueError, match='header analysis_period'): + HourlyContinuousCollection(annual_header, [20]) + + def test_init_hourly(): """Test the init methods for discontinuous collections.""" a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13) @@ -1336,6 +1356,22 @@ def test_filter_collections_by_statement(): assert isinstance(filt_coll[0], HourlyDiscontinuousCollection) +def test_filter_no_match_error_message(): + """A filter that matches nothing reports why instead of a generic error.""" + a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13) + dts = [DateTime(6, 21, 12), DateTime(6, 21, 13)] + dc = HourlyDiscontinuousCollection(Header(Temperature(), 'C', a_per), [20, 25], dts) + with pytest.raises(ValueError, match='No value meets the conditional'): + dc.filter_by_conditional_statement('a > 100') + with pytest.raises(ValueError, match='No value was found in the range'): + dc.filter_by_range(100, 200) + + header = Header(Temperature(), 'C', AnalysisPeriod(end_month=1, end_day=1)) + cont = HourlyContinuousCollection(header, list(xrange(24))) + with pytest.raises(ValueError, match='No value meets the conditional'): + HourlyContinuousCollection.filter_collections_by_statement([cont], 'a > 100') + + def test_is_in_range_data_type(): """Test the function to check whether values are in range for the data_type.""" header1 = Header(Temperature(), 'C', AnalysisPeriod()) diff --git a/tests/datacollectionimmutable_test.py b/tests/datacollectionimmutable_test.py index bbb63346..e3a17c0b 100644 --- a/tests/datacollectionimmutable_test.py +++ b/tests/datacollectionimmutable_test.py @@ -94,6 +94,97 @@ def test_init_continuous(): assert not dc4.is_mutable +def _make_collection(kind): + """Get a mutable Celsius temperature collection of the requested kind.""" + a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13) + day_per = AnalysisPeriod(6, 21, 0, 6, 22, 23) + mon_per = AnalysisPeriod(6, 1, 0, 7, 31, 23) + builders = { + 'discontinuous': lambda: HourlyDiscontinuousCollection( + Header(Temperature(), 'C', a_per), [20, 25], + [DateTime(6, 21, 12), DateTime(6, 21, 13)]), + 'continuous': lambda: HourlyContinuousCollection( + Header(Temperature(), 'C', AnalysisPeriod(end_month=1, end_day=1)), + [20] * 24), + 'daily': lambda: DailyCollection( + Header(Temperature(), 'C', day_per), [20, 25], day_per.doys_int), + 'monthly': lambda: MonthlyCollection( + Header(Temperature(), 'C', mon_per), [20, 25], mon_per.months_int), + 'monthly_per_hour': lambda: MonthlyPerHourCollection( + Header(Temperature(), 'C', mon_per), [20] * 24 + [25] * 24, + mon_per.months_per_hour) + } + return builders[kind]() + + +COLLECTION_KINDS = \ + ('discontinuous', 'continuous', 'daily', 'monthly', 'monthly_per_hour') + + +@pytest.mark.parametrize('kind', COLLECTION_KINDS) +def test_mutable_immutable_conversion_does_not_share_header(kind): + """Converting between mutable and immutable must give each its own header.""" + source = _make_collection(kind) + immutable = source.to_immutable() + assert immutable.header is not source.header + assert immutable.to_mutable().header is not immutable.header + assert source.to_mutable().header is not source.header + + +@pytest.mark.parametrize('kind', COLLECTION_KINDS) +def test_unit_conversion_on_immutable_collection(kind): + """In-place conversion is blocked; to_* returns a converted mutable copy.""" + source = _make_collection(kind) + immutable = source.to_immutable() + + with pytest.raises(AttributeError): + immutable.convert_to_ip() + with pytest.raises(AttributeError): + immutable.convert_to_si() + with pytest.raises(AttributeError): + immutable.convert_to_unit('F') + + converted = immutable.to_ip() + assert type(converted) is type(source) + assert converted.is_mutable + assert converted.header.unit == 'F' + assert converted.values[0] == pytest.approx(68) + assert immutable.to_si().is_mutable + assert immutable.to_unit('F').header.unit == 'F' + assert immutable.header.unit == 'C' + assert immutable.values[0] == 20 + assert source.header.unit == 'C' + assert source.values[0] == 20 + + source.convert_to_ip() + assert immutable.header.unit == 'C' + + +@pytest.mark.parametrize('kind', COLLECTION_KINDS) +def test_mutable_and_immutable_classes_point_at_each_other(kind): + """Each mutable class and its immutable counterpart must name one another.""" + source = _make_collection(kind) + immutable = source.to_immutable() + assert source.mutable_class is type(source) + assert source.immutable_class is type(immutable) + assert immutable.mutable_class is type(source) + assert immutable.immutable_class is type(immutable) + + +@pytest.mark.parametrize('kind', COLLECTION_KINDS) +def test_get_aligned_collection_mutability(kind): + """get_aligned_collection honors mutable= from a mutable or immutable start.""" + source = _make_collection(kind) + immutable = source.to_immutable() + for start in (source, immutable): + assert type(start.get_aligned_collection(5)) is type(start) + assert type(start.get_aligned_collection(5, mutable=False)) is type(immutable) + aligned = start.get_aligned_collection(5, mutable=True) + assert type(aligned) is type(source) + assert aligned.is_mutable + assert set(aligned.values) == {5} + + def test_daily(): """Test the daily collections.""" a_per = AnalysisPeriod(6, 21, 0, 6, 22, 23)