diff --git a/src/nitypes/_arguments.py b/src/nitypes/_arguments.py index 2491154d..5afbe94a 100644 --- a/src/nitypes/_arguments.py +++ b/src/nitypes/_arguments.py @@ -6,6 +6,8 @@ import numpy as np import numpy.typing as npt +from nitypes._exceptions import invalid_arg_type, invalid_arg_value, unsupported_arg + def arg_to_float( arg_description: str, value: SupportsFloat | None, default_value: float | None = None @@ -39,10 +41,7 @@ def arg_to_float( """ if value is None: if default_value is None: - raise TypeError( - f"The {arg_description} must be a floating point number.\n\n" - f"Provided value: {value!r}" - ) + raise invalid_arg_type(arg_description, "floating point number", value) value = default_value if not isinstance(value, float): @@ -50,10 +49,7 @@ def arg_to_float( # Use value.__float__() because float(value) also accepts strings. return value.__float__() except Exception: - raise TypeError( - f"The {arg_description} must be a floating point number.\n\n" - f"Provided value: {value!r}" - ) from None + raise invalid_arg_type(arg_description, "floating point number", value) from None return value @@ -90,18 +86,14 @@ def arg_to_int( """ if value is None: if default_value is None: - raise TypeError( - f"The {arg_description} must be an integer.\n\n" f"Provided value: {value!r}" - ) + raise invalid_arg_type(arg_description, "integer", value) value = default_value if not isinstance(value, int): try: return operator.index(value) except Exception: - raise TypeError( - f"The {arg_description} must be an integer.\n\n" f"Provided value: {value!r}" - ) from None + raise invalid_arg_type(arg_description, "integer", value) from None return value @@ -132,10 +124,7 @@ def arg_to_uint( """ value = arg_to_int(arg_description, value, default_value) if value < 0: - raise ValueError( - f"The {arg_description} must be a non-negative integer.\n\n" - f"Provided value: {value!r}" - ) + raise invalid_arg_value(arg_description, "non-negative integer", value) return value @@ -163,3 +152,9 @@ def validate_dtype(dtype: npt.DTypeLike, supported_dtypes: tuple[npt.DTypeLike, f"Data type: {np.dtype(dtype)}\n" f"Supported data types: {', '.join(supported_dtype_names)}" ) + + +def validate_unsupported_arg(arg_description: str, value: object) -> None: + """Validate that an unsupported argument is None.""" + if value is not None: + raise unsupported_arg(arg_description, value) diff --git a/src/nitypes/_exceptions.py b/src/nitypes/_exceptions.py index fe011591..d49cc2fd 100644 --- a/src/nitypes/_exceptions.py +++ b/src/nitypes/_exceptions.py @@ -1,5 +1,6 @@ from __future__ import annotations +import reprlib import sys @@ -21,3 +22,54 @@ def add_note(exception: Exception, note: str) -> None: else: message = exception.args[0] + "\n" + note exception.args = (message,) + exception.args[1:] + + +def invalid_arg_value( + arg_description: str, valid_value_description: str, value: object +) -> ValueError: + """Create a ValueError for an invalid argument value.""" + return ValueError( + f"The {arg_description} must be {_a(valid_value_description)}.\n\n" + f"Provided value: {reprlib.repr(value)}" + ) + + +def invalid_arg_type(arg_description: str, type_description: str, value: object) -> TypeError: + """Create a TypeError for an invalid argument type.""" + return TypeError( + f"The {arg_description} must be {_a(type_description)}.\n\n" + f"Provided value: {reprlib.repr(value)}" + ) + + +def invalid_array_ndim(arg_description: str, valid_value_description: str, ndim: int) -> ValueError: + """Create a ValueError for an array with an invalid number of dimensions.""" + raise ValueError( + f"The {arg_description} must be {_a(valid_value_description)}.\n\n" + f"Number of dimensions: {ndim}" + ) + + +def invalid_requested_type(type_description: str, requested_type: type) -> TypeError: + """Create a TypeError for an invalid requested type.""" + raise TypeError( + f"The requested type must be {_a(type_description)} type.\n\n" + f"Requested type: {requested_type}" + ) + + +def unsupported_arg(arg_description: str, value: object) -> ValueError: + """Create a ValueError for an unsupported argument.""" + raise ValueError( + f"The {arg_description} argument is not supported.\n\n" + f"Provided value: {reprlib.repr(value)}" + ) + + +# English-specific hack. This is why we prefer "Key: value" for localizable errors. TODO: consider +# moving the full strings into a string table instead of building them out of English noun phrases. +def _a(noun: str) -> str: + indefinite_article = "an" if noun[0] in "AEIOUaeiou" else "a" + if noun.startswith("one-"): + indefinite_article = "a" + return f"{indefinite_article} {noun}" diff --git a/src/nitypes/time/_conversion.py b/src/nitypes/time/_conversion.py index 019ea22d..4e745c5b 100644 --- a/src/nitypes/time/_conversion.py +++ b/src/nitypes/time/_conversion.py @@ -8,6 +8,8 @@ import hightime as ht +from nitypes._exceptions import invalid_arg_type, invalid_requested_type + if sys.version_info >= (3, 10): from typing import TypeAlias else: @@ -24,15 +26,13 @@ def convert_datetime(requested_type: type[_TDateTime], value: _AnyDateTime, /) - """Convert a datetime object to the specified type.""" convert_func = _CONVERT_DATETIME_FOR_TYPE.get(requested_type) if convert_func is None: - raise TypeError( - "The requested type must be a datetime type.\n\n" f"Requested type: {requested_type}" - ) + raise invalid_requested_type("datetime", requested_type) return cast(_TDateTime, convert_func(value)) @singledispatch def _convert_to_dt_datetime(value: object, /) -> dt.datetime: - raise TypeError("The value must be a datetime.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("value", "datetime", value) @_convert_to_dt_datetime.register @@ -57,7 +57,7 @@ def _(value: ht.datetime, /) -> dt.datetime: @singledispatch def _convert_to_ht_datetime(value: object, /) -> ht.datetime: - raise TypeError("The value must be a datetime.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("value", "datetime", value) @_convert_to_ht_datetime.register @@ -90,15 +90,13 @@ def convert_timedelta(requested_type: type[_TTimeDelta], value: _AnyTimeDelta, / """Convert a timedelta object to the specified type.""" convert_func = _CONVERT_TIMEDELTA_FOR_TYPE.get(requested_type) if convert_func is None: - raise TypeError( - "The requested type must be a timedelta type.\n\n" f"Requested type: {requested_type}" - ) + raise invalid_requested_type("timedelta", requested_type) return cast(_TTimeDelta, convert_func(value)) @singledispatch def _convert_to_dt_timedelta(value: object, /) -> dt.timedelta: - raise TypeError("The value must be a timedelta.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("value", "timedelta", value) @_convert_to_dt_timedelta.register @@ -113,7 +111,7 @@ def _(value: ht.timedelta, /) -> dt.timedelta: @singledispatch def _convert_to_ht_timedelta(value: object, /) -> ht.timedelta: - raise TypeError("The value must be a timedelta.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("value", "timedelta", value) @_convert_to_ht_timedelta.register diff --git a/src/nitypes/waveform/_analog_waveform.py b/src/nitypes/waveform/_analog_waveform.py index b7c6f912..d84d3e99 100644 --- a/src/nitypes/waveform/_analog_waveform.py +++ b/src/nitypes/waveform/_analog_waveform.py @@ -8,6 +8,7 @@ import numpy.typing as npt from nitypes._arguments import arg_to_uint, validate_dtype +from nitypes._exceptions import invalid_arg_type, invalid_array_ndim from nitypes.waveform._extended_properties import ( CHANNEL_NAME, UNIT_DESCRIPTION, @@ -124,8 +125,8 @@ def from_array_1d( """ if isinstance(array, np.ndarray): if array.ndim != 1: - raise ValueError( - f"The input array must be a one-dimensional array or sequence.\n\nNumber of dimensions: {array.ndim}" + raise invalid_array_ndim( + "input array", "one-dimensional array or sequence", array.ndim ) elif isinstance(array, Sequence) or ( sys.version_info < (3, 10) and isinstance(array, std_array.array) @@ -133,9 +134,7 @@ def from_array_1d( if dtype is None: raise ValueError("You must specify a dtype when the input array is a sequence.") else: - raise TypeError( - f"The input array must be a one-dimensional array or sequence.\n\nType: {type(array)}" - ) + raise invalid_arg_type("input array", "one-dimensional array or sequence", array) return AnalogWaveform( _data=np.asarray(array, dtype, copy=copy), @@ -200,8 +199,8 @@ def from_array_2d( """ if isinstance(array, np.ndarray): if array.ndim != 2: - raise ValueError( - f"The input array must be a two-dimensional array or nested sequence.\n\nNumber of dimensions: {array.ndim}" + raise invalid_array_ndim( + "input array", "two-dimensional array or nested sequence", array.ndim ) elif isinstance(array, Sequence) or ( sys.version_info < (3, 10) and isinstance(array, std_array.array) @@ -209,9 +208,7 @@ def from_array_2d( if dtype is None: raise ValueError("You must specify a dtype when the input array is a sequence.") else: - raise TypeError( - f"The input array must be a two-dimensional array or nested sequence.\n\nType: {type(array)}" - ) + raise invalid_arg_type("input array", "two-dimensional array or nested sequence", array) return [ AnalogWaveform( @@ -369,16 +366,18 @@ def _init_with_provided_array( capacity: SupportsIndex | None = None, ) -> None: if not isinstance(data, np.ndarray): - raise TypeError("The input array must be a one-dimensional array.") + raise invalid_arg_type("input array", "one-dimensional array", data) if data.ndim != 1: - raise ValueError("The input array must be a one-dimensional array.") + raise invalid_array_ndim("input array", "one-dimensional array", data.ndim) if dtype is None: dtype = data.dtype if dtype != data.dtype: - # from_array_1d() converts the input array to the requested dtype, so this error may be - # unreachable. - raise TypeError("The data type of the input array must match the requested data type.") + raise TypeError( + "The data type of the input array must match the requested data type.\n\n" + f"Array data type: {data.dtype}\n" + f"Requested data type: {np.dtype(dtype)}" + ) validate_dtype(dtype, _ANALOG_DTYPES) capacity = arg_to_uint("capacity", capacity, len(data)) @@ -530,10 +529,7 @@ def capacity(self) -> int: @capacity.setter def capacity(self, value: int) -> None: - if value < 0: - raise ValueError( - "The capacity must be a non-negative integer.\n\n" f"Capacity: {value}" - ) + value = arg_to_uint("capacity", value) if value < self._start_index + self._sample_count: raise ValueError( "The capacity must be equal to or greater than the number of samples in the waveform.\n\n" @@ -563,7 +559,7 @@ def channel_name(self) -> str: @channel_name.setter def channel_name(self, value: str) -> None: if not isinstance(value, str): - raise TypeError("The channel name must be a str.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("channel name", "str", value) self._extended_properties[CHANNEL_NAME] = value @property @@ -576,7 +572,7 @@ def unit_description(self) -> str: @unit_description.setter def unit_description(self, value: str) -> None: if not isinstance(value, str): - raise TypeError("The unit description must be a str.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("unit description", "str", value) self._extended_properties[UNIT_DESCRIPTION] = value @property @@ -597,7 +593,7 @@ def timing(self) -> Timing: @timing.setter def timing(self, value: Timing) -> None: if not isinstance(value, Timing): - raise TypeError("The timing information must be a Timing object.") + raise invalid_arg_type("timing information", "Timing object", value) self._timing = value self._precision_timing = None @@ -634,7 +630,7 @@ def precision_timing(self) -> PrecisionTiming: @precision_timing.setter def precision_timing(self, value: PrecisionTiming) -> None: if not isinstance(value, PrecisionTiming): - raise TypeError("The precision timing information must be a PrecisionTiming object.") + raise invalid_arg_type("precision timing information", "PrecisionTiming object", value) self._precision_timing = value self._timing = None @@ -646,7 +642,5 @@ def scale_mode(self) -> ScaleMode: @scale_mode.setter def scale_mode(self, value: ScaleMode) -> None: if not isinstance(value, ScaleMode): - raise TypeError( - "The scale mode must be a ScaleMode object.\n\n" f"Provided value: {value!r}" - ) + raise invalid_arg_type("scale mode", "ScaleMode object", value) self._scale_mode = value diff --git a/src/nitypes/waveform/_timing/_base.py b/src/nitypes/waveform/_timing/_base.py index 9a12d011..e3b97939 100644 --- a/src/nitypes/waveform/_timing/_base.py +++ b/src/nitypes/waveform/_timing/_base.py @@ -7,7 +7,8 @@ from enum import Enum from typing import Generic, SupportsIndex, TypeVar -from nitypes._exceptions import add_note +from nitypes._arguments import validate_unsupported_arg +from nitypes._exceptions import add_note, invalid_arg_type class SampleIntervalMode(Enum): @@ -28,13 +29,6 @@ class SampleIntervalMode(Enum): _TTimeDelta_co = TypeVar("_TTimeDelta_co", bound=dt.timedelta) -def _validate_unsupported_arg(arg_description: str, value: object) -> None: - if value is not None: - raise ValueError( - f"The {arg_description} argument is not supported.\n\n" f"Provided value: {value!r}" - ) - - class BaseTiming(ABC, Generic[_TDateTime_co, _TTimeDelta_co]): """Base class for waveform timing information.""" @@ -118,16 +112,11 @@ def _validate_init_args_none( datetime_type = self.__class__._get_datetime_type() timedelta_type = self.__class__._get_timedelta_type() if not isinstance(timestamp, (datetime_type, type(None))): - raise TypeError( - "The timestamp must be a datetime or None.\n\n" f"Provided value: {timestamp!r}" - ) + raise invalid_arg_type("timestamp", "datetime or None", timestamp) if not isinstance(time_offset, (timedelta_type, type(None))): - raise TypeError( - f"The time offset must be a timedelta or None.\n\n" - f"Provided value: {time_offset!r}" - ) - _validate_unsupported_arg("sample interval", sample_interval) - _validate_unsupported_arg("timestamps", timestamps) + raise invalid_arg_type("time offset", "timedelta or None", time_offset) + validate_unsupported_arg("sample interval", sample_interval) + validate_unsupported_arg("timestamps", timestamps) def _validate_init_args_regular( self, @@ -139,20 +128,12 @@ def _validate_init_args_regular( datetime_type = self.__class__._get_datetime_type() timedelta_type = self.__class__._get_timedelta_type() if not isinstance(timestamp, (datetime_type, type(None))): - raise TypeError( - "The timestamp must be a datetime or None.\n\n" f"Provided value: {timestamp!r}" - ) + raise invalid_arg_type("timestamp", "datetime or None", timestamp) if not isinstance(time_offset, (timedelta_type, type(None))): - raise TypeError( - f"The time offset must be a timedelta or None.\n\n" - f"Provided value: {time_offset!r}" - ) + raise invalid_arg_type("time offset", "timedelta or None", time_offset) if not isinstance(sample_interval, timedelta_type): - raise TypeError( - "The sample interval must be a timedelta.\n\n" - f"Provided value: {sample_interval!r}" - ) - _validate_unsupported_arg("timestamps", timestamps) + raise invalid_arg_type("sample interval", "timedelta", sample_interval) + validate_unsupported_arg("timestamps", timestamps) def _validate_init_args_irregular( self, @@ -162,16 +143,13 @@ def _validate_init_args_irregular( timestamps: Sequence[_TDateTime_co] | None, ) -> None: datetime_type = self.__class__._get_datetime_type() - _validate_unsupported_arg("timestamp", timestamp) - _validate_unsupported_arg("time offset", time_offset) - _validate_unsupported_arg("sample interval", sample_interval) + validate_unsupported_arg("timestamp", timestamp) + validate_unsupported_arg("time offset", time_offset) + validate_unsupported_arg("sample interval", sample_interval) if not isinstance(timestamps, Sequence) or not all( isinstance(ts, datetime_type) for ts in timestamps ): - raise TypeError( - "The timestamps must be a sequence of datetime objects.\n\n" - f"Provided value: {timestamps!r}" - ) + raise invalid_arg_type("timestamps", "sequence of datetime objects", timestamps) _VALIDATE_INIT_ARGS_FOR_MODE = { SampleIntervalMode.NONE: _validate_init_args_none, diff --git a/src/nitypes/waveform/_timing/_conversion.py b/src/nitypes/waveform/_timing/_conversion.py index 0eac8c44..6e083506 100644 --- a/src/nitypes/waveform/_timing/_conversion.py +++ b/src/nitypes/waveform/_timing/_conversion.py @@ -8,6 +8,7 @@ import hightime as ht +from nitypes._exceptions import invalid_arg_type, invalid_requested_type from nitypes.time._conversion import convert_datetime, convert_timedelta from nitypes.waveform._timing._precision import PrecisionTiming from nitypes.waveform._timing._standard import Timing @@ -25,16 +26,13 @@ def convert_timing(requested_type: type[_TTiming], value: _AnyTiming, /) -> _TTi """Convert a waveform timing object to the specified type.""" convert_func = _CONVERT_TIMING_FOR_TYPE.get(requested_type) if convert_func is None: - raise TypeError( - "The requested type must be a waveform timing type.\n\n" - f"Requested type: {requested_type}" - ) + raise invalid_requested_type("waveform timing", requested_type) return cast(_TTiming, convert_func(value)) @singledispatch def _convert_to_standard_timing(value: object, /) -> Timing: - raise TypeError("The value must be a waveform timing object.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("value", "waveform timing object", value) @_convert_to_standard_timing.register @@ -67,7 +65,7 @@ def _(value: PrecisionTiming, /) -> Timing: @singledispatch def _convert_to_precision_timing(value: object, /) -> PrecisionTiming: - raise TypeError("The value must be a waveform timing object.\n\n" f"Provided value: {value!r}") + raise invalid_arg_type("value", "waveform timing object", value) @_convert_to_precision_timing.register