From 36866951c8ac44412583134eb4fbe4d6f0f53df4 Mon Sep 17 00:00:00 2001 From: Kimboleh Date: Wed, 24 Jun 2026 19:23:28 -0700 Subject: [PATCH 1/3] feat: allow file paths to be passed in as pathlib.Path objects --- CHANGELOG.md | 4 +++ .../files_and_formats/abstracted_file.py | 3 ++- .../files_and_formats/mat/mat.py | 10 ++++++-- src/tm_data_types/io_factory_methods.py | 25 +++++++++++-------- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aae9fc..b2a3ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Valid subsections within a version are: Things to be included in the next release go here. +### Added + +- All functions/methods/classes now accept pathlike objects, in addition to strings ([#33](https://github.com/tektronix/tm_data_types/issues/33)) + --- ## v0.4.1 (2026-05-21) diff --git a/src/tm_data_types/files_and_formats/abstracted_file.py b/src/tm_data_types/files_and_formats/abstracted_file.py index 18ff06f..4bbf958 100644 --- a/src/tm_data_types/files_and_formats/abstracted_file.py +++ b/src/tm_data_types/files_and_formats/abstracted_file.py @@ -1,6 +1,7 @@ """The base file type which abstracts all file formats.""" from abc import ABC, abstractmethod +from pathlib import Path from typing import Dict, Generic, Optional, TextIO, TypeVar from bidict import bidict @@ -21,7 +22,7 @@ class AbstractedFile(ABC, Generic[DATUM_TYPE_VAR]): def __init__( self, - file_path: str, + file_path: str | Path, io_type: str, product: Optional[InstrumentSeries] = InstrumentSeries.TEKSCOPE, ) -> None: diff --git a/src/tm_data_types/files_and_formats/mat/mat.py b/src/tm_data_types/files_and_formats/mat/mat.py index cd8066a..fac7d8d 100644 --- a/src/tm_data_types/files_and_formats/mat/mat.py +++ b/src/tm_data_types/files_and_formats/mat/mat.py @@ -111,7 +111,10 @@ def read_datum(self) -> WAVEFORM_TYPE: Returns: A waveform filled with information from the .wfm file. """ - formatted_data = sio.loadmat(self.file_path) + file_path_str = ( + self.file_path if isinstance(self.file_path, str) else self.file_path.as_posix() + ) + formatted_data = sio.loadmat(file_path_str) return self._convert_from_formatted_data(formatted_data) # Writing @@ -121,8 +124,11 @@ def write_datum(self, waveform: Waveform) -> None: Args: waveform: The waveform to pack into the .wfm file. """ + file_path_str = ( + self.file_path if isinstance(self.file_path, str) else self.file_path.as_posix() + ) formatted_data = self._convert_to_formatted_data(waveform) - sio.savemat(self.file_path, formatted_data) + sio.savemat(file_path_str, formatted_data) ################################################################################################ # Private Methods diff --git a/src/tm_data_types/io_factory_methods.py b/src/tm_data_types/io_factory_methods.py index c9373b7..8761c19 100644 --- a/src/tm_data_types/io_factory_methods.py +++ b/src/tm_data_types/io_factory_methods.py @@ -3,6 +3,7 @@ import multiprocessing import os +from pathlib import Path from typing import List, Optional, TYPE_CHECKING from typing_extensions import TypeVar @@ -27,7 +28,7 @@ # pylint: disable=unused-argument def write_file( - path: str, + file_path: str | Path, waveform: Datum, product: InstrumentSeries = InstrumentSeries.TEKSCOPE, file_format: Optional[CSVFormats] = None, # noqa: ARG001 @@ -50,12 +51,12 @@ def write_file( stored separately. Args: - path: The path file to write to. + file_path: The path file to write to. waveform: The waveform that is being written. product: The product being written to. file_format: A specialized file format we are writing as. """ - _, path_extension = os.path.splitext(path) + _, path_extension = os.path.splitext(file_path) try: file_extension = FileExtensions[path_extension.replace(".", "").upper()] except KeyError as e: @@ -64,12 +65,12 @@ def write_file( # find the format based on the waveform extension format_class: AbstractedFile = find_class_format(file_extension, type(waveform)) # using __init__ for instantiation due to pyright confusion - format_class = format_class(path, access_type(file_extension, write=True), product) + format_class = format_class(file_path, access_type(file_extension, write=True), product) with format_class as fd: fd.write_datum(waveform) -def read_file(file_path: str) -> DatumAlias: +def read_file(file_path: str | Path) -> DatumAlias: """Read a waveform from a provided file. Process Overview: @@ -104,7 +105,7 @@ def read_file(file_path: str) -> DatumAlias: raise TypeError(msg) -def read_analog_file(file_path: str) -> AnalogWaveform: +def read_analog_file(file_path: str | Path) -> AnalogWaveform: """Read a waveform from a provided file, type hinted as an AnalogWaveform. Args: @@ -113,7 +114,7 @@ def read_analog_file(file_path: str) -> AnalogWaveform: return read_file(file_path) -def read_iq_file(file_path: str) -> IQWaveform: +def read_iq_file(file_path: str | Path) -> IQWaveform: """Read a waveform from a provided file, type hinted as an IQWaveform. Args: @@ -131,7 +132,7 @@ def _write_files( """Write a list of waveforms to a list of provided files. Args: - file_paths: The path file to write to. + file_paths: The list of path files to write to. datums: The datum that is being written. product: The product being written to. file_format: A specialized file format we are writing as. @@ -141,7 +142,7 @@ def _write_files( def write_files_in_parallel( - file_paths: List[str], + file_paths: List[str] | List[Path], datums: List[Datum], force_process_count: int = 4, product: InstrumentSeries = InstrumentSeries.TEKSCOPE, @@ -160,7 +161,7 @@ def write_files_in_parallel( This method is particularly useful for saving multiple waveform files efficiently. Args: - file_paths: The path file to write to. + file_paths: The list of path files to write to. datums: The datum that is being written. force_process_count: The number of processes that will be created. product: The product being written to. @@ -207,7 +208,9 @@ def _read_files(file_paths: str, file_queue: multiprocessing.Queue) -> None: file_queue.put((file_path, read_file(file_path))) -def read_files_in_parallel(file_paths: List[str], force_process_count: int = 4) -> List[Datum]: +def read_files_in_parallel( + file_paths: List[str] | List[Path], force_process_count: int = 4 +) -> List[Datum]: """Read a list of files in parallel. This method allows for the parallel reading of multiple waveform files. From 4f75e842630e82c618702ab29ed13aeeef32c4b3 Mon Sep 17 00:00:00 2001 From: Kimboleh Date: Wed, 24 Jun 2026 19:23:28 -0700 Subject: [PATCH 2/3] test: add tests for file paths passed as pathlib.Path objects --- pyproject.toml | 1 - tests/benchmark.py | 5 +-- tests/test_tm_data_types.py | 63 ++++++++++++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 14cf105..c076927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ documentation = "https://tm-data-types.readthedocs.io" homepage = "https://pypi.org/project/tm_data_types/" repository = "https://github.com/tektronix/tm_data_types" - [tool.coverage.paths] source = [ "src/tm_data_types", diff --git a/tests/benchmark.py b/tests/benchmark.py index 84cedca..93183b6 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import asdict, dataclass +from pathlib import Path from typing import List, Optional, Tuple import matplotlib.pyplot as plt @@ -35,7 +36,7 @@ class Performance: reads_per_second: NDArray -def write_files_serial(file_paths: List[str], datums: List[AnalogWaveform]) -> None: +def write_files_serial(file_paths: List[str] | List[Path], datums: List[AnalogWaveform]) -> None: """Write to the provided file paths using the waveforms serially. Args: @@ -46,7 +47,7 @@ def write_files_serial(file_paths: List[str], datums: List[AnalogWaveform]) -> N write_file(file_path, datum) -def read_files_serial(file_paths: List[str]) -> None: +def read_files_serial(file_paths: List[str] | List[Path]) -> None: """Read the provided file paths serially. Args: diff --git a/tests/test_tm_data_types.py b/tests/test_tm_data_types.py index efbd7a9..073801a 100644 --- a/tests/test_tm_data_types.py +++ b/tests/test_tm_data_types.py @@ -86,6 +86,7 @@ def test_parallel(tmp_path: Path) -> None: ) waveform_info[waveform_path.as_posix()] = waveform + # test file paths as strings write_files_in_parallel(list(waveform_info.keys()), list(waveform_info.values())) if read_info := read_files_in_parallel(list(waveform_info.keys())): @@ -95,6 +96,19 @@ def test_parallel(tmp_path: Path) -> None: msg = "No Files written/read." raise IOError(msg) + # test file paths as Path objects + path_keys = [Path(key) for key in waveform_info] + write_files_in_parallel(list(path_keys), list(waveform_info.values())) + + if read_info := read_files_in_parallel(list(path_keys)): + for file_path, waveform in read_info: + assert np.array_equal( + waveform.y_axis_values, waveform_info[file_path.as_posix()].y_axis_values + ) + else: + msg = "No Files written/read." + raise IOError(msg) + @pytest.mark.parametrize( ("waveform_type", "waveform_meta_info"), @@ -116,11 +130,20 @@ def test_wfm( waveform.y_axis_values = values waveform.y_axis_spacing = 1 / type_max(np.dtype(np.int16)) - write_file(waveform_path.as_posix(), waveform) + # test write_file with string + write_file(waveform_path.as_posix(), waveform) with open(waveform_path, "rb+") as wfm: raw_test_data = wfm.read() + with open(golden_path, "rb+") as wfm: + golden_format_data = wfm.read() + + assert raw_test_data == golden_format_data + # test write_file with Path object + write_file(waveform_path, waveform) + with open(waveform_path, "rb+") as wfm: + raw_test_data = wfm.read() with open(golden_path, "rb+") as wfm: golden_format_data = wfm.read() @@ -129,9 +152,9 @@ def test_wfm( def read_write_read( vertical_data: str, - waveform_path: str, - data_path: str, - temp_path: str, + waveform_path: str | Path, + data_path: str | Path, + temp_path: str | Path, ) -> None: """Read a file, then write the waveform from the file, then read it again. @@ -170,7 +193,16 @@ def read_write_read( atol=0.0005, ), ) - if waveform_path.rsplit(".", maxsplit=1)[-1] == temp_path.rsplit(".", maxsplit=1)[-1]: + + # Extract extensions + ext1 = ( + waveform_path.rsplit(".", maxsplit=1)[-1] + if isinstance(waveform_path, str) + else waveform_path.suffix + ) + ext2 = temp_path.rsplit(".", maxsplit=1)[-1] if isinstance(temp_path, str) else temp_path.suffix + + if ext1 == ext2: assert np.array_equal(getattr(re_read_waveform, vertical_data), read_wfm_values) else: re_read_values = getattr(re_read_waveform, vertical_data) @@ -207,6 +239,15 @@ def test_analog(tmp_path: Path) -> None: temporary_path.as_posix(), ) + # verify that Path objects are accepted in addition to strings + path_waveform = tmp_path / f"test_analog_pathlib.{extensions[0]}" + read_write_read( + "y_axis_values", + Path(waveform_path + extensions[0]), # only need to test one file to see if it works + data_path, + path_waveform, + ) + def test_iq(tmp_path: Path) -> None: """Test to see if IQ waveforms will return the same data when saved and loaded.""" @@ -232,6 +273,12 @@ def test_iq(tmp_path: Path) -> None: temporary_path.as_posix(), ) + # verify that Path objects are accepted in addition to strings + path_waveform = tmp_path / f"test_iq_pathlib.{extensions[0]}" + read_write_read( + "interleaved_iq_axis_values", Path(waveform_path + extensions[0]), data_path, path_waveform + ) + def test_digital(tmp_path: Path) -> None: """Test to see if digital waveforms will return the same data when saved and loaded.""" @@ -257,6 +304,12 @@ def test_digital(tmp_path: Path) -> None: temporary_path.as_posix(), ) + # verify that Path objects are accepted in addition to strings + path_waveform = tmp_path / f"test_digital_pathlib.{extensions[0]}" + read_write_read( + "y_axis_byte_values", Path(waveform_path + extensions[0]), data_path, path_waveform + ) + def test_data() -> None: # pylint: disable=too-many-locals """Test if normalized data is correctly represented, and that data types can be converted.""" From d46864d0ba6505c878085e4d96de93acbbcff7a6 Mon Sep 17 00:00:00 2001 From: Kimboleh Date: Thu, 25 Jun 2026 16:26:09 -0700 Subject: [PATCH 3/3] refactor: read_file and write_file to only use a Path object internally --- src/tm_data_types/io_factory_methods.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/tm_data_types/io_factory_methods.py b/src/tm_data_types/io_factory_methods.py index 8761c19..93069a7 100644 --- a/src/tm_data_types/io_factory_methods.py +++ b/src/tm_data_types/io_factory_methods.py @@ -1,7 +1,6 @@ """Factory methods to read and write to a generic file using generic data.""" import multiprocessing -import os from pathlib import Path from typing import List, Optional, TYPE_CHECKING @@ -56,12 +55,15 @@ def write_file( product: The product being written to. file_format: A specialized file format we are writing as. """ - _, path_extension = os.path.splitext(file_path) + file_path = Path(file_path) if isinstance(file_path, str) else file_path + + # make sure the extension is supported try: - file_extension = FileExtensions[path_extension.replace(".", "").upper()] + file_extension = FileExtensions[file_path.suffix.lstrip(".").upper()] except KeyError as e: - msg = f"The {path_extension} extension cannot be written to." + msg = f"The {file_path.suffix} extension cannot be written to." raise IOError(msg) from e + # find the format based on the waveform extension format_class: AbstractedFile = find_class_format(file_extension, type(waveform)) # using __init__ for instantiation due to pyright confusion @@ -90,12 +92,15 @@ def read_file(file_path: str | Path) -> DatumAlias: Args: file_path: The path file to read from. """ - _, path_extension = os.path.splitext(file_path) + file_path = Path(file_path) if isinstance(file_path, str) else file_path + + # ensure the extension is supported try: - file_extension = FileExtensions[path_extension.replace(".", "").upper()] + file_extension = FileExtensions[file_path.suffix.lstrip(".").upper()] except KeyError as e: - msg = f"The {path_extension} extension cannot be read from." + msg = f"The {file_path.suffix} extension cannot be read from." raise IOError(msg) from e + class_formats: List[AbstractedFile] = find_class_format_list(file_extension) for file_format in class_formats: with file_format(file_path, access_type(file_extension, write=False)) as fd: